Merge pull request 'production' (#5) from production into main
Reviewed-on: #5
This commit is contained in:
commit
588793f66c
@ -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")
|
||||
|
||||
95
main.ipynb
95
main.ipynb
@ -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
92
main.py
@ -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,
|
||||
|
||||
@ -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')){
|
||||
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, '"'));
|
||||
|
||||
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",}));
|
||||
|
||||
async function uploadMediaFiles(files) {
|
||||
if (!files || files.length === 0) return [];
|
||||
let filesOrdered = [];
|
||||
let isSubmitting = false;
|
||||
|
||||
const existingMedias = (post?.medias_original ?? []).map((media) => ({
|
||||
url: `/b/posts/${media}`,
|
||||
name: `${media}`,
|
||||
isExisting: true,
|
||||
type: "image",
|
||||
}));
|
||||
|
||||
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();
|
||||
for (let file of files) {
|
||||
formData.append('medias', file);
|
||||
}
|
||||
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', {
|
||||
const res = await fetch('/api/posts/media/chunk', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Ошибка загрузки медиа');
|
||||
return await res.json();
|
||||
if (!res.ok) throw new Error(`Chunk ${i} failed`);
|
||||
|
||||
onProgress((i + 1) / totalChunks);
|
||||
}
|
||||
|
||||
function loadExistingMedias() {
|
||||
existingMedias.forEach(media => {
|
||||
filesOrdered.push({
|
||||
name: media.name,
|
||||
url: media.url,
|
||||
isExisting: true,
|
||||
type: media.type
|
||||
const finalRes = await fetch('/api/posts/media/finalize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file_id: fileId })
|
||||
});
|
||||
});
|
||||
renderPreviews();
|
||||
|
||||
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) {
|
||||
@ -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,53 +284,61 @@
|
||||
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 = '';
|
||||
}
|
||||
});
|
||||
|
||||
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 => {
|
||||
@ -276,24 +348,35 @@
|
||||
: !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 {
|
||||
@ -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;
|
||||
|
||||
@ -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; }
|
||||
|
||||
6
utils.py
6
utils.py
@ -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‑байты готового композитного изображения
|
||||
|
||||
Loading…
Reference in New Issue
Block a user