Merge pull request 'production' (#5) from production into main

Reviewed-on: #5
This commit is contained in:
vladislav 2026-08-31 18:19:47 +00:00
commit 588793f66c
6 changed files with 807 additions and 367 deletions

View File

@ -5,7 +5,7 @@ from datetime import timedelta
ctx = ssl.create_default_context()
ctx.load_verify_locations(cafile="certs.pem")
CHUNK_TTL = 3600
PROD = os.getenv("PROD", "True").lower() != "false"
SOCIALS = {'vk', 'tg', 'max', 'fb', 'inst'}
ATOKEN = os.getenv("ATOKEN")

View File

@ -72,6 +72,7 @@
"import asyncio\n",
"from fastapi.middleware.cors import CORSMiddleware\n",
"from fastapi.responses import HTMLResponse, StreamingResponse, RedirectResponse\n",
"from starlette.datastructures import UploadFile as StarletteUploadFile\n",
"from fastapi.staticfiles import StaticFiles\n",
"import mimetypes\n",
"from urllib.parse import quote\n",
@ -3022,8 +3023,6 @@
"metadata": {},
"outputs": [],
"source": [
"\n",
"\n",
"@app.get(\"/form/exhibition/{exhibition_id}\", response_class=HTMLResponse)\n",
"async def form_exhibition(\n",
" request: Request,\n",
@ -3054,11 +3053,11 @@
" try:\n",
" avatar_id = await save_to_gridfs(avatar, 'users')\n",
" await users_collection.update_one({'_id': ObjectId(current_user.get('_id'))}, {'$set': {'img': avatar_id}})\n",
" current_user.update({'img': avatar_id})\n",
" background_tasks.add_task(logo_user_task, bson_clear(current_user))\n",
" return {'ok': True}\n",
" except Exception as e:\n",
" raise HTTPException(status_code=500, detail=str(e))\n",
"\n"
" raise HTTPException(status_code=500, detail=str(e))\n"
],
"display_name": ".venv"
},
@ -3308,16 +3307,98 @@
"metadata": {},
"outputs": [],
"source": [
"@app.post(\"/api/posts/media/chunk\")\n",
"async def upload_chunk(\n",
" chunk: UploadFile = File(...),\n",
" file_id: str = Form(...),\n",
" chunk_index: int = Form(...),\n",
" total_chunks: int = Form(...),\n",
" filename: str = Form(...),\n",
" content_type: str = Form(...),\n",
" current_user: Dict = Security(get_current_user, scopes=[\"create:posts\"])\n",
"):\n",
" try:\n",
" data = await chunk.read()\n",
" b64_data = base64.b64encode(data).decode(\"ascii\")\n",
"\n",
" key = f\"upload:{file_id}\"\n",
"\n",
" # Store meta only once\n",
" meta = await redis_client.hgetall(key)\n",
" if not meta:\n",
" await redis_client.hset(key, mapping={\n",
" \"filename\": filename,\n",
" \"content_type\": content_type,\n",
" \"total_chunks\": str(total_chunks),\n",
" \"user_id\": str(current_user.get(\"id\", \"\")),\n",
" })\n",
" await redis_client.expire(key, CHUNK_TTL)\n",
"\n",
" # Store the chunk\n",
" await redis_client.hset(key, f\"chunk:{chunk_index}\", b64_data)\n",
" await redis_client.expire(key, CHUNK_TTL)\n",
"\n",
" return {\"ok\": True, \"received\": chunk_index}\n",
" except Exception as e:\n",
" raise HTTPException(status_code=500, detail=str(e))\n",
"\n",
"\n",
"@app.post(\"/api/posts/media/finalize\")\n",
"async def finalize_upload(\n",
" payload: dict,\n",
" current_user: Dict = Security(get_current_user, scopes=[\"create:posts\"])\n",
"):\n",
" file_id = payload.get(\"file_id\")\n",
" if not file_id:\n",
" raise HTTPException(status_code=400, detail=\"file_id required\")\n",
"\n",
" key = f\"upload:{file_id}\"\n",
" meta = await redis_client.hgetall(key)\n",
"\n",
" if not meta:\n",
" raise HTTPException(status_code=400, detail=\"Unknown or expired file_id\")\n",
"\n",
" total_chunks = int(meta[\"total_chunks\"])\n",
" filename = meta[\"filename\"]\n",
" content_type = meta[\"content_type\"]\n",
"\n",
" # Collect all chunks\n",
" chunks = []\n",
" for i in range(total_chunks):\n",
" b64 = await redis_client.hget(key, f\"chunk:{i}\")\n",
" if b64 is None:\n",
" raise HTTPException(status_code=400, detail=f\"Missing chunk {i}\")\n",
" chunks.append(base64.b64decode(b64))\n",
"\n",
" full_content = b\"\".join(chunks)\n",
"\n",
" temp_file = StarletteUploadFile(\n",
" file=BytesIO(full_content),\n",
" filename=filename,\n",
" headers={\"content-type\": content_type}\n",
" )\n",
"\n",
" try:\n",
" saved_name = await save_to_gridfs(temp_file, \"posts\")\n",
" return {\"post_medias\": [saved_name]}\n",
" finally:\n",
" # Cleanup\n",
" await redis_client.delete(key)\n",
"\n",
"\n",
"# Optional fallback (non-chunked)\n",
"@app.post(\"/api/posts/media\", response_model=dict)\n",
"async def post_media(\n",
" medias: Optional[List[UploadFile]] = File(None),\n",
" current_user: Dict = Security(get_current_user, scopes=[\"create:posts\"])\n",
"):\n",
" try:\n",
" post_medias = await asyncio.gather(*[save_to_gridfs(media, 'posts') for media in medias])\n",
" return {'post_medias': post_medias}\n",
" post_medias = await asyncio.gather(\n",
" *[save_to_gridfs(media, \"posts\") for media in medias or []]\n",
" )\n",
" return {\"post_medias\": post_medias}\n",
" except Exception as e:\n",
" raise HTTPException(status_code=500, detail=str(e))\n"
" raise HTTPException(status_code=500, detail=str(e))"
],
"display_name": ".venv"
},

92
main.py
View File

@ -11,6 +11,7 @@ import io
import asyncio
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, StreamingResponse, RedirectResponse
from starlette.datastructures import UploadFile as StarletteUploadFile
from fastapi.staticfiles import StaticFiles
import mimetypes
from urllib.parse import quote
@ -2238,8 +2239,6 @@ async def form_exhibition(
request=request, name="form/exhibition.html", context={"id": 1, "user": user}
)
@app.get("/form/exhibition/{exhibition_id}", response_class=HTMLResponse)
async def form_exhibition(
request: Request,
@ -2261,13 +2260,13 @@ async def avatar_change(
try:
avatar_id = await save_to_gridfs(avatar, 'users')
await users_collection.update_one({'_id': ObjectId(current_user.get('_id'))}, {'$set': {'img': avatar_id}})
current_user.update({'img': avatar_id})
background_tasks.add_task(logo_user_task, bson_clear(current_user))
return {'ok': True}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.put("/api/user/form", response_model=dict)
async def user_form(
request: Request,
@ -2466,18 +2465,99 @@ async def put_painting(
return doc_out
@app.post("/api/posts/media/chunk")
async def upload_chunk(
chunk: UploadFile = File(...),
file_id: str = Form(...),
chunk_index: int = Form(...),
total_chunks: int = Form(...),
filename: str = Form(...),
content_type: str = Form(...),
current_user: Dict = Security(get_current_user, scopes=["create:posts"])
):
try:
data = await chunk.read()
b64_data = base64.b64encode(data).decode("ascii")
key = f"upload:{file_id}"
# Store meta only once
meta = await redis_client.hgetall(key)
if not meta:
await redis_client.hset(key, mapping={
"filename": filename,
"content_type": content_type,
"total_chunks": str(total_chunks),
"user_id": str(current_user.get("id", "")),
})
await redis_client.expire(key, CHUNK_TTL)
# Store the chunk
await redis_client.hset(key, f"chunk:{chunk_index}", b64_data)
await redis_client.expire(key, CHUNK_TTL)
return {"ok": True, "received": chunk_index}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/posts/media/finalize")
async def finalize_upload(
payload: dict,
current_user: Dict = Security(get_current_user, scopes=["create:posts"])
):
file_id = payload.get("file_id")
if not file_id:
raise HTTPException(status_code=400, detail="file_id required")
key = f"upload:{file_id}"
meta = await redis_client.hgetall(key)
if not meta:
raise HTTPException(status_code=400, detail="Unknown or expired file_id")
total_chunks = int(meta["total_chunks"])
filename = meta["filename"]
content_type = meta["content_type"]
# Collect all chunks
chunks = []
for i in range(total_chunks):
b64 = await redis_client.hget(key, f"chunk:{i}")
if b64 is None:
raise HTTPException(status_code=400, detail=f"Missing chunk {i}")
chunks.append(base64.b64decode(b64))
full_content = b"".join(chunks)
temp_file = StarletteUploadFile(
file=BytesIO(full_content),
filename=filename,
headers={"content-type": content_type}
)
try:
saved_name = await save_to_gridfs(temp_file, "posts")
return {"post_medias": [saved_name]}
finally:
# Cleanup
await redis_client.delete(key)
# Optional fallback (non-chunked)
@app.post("/api/posts/media", response_model=dict)
async def post_media(
medias: Optional[List[UploadFile]] = File(None),
current_user: Dict = Security(get_current_user, scopes=["create:posts"])
):
try:
post_medias = await asyncio.gather(*[save_to_gridfs(media, 'posts') for media in medias])
return {'post_medias': post_medias}
post_medias = await asyncio.gather(
*[save_to_gridfs(media, "posts") for media in medias or []]
)
return {"post_medias": post_medias}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/news", response_model=dict)
async def post_news(
background_tasks: BackgroundTasks,

View File

@ -51,28 +51,27 @@
<div class="preview" id="preview"></div>
</div>
</form>
<script defer>
const post = JSON.parse({{post|tojson}})
const postId = post._id
<script>
const post = JSON.parse({{ post|tojson }});
const postId = post._id;
const fileInput = document.getElementById('fileInput');
const preview = document.getElementById('preview');
const createBtn = document.getElementById('createBtn');
const form = document.getElementById('postForm');
// Fill form fields
document.getElementById('author').value = post.author;
if (document.getElementById('grade')){
document.getElementById('grade').value = post.grade;
if (document.getElementById('grade')) {
document.getElementById('grade').value = post.grade;
const inputEl = document.getElementById('grade');
let list = inputEl.dataset.list;
list = JSON.parse(list.replace(/'/g, '"'));
let list = inputEl.dataset.list;
list = JSON.parse(list.replace(/'/g, '"'));
list.push(post.grade);
list = [...new Set(list)];
inputEl.dataset.list = JSON.stringify(list);
inputEl.dataset.value = post.grade;
}
document.getElementById('title').value = post.title;
document.getElementById('city').value = post.city;
document.getElementById('size').value = post.size;
@ -80,39 +79,110 @@
document.getElementById('genre').value = post.genre;
document.getElementById('price').value = post.price;
document.getElementById('description').value = post.description;
let filesOrdered = [];
console.log(post.medias_original)
const existingMedias = (post?.medias_original ?? []).map((media) => ({ url: `/b/posts/${media}`, name: `${media}`, isExisting: true, type: "image",}));
let isSubmitting = false;
async function uploadMediaFiles(files) {
if (!files || files.length === 0) return [];
const existingMedias = (post?.medias_original ?? []).map((media) => ({
url: `/b/posts/${media}`,
name: `${media}`,
isExisting: true,
type: "image",
}));
const formData = new FormData();
for (let file of files) {
formData.append('medias', file);
const CHUNK_SIZE = 2 * 1024 * 1024; // 2 MB
// ========== Chunked upload ==========
async function uploadFileInChunks(file, onProgress) {
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const fileId = crypto.randomUUID();
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('chunk', chunk, file.name);
formData.append('file_id', fileId);
formData.append('chunk_index', i);
formData.append('total_chunks', totalChunks);
formData.append('filename', file.name);
formData.append('content_type', file.type || 'application/octet-stream');
const res = await fetch('/api/posts/media/chunk', {
method: 'POST',
body: formData
});
if (!res.ok) throw new Error(`Chunk ${i} failed`);
onProgress((i + 1) / totalChunks);
}
const res = await fetch('/api/posts/media', {
const finalRes = await fetch('/api/posts/media/finalize', {
method: 'POST',
body: formData
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_id: fileId })
});
if (!res.ok) throw new Error('Ошибка загрузки медиа');
return await res.json();
if (!finalRes.ok) throw new Error('Finalize failed');
const data = await finalRes.json();
return data.post_medias[0];
}
function loadExistingMedias() {
existingMedias.forEach(media => {
filesOrdered.push({
name: media.name,
url: media.url,
isExisting: true,
type: media.type
});
});
renderPreviews();
function createProgressBar(fileName) {
const wrapper = document.createElement('div');
wrapper.className = 'upload-item';
wrapper.innerHTML = `
<div class="upload-name">${fileName}</div>
<div class="progress-bar"><div class="progress-fill"></div></div>
<div class="progress-text">0%</div>
`;
return wrapper;
}
async function uploadMediaFiles(files) {
if (!files || files.length === 0) return { post_medias: [] };
let progressContainer = document.getElementById('uploadProgress');
if (!progressContainer) {
progressContainer = document.createElement('div');
progressContainer.id = 'uploadProgress';
progressContainer.className = 'upload-progress-container';
preview.parentNode.insertBefore(progressContainer, preview);
}
progressContainer.innerHTML = '';
const results = [];
for (const file of files) {
const progressEl = createProgressBar(file.name);
progressContainer.appendChild(progressEl);
const fill = progressEl.querySelector('.progress-fill');
const text = progressEl.querySelector('.progress-text');
try {
const name = await uploadFileInChunks(file, (p) => {
const pct = Math.round(p * 100);
fill.style.width = pct + '%';
text.textContent = pct + '%';
});
results.push(name);
progressEl.classList.add('done');
text.textContent = '✓';
} catch (err) {
progressEl.classList.add('error');
text.textContent = 'Ошибка';
throw err;
}
}
setTimeout(() => progressContainer.remove(), 1200);
return { post_medias: results };
}
// ========== Preview helpers ==========
function createObjectURLSafe(item) {
if (item.isExisting || !item.file) return item.url;
if (!item.previewURL) {
@ -130,7 +200,6 @@
function renderPreviews() {
preview.innerHTML = '';
filesOrdered.forEach((item, idx) => {
const div = document.createElement('div');
div.className = 'item';
@ -142,13 +211,11 @@
title.textContent = idx === 0 ? 'Титульная' : `${idx + 1}`;
const isImage = item.type && item.type.startsWith('image');
const mediaEl = document.createElement(isImage ? 'img' : 'video');
mediaEl.className = 'thumb';
mediaEl.src = createObjectURLSafe(item);
if (!isImage) mediaEl.controls = true;
// Контролы
const controls = document.createElement('div');
controls.className = 'controls';
@ -159,13 +226,12 @@
makeFirstBtn.addEventListener('click', () => moveFile(idx, 0));
const leftBtn = document.createElement('button');
leftBtn.textContent = idx;
leftBtn.textContent = '←';
leftBtn.disabled = idx === 0;
leftBtn.addEventListener('click', () => moveFile(idx, idx - 1));
const rightBtn = document.createElement('button');
rightBtn.textContent = idx + 2;
rightBtn.textContent = '→';
rightBtn.disabled = idx === filesOrdered.length - 1;
rightBtn.addEventListener('click', () => moveFile(idx, idx + 1));
@ -173,7 +239,9 @@
removeBtn.textContent = '✖';
removeBtn.addEventListener('click', () => removeFile(idx));
controls.append(idx>0?makeFirstBtn:'', idx>0?leftBtn:'', idx < filesOrdered.length - 1?rightBtn:'', removeBtn);
if (idx > 0) controls.append(makeFirstBtn, leftBtn);
if (idx < filesOrdered.length - 1) controls.append(rightBtn);
controls.append(removeBtn);
div.append(mediaEl, title, controls);
preview.appendChild(div);
@ -183,19 +251,15 @@
e.dataTransfer.setData('text/plain', idx.toString());
div.classList.add('dragging');
});
div.addEventListener('dragend', () => {
div.classList.remove('dragging');
document.querySelectorAll('.item').forEach(el => el.classList.remove('drag-over'));
});
div.addEventListener('dragover', (e) => {
e.preventDefault();
div.classList.add('drag-over');
});
div.addEventListener('dragleave', () => div.classList.remove('drag-over'));
div.addEventListener('drop', (e) => {
e.preventDefault();
div.classList.remove('drag-over');
@ -220,86 +284,105 @@
renderPreviews();
}
function loadExistingMedias() {
existingMedias.forEach(media => {
filesOrdered.push({
name: media.name,
url: media.url,
isExisting: true,
type: media.type
});
});
renderPreviews();
}
// ========== File select → upload → show blob only after success ==========
fileInput.addEventListener('change', async (e) => {
const newFiles = Array.from(e.target.files);
if (newFiles.length === 0) return;
createBtn.disabled = true;
try {
const uploadedNames = await uploadMediaFiles(newFiles);
const uploaded = await uploadMediaFiles(newFiles);
newFiles.forEach((file, i) => {
filesOrdered.push({
name: uploadedNames['post_medias'][i],
url: `/b/posts/${uploadedNames['post_medias'][i]}`,
name: uploaded.post_medias[i],
url: `/b/posts/${uploaded.post_medias[i]}`,
file: file,
type: file.type,
isExisting: false
});
});
renderPreviews();
renderPreviews(); // blob appears only here
} catch (err) {
console.error('Ошибка загрузки фото:', err);
alert('Не удалось загрузить фото. Попробуйте ещё раз.');
} finally {
createBtn.disabled = false;
fileInput.value = '';
}
fileInput.value = '';
});
window.addEventListener('load', () => {
loadExistingMedias();
});
window.addEventListener('beforeunload', () => {
filesOrdered.forEach(revokeObjectURLSafe);
});
// === Отправка формы ===
// ========== Save button – only one request ==========
document.addEventListener('DOMContentLoaded', () => {
const author = '{{user.username}}';
const grade = '{{user.grade}}';
const form = document.getElementById('postForm');
const createBtn = document.getElementById('createBtn');
const author = '{{ user.username }}';
const grade = '{{ user.grade }}';
createBtn.addEventListener('click', async (e) => {
e.preventDefault();
if (isSubmitting) return;
// ... (проверка обязательных полей остаётся без изменений)
const requiredFields = [...document.querySelectorAll('input[required]'), ...document.querySelectorAll('textarea[required]')];
const requiredFields = [
...document.querySelectorAll('input[required]'),
...document.querySelectorAll('textarea[required]')
];
let hasError = false;
requiredFields.forEach(field => {
const group = field.closest('.floating-group');
const isEmpty = field.type === 'file'
? !field.files || field.files.length === 0
const isEmpty = field.type === 'file'
? !field.files || field.files.length === 0
: !field.value.trim();
if (isEmpty) {
group.classList.add('error');
group?.classList.add('error');
hasError = true;
} else {
group.classList.remove('error');
group?.classList.remove('error');
}
});
if (hasError) return;
// === Подготовка данных ===
if (filesOrdered.length === 0) {
showToast({ message: 'Поле фото должно быть заполнено', duration: 2500 });
return;
}
// Lock
isSubmitting = true;
createBtn.disabled = true;
createBtn.classList.add('disabled');
const data = new FormData(form);
if (document.querySelector('#author').value === '') {
if (document.querySelector('#author')?.value === '') {
data.set('author', author);
}
{% if 'user' in user.get('roles') and user.get('grade') is not none %}
data.set('grade', grade);
{% endif %}
filesOrdered.forEach(f => data.append('post_medias', f.name));
try {
const res = await fetch(`/api/post/${postId}`, {
method: 'PUT',
body: data
const res = await fetch(`/api/post/${postId}`, {
method: 'PUT',
body: data
});
if (res.ok) {
@ -307,21 +390,31 @@
const next = params.get('next') || '/profile/posts';
window.location.href = next;
} else {
showToast({message: 'Ошибка при сохранение поста', status: "error", duration:2500});
showToast({ message: 'Ошибка при сохранении поста', status: 'error', duration: 2500 });
isSubmitting = false;
createBtn.disabled = false;
createBtn.classList.remove('disabled');
}
} catch (err) {
console.error(err);
showToast({message: 'Ошибка соединения', status: "error", duration:2500});
showToast({ message: 'Ошибка соединения', status: 'error', duration: 2500 });
isSubmitting = false;
createBtn.disabled = false;
createBtn.classList.remove('disabled');
}
});
// Удаление ошибки при вводе
document.querySelectorAll('.floating-group input, .floating-group textarea').forEach(input => {
input.addEventListener('input', () => {
input.closest('.floating-group').classList.remove('error');
input.closest('.floating-group')?.classList.remove('error');
});
});
});
window.addEventListener('load', loadExistingMedias);
window.addEventListener('beforeunload', () => {
filesOrdered.forEach(revokeObjectURLSafe);
});
</script>
@ -329,6 +422,62 @@
{% macro style() -%}
<style>
.upload-progress-container {
margin: 12px 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.upload-item {
background: #f5f5f5;
border-radius: 8px;
padding: 10px 14px;
font-size: 13px;
}
.upload-name {
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.progress-bar {
height: 6px;
background: #e0e0e0;
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #4f46e5, #7c3aed);
border-radius: 3px;
transition: width 0.25s ease;
}
.progress-text {
margin-top: 4px;
font-size: 12px;
color: #666;
}
.upload-item.done .progress-fill {
background: #16a34a;
}
.upload-item.error .progress-fill {
background: #dc2626;
}
#createBtn.disabled,
#createBtn:disabled {
opacity: 0.6;
pointer-events: none;
cursor: not-allowed;
}
.preview {
display: flex;
gap: 8px;

View File

@ -124,7 +124,350 @@
<span class="c_som9s">Опубликовать</span>
</button>
</div>
<script async>
<div class="c_rtopc">
<label class="c_aq9et ba" for="fileInput">
<span class="c_som9s">Добавить фото</span>
</label>
<input class="c_1gai0" id="fileInput" type="file" multiple accept="image/*" style="display: none;"/>
<div class="preview" id="preview"></div>
</div>
</form>
<script>
const fileInput = document.getElementById('fileInput');
const preview = document.getElementById('preview');
const createBtn = document.getElementById('createBtn');
const form = document.getElementById('postForm');
let filesOrdered = [];
const existingMedias = [];
let isSubmitting = false;
const CHUNK_SIZE = 2 * 1024 * 1024; // 2 MB
// ========== Chunked upload ==========
async function uploadFileInChunks(file, onProgress) {
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const fileId = crypto.randomUUID();
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('chunk', chunk, file.name);
formData.append('file_id', fileId);
formData.append('chunk_index', i);
formData.append('total_chunks', totalChunks);
formData.append('filename', file.name);
formData.append('content_type', file.type || 'application/octet-stream');
const res = await fetch('/api/posts/media/chunk', {
method: 'POST',
body: formData
});
if (!res.ok) throw new Error(`Chunk ${i} failed`);
onProgress((i + 1) / totalChunks);
}
const finalRes = await fetch('/api/posts/media/finalize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_id: fileId })
});
if (!finalRes.ok) throw new Error('Finalize failed');
const data = await finalRes.json();
return data.post_medias[0];
}
function createProgressBar(fileName) {
const wrapper = document.createElement('div');
wrapper.className = 'upload-item';
wrapper.innerHTML = `
<div class="upload-name">${fileName}</div>
<div class="progress-bar"><div class="progress-fill"></div></div>
<div class="progress-text">0%</div>
`;
return wrapper;
}
async function uploadMediaFiles(files) {
if (!files || files.length === 0) return { post_medias: [] };
let progressContainer = document.getElementById('uploadProgress');
if (!progressContainer) {
progressContainer = document.createElement('div');
progressContainer.id = 'uploadProgress';
progressContainer.className = 'upload-progress-container';
preview.parentNode.insertBefore(progressContainer, preview);
}
progressContainer.innerHTML = '';
const results = [];
for (const file of files) {
const progressEl = createProgressBar(file.name);
progressContainer.appendChild(progressEl);
const fill = progressEl.querySelector('.progress-fill');
const text = progressEl.querySelector('.progress-text');
try {
const name = await uploadFileInChunks(file, (p) => {
const pct = Math.round(p * 100);
fill.style.width = pct + '%';
text.textContent = pct + '%';
});
results.push(name);
progressEl.classList.add('done');
text.textContent = '✓';
} catch (err) {
progressEl.classList.add('error');
text.textContent = 'Ошибка';
throw err;
}
}
setTimeout(() => progressContainer.remove(), 1200);
return { post_medias: results };
}
// ========== Preview helpers ==========
function createObjectURLSafe(item) {
if (item.isExisting || !item.file) return item.url;
if (!item.previewURL) item.previewURL = URL.createObjectURL(item.file);
return item.previewURL;
}
function revokeObjectURLSafe(item) {
if (item.previewURL) {
URL.revokeObjectURL(item.previewURL);
item.previewURL = null;
}
}
function renderPreviews() {
preview.innerHTML = '';
filesOrdered.forEach((item, idx) => {
const div = document.createElement('div');
div.className = 'item';
div.draggable = true;
div.dataset.index = idx;
const title = document.createElement('div');
title.className = 'title';
title.textContent = idx === 0 ? 'Титульная' : `${idx + 1}`;
const isImage = item.type && item.type.startsWith('image');
const mediaEl = document.createElement(isImage ? 'img' : 'video');
mediaEl.className = 'thumb';
mediaEl.src = createObjectURLSafe(item);
if (!isImage) mediaEl.controls = true;
const controls = document.createElement('div');
controls.className = 'controls';
const makeFirstBtn = document.createElement('button');
makeFirstBtn.textContent = 'Титульная';
makeFirstBtn.title = 'Сделать титульной';
makeFirstBtn.disabled = idx === 0;
makeFirstBtn.addEventListener('click', () => moveFile(idx, 0));
const leftBtn = document.createElement('button');
leftBtn.textContent = '←';
leftBtn.disabled = idx === 0;
leftBtn.addEventListener('click', () => moveFile(idx, idx - 1));
const rightBtn = document.createElement('button');
rightBtn.textContent = '→';
rightBtn.disabled = idx === filesOrdered.length - 1;
rightBtn.addEventListener('click', () => moveFile(idx, idx + 1));
const removeBtn = document.createElement('button');
removeBtn.textContent = '✖';
removeBtn.addEventListener('click', () => removeFile(idx));
if (idx > 0) controls.append(makeFirstBtn, leftBtn);
if (idx < filesOrdered.length - 1) controls.append(rightBtn);
controls.append(removeBtn);
div.append(mediaEl, title, controls);
preview.appendChild(div);
// Drag & Drop
div.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', idx.toString());
div.classList.add('dragging');
});
div.addEventListener('dragend', () => {
div.classList.remove('dragging');
document.querySelectorAll('.item').forEach(el => el.classList.remove('drag-over'));
});
div.addEventListener('dragover', (e) => {
e.preventDefault();
div.classList.add('drag-over');
});
div.addEventListener('dragleave', () => div.classList.remove('drag-over'));
div.addEventListener('drop', (e) => {
e.preventDefault();
div.classList.remove('drag-over');
const from = parseInt(e.dataTransfer.getData('text/plain'));
const to = parseInt(div.dataset.index);
if (from !== to) moveFile(from, to);
});
});
}
function moveFile(from, to) {
if (to < 0 || to >= filesOrdered.length || from === to) return;
const [moved] = filesOrdered.splice(from, 1);
filesOrdered.splice(to, 0, moved);
renderPreviews();
}
function removeFile(index) {
const item = filesOrdered[index];
revokeObjectURLSafe(item);
filesOrdered.splice(index, 1);
renderPreviews();
}
function loadExistingMedias() {
existingMedias.forEach(media => {
filesOrdered.push({
name: media.name,
url: media.url,
isExisting: true,
type: media.type
});
});
renderPreviews();
}
// ========== File select → upload → show blob only after success ==========
fileInput.addEventListener('change', async (e) => {
const newFiles = Array.from(e.target.files);
if (newFiles.length === 0) return;
createBtn.disabled = true;
try {
const uploaded = await uploadMediaFiles(newFiles);
newFiles.forEach((file, i) => {
filesOrdered.push({
name: uploaded.post_medias[i],
url: `/b/posts/${uploaded.post_medias[i]}`,
file: file,
type: file.type,
isExisting: false
});
});
renderPreviews(); // ← blob appears only here
} catch (err) {
console.error('Ошибка загрузки фото:', err);
alert('Не удалось загрузить фото. Попробуйте ещё раз.');
} finally {
createBtn.disabled = false;
fileInput.value = '';
}
});
// ========== Publish button – only one request ==========
document.addEventListener('DOMContentLoaded', () => {
const author = '{{ user.username }}';
const grade = '{{ user.grade }}';
createBtn.addEventListener('click', async (e) => {
e.preventDefault();
if (isSubmitting) return;
const requiredFields = [
...document.querySelectorAll('input[required]'),
...document.querySelectorAll('textarea[required]')
];
let hasError = false;
requiredFields.forEach(field => {
const group = field.closest('.floating-group');
const isEmpty = field.type === 'file'
? !field.files || field.files.length === 0
: !field.value.trim();
if (isEmpty) {
group?.classList.add('error');
hasError = true;
showToast({ message: field.dataset.toast, duration: 2500 });
} else {
group?.classList.remove('error');
}
});
if (hasError) return;
if (filesOrdered.length === 0) {
showToast({ message: 'Поле фото должно быть заполнено', duration: 2500 });
return;
}
// Lock
isSubmitting = true;
createBtn.disabled = true;
createBtn.classList.add('disabled');
const data = new FormData(form);
if (document.querySelector('#author')?.value === '') {
data.set('author', author);
}
{% if 'user' in user.get('roles') and user.get('grade') is not none %}
data.set('grade', grade);
{% endif %}
filesOrdered.forEach(f => data.append('post_medias', f.name));
try {
const res = await fetch('/api/post', {
method: 'POST',
body: data
});
if (res.ok) {
const params = new URLSearchParams(window.location.search);
const next = params.get('next') || '/profile/posts';
window.location.href = next;
} else {
showToast({ message: 'Ошибка при создании поста', status: 'error', duration: 2500 });
isSubmitting = false;
createBtn.disabled = false;
createBtn.classList.remove('disabled');
}
} catch (err) {
console.error(err);
showToast({ message: 'Ошибка соединения', status: 'error', duration: 2500 });
isSubmitting = false;
createBtn.disabled = false;
createBtn.classList.remove('disabled');
}
});
document.querySelectorAll('.floating-group input, .floating-group textarea').forEach(input => {
input.addEventListener('input', () => {
input.closest('.floating-group')?.classList.remove('error');
});
});
});
window.addEventListener('load', loadExistingMedias);
window.addEventListener('beforeunload', () => {
filesOrdered.forEach(revokeObjectURLSafe);
});
</script>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('genre').dataset.list=JSON.stringify([
{
@ -245,275 +588,6 @@
}
])
})
</script>
<div class="c_rtopc">
<label class="c_aq9et ba" for="fileInput">
<span class="c_som9s">Добавить фото</span>
</label>
<input class="c_1gai0" id="fileInput" type="file" multiple accept="image/*" style="display: none;"/>
<div class="preview" id="preview"></div>
</div>
</form>
<script>
const fileInput = document.getElementById('fileInput');
const preview = document.getElementById('preview');
let filesOrdered = [];
const existingMedias = [
{% if post %}
{% for media in post.medias %}
{
url: "/b/posts/{{ media }}",
name: "{{ media }}",
isExisting: true,
type: "image"
}{% if not loop.last %},{% endif %}
{% endfor %}
{% endif %}
];
async function uploadMediaFiles(files) {
if (!files || files.length === 0) return [];
const formData = new FormData();
for (let file of files) {
formData.append('medias', file);
}
const res = await fetch('/api/posts/media', {
method: 'POST',
body: formData
});
if (!res.ok) throw new Error('Ошибка загрузки медиа');
return await res.json();
}
function loadExistingMedias() {
existingMedias.forEach(media => {
filesOrdered.push({
name: media.name,
url: media.url,
isExisting: true,
type: media.type
});
});
renderPreviews();
}
function createObjectURLSafe(item) {
if (item.isExisting || !item.file) return item.url;
if (!item.previewURL) {
item.previewURL = URL.createObjectURL(item.file);
}
return item.previewURL;
}
function revokeObjectURLSafe(item) {
if (item.previewURL) {
URL.revokeObjectURL(item.previewURL);
item.previewURL = null;
}
}
function renderPreviews() {
preview.innerHTML = '';
filesOrdered.forEach((item, idx) => {
const div = document.createElement('div');
div.className = 'item';
div.draggable = true;
div.dataset.index = idx;
const title = document.createElement('div');
title.className = 'title';
title.textContent = idx === 0 ? 'Титульная' : `${idx + 1}`;
const isImage = item.type && item.type.startsWith('image');
const mediaEl = document.createElement(isImage ? 'img' : 'video');
mediaEl.className = 'thumb';
mediaEl.src = createObjectURLSafe(item);
if (!isImage) mediaEl.controls = true;
// Контролы
const controls = document.createElement('div');
controls.className = 'controls';
const makeFirstBtn = document.createElement('button');
makeFirstBtn.textContent = 'Титульная';
makeFirstBtn.title = 'Сделать титульной';
makeFirstBtn.disabled = idx === 0;
makeFirstBtn.addEventListener('click', () => moveFile(idx, 0));
const leftBtn = document.createElement('button');
leftBtn.textContent = idx + 1 - 1;
leftBtn.disabled = idx === 0;
leftBtn.addEventListener('click', () => moveFile(idx, idx - 1));
const rightBtn = document.createElement('button');
rightBtn.textContent = idx + 2;
rightBtn.disabled = idx === filesOrdered.length - 1;
rightBtn.addEventListener('click', () => moveFile(idx, idx + 1));
const removeBtn = document.createElement('button');
removeBtn.textContent = '✖';
removeBtn.addEventListener('click', () => removeFile(idx));
controls.append(idx>0?makeFirstBtn:'', idx>0?leftBtn:'', idx < filesOrdered.length - 1?rightBtn:'', removeBtn);
div.append(mediaEl, title, controls);
preview.appendChild(div);
// Drag & Drop
div.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', idx.toString());
div.classList.add('dragging');
});
div.addEventListener('dragend', () => {
div.classList.remove('dragging');
document.querySelectorAll('.item').forEach(el => el.classList.remove('drag-over'));
});
div.addEventListener('dragover', (e) => {
e.preventDefault();
div.classList.add('drag-over');
});
div.addEventListener('dragleave', () => div.classList.remove('drag-over'));
div.addEventListener('drop', (e) => {
e.preventDefault();
div.classList.remove('drag-over');
const from = parseInt(e.dataTransfer.getData('text/plain'));
const to = parseInt(div.dataset.index);
if (from !== to) moveFile(from, to);
});
});
}
function moveFile(from, to) {
if (to < 0 || to >= filesOrdered.length || from === to) return;
const [moved] = filesOrdered.splice(from, 1);
filesOrdered.splice(to, 0, moved);
renderPreviews();
}
function removeFile(index) {
const item = filesOrdered[index];
revokeObjectURLSafe(item);
filesOrdered.splice(index, 1);
renderPreviews();
}
fileInput.addEventListener('change', async (e) => {
const newFiles = Array.from(e.target.files);
if (newFiles.length === 0) return;
try {
const uploadedNames = await uploadMediaFiles(newFiles);
newFiles.forEach((file, i) => {
filesOrdered.push({
name: uploadedNames['post_medias'][i],
url: `/b/posts/${uploadedNames['post_medias'][i]}`,
file: file,
type: file.type,
isExisting: false
});
});
renderPreviews();
} catch (err) {
console.error('Ошибка загрузки фото:', err);
alert('Не удалось загрузить фото. Попробуйте ещё раз.');
}
fileInput.value = '';
});
window.addEventListener('load', () => {
loadExistingMedias();
});
window.addEventListener('beforeunload', () => {
filesOrdered.forEach(revokeObjectURLSafe);
});
document.addEventListener('DOMContentLoaded', () => {
const author = '{{user.username}}';
const grade = '{{user.grade}}';
const form = document.getElementById('postForm');
const createBtn = document.getElementById('createBtn');
createBtn.addEventListener('click', async (e) => {
e.preventDefault();
const requiredFields = [...document.querySelectorAll('input[required]'), ...document.querySelectorAll('textarea[required]')];
let hasError = false;
requiredFields.forEach(field => {
const group = field.closest('.floating-group');
const isEmpty = field.type === 'file'
? !field.files || field.files.length === 0
: !field.value.trim();
if (isEmpty) {
group.classList.add('error');
hasError = true;
showToast({message: field.dataset.toast, duration:2500});
} else {
group.classList.remove('error');
}
});
if (hasError) return;
const data = new FormData(form);
if (document.querySelector('#author').value === '') {
data.set('author', author);
}
{% if 'user' in user.get('roles') and user.get('grade') is not none %}
data.set('grade', grade);
{% endif %}
filesOrdered.forEach(f => data.append('post_medias', f.name));
if (filesOrdered.length === 0){
showToast({message: 'Поле фото должно быть заполнено', duration:2500});
return;
}
try {
const res = await fetch('/api/post', {
method: 'POST',
body: data
});
if (res.ok) {
const params = new URLSearchParams(window.location.search);
const next = params.get('next') || '/profile/posts';
window.location.href = next;
} else {
showToast({message: 'Ошибка при создании поста', status: "error", duration:2500});
}
} catch (err) {
console.error(err);
showToast({message: 'Ошибка соединения', status: "error", duration:2500});
}
});
document.querySelectorAll('.floating-group input, .floating-group textarea').forEach(input => {
input.addEventListener('input', () => {
input.closest('.floating-group').classList.remove('error');
});
});
});
</script>
{{ footer.section() }}
{% endblock %}
@ -522,6 +596,62 @@
{% block head %}
{{ header.style() }}
<style>
.upload-progress-container {
margin: 12px 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.upload-item {
background: #f5f5f5;
border-radius: 8px;
padding: 10px 14px;
font-size: 13px;
}
.upload-name {
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.progress-bar {
height: 6px;
background: #e0e0e0;
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #4f46e5, #7c3aed);
border-radius: 3px;
transition: width 0.25s ease;
}
.progress-text {
margin-top: 4px;
font-size: 12px;
color: #666;
}
.upload-item.done .progress-fill {
background: #16a34a;
}
.upload-item.error .progress-fill {
background: #dc2626;
}
#createBtn.disabled,
#createBtn:disabled {
opacity: 0.6;
pointer-events: none;
cursor: not-allowed;
}
.b_xj1br { color: #FFF; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; }
.b_dc4rx { align-items: center; align-self: stretch; background: #5F1798; border-radius: 5px; display: flex; justify-content: center; padding: 12px 0; }
.b_5rck0 { align-items: center; align-self: stretch; background: #F39318; border-radius: 5px; display: flex; justify-content: center; padding: 12px 0; }

View File

@ -352,7 +352,7 @@ def is_safe_redirect(next_url: str) -> bool:
return False
async def build_composite(data, scale: float = 1.5, new_w: int = 500):
async def build_composite(data, scale: float = 1.5, new_w: int = 500, quality: int = 90, GaussianBlur_radius: int = 80):
src_img = Image.open(BytesIO(data)).convert('RGB')
# --------------------------------------------------------------
@ -391,7 +391,7 @@ async def build_composite(data, scale: float = 1.5, new_w: int = 500):
bg_cropped = bg_resized.crop((left, top, right, bottom))
# размытие
bg_blurred = bg_cropped.filter(ImageFilter.GaussianBlur(radius=3))
bg_blurred = bg_cropped.filter(ImageFilter.GaussianBlur(radius=GaussianBlur_radius))
# --------------------------------------------------------------
# 4️⃣ Переднее изображение – contain + центрировать
@ -427,6 +427,6 @@ async def build_composite(data, scale: float = 1.5, new_w: int = 500):
# --------------------------------------------------------------
out_bytes = BytesIO()
# final_img.save(out_bytes, format='JPEG', quality=85)
final_img.save(out_bytes, format="WEBP", quality=85, method=6)
final_img.save(out_bytes, format="WEBP", quality=quality, method=6)
out_bytes.seek(0)
return out_bytes # JPEG‑байты готового композитного изображения