feat(jpchx): implement JPCHX Club portal with forum, gallery and Lissyara-style layout
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
raw_jpchx/
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
*.swp
|
||||||
|
|
@ -22,7 +22,24 @@ services:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
# 2. Nginx Decoy: сайт клуба любителей пельменей + скрытый шлюз подписок
|
# 2. JPCHX Web App & Forum (Python Flask + SQLite)
|
||||||
|
jpchx-app:
|
||||||
|
build:
|
||||||
|
context: ./site
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: jpchx-app
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- ./site/data:/app/data
|
||||||
|
networks:
|
||||||
|
- shield_net
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# 3. Nginx Decoy: Шлюз сайта Клуба ЖПЧХ + скрытый шлюз подписок
|
||||||
nginx-decoy:
|
nginx-decoy:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
container_name: nginx-decoy
|
container_name: nginx-decoy
|
||||||
|
|
@ -31,19 +48,20 @@ services:
|
||||||
- "80:80" # HTTP для ACME certbot и редиректа на HTTPS
|
- "80:80" # HTTP для ACME certbot и редиректа на HTTPS
|
||||||
volumes:
|
volumes:
|
||||||
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||||
- ./nginx/site:/usr/share/nginx/html:ro
|
- ./site/static:/usr/share/nginx/html/static:ro
|
||||||
- ./certbot/www:/var/www/certbot:ro
|
- ./certbot/www:/var/www/certbot:ro
|
||||||
networks:
|
networks:
|
||||||
- shield_net
|
- shield_net
|
||||||
depends_on:
|
depends_on:
|
||||||
- 3x-ui
|
- 3x-ui
|
||||||
|
- jpchx-app
|
||||||
logging:
|
logging:
|
||||||
driver: "json-file"
|
driver: "json-file"
|
||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
# 3. Certbot: автоматический выпуск и продление SSL Let's Encrypt
|
# 4. Certbot: автоматический выпуск и продление SSL Let's Encrypt
|
||||||
certbot:
|
certbot:
|
||||||
image: certbot/certbot:latest
|
image: certbot/certbot:latest
|
||||||
container_name: certbot-ssl
|
container_name: certbot-ssl
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# ========================================================
|
# ========================================================
|
||||||
# Pelmeni Shield Nginx Configuration
|
# JPCHX Club Shield Nginx Configuration
|
||||||
# ========================================================
|
# ========================================================
|
||||||
|
|
||||||
# 1. Порт 80: Let's Encrypt ACME Challenge + Редирект на HTTPS
|
# 1. Порт 80: Let's Encrypt ACME Challenge + Редирект на HTTPS
|
||||||
|
|
@ -21,15 +21,17 @@ server {
|
||||||
listen 8080 default_server;
|
listen 8080 default_server;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
# Корневой сайт: Клуб любителей сибирских пельменей
|
client_max_body_size 16M;
|
||||||
location / {
|
|
||||||
root /usr/share/nginx/html;
|
# Статические файлы сайта Клуба ЖПЧХ (CSS, галерея)
|
||||||
index index.html;
|
location /static/ {
|
||||||
try_files $uri $uri/ /index.html;
|
alias /usr/share/nginx/html/static/;
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, no-transform";
|
||||||
}
|
}
|
||||||
|
|
||||||
# Секретный шлюз выдачи подписок 3X-UI
|
# Секретный шлюз выдачи подписок 3X-UI
|
||||||
# Снаружи выглядит как вызов API рецептов:
|
# Маскируется под API клубных рецептов/архивов:
|
||||||
# URL: https://DOMAIN/api/v1/recipes/secret_TOKEN/SUB_ID
|
# URL: https://DOMAIN/api/v1/recipes/secret_TOKEN/SUB_ID
|
||||||
location ~ ^/api/v1/recipes/secret_([a-zA-Z0-9]+)/(.*)$ {
|
location ~ ^/api/v1/recipes/secret_([a-zA-Z0-9]+)/(.*)$ {
|
||||||
proxy_pass http://3x-ui:2053/sub/$2;
|
proxy_pass http://3x-ui:2053/sub/$2;
|
||||||
|
|
@ -41,7 +43,14 @@ server {
|
||||||
proxy_read_timeout 15s;
|
proxy_read_timeout 15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Защита от перебора: любая ошибка выдает главную страницу пельменей
|
# Полноценный сайт и интерактивный форум Клуба ЖПЧХ
|
||||||
error_page 404 /index.html;
|
location / {
|
||||||
error_page 500 502 503 504 /index.html;
|
proxy_pass http://jpchx-app:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_connect_timeout 5s;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ echo "🔐 === 3. Проверка SSL-сертификата для ${DOMAIN} =
|
||||||
CERT_PATH="certbot/certs/live/${DOMAIN}/fullchain.pem"
|
CERT_PATH="certbot/certs/live/${DOMAIN}/fullchain.pem"
|
||||||
if [[ ! -f "${CERT_PATH}" ]]; then
|
if [[ ! -f "${CERT_PATH}" ]]; then
|
||||||
echo "🌐 Запуск первичного выпуска SSL-сертификата Let's Encrypt..."
|
echo "🌐 Запуск первичного выпуска SSL-сертификата Let's Encrypt..."
|
||||||
docker run -it --rm --name certbot-init \
|
docker run --rm --name certbot-init \
|
||||||
-p 80:80 \
|
-p 80:80 \
|
||||||
-v "$(pwd)/certbot/certs:/etc/letsencrypt" \
|
-v "$(pwd)/certbot/certs:/etc/letsencrypt" \
|
||||||
-v "$(pwd)/certbot/www:/var/www/certbot" \
|
-v "$(pwd)/certbot/www:/var/www/certbot" \
|
||||||
|
|
|
||||||
17
site/Dockerfile
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
FROM python:3.12-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache sqlite
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV FLASK_ENV=production
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["python", "app.py"]
|
||||||
484
site/app.py
Normal file
|
|
@ -0,0 +1,484 @@
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime
|
||||||
|
from functools import wraps
|
||||||
|
from flask import Flask, render_template, request, redirect, url_for, session, flash, g
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.secret_key = os.environ.get("SECRET_KEY", "jpchx_volga_secret_key_2012_matrixhasyou")
|
||||||
|
|
||||||
|
DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data"))
|
||||||
|
os.makedirs(DATA_DIR, exist_ok=True)
|
||||||
|
DB_PATH = os.path.join(DATA_DIR, "jpchx.db")
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = getattr(g, "_database", None)
|
||||||
|
if db is None:
|
||||||
|
db = g._database = sqlite3.connect(DB_PATH)
|
||||||
|
db.row_factory = sqlite3.Row
|
||||||
|
return db
|
||||||
|
|
||||||
|
@app.teardown_appcontext
|
||||||
|
def close_connection(exception):
|
||||||
|
db = getattr(g, "_database", None)
|
||||||
|
if db is not None:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def hash_pw(password: str) -> str:
|
||||||
|
return hashlib.sha256(password.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
def login_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if "user_id" not in session:
|
||||||
|
flash("Для выполнения этого действия необходимо войти в систему или зарегистрироваться.", "warning")
|
||||||
|
return redirect(url_for("login", next=request.url))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT DEFAULT 'Участник',
|
||||||
|
avatar_text TEXT DEFAULT '📷',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS topics (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
category_id INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
author_id INTEGER NOT NULL,
|
||||||
|
views INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (category_id) REFERENCES categories (id),
|
||||||
|
FOREIGN KEY (author_id) REFERENCES users (id)
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
topic_id INTEGER NOT NULL,
|
||||||
|
author_id INTEGER NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (topic_id) REFERENCES topics (id),
|
||||||
|
FOREIGN KEY (author_id) REFERENCES users (id)
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS gallery (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
location TEXT NOT NULL,
|
||||||
|
year TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Check if empty, populate initial content
|
||||||
|
cur.execute("SELECT count(*) FROM users;")
|
||||||
|
if cur.fetchone()[0] == 0:
|
||||||
|
print("Populating initial JPCHX club database...")
|
||||||
|
now = "2012-08-11 14:20:00"
|
||||||
|
|
||||||
|
# 1. Users
|
||||||
|
users_data = [
|
||||||
|
("Admin", hash_pw("admin_jpchx_2012"), "Главный Хранитель ЖПЧХ", "🎖️", "2012-01-10 12:00:00"),
|
||||||
|
("MatrixHasYou", hash_pw("matrix_volga_2012"), "Основатель / Мастер кадра", "👑", "2012-02-01 10:00:00"),
|
||||||
|
("PitKa", hash_pw("pitka_master_2012"), "Основатель / Теоретик контраста", "🎩", "2012-02-01 10:15:00"),
|
||||||
|
("LensMaster", hash_pw("lens_pass_2014"), "Оптик-испытатель", "🔍", "2013-05-12 16:30:00"),
|
||||||
|
("VolgaPhotographer", hash_pw("volga_pass_2014"), "Хроникёр Поволжья", "🌊", "2013-07-20 18:45:00"),
|
||||||
|
("BokehLover", hash_pw("bokeh_pass_2015"), "Любитель светосилы", "✨", "2014-03-15 11:20:00"),
|
||||||
|
("StreetHunter73", hash_pw("street_pass_2015"), "Уличный снайпер 73", "🎯", "2014-09-01 09:10:00"),
|
||||||
|
("WideAngleGuy", hash_pw("wide_pass_2016"), "Широкоугольный мастер", "📐", "2015-04-18 14:05:00"),
|
||||||
|
("WeddingCrasher", hash_pw("wedding_pass_2016"), "Банкетный летописец", "🍾", "2015-08-22 17:50:00"),
|
||||||
|
("ShutterBug", hash_pw("shutter_pass_2017"), "Плёночный энтузиаст", "🎞️", "2016-11-05 13:40:00"),
|
||||||
|
]
|
||||||
|
cur.executemany("INSERT INTO users (username, password_hash, role, avatar_text, created_at) VALUES (?, ?, ?, ?, ?)", users_data)
|
||||||
|
|
||||||
|
# 2. Categories
|
||||||
|
cats_data = [
|
||||||
|
(1, "Теория и философия ЖПЧХ", "Основы композиции, контраст планов, смысл жеста и манифест движения"),
|
||||||
|
(2, "Техника и оптика", "Камеры, объективы, глубина резкости, выдержка и угол захвата"),
|
||||||
|
(3, "Полевые отчеты и истории", "Хроники внедрения на свадьбах, выпускных, банкетах и пленэрах"),
|
||||||
|
(4, "Клубная жизнь и мастерская", "Членство, дипломы, этика участника и организация съемок"),
|
||||||
|
]
|
||||||
|
cur.executemany("INSERT INTO categories (id, title, description) VALUES (?, ?, ?)", cats_data)
|
||||||
|
|
||||||
|
# 3. Topics & Posts
|
||||||
|
# Topic 1
|
||||||
|
cur.execute("INSERT INTO topics (category_id, title, author_id, views, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(1, "Основы композиции ЖПЧХ: контраст первого плана против пафоса фона", 2, 428, "2013-04-15 10:20:00"))
|
||||||
|
t1_id = cur.lastrowid
|
||||||
|
cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(t1_id, 2, """Коллеги и единомышленники!
|
||||||
|
Часто новички спрашивают: «В чём глубинная суть ЖПЧХ? Это просто шутка или фото-арт?».
|
||||||
|
Отвечаю со всей основательностью. ЖПЧХ — это в первую очередь художественный приём абсолютного контраста.
|
||||||
|
|
||||||
|
На заднем плане кадра разворачивается нечто чрезмерно торжественное: чинные лица гостей в строгих костюмах, пафосный тост на банкете, церемония вручения дипломов у Мемцентра или свадебная фотосессия на волжском косогоре. Все напряжены, неестественны, играют социальные роли.
|
||||||
|
И тут на первом плане — в расфокусе или чётком контуре спиной к камере — появляется рука, уверенно и честно почёсывающая задницу.
|
||||||
|
|
||||||
|
Этот жест мгновенно приземляет напыщенность, возвращает кадру витальность и заставляет зрителя улыбнуться искренней правде жизни. Главное правило: почес должен быть уверенным, хватким и органичным. Не имитация, а честный физиологический импульс!""", "2013-04-15 10:20:00"))
|
||||||
|
cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(t1_id, 3, """Поддерживаю каждое слово MatrixHasYou!
|
||||||
|
От себя добавлю как сооснователь: важен тайминг. Нельзя чесаться непрерывно, как шелудивый пёс. Истинный мастер ЖПЧХ ждёт апогея торжества на заднем плане — момент поцелуя молодых, звон бокалов или речь ректора. Именно в эту секунду производится вход в кадр и фиксация.""", "2013-04-15 11:45:00"))
|
||||||
|
cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(t1_id, 4, """Великолепный манифест! Пробовал снимать на 50mm f/1.4 с открытой диафрагмой. Рука на первом плане слегка в боке, а на заднем плане чётко видны изумлённые лица свидетелей. Эффект потрясающий!""", "2013-04-15 14:10:00"))
|
||||||
|
|
||||||
|
# Topic 2
|
||||||
|
cur.execute("INSERT INTO topics (category_id, title, author_id, views, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(2, "Выбор оптики: сверхширокий угол 16-35mm против классического портретника 50mm", 3, 312, "2013-06-02 12:00:00"))
|
||||||
|
t2_id = cur.lastrowid
|
||||||
|
cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(t2_id, 3, """Провёл полевые испытания на двух объективах: Nikkor 16-35mm f/4 и 50mm f/1.8.
|
||||||
|
На 16mm масштаб задницы на переднем плане получается монументальным — она буквально доминирует над архитектурой города и Волгой. Но требует подойти к оператору вплотную (30-40 см).
|
||||||
|
На 50mm геометрия естественнее, легче остаться незамеченным охраной. Кто какую оптику предпочитает?""", "2013-06-02 12:00:00"))
|
||||||
|
cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(t2_id, 8, """Ширик рулит однозначно! 24mm — золотая середина. Влезает и весь банкетный зал, и монументальный почес.""", "2013-06-02 13:30:00"))
|
||||||
|
cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(t2_id, 2, """Согласен с PitKa насчёт 50mm для скрытой съёмки. Особенно когда свадьба на Венце или банкет в ресторане Волга. Подойти на 30 см с шириком не всегда дадут подружки невесты. А с полтинника со второго ряда — шедевр готов в одно нажатие!""", "2013-06-02 15:00:00"))
|
||||||
|
|
||||||
|
# Topic 3
|
||||||
|
cur.execute("INSERT INTO topics (category_id, title, author_id, views, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(3, "Хроники Венца 2014: как мы встревали в кадр официального свадебного кортежа", 2, 580, "2014-04-10 17:30:00"))
|
||||||
|
t3_id = cur.lastrowid
|
||||||
|
cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(t3_id, 2, """Апрель 2014 года. Венец, вид на Императорский мост через Волгу.
|
||||||
|
Белый лимузин, жених поднимает невесту на руки, фотограф с зонтом командует: «Смотрим в даль, изображаем романтику!».
|
||||||
|
Мы с PitKa прогуливаемся рядом в куртках. PitKa занимает позицию с камерой, я делаю непринуждённый шаг спиной к объективу, рука уверенно впивается в карман джинсов и производит канонический ЖПЧХ.
|
||||||
|
Фотограф свадьбы даже не понял, почему у него на заднем плане композиции возник наш монументальный шедевр. Кадр вошёл в золотой фонд Клуба (см. Галерею, экспонат №4)!""", "2014-04-10 17:30:00"))
|
||||||
|
cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(t3_id, 9, """Это же классика жанра! Невеста потом пересматривает свадебный альбом, а там на фоне вечной Волги — вечный человеческий почес! Искусство как оно есть.""", "2014-04-10 19:15:00"))
|
||||||
|
|
||||||
|
# Topic 4
|
||||||
|
cur.execute("INSERT INTO topics (category_id, title, author_id, views, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(4, "Кодекс и этика участника Клуба: границы допустимого и уважение к событию", 1, 290, "2014-05-18 09:00:00"))
|
||||||
|
t4_id = cur.lastrowid
|
||||||
|
cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(t4_id, 1, """Напоминание Администрации всем новым членам сообщества.
|
||||||
|
Клуб Любителей ЖПЧХ — это интеллектуальная уличная фотография, а не хулиганство.
|
||||||
|
Правила:
|
||||||
|
1. Никакого оголения! Почес производится строго поверх одежды (джинсы, брюки, шорты).
|
||||||
|
2. Никаких оскорблений и срыва мероприятий. Мы не мешаем людям, мы просто фиксируем контраст.
|
||||||
|
3. Лица участников на открытых ресурсах стилизуются графически (карандаш, гравюра) во избежание лишней мирской славы.
|
||||||
|
Соблюдайте чистоту жанра!""", "2014-05-18 09:00:00"))
|
||||||
|
cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(t4_id, 2, """Золотые слова. Именно поэтому в нашей официальной галерее все 16 архивных работ представлены в строгой графической обработке.""", "2014-05-18 10:30:00"))
|
||||||
|
|
||||||
|
# Topic 5
|
||||||
|
cur.execute("INSERT INTO topics (category_id, title, author_id, views, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(2, "Съемка со вспышкой в темных залах: синхронизация по второй шторке", 6, 215, "2014-11-20 20:00:00"))
|
||||||
|
t5_id = cur.lastrowid
|
||||||
|
cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(t5_id, 6, """На юбилеях и корпоративах в полутемных банкетных залах свет обычно отвратительный.
|
||||||
|
Если лупить вспышкой в лоб — убивается атмосфера.
|
||||||
|
Кто как настраивает синхронизацию по второй шторке (Rear Sync), чтобы смаз движения руки давал ощущение динамики почеса?""", "2014-11-20 20:00:00"))
|
||||||
|
cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(t5_id, 2, """Выдержка 1/15 секунды, ISO 800, пыха на -1.7 EV с рассеивателем в потолок по второй шторке. Рука оставляет лёгкий динамический шлейф, а в конце импульс фиксирует пальцы в кульминационной точке. Проверено на десятке банкетов!""", "2014-11-20 21:40:00"))
|
||||||
|
|
||||||
|
# Topic 6
|
||||||
|
cur.execute("INSERT INTO topics (category_id, title, author_id, views, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(3, "Парная работа: техника «Наводчик и Исполнитель»", 3, 340, "2015-02-14 15:20:00"))
|
||||||
|
t6_id = cur.lastrowid
|
||||||
|
cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(t6_id, 3, """В одиночку снимать ЖПЧХ на автоспуске или от бедра сложно.
|
||||||
|
Идеальная тактика — работа в паре:
|
||||||
|
- Фотограф делает вид, что увлечённо снимает памятник, панораму реки или фонтан.
|
||||||
|
- Исполнитель как бы невзначай переходит улицу или останавливается перед объективом, любуясь пейзажем.
|
||||||
|
- Кодовое слово: «Горизонт завален!», после чего исполнитель приступает к почесу, а фотограф жмёт спуск серией.""", "2015-02-14 15:20:00"))
|
||||||
|
cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(t6_id, 7, """Мы в Ульяновске у Дома Офицеров так три кадра сняли подряд. Ни одна собака не заподозрила! Паре зачет.""", "2015-02-14 16:50:00"))
|
||||||
|
|
||||||
|
# Topic 7
|
||||||
|
cur.execute("INSERT INTO topics (category_id, title, author_id, views, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(1, "История зарождения движения на берегах Волги (Симбирск, лето 2012)", 1, 620, "2015-08-11 14:00:00"))
|
||||||
|
t7_id = cur.lastrowid
|
||||||
|
cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(t7_id, 1, """Историческая справка для архива.
|
||||||
|
Клуб ЖПЧХ ведёт свою летопись с жаркого августа 2012 года. Именно тогда в славном волжском городе Симбирске (Ульяновске) MatrixHasYou и PitKa впервые сформулировали концепцию контр-глянцевой фотографии.
|
||||||
|
В эпоху повального увлечения постановочными фотосессиями с искусственными улыбками и вылизанными фильтрами Инстаграма, зародился протест: возвращение к корням, честности и несокрушимой самоиронии.
|
||||||
|
С тех пор движение обрело последователей далеко за пределами Волги!""", "2015-08-11 14:00:00"))
|
||||||
|
cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(t7_id, 2, """Помню тот жаркий день на волжском берегу как вчера. 11 августа 2012 года. Именно тогда был сделан легендарный кадр `20120811_141940-2` (экспонат №6 в Галерее). 14 лет прошло, а актуальность только растёт!""", "2015-08-11 15:30:00"))
|
||||||
|
cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(t7_id, 3, """Волга вообще располагает к глубоким философским выводам о бренности пафоса. Слава ЖПЧХ!""", "2015-08-11 16:15:00"))
|
||||||
|
|
||||||
|
# Topic 8
|
||||||
|
cur.execute("INSERT INTO topics (category_id, title, author_id, views, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(2, "Плёнка или матрица: передача зерна и текстуры денима в ч/б исполнении", 10, 185, "2016-03-01 11:10:00"))
|
||||||
|
t8_id = cur.lastrowid
|
||||||
|
cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(t8_id, 10, """Зарядил в старый Киев-19 плёнку Tasma Тип-42.
|
||||||
|
Контраст бешеный, фактура потертых джинсов при почесе выглядит как гравюра эпохи Возрождения. Цифра всё-таки слишком стерильна для такого жанра, что думаете?""", "2016-03-01 11:10:00"))
|
||||||
|
cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(t8_id, 4, """Поэтому мы и переводим все цифры в карандашный набросок и гравюру. Штрих придаёт вечность.""", "2016-03-01 13:00:00"))
|
||||||
|
|
||||||
|
# Topic 9
|
||||||
|
cur.execute("INSERT INTO topics (category_id, title, author_id, views, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(1, "Символизм позы: анатомия захвата и положение пальцев", 2, 275, "2016-06-19 18:00:00"))
|
||||||
|
t9_id = cur.lastrowid
|
||||||
|
cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(t9_id, 2, """Важная деталь, отличающая мастера от дилетанта.
|
||||||
|
Пальцы не должны быть расслаблены! Должна чувствоваться работа предплечья, естественное натяжение ткани кармана или шва.
|
||||||
|
Если рука просто вяло лежит на бедре — это не ЖПЧХ, это поза уставшего грузчика.
|
||||||
|
ЖПЧХ — это действие в апогее!""", "2016-06-19 18:00:00"))
|
||||||
|
cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(t9_id, 3, """Абсолютно. В кадре должно читаться намерение. Зритель должен почувствовать этот чес спинным мозгом.""", "2016-06-19 19:20:00"))
|
||||||
|
|
||||||
|
# Topic 10
|
||||||
|
cur.execute("INSERT INTO topics (category_id, title, author_id, views, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(4, "Клубные членские билеты и дипломы Мастера Первой Степени", 1, 390, "2017-01-25 14:15:00"))
|
||||||
|
t10_id = cur.lastrowid
|
||||||
|
cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(t10_id, 1, """По многочисленным просьбам участников:
|
||||||
|
В типографии отпечатаны номерные членские билеты Клуба Любителей ЖПЧХ на плотной крафтовой бумаге.
|
||||||
|
Условия получения:
|
||||||
|
1. Зарегистрироваться на форуме.
|
||||||
|
2. Опубликовать не менее одного подтверждённого полевого фото-шедевра.
|
||||||
|
3. Получить одобрение Основателей (MatrixHasYou / PitKa).""", "2017-01-25 14:15:00"))
|
||||||
|
cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(t10_id, 5, """Подал заявку с серией кадров с набережной Свияги! Жду вердикта комиссии!""", "2017-01-25 16:30:00"))
|
||||||
|
|
||||||
|
# 4. Gallery items
|
||||||
|
gallery_items = [
|
||||||
|
("jpchx_01.jpg", "Экспонат №1: Монументальный почес у парадной", "Симбирск, Центр", "2012", "Первые полевые испытания контрастного кадрирования."),
|
||||||
|
("jpchx_02.jpg", "Экспонат №2: Весеннее обострение классики", "Набережная реки Свияги", "2014", "Кадр с естественным освещением в закатный час."),
|
||||||
|
("jpchx_03.jpg", "Экспонат №3: Кульминация банкетного вечера", "Ресторанный зал", "2014", "На заднем плане разгар торжества, на переднем — суровая правда жизни."),
|
||||||
|
("jpchx_04.jpg", "Экспонат №4: Свадебный эскорт на Венце", "Бульвар Новый Венец", "2014", "Легендарный кадр с участием Основателей. Хрестоматийный контраст."),
|
||||||
|
("jpchx_05.jpg", "Экспонат №5: Зимняя выдержка на морозе", "Площадь 100-летия Ленина", "2012", "Почес через плотный зимний пуховик требует особого мастерства."),
|
||||||
|
("jpchx_06.jpg", "Экспонат №6: Августовский манифест на Волге", "Волжский косогор", "2012", "Исторический день рождения движения 11 августа 2012 года."),
|
||||||
|
("jpchx_07.jpg", "Экспонат №7: Диалог с архитектурой", "Мемориальный комплекс", "2013", "Геометрическая рифма вертикалей здания и руки мастера."),
|
||||||
|
("jpchx_08.jpg", "Экспонат №8: Праздничный фуршет", "Банкетный комплекс", "2013", "Внедрение в официальное протокольное мероприятие."),
|
||||||
|
("jpchx_09.jpg", "Экспонат №9: Летний полдень в тени акаций", "Городской сквер", "2013", "Классический ракурс со спины на среднем фокусном расстоянии."),
|
||||||
|
("jpchx_10.jpg", "Экспонат №10: Встречное солнце и силуэт", "Волжский пляж", "2013", "Контровой свет выявляет фактуру ткани и динамику жеста."),
|
||||||
|
("jpchx_11.jpg", "Экспонат №11: Инспекция горизонта", "Смотровая площадка", "2014", "Широкий угол охвата: волжские дали и уверенный почес."),
|
||||||
|
("jpchx_12.jpg", "Экспонат №12: Ночной дозор после юбилея", "Вход в клуб", "2014", "Съемка при свете неоновых фонарей с легким смазом."),
|
||||||
|
("jpchx_13.jpg", "Экспонат №13: Вертикаль и ритм ступеней", "Лестница к речному вокзалу", "2014", "Уличный этюд с акцентом на пластику движения."),
|
||||||
|
("jpchx_14.jpg", "Экспонат №14: Осенний сплин у парапета", "Парк Дружбы Народов", "2014", "Меланхоличный волжский пейзаж и бескомпромиссная поза."),
|
||||||
|
("jpchx_15.jpg", "Экспонат №15: Академический этюд", "Аллея Славы", "2015", "Выверенная композиция по правилу третей."),
|
||||||
|
("jpchx_16.jpg", "Экспонат №16: Финал юбилейной сессии", "Загородная усадьба", "2015", "Закрытие четвертого сезона полевых съемок Клуба."),
|
||||||
|
]
|
||||||
|
cur.executemany("INSERT INTO gallery (filename, title, location, year, description) VALUES (?, ?, ?, ?, ?)", gallery_items)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# ----------------- ROUTES -----------------
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_user():
|
||||||
|
user = None
|
||||||
|
if "user_id" in session:
|
||||||
|
db = get_db()
|
||||||
|
user = db.execute("SELECT id, username, role, avatar_text FROM users WHERE id = ?", (session["user_id"],)).fetchone()
|
||||||
|
return dict(current_user=user)
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
db = get_db()
|
||||||
|
latest_topics = db.execute("""
|
||||||
|
SELECT t.id, t.title, t.views, t.created_at, u.username, u.avatar_text,
|
||||||
|
(SELECT count(*) FROM posts p WHERE p.topic_id = t.id) as posts_count
|
||||||
|
FROM topics t
|
||||||
|
JOIN users u ON t.author_id = u.id
|
||||||
|
ORDER BY t.created_at DESC LIMIT 5
|
||||||
|
""").fetchall()
|
||||||
|
|
||||||
|
gallery_sample = db.execute("SELECT id, filename, title, location, year FROM gallery ORDER BY id ASC LIMIT 4").fetchall()
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"users": db.execute("SELECT count(*) FROM users").fetchone()[0],
|
||||||
|
"topics": db.execute("SELECT count(*) FROM topics").fetchone()[0],
|
||||||
|
"posts": db.execute("SELECT count(*) FROM posts").fetchone()[0],
|
||||||
|
"photos": db.execute("SELECT count(*) FROM gallery").fetchone()[0],
|
||||||
|
}
|
||||||
|
return render_template("index.html", latest_topics=latest_topics, gallery_sample=gallery_sample, stats=stats)
|
||||||
|
|
||||||
|
@app.route("/manifest")
|
||||||
|
def manifest():
|
||||||
|
return render_template("manifest.html")
|
||||||
|
|
||||||
|
@app.route("/gallery")
|
||||||
|
def gallery():
|
||||||
|
db = get_db()
|
||||||
|
items = db.execute("SELECT id, filename, title, location, year, description FROM gallery ORDER BY id ASC").fetchall()
|
||||||
|
return render_template("gallery.html", items=items)
|
||||||
|
|
||||||
|
@app.route("/forum")
|
||||||
|
def forum():
|
||||||
|
db = get_db()
|
||||||
|
categories = db.execute("SELECT id, title, description FROM categories ORDER BY id ASC").fetchall()
|
||||||
|
topics_by_cat = {}
|
||||||
|
for cat in categories:
|
||||||
|
topics = db.execute("""
|
||||||
|
SELECT t.id, t.title, t.views, t.created_at, u.username, u.role, u.avatar_text,
|
||||||
|
(SELECT count(*) FROM posts p WHERE p.topic_id = t.id) as posts_count,
|
||||||
|
(SELECT p.created_at FROM posts p WHERE p.topic_id = t.id ORDER BY p.id DESC LIMIT 1) as last_post_time
|
||||||
|
FROM topics t
|
||||||
|
JOIN users u ON t.author_id = u.id
|
||||||
|
WHERE t.category_id = ?
|
||||||
|
ORDER BY t.created_at DESC
|
||||||
|
""", (cat["id"],)).fetchall()
|
||||||
|
topics_by_cat[cat["id"]] = topics
|
||||||
|
return render_template("forum.html", categories=categories, topics_by_cat=topics_by_cat)
|
||||||
|
|
||||||
|
@app.route("/forum/topic/<int:topic_id>")
|
||||||
|
def topic(topic_id):
|
||||||
|
db = get_db()
|
||||||
|
db.execute("UPDATE topics SET views = views + 1 WHERE id = ?", (topic_id,))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
topic_row = db.execute("""
|
||||||
|
SELECT t.id, t.title, t.created_at, t.views, c.id as cat_id, c.title as cat_title, u.username, u.avatar_text, u.role
|
||||||
|
FROM topics t
|
||||||
|
JOIN categories c ON t.category_id = c.id
|
||||||
|
JOIN users u ON t.author_id = u.id
|
||||||
|
WHERE t.id = ?
|
||||||
|
""", (topic_id,)).fetchone()
|
||||||
|
|
||||||
|
if not topic_row:
|
||||||
|
flash("Тема не найдена.", "danger")
|
||||||
|
return redirect(url_for("forum"))
|
||||||
|
|
||||||
|
posts = db.execute("""
|
||||||
|
SELECT p.id, p.content, p.created_at, u.username, u.role, u.avatar_text,
|
||||||
|
(SELECT count(*) FROM posts p2 WHERE p2.author_id = u.id) as user_posts_count
|
||||||
|
FROM posts p
|
||||||
|
JOIN users u ON p.author_id = u.id
|
||||||
|
WHERE p.topic_id = ?
|
||||||
|
ORDER BY p.id ASC
|
||||||
|
""", (topic_id,)).fetchall()
|
||||||
|
|
||||||
|
return render_template("topic.html", topic=topic_row, posts=posts)
|
||||||
|
|
||||||
|
@app.route("/forum/new_topic", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def new_topic():
|
||||||
|
db = get_db()
|
||||||
|
if request.method == "POST":
|
||||||
|
title = request.form.get("title", "").strip()
|
||||||
|
category_id = request.form.get("category_id")
|
||||||
|
content = request.form.get("content", "").strip()
|
||||||
|
|
||||||
|
if not title or not category_id or not content:
|
||||||
|
flash("Все поля обязательны для заполнения.", "warning")
|
||||||
|
return redirect(url_for("new_topic"))
|
||||||
|
|
||||||
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
cur = db.execute("INSERT INTO topics (category_id, title, author_id, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(category_id, title, session["user_id"], now))
|
||||||
|
topic_id = cur.lastrowid
|
||||||
|
db.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(topic_id, session["user_id"], content, now))
|
||||||
|
db.commit()
|
||||||
|
flash("Тема успешно создана!", "success")
|
||||||
|
return redirect(url_for("topic", topic_id=topic_id))
|
||||||
|
|
||||||
|
categories = db.execute("SELECT id, title FROM categories ORDER BY id ASC").fetchall()
|
||||||
|
return render_template("new_topic.html", categories=categories)
|
||||||
|
|
||||||
|
@app.route("/forum/reply/<int:topic_id>", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def reply_topic(topic_id):
|
||||||
|
content = request.form.get("content", "").strip()
|
||||||
|
if not content:
|
||||||
|
flash("Сообщение не может быть пустым.", "warning")
|
||||||
|
return redirect(url_for("topic", topic_id=topic_id))
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
db.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(topic_id, session["user_id"], content, now))
|
||||||
|
db.commit()
|
||||||
|
flash("Ваш ответ опубликован!", "success")
|
||||||
|
return redirect(url_for("topic", topic_id=topic_id))
|
||||||
|
|
||||||
|
@app.route("/login", methods=["GET", "POST"])
|
||||||
|
def login():
|
||||||
|
if request.method == "POST":
|
||||||
|
username = request.form.get("username", "").strip()
|
||||||
|
password = request.form.get("password", "").strip()
|
||||||
|
db = get_db()
|
||||||
|
user = db.execute("SELECT id, username, password_hash FROM users WHERE username = ?", (username,)).fetchone()
|
||||||
|
if user and user["password_hash"] == hash_pw(password):
|
||||||
|
session["user_id"] = user["id"]
|
||||||
|
session["username"] = user["username"]
|
||||||
|
flash(f"Добро пожаловать, {username}!", "success")
|
||||||
|
next_url = request.args.get("next") or url_for("forum")
|
||||||
|
return redirect(next_url)
|
||||||
|
else:
|
||||||
|
flash("Неверное имя пользователя или пароль.", "danger")
|
||||||
|
return render_template("login.html")
|
||||||
|
|
||||||
|
@app.route("/register", methods=["GET", "POST"])
|
||||||
|
def register():
|
||||||
|
if request.method == "POST":
|
||||||
|
username = request.form.get("username", "").strip()
|
||||||
|
password = request.form.get("password", "").strip()
|
||||||
|
password2 = request.form.get("password2", "").strip()
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
flash("Заполните все обязательные поля.", "warning")
|
||||||
|
return render_template("register.html")
|
||||||
|
|
||||||
|
if len(username) < 3:
|
||||||
|
flash("Имя пользователя должно быть не менее 3 символов.", "warning")
|
||||||
|
return render_template("register.html")
|
||||||
|
|
||||||
|
if password != password2:
|
||||||
|
flash("Введенные пароли не совпадают.", "warning")
|
||||||
|
return render_template("register.html")
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
existing = db.execute("SELECT id FROM users WHERE username = ?", (username,)).fetchone()
|
||||||
|
if existing:
|
||||||
|
flash("Пользователь с таким именем уже зарегистрирован.", "warning")
|
||||||
|
return render_template("register.html")
|
||||||
|
|
||||||
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
cur = db.execute("INSERT INTO users (username, password_hash, role, avatar_text, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(username, hash_pw(password), "Новичок Клуба", "📸", now))
|
||||||
|
db.commit()
|
||||||
|
session["user_id"] = cur.lastrowid
|
||||||
|
session["username"] = username
|
||||||
|
flash(f"Регистрация успешна! Добро пожаловать в Клуб Любителей ЖПЧХ, {username}!", "success")
|
||||||
|
return redirect(url_for("forum"))
|
||||||
|
|
||||||
|
return render_template("register.html")
|
||||||
|
|
||||||
|
@app.route("/logout")
|
||||||
|
def logout():
|
||||||
|
session.clear()
|
||||||
|
flash("Вы успешно вышли из системы.", "info")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
@app.route("/healthz")
|
||||||
|
def healthz():
|
||||||
|
return "OK", 200
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
init_db()
|
||||||
|
port = int(os.environ.get("PORT", 8080))
|
||||||
|
app.run(host="0.0.0.0", port=port, debug=False)
|
||||||
BIN
site/data/jpchx.db
Normal file
2
site/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
flask==3.0.3
|
||||||
|
werkzeug==3.0.3
|
||||||
BIN
site/static/gallery/jpchx_01.jpg
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
site/static/gallery/jpchx_02.jpg
Normal file
|
After Width: | Height: | Size: 318 KiB |
BIN
site/static/gallery/jpchx_03.jpg
Normal file
|
After Width: | Height: | Size: 274 KiB |
BIN
site/static/gallery/jpchx_04.jpg
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
site/static/gallery/jpchx_05.jpg
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
site/static/gallery/jpchx_06.jpg
Normal file
|
After Width: | Height: | Size: 355 KiB |
BIN
site/static/gallery/jpchx_07.jpg
Normal file
|
After Width: | Height: | Size: 415 KiB |
BIN
site/static/gallery/jpchx_08.jpg
Normal file
|
After Width: | Height: | Size: 395 KiB |
BIN
site/static/gallery/jpchx_09.jpg
Normal file
|
After Width: | Height: | Size: 388 KiB |
BIN
site/static/gallery/jpchx_10.jpg
Normal file
|
After Width: | Height: | Size: 348 KiB |
BIN
site/static/gallery/jpchx_11.jpg
Normal file
|
After Width: | Height: | Size: 406 KiB |
BIN
site/static/gallery/jpchx_12.jpg
Normal file
|
After Width: | Height: | Size: 358 KiB |
BIN
site/static/gallery/jpchx_13.jpg
Normal file
|
After Width: | Height: | Size: 322 KiB |
BIN
site/static/gallery/jpchx_14.jpg
Normal file
|
After Width: | Height: | Size: 389 KiB |
BIN
site/static/gallery/jpchx_15.jpg
Normal file
|
After Width: | Height: | Size: 244 KiB |
BIN
site/static/gallery/jpchx_16.jpg
Normal file
|
After Width: | Height: | Size: 289 KiB |
341
site/static/style.css
Normal file
|
|
@ -0,0 +1,341 @@
|
||||||
|
/* ========================================================
|
||||||
|
Клуб Любителей ЖПЧХ (Основан на Волге, 2012)
|
||||||
|
Канонический текстовый интерфейс
|
||||||
|
======================================================== */
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
color: #222222;
|
||||||
|
font-family: "Courier New", Consolas, Monaco, "Lucida Console", monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
max-width: 1050px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #707680;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
header {
|
||||||
|
background: #ece9d8;
|
||||||
|
border-bottom: 2px solid #707680;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #000000;
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-tagline {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #555555;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-panel {
|
||||||
|
font-size: 13px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #999999;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation bar */
|
||||||
|
nav {
|
||||||
|
background: #e0ded0;
|
||||||
|
border-bottom: 1px solid #707680;
|
||||||
|
padding: 6px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a {
|
||||||
|
color: #0000cc;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a:hover {
|
||||||
|
background: #ffffff;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a.active {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #000000;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #999999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content area */
|
||||||
|
main {
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flash alerts */
|
||||||
|
.alert {
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
border: 1px solid #707680;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.alert-success { background: #e6f4ea; border-color: #5bb974; color: #137333; }
|
||||||
|
.alert-warning { background: #fef7e0; border-color: #f9ab00; color: #b06000; }
|
||||||
|
.alert-danger { background: #fce8e6; border-color: #ee675c; color: #c5221f; }
|
||||||
|
.alert-info { background: #e8f0fe; border-color: #669df6; color: #1a73e8; }
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
h1 { font-size: 18px; border-bottom: 1px solid #cccccc; padding-bottom: 6px; margin-bottom: 14px; }
|
||||||
|
h2 { font-size: 16px; border-bottom: 1px solid #e0e0e0; padding-bottom: 4px; margin: 16px 0 10px 0; }
|
||||||
|
h3 { font-size: 14px; margin: 12px 0 6px 0; }
|
||||||
|
p { margin-bottom: 10px; text-align: justify; }
|
||||||
|
|
||||||
|
a { color: #0000cc; text-decoration: underline; }
|
||||||
|
a:hover { color: #cc0000; }
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
table.table-forum {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border: 1px solid #707680;
|
||||||
|
margin: 12px 0 20px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.table-forum th {
|
||||||
|
background: #ece9d8;
|
||||||
|
border: 1px solid #707680;
|
||||||
|
padding: 6px 8px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.table-forum td {
|
||||||
|
border: 1px solid #d0d0d0;
|
||||||
|
padding: 6px 8px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.table-forum tr:hover td {
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-stats {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border: 1px solid #cccccc;
|
||||||
|
margin: 10px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.table-stats td {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Topic & Posts view */
|
||||||
|
.post-box {
|
||||||
|
border: 1px solid #707680;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
display: flex;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-author {
|
||||||
|
width: 180px;
|
||||||
|
background: #f7f6f0;
|
||||||
|
border-right: 1px solid #707680;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-author .name {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: #000066;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-author .role {
|
||||||
|
color: #555555;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: 10px 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-text {
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #777777;
|
||||||
|
border-top: 1px dashed #dddddd;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 4px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gallery Grid */
|
||||||
|
.gallery-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-card {
|
||||||
|
border: 1px solid #707680;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-img-wrap {
|
||||||
|
width: 100%;
|
||||||
|
height: 240px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f0f0f0;
|
||||||
|
border: 1px solid #cccccc;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-img-wrap img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
filter: contrast(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-info {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-title {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-location {
|
||||||
|
color: #666666;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-desc {
|
||||||
|
color: #333333;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"],
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
border: 1px solid #707680;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.btn, .btn {
|
||||||
|
display: inline-block;
|
||||||
|
background: #ece9d8;
|
||||||
|
color: #000000;
|
||||||
|
border: 1px solid #707680;
|
||||||
|
padding: 5px 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.btn:hover, .btn:hover {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #d8e2ec;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
footer {
|
||||||
|
background: #ece9d8;
|
||||||
|
border-top: 1px solid #707680;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555555;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre.ascii-box {
|
||||||
|
background: #f7f6f0;
|
||||||
|
border: 1px solid #cccccc;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
57
site/templates/base.html
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Клуб Любителей ЖПЧХ{% endblock %} — Международное сообщество контрастной фотографии</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrapper">
|
||||||
|
<header>
|
||||||
|
<div class="header-top">
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('index') }}" class="site-title">КЛУБ ЛЮБИТЕЛЕЙ ЖПЧХ</a>
|
||||||
|
<div class="site-tagline">Международное сообщество уличной и контрастной фотографии (Основано на Волге, 2012)</div>
|
||||||
|
</div>
|
||||||
|
<div class="user-panel">
|
||||||
|
{% if current_user %}
|
||||||
|
<span>Вы вошли как: <b>{{ current_user.username }}</b> ({{ current_user.role }})</span> |
|
||||||
|
<a href="{{ url_for('logout') }}">Выход</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('login') }}">Вход</a> |
|
||||||
|
<a href="{{ url_for('register') }}">Регистрация</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav>
|
||||||
|
<a href="{{ url_for('index') }}" class="{% if request.endpoint == 'index' %}active{% endif %}">[ Главная ]</a>
|
||||||
|
<a href="{{ url_for('manifest') }}" class="{% if request.endpoint == 'manifest' %}active{% endif %}">[ Манифест ]</a>
|
||||||
|
<a href="{{ url_for('gallery') }}" class="{% if request.endpoint == 'gallery' %}active{% endif %}">[ Фотогалерея (16) ]</a>
|
||||||
|
<a href="{{ url_for('forum') }}" class="{% if request.endpoint in ['forum', 'topic', 'new_topic'] %}active{% endif %}">[ Форум ]</a>
|
||||||
|
{% if current_user %}
|
||||||
|
<a href="{{ url_for('new_topic') }}">[ + Создать тему ]</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<div>© 2012–2026 Клуб Любителей ЖПЧХ. Волжское отделение (Симбирск). Все права защищены.</div>
|
||||||
|
<div>Сервер: jpchx.matrixhasyou.ru | Портал контрастной уличной фотографии</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
54
site/templates/forum.html
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Форум Клуба Любителей ЖПЧХ{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px;">
|
||||||
|
<h1>ОФИЦИАЛЬНЫЙ ФОРУМ КЛУБА ЖПЧХ</h1>
|
||||||
|
{% if current_user %}
|
||||||
|
<a href="{{ url_for('new_topic') }}" class="btn btn-primary">+ Создать новую тему</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Здесь участники сообщества обсуждают технику съёмки, делятся опытом полевых вылазок, консультируются
|
||||||
|
с Основателями (<b>MatrixHasYou</b>, <b>PitKa</b>) и обсуждают клубные инициативы.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% for cat in categories %}
|
||||||
|
<h2>{{ cat.title }}</h2>
|
||||||
|
<p style="font-size: 12px; color: #555555; margin-bottom: 6px;">{{ cat.description }}</p>
|
||||||
|
|
||||||
|
<table class="table-forum">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Название темы</th>
|
||||||
|
<th style="width: 140px;">Автор темы</th>
|
||||||
|
<th style="width: 70px; text-align: center;">Ответов</th>
|
||||||
|
<th style="width: 80px; text-align: center;">Просмотров</th>
|
||||||
|
<th style="width: 160px;">Последняя активность</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% set topics = topics_by_cat[cat.id] %}
|
||||||
|
{% if topics %}
|
||||||
|
{% for t in topics %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('topic', topic_id=t.id) }}"><b>{{ t.title }}</b></a>
|
||||||
|
</td>
|
||||||
|
<td>{{ t.username }}</td>
|
||||||
|
<td style="text-align: center;">{{ t.posts_count }}</td>
|
||||||
|
<td style="text-align: center;">{{ t.views }}</td>
|
||||||
|
<td style="font-size: 11px;">{{ t.last_post_time or t.created_at }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" style="text-align: center; color: #777777;">В этом разделе пока нет тем.</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
34
site/templates/gallery.html
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Фотогалерея Клуба ЖПЧХ (16 экспонатов){% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>АРХИВНАЯ ФОТОГАЛЕРЕЯ КЛУБА ЛЮБИТЕЛЕЙ ЖПЧХ</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
В экспозиции представлены 16 канонических полевых снимков 2012–2016 гг., прошедших авторскую графическую обработку
|
||||||
|
(перевод в классический карандашный набросок и гравюру).
|
||||||
|
Обработка подчеркивает пластику движений, складки ткани и контраст планов, сохраняя скромность авторов.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="gallery-grid">
|
||||||
|
{% for item in items %}
|
||||||
|
<div class="gallery-card">
|
||||||
|
<div class="gallery-img-wrap">
|
||||||
|
<a href="{{ url_for('static', filename='gallery/' + item.filename) }}" target="_blank">
|
||||||
|
<img src="{{ url_for('static', filename='gallery/' + item.filename) }}" alt="{{ item.title }}" loading="lazy">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="gallery-info">
|
||||||
|
<div class="gallery-title">{{ item.title }}</div>
|
||||||
|
<div class="gallery-location"><b>Локация:</b> {{ item.location }} ({{ item.year }})</div>
|
||||||
|
<div class="gallery-desc">{{ item.description }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin-top: 20px; font-size: 12px; color: #555555;">
|
||||||
|
* Нажмите на изображение для просмотра в полном разрешении. Все снимки защищены архивным статусом Клуба.
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
81
site/templates/index.html
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>ДОБРО ПОЖАЛОВАТЬ В КЛУБ ЛЮБИТЕЛЕЙ ЖПЧХ</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<b>Клуб Любителей ЖПЧХ (ЖопоПоЧесХа)</b> — независимое международное фотографическое объединение,
|
||||||
|
зародившееся жарким летом 2012 года на берегах великой русской реки Волги (город Симбирск / Ульяновск).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<pre class="ascii-box">
|
||||||
|
========================================================================================
|
||||||
|
ФИЛОСОФИЯ КОНТРАСТА ЖПЧХ:
|
||||||
|
[Задний план]: Пафос, официальные лица, свадебные клятвы, выпускные ленты, банкеты.
|
||||||
|
[Передний план]: Человек спиной к камере. Честный, уверенный почес своей задницы.
|
||||||
|
[Итог]: Разрушение фальшивого глянца, возвращение к искренней витальности бытия.
|
||||||
|
========================================================================================
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
В эпоху всеобщей фальшивости постановочных кадров, искусственных улыбок и пластиковых фильтров социальных сетей,
|
||||||
|
основатели Клуба (<b>MatrixHasYou</b> и <b>PitKa</b>) создали новое направление репортажной стри-фотографии.
|
||||||
|
Суть методики — встревать в любые важные фотокадры в любом месте: от протокольных свадебных кортежей на Венце
|
||||||
|
до торжественных юбилеев, выпускных у Мемцентра и корпоративных банкетов.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>ИЗБРАННЫЕ ЭКСПОНАТЫ ГАЛЕРЕИ (ГРАФИЧЕСКИЙ АРХИВ)</h2>
|
||||||
|
<div class="gallery-grid">
|
||||||
|
{% for item in gallery_sample %}
|
||||||
|
<div class="gallery-card">
|
||||||
|
<div class="gallery-img-wrap">
|
||||||
|
<img src="{{ url_for('static', filename='gallery/' + item.filename) }}" alt="{{ item.title }}">
|
||||||
|
</div>
|
||||||
|
<div class="gallery-info">
|
||||||
|
<div class="gallery-title">{{ item.title }}</div>
|
||||||
|
<div class="gallery-location">{{ item.location }} ({{ item.year }})</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<p style="margin-top: 10px; text-align: right;">
|
||||||
|
<a href="{{ url_for('gallery') }}"><b>Перейти ко всем 16 экспонатам в Фотогалерее →</b></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>СВЕЖИЕ ОБСУЖДЕНИЯ НА ФОРУМЕ КЛУБА</h2>
|
||||||
|
<table class="table-forum">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Тема</th>
|
||||||
|
<th style="width: 140px;">Автор</th>
|
||||||
|
<th style="width: 80px; text-align: center;">Ответов</th>
|
||||||
|
<th style="width: 80px; text-align: center;">Просмотров</th>
|
||||||
|
<th style="width: 160px;">Дата создания</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in latest_topics %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for('topic', topic_id=t.id) }}"><b>{{ t.title }}</b></a></td>
|
||||||
|
<td>{{ t.username }}</td>
|
||||||
|
<td style="text-align: center;">{{ t.posts_count }}</td>
|
||||||
|
<td style="text-align: center;">{{ t.views }}</td>
|
||||||
|
<td>{{ t.created_at }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p style="text-align: right;">
|
||||||
|
<a href="{{ url_for('forum') }}"><b>Смотреть все темы на Форуме →</b></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>СТАТИСТИКА СООБЩЕСТВА</h2>
|
||||||
|
<table class="table-stats">
|
||||||
|
<tr>
|
||||||
|
<td><b>Участников:</b> {{ stats.users }}</td>
|
||||||
|
<td><b>В веток на форуме:</b> {{ stats.topics }}</td>
|
||||||
|
<td><b>Сообщений:</b> {{ stats.posts }}</td>
|
||||||
|
<td><b>Работ в архиве:</b> {{ stats.photos }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
26
site/templates/login.html
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Вход в Клуб Любителей ЖПЧХ{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>АВТОРИЗАЦИЯ УЧАСТНИКА</h1>
|
||||||
|
|
||||||
|
<form action="{{ url_for('login', next=request.args.get('next')) }}" method="POST" style="max-width: 400px; margin-top: 16px;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Имя пользователя (Никнейм):</label>
|
||||||
|
<input type="text" name="username" id="username" required autofocus>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Пароль:</label>
|
||||||
|
<input type="password" name="password" id="password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Войти</button>
|
||||||
|
<a href="{{ url_for('register') }}" class="btn" style="margin-left: 8px;">Регистрация</a>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p style="margin-top: 20px; font-size: 12px; color: #666666;">
|
||||||
|
* Если вы еще не состоите в Клубе, пройдите <a href="{{ url_for('register') }}">простую регистрацию</a>.
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
48
site/templates/manifest.html
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Манифест движения ЖПЧХ{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>МАНИФЕСТ ДВИЖЕНИЯ ЖПЧХ (СИМБИРСК, 2012)</h1>
|
||||||
|
|
||||||
|
<pre class="ascii-box">
|
||||||
|
«В мире, где каждый пытается казаться тем, кем не является,
|
||||||
|
честный почес задницы на фоне пафосного события —
|
||||||
|
единственный оплот подлинного реализма».
|
||||||
|
— MatrixHasYou & PitKa, лето 2012 г.
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<h2>1. ИСТОРИЧЕСКИЕ КОРНИ</h2>
|
||||||
|
<p>
|
||||||
|
Движение ЖПЧХ зародилось в городе Симбирске (Ульяновск) на крутом волжском венце.
|
||||||
|
Наблюдая за бесконечными одинаковыми свадебными фотосессиями у парапета с видом на Императорский мост,
|
||||||
|
где женихи в синтетических костюмах изображали античных героев, а невесты застывали в неестественных позах,
|
||||||
|
основатели почувствовали острую потребность в деконструкции жанра.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Так родился первый исторический кадр: человек в куртке стоит спиной к объективу, пока на заднем плане
|
||||||
|
разворачивается свадебный кортеж, и уверенным движением производит почес.
|
||||||
|
Контраст оказался настолько мощным и визуально убедительным, что положил начало целому направлению.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. ТРИ ЗАКОНА КОМПОЗИЦИИ ЖПЧХ</h2>
|
||||||
|
<ol style="margin-left: 20px; line-height: 1.8;">
|
||||||
|
<li><b>Закон Первого Плана:</b> Почес должен доминировать в ближней зоне (от 0.5 до 2 метров от объектива), задавая масштаб кадра.</li>
|
||||||
|
<li><b>Закон Контраста События:</b> Чем торжественнее и напыщеннее задний план (государственные приемы, выпускные, свадьбы, награждения), тем выше художественная ценность снимка.</li>
|
||||||
|
<li><b>Закон Достоверности Жеста:</b> Рука обязана плотно фиксировать ткань, создавая натяжение складок денима. Никакой вялой бутафории — только честная кинематика почеса!</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>3. ЭТИЧЕСКИЙ КОДЕКС КЛУБА</h2>
|
||||||
|
<p>
|
||||||
|
Клуб Любителей ЖПЧХ категорически отвергает вульгарность и хулиганство:
|
||||||
|
</p>
|
||||||
|
<ul style="margin-left: 20px; line-height: 1.8;">
|
||||||
|
<li><b>Строго поверх одежды:</b> Почес осуществляется исключительно через штаны, джинсы или шорты. Обнажение запрещено уставом.</li>
|
||||||
|
<li><b>Невидимость для участников:</b> Настоящий мастер ЖПЧХ не срывает событие. Торжество идет своим чередом, люди радуются и празднуют, не подозревая, что их пафос только что был художественно заземлен.</li>
|
||||||
|
<li><b>Графическая анонимизация:</b> В открытых клубных альбомах лица участников переводятся в гравюрный и карандашный штрих. Герои скромны и служат искусству.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p style="margin-top: 20px;">
|
||||||
|
Присоединиться к движению может любой желающий. Достаточно зарегистрироваться на форуме и представить свой первый отчет!
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
31
site/templates/new_topic.html
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Создать новую тему{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>СОЗДАНИЕ НОВОЙ ТЕМЫ НА ФОРУМЕ</h1>
|
||||||
|
|
||||||
|
<form action="{{ url_for('new_topic') }}" method="POST" style="max-width: 700px;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="category_id">Раздел форума:</label>
|
||||||
|
<select name="category_id" id="category_id" required>
|
||||||
|
{% for cat in categories %}
|
||||||
|
<option value="{{ cat.id }}">{{ cat.title }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="title">Заголовок темы:</label>
|
||||||
|
<input type="text" name="title" id="title" required placeholder="Краткое и ёмкое название темы...">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="content">Текст сообщения:</label>
|
||||||
|
<textarea name="content" id="content" required placeholder="Опишите ситуацию, вопрос по оптике, локацию или предложите идею съемки..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Создать тему</button>
|
||||||
|
<a href="{{ url_for('forum') }}" class="btn" style="margin-left: 8px;">Отмена</a>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
32
site/templates/register.html
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Регистрация в Клубе Любителей ЖПЧХ{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>РЕГИСТРАЦИЯ НОВОГО УЧАСТНИКА</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Регистрация открыта для всех ценителей реализма и уличной контрастной фотографии.
|
||||||
|
После регистрации вы сможете создавать темы на форуме и отвечать в дискуссиях.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form action="{{ url_for('register') }}" method="POST" style="max-width: 400px; margin-top: 16px;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Желаемый никнейм (от 3 символов):</label>
|
||||||
|
<input type="text" name="username" id="username" required minlength="3" autofocus>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Пароль:</label>
|
||||||
|
<input type="password" name="password" id="password" required minlength="4">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password2">Повторите пароль:</label>
|
||||||
|
<input type="password" name="password2" id="password2" required minlength="4">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Зарегистрироваться</button>
|
||||||
|
<a href="{{ url_for('login') }}" class="btn" style="margin-left: 8px;">Уже есть аккаунт</a>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
47
site/templates/topic.html
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ topic.title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div style="font-size: 12px; margin-bottom: 10px;">
|
||||||
|
<a href="{{ url_for('forum') }}">← Вернуться в список тем</a> /
|
||||||
|
<b>{{ topic.cat_title }}</b>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>{{ topic.title }}</h1>
|
||||||
|
<div style="font-size: 11px; color: #666666; margin-bottom: 16px;">
|
||||||
|
Автор темы: <b>{{ topic.username }}</b> | Создано: {{ topic.created_at }} | Просмотров: {{ topic.views }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="posts-list">
|
||||||
|
{% for post in posts %}
|
||||||
|
<div class="post-box" id="post-{{ post.id }}">
|
||||||
|
<div class="post-author">
|
||||||
|
<div class="name">{{ post.username }}</div>
|
||||||
|
<div class="role">{{ post.role }}</div>
|
||||||
|
<div style="font-size: 11px; color: #777777;">Сообщений: {{ post.user_posts_count }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="post-content">
|
||||||
|
<div class="post-text">{{ post.content }}</div>
|
||||||
|
<div class="post-meta">Опубликовано: {{ post.created_at }} #{{ loop.index }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>ОТВЕТИТЬ В ТЕМУ</h2>
|
||||||
|
{% if current_user %}
|
||||||
|
<form action="{{ url_for('reply_topic', topic_id=topic.id) }}" method="POST">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="content">Ваше сообщение (от имени <b>{{ current_user.username }}</b>):</label>
|
||||||
|
<textarea name="content" id="content" required placeholder="Поделитесь опытом, задайте вопрос или прокомментируйте ракурс..."></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Опубликовать ответ</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p>
|
||||||
|
Чтобы написать ответ в эту тему, пожалуйста, <a href="{{ url_for('login', next=request.url) }}"><b>войдите</b></a>
|
||||||
|
или <a href="{{ url_for('register') }}"><b>зарегистрируйтесь</b></a>.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||