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 = ssl.create_default_context()
|
||||||
ctx.load_verify_locations(cafile="certs.pem")
|
ctx.load_verify_locations(cafile="certs.pem")
|
||||||
|
|
||||||
|
CHUNK_TTL = 3600
|
||||||
PROD = os.getenv("PROD", "True").lower() != "false"
|
PROD = os.getenv("PROD", "True").lower() != "false"
|
||||||
SOCIALS = {'vk', 'tg', 'max', 'fb', 'inst'}
|
SOCIALS = {'vk', 'tg', 'max', 'fb', 'inst'}
|
||||||
ATOKEN = os.getenv("ATOKEN")
|
ATOKEN = os.getenv("ATOKEN")
|
||||||
|
|||||||
95
main.ipynb
95
main.ipynb
@ -72,6 +72,7 @@
|
|||||||
"import asyncio\n",
|
"import asyncio\n",
|
||||||
"from fastapi.middleware.cors import CORSMiddleware\n",
|
"from fastapi.middleware.cors import CORSMiddleware\n",
|
||||||
"from fastapi.responses import HTMLResponse, StreamingResponse, RedirectResponse\n",
|
"from fastapi.responses import HTMLResponse, StreamingResponse, RedirectResponse\n",
|
||||||
|
"from starlette.datastructures import UploadFile as StarletteUploadFile\n",
|
||||||
"from fastapi.staticfiles import StaticFiles\n",
|
"from fastapi.staticfiles import StaticFiles\n",
|
||||||
"import mimetypes\n",
|
"import mimetypes\n",
|
||||||
"from urllib.parse import quote\n",
|
"from urllib.parse import quote\n",
|
||||||
@ -3022,8 +3023,6 @@
|
|||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": [
|
"source": [
|
||||||
"\n",
|
|
||||||
"\n",
|
|
||||||
"@app.get(\"/form/exhibition/{exhibition_id}\", response_class=HTMLResponse)\n",
|
"@app.get(\"/form/exhibition/{exhibition_id}\", response_class=HTMLResponse)\n",
|
||||||
"async def form_exhibition(\n",
|
"async def form_exhibition(\n",
|
||||||
" request: Request,\n",
|
" request: Request,\n",
|
||||||
@ -3054,11 +3053,11 @@
|
|||||||
" try:\n",
|
" try:\n",
|
||||||
" avatar_id = await save_to_gridfs(avatar, 'users')\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",
|
" 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",
|
" background_tasks.add_task(logo_user_task, bson_clear(current_user))\n",
|
||||||
" return {'ok': True}\n",
|
" return {'ok': True}\n",
|
||||||
" except Exception as e:\n",
|
" except Exception as e:\n",
|
||||||
" raise HTTPException(status_code=500, detail=str(e))\n",
|
" raise HTTPException(status_code=500, detail=str(e))\n"
|
||||||
"\n"
|
|
||||||
],
|
],
|
||||||
"display_name": ".venv"
|
"display_name": ".venv"
|
||||||
},
|
},
|
||||||
@ -3308,16 +3307,98 @@
|
|||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": [
|
"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",
|
"@app.post(\"/api/posts/media\", response_model=dict)\n",
|
||||||
"async def post_media(\n",
|
"async def post_media(\n",
|
||||||
" medias: Optional[List[UploadFile]] = File(None),\n",
|
" medias: Optional[List[UploadFile]] = File(None),\n",
|
||||||
" current_user: Dict = Security(get_current_user, scopes=[\"create:posts\"])\n",
|
" current_user: Dict = Security(get_current_user, scopes=[\"create:posts\"])\n",
|
||||||
"):\n",
|
"):\n",
|
||||||
" try:\n",
|
" try:\n",
|
||||||
" post_medias = await asyncio.gather(*[save_to_gridfs(media, 'posts') for media in medias])\n",
|
" post_medias = await asyncio.gather(\n",
|
||||||
" return {'post_medias': post_medias}\n",
|
" *[save_to_gridfs(media, \"posts\") for media in medias or []]\n",
|
||||||
|
" )\n",
|
||||||
|
" return {\"post_medias\": post_medias}\n",
|
||||||
" except Exception as e:\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"
|
"display_name": ".venv"
|
||||||
},
|
},
|
||||||
|
|||||||
92
main.py
92
main.py
@ -11,6 +11,7 @@ import io
|
|||||||
import asyncio
|
import asyncio
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import HTMLResponse, StreamingResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, StreamingResponse, RedirectResponse
|
||||||
|
from starlette.datastructures import UploadFile as StarletteUploadFile
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
import mimetypes
|
import mimetypes
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
@ -2238,8 +2239,6 @@ async def form_exhibition(
|
|||||||
request=request, name="form/exhibition.html", context={"id": 1, "user": user}
|
request=request, name="form/exhibition.html", context={"id": 1, "user": user}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/form/exhibition/{exhibition_id}", response_class=HTMLResponse)
|
@app.get("/form/exhibition/{exhibition_id}", response_class=HTMLResponse)
|
||||||
async def form_exhibition(
|
async def form_exhibition(
|
||||||
request: Request,
|
request: Request,
|
||||||
@ -2261,13 +2260,13 @@ async def avatar_change(
|
|||||||
try:
|
try:
|
||||||
avatar_id = await save_to_gridfs(avatar, 'users')
|
avatar_id = await save_to_gridfs(avatar, 'users')
|
||||||
await users_collection.update_one({'_id': ObjectId(current_user.get('_id'))}, {'$set': {'img': avatar_id}})
|
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))
|
background_tasks.add_task(logo_user_task, bson_clear(current_user))
|
||||||
return {'ok': True}
|
return {'ok': True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.put("/api/user/form", response_model=dict)
|
@app.put("/api/user/form", response_model=dict)
|
||||||
async def user_form(
|
async def user_form(
|
||||||
request: Request,
|
request: Request,
|
||||||
@ -2466,18 +2465,99 @@ async def put_painting(
|
|||||||
return doc_out
|
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)
|
@app.post("/api/posts/media", response_model=dict)
|
||||||
async def post_media(
|
async def post_media(
|
||||||
medias: Optional[List[UploadFile]] = File(None),
|
medias: Optional[List[UploadFile]] = File(None),
|
||||||
current_user: Dict = Security(get_current_user, scopes=["create:posts"])
|
current_user: Dict = Security(get_current_user, scopes=["create:posts"])
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
post_medias = await asyncio.gather(*[save_to_gridfs(media, 'posts') for media in medias])
|
post_medias = await asyncio.gather(
|
||||||
return {'post_medias': post_medias}
|
*[save_to_gridfs(media, "posts") for media in medias or []]
|
||||||
|
)
|
||||||
|
return {"post_medias": post_medias}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/news", response_model=dict)
|
@app.post("/api/news", response_model=dict)
|
||||||
async def post_news(
|
async def post_news(
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
|
|||||||
@ -51,28 +51,27 @@
|
|||||||
<div class="preview" id="preview"></div>
|
<div class="preview" id="preview"></div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<script defer>
|
<script>
|
||||||
const post = JSON.parse({{post|tojson}})
|
const post = JSON.parse({{ post|tojson }});
|
||||||
const postId = post._id
|
const postId = post._id;
|
||||||
|
|
||||||
const fileInput = document.getElementById('fileInput');
|
const fileInput = document.getElementById('fileInput');
|
||||||
const preview = document.getElementById('preview');
|
const preview = document.getElementById('preview');
|
||||||
|
const createBtn = document.getElementById('createBtn');
|
||||||
|
const form = document.getElementById('postForm');
|
||||||
|
|
||||||
|
// Fill form fields
|
||||||
document.getElementById('author').value = post.author;
|
document.getElementById('author').value = post.author;
|
||||||
if (document.getElementById('grade')){
|
if (document.getElementById('grade')) {
|
||||||
document.getElementById('grade').value = post.grade;
|
document.getElementById('grade').value = post.grade;
|
||||||
|
|
||||||
const inputEl = document.getElementById('grade');
|
const inputEl = document.getElementById('grade');
|
||||||
|
let list = inputEl.dataset.list;
|
||||||
let list = inputEl.dataset.list;
|
list = JSON.parse(list.replace(/'/g, '"'));
|
||||||
list = JSON.parse(list.replace(/'/g, '"'));
|
|
||||||
|
|
||||||
list.push(post.grade);
|
list.push(post.grade);
|
||||||
list = [...new Set(list)];
|
list = [...new Set(list)];
|
||||||
|
|
||||||
inputEl.dataset.list = JSON.stringify(list);
|
inputEl.dataset.list = JSON.stringify(list);
|
||||||
inputEl.dataset.value = post.grade;
|
inputEl.dataset.value = post.grade;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('title').value = post.title;
|
document.getElementById('title').value = post.title;
|
||||||
document.getElementById('city').value = post.city;
|
document.getElementById('city').value = post.city;
|
||||||
document.getElementById('size').value = post.size;
|
document.getElementById('size').value = post.size;
|
||||||
@ -80,39 +79,110 @@
|
|||||||
document.getElementById('genre').value = post.genre;
|
document.getElementById('genre').value = post.genre;
|
||||||
document.getElementById('price').value = post.price;
|
document.getElementById('price').value = post.price;
|
||||||
document.getElementById('description').value = post.description;
|
document.getElementById('description').value = post.description;
|
||||||
|
|
||||||
let filesOrdered = [];
|
let filesOrdered = [];
|
||||||
console.log(post.medias_original)
|
let isSubmitting = false;
|
||||||
const existingMedias = (post?.medias_original ?? []).map((media) => ({ url: `/b/posts/${media}`, name: `${media}`, isExisting: true, type: "image",}));
|
|
||||||
|
|
||||||
async function uploadMediaFiles(files) {
|
const existingMedias = (post?.medias_original ?? []).map((media) => ({
|
||||||
if (!files || files.length === 0) return [];
|
url: `/b/posts/${media}`,
|
||||||
|
name: `${media}`,
|
||||||
|
isExisting: true,
|
||||||
|
type: "image",
|
||||||
|
}));
|
||||||
|
|
||||||
const formData = new FormData();
|
const CHUNK_SIZE = 2 * 1024 * 1024; // 2 MB
|
||||||
for (let file of files) {
|
|
||||||
formData.append('medias', file);
|
// ========== 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',
|
method: 'POST',
|
||||||
body: formData
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ file_id: fileId })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) throw new Error('Ошибка загрузки медиа');
|
if (!finalRes.ok) throw new Error('Finalize failed');
|
||||||
return await res.json();
|
const data = await finalRes.json();
|
||||||
|
return data.post_medias[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadExistingMedias() {
|
function createProgressBar(fileName) {
|
||||||
existingMedias.forEach(media => {
|
const wrapper = document.createElement('div');
|
||||||
filesOrdered.push({
|
wrapper.className = 'upload-item';
|
||||||
name: media.name,
|
wrapper.innerHTML = `
|
||||||
url: media.url,
|
<div class="upload-name">${fileName}</div>
|
||||||
isExisting: true,
|
<div class="progress-bar"><div class="progress-fill"></div></div>
|
||||||
type: media.type
|
<div class="progress-text">0%</div>
|
||||||
});
|
`;
|
||||||
});
|
return wrapper;
|
||||||
renderPreviews();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function createObjectURLSafe(item) {
|
||||||
if (item.isExisting || !item.file) return item.url;
|
if (item.isExisting || !item.file) return item.url;
|
||||||
if (!item.previewURL) {
|
if (!item.previewURL) {
|
||||||
@ -130,7 +200,6 @@
|
|||||||
|
|
||||||
function renderPreviews() {
|
function renderPreviews() {
|
||||||
preview.innerHTML = '';
|
preview.innerHTML = '';
|
||||||
|
|
||||||
filesOrdered.forEach((item, idx) => {
|
filesOrdered.forEach((item, idx) => {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'item';
|
div.className = 'item';
|
||||||
@ -142,13 +211,11 @@
|
|||||||
title.textContent = idx === 0 ? 'Титульная' : `${idx + 1}`;
|
title.textContent = idx === 0 ? 'Титульная' : `${idx + 1}`;
|
||||||
|
|
||||||
const isImage = item.type && item.type.startsWith('image');
|
const isImage = item.type && item.type.startsWith('image');
|
||||||
|
|
||||||
const mediaEl = document.createElement(isImage ? 'img' : 'video');
|
const mediaEl = document.createElement(isImage ? 'img' : 'video');
|
||||||
mediaEl.className = 'thumb';
|
mediaEl.className = 'thumb';
|
||||||
mediaEl.src = createObjectURLSafe(item);
|
mediaEl.src = createObjectURLSafe(item);
|
||||||
if (!isImage) mediaEl.controls = true;
|
if (!isImage) mediaEl.controls = true;
|
||||||
|
|
||||||
// Контролы
|
|
||||||
const controls = document.createElement('div');
|
const controls = document.createElement('div');
|
||||||
controls.className = 'controls';
|
controls.className = 'controls';
|
||||||
|
|
||||||
@ -159,13 +226,12 @@
|
|||||||
makeFirstBtn.addEventListener('click', () => moveFile(idx, 0));
|
makeFirstBtn.addEventListener('click', () => moveFile(idx, 0));
|
||||||
|
|
||||||
const leftBtn = document.createElement('button');
|
const leftBtn = document.createElement('button');
|
||||||
|
leftBtn.textContent = '←';
|
||||||
leftBtn.textContent = idx;
|
|
||||||
leftBtn.disabled = idx === 0;
|
leftBtn.disabled = idx === 0;
|
||||||
leftBtn.addEventListener('click', () => moveFile(idx, idx - 1));
|
leftBtn.addEventListener('click', () => moveFile(idx, idx - 1));
|
||||||
|
|
||||||
const rightBtn = document.createElement('button');
|
const rightBtn = document.createElement('button');
|
||||||
rightBtn.textContent = idx + 2;
|
rightBtn.textContent = '→';
|
||||||
rightBtn.disabled = idx === filesOrdered.length - 1;
|
rightBtn.disabled = idx === filesOrdered.length - 1;
|
||||||
rightBtn.addEventListener('click', () => moveFile(idx, idx + 1));
|
rightBtn.addEventListener('click', () => moveFile(idx, idx + 1));
|
||||||
|
|
||||||
@ -173,7 +239,9 @@
|
|||||||
removeBtn.textContent = '✖';
|
removeBtn.textContent = '✖';
|
||||||
removeBtn.addEventListener('click', () => removeFile(idx));
|
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);
|
div.append(mediaEl, title, controls);
|
||||||
preview.appendChild(div);
|
preview.appendChild(div);
|
||||||
@ -183,19 +251,15 @@
|
|||||||
e.dataTransfer.setData('text/plain', idx.toString());
|
e.dataTransfer.setData('text/plain', idx.toString());
|
||||||
div.classList.add('dragging');
|
div.classList.add('dragging');
|
||||||
});
|
});
|
||||||
|
|
||||||
div.addEventListener('dragend', () => {
|
div.addEventListener('dragend', () => {
|
||||||
div.classList.remove('dragging');
|
div.classList.remove('dragging');
|
||||||
document.querySelectorAll('.item').forEach(el => el.classList.remove('drag-over'));
|
document.querySelectorAll('.item').forEach(el => el.classList.remove('drag-over'));
|
||||||
});
|
});
|
||||||
|
|
||||||
div.addEventListener('dragover', (e) => {
|
div.addEventListener('dragover', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
div.classList.add('drag-over');
|
div.classList.add('drag-over');
|
||||||
});
|
});
|
||||||
|
|
||||||
div.addEventListener('dragleave', () => div.classList.remove('drag-over'));
|
div.addEventListener('dragleave', () => div.classList.remove('drag-over'));
|
||||||
|
|
||||||
div.addEventListener('drop', (e) => {
|
div.addEventListener('drop', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
div.classList.remove('drag-over');
|
div.classList.remove('drag-over');
|
||||||
@ -220,86 +284,105 @@
|
|||||||
renderPreviews();
|
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) => {
|
fileInput.addEventListener('change', async (e) => {
|
||||||
const newFiles = Array.from(e.target.files);
|
const newFiles = Array.from(e.target.files);
|
||||||
if (newFiles.length === 0) return;
|
if (newFiles.length === 0) return;
|
||||||
|
|
||||||
|
createBtn.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const uploadedNames = await uploadMediaFiles(newFiles);
|
const uploaded = await uploadMediaFiles(newFiles);
|
||||||
|
|
||||||
newFiles.forEach((file, i) => {
|
newFiles.forEach((file, i) => {
|
||||||
filesOrdered.push({
|
filesOrdered.push({
|
||||||
name: uploadedNames['post_medias'][i],
|
name: uploaded.post_medias[i],
|
||||||
url: `/b/posts/${uploadedNames['post_medias'][i]}`,
|
url: `/b/posts/${uploaded.post_medias[i]}`,
|
||||||
file: file,
|
file: file,
|
||||||
type: file.type,
|
type: file.type,
|
||||||
isExisting: false
|
isExisting: false
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
renderPreviews();
|
renderPreviews(); // blob appears only here
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка загрузки фото:', err);
|
console.error('Ошибка загрузки фото:', err);
|
||||||
alert('Не удалось загрузить фото. Попробуйте ещё раз.');
|
alert('Не удалось загрузить фото. Попробуйте ещё раз.');
|
||||||
|
} finally {
|
||||||
|
createBtn.disabled = false;
|
||||||
|
fileInput.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
fileInput.value = '';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('load', () => {
|
// ========== Save button – only one request ==========
|
||||||
loadExistingMedias();
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('beforeunload', () => {
|
|
||||||
filesOrdered.forEach(revokeObjectURLSafe);
|
|
||||||
});
|
|
||||||
|
|
||||||
// === Отправка формы ===
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const author = '{{user.username}}';
|
const author = '{{ user.username }}';
|
||||||
const grade = '{{user.grade}}';
|
const grade = '{{ user.grade }}';
|
||||||
const form = document.getElementById('postForm');
|
|
||||||
const createBtn = document.getElementById('createBtn');
|
|
||||||
|
|
||||||
createBtn.addEventListener('click', async (e) => {
|
createBtn.addEventListener('click', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (isSubmitting) return;
|
||||||
|
|
||||||
// ... (проверка обязательных полей остаётся без изменений)
|
const requiredFields = [
|
||||||
|
...document.querySelectorAll('input[required]'),
|
||||||
const requiredFields = [...document.querySelectorAll('input[required]'), ...document.querySelectorAll('textarea[required]')];
|
...document.querySelectorAll('textarea[required]')
|
||||||
|
];
|
||||||
let hasError = false;
|
let hasError = false;
|
||||||
|
|
||||||
requiredFields.forEach(field => {
|
requiredFields.forEach(field => {
|
||||||
const group = field.closest('.floating-group');
|
const group = field.closest('.floating-group');
|
||||||
const isEmpty = field.type === 'file'
|
const isEmpty = field.type === 'file'
|
||||||
? !field.files || field.files.length === 0
|
? !field.files || field.files.length === 0
|
||||||
: !field.value.trim();
|
: !field.value.trim();
|
||||||
|
|
||||||
if (isEmpty) {
|
if (isEmpty) {
|
||||||
group.classList.add('error');
|
group?.classList.add('error');
|
||||||
hasError = true;
|
hasError = true;
|
||||||
} else {
|
} else {
|
||||||
group.classList.remove('error');
|
group?.classList.remove('error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasError) return;
|
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);
|
const data = new FormData(form);
|
||||||
|
|
||||||
if (document.querySelector('#author').value === '') {
|
if (document.querySelector('#author')?.value === '') {
|
||||||
data.set('author', author);
|
data.set('author', author);
|
||||||
}
|
}
|
||||||
|
|
||||||
{% if 'user' in user.get('roles') and user.get('grade') is not none %}
|
{% if 'user' in user.get('roles') and user.get('grade') is not none %}
|
||||||
data.set('grade', grade);
|
data.set('grade', grade);
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
filesOrdered.forEach(f => data.append('post_medias', f.name));
|
filesOrdered.forEach(f => data.append('post_medias', f.name));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/post/${postId}`, {
|
const res = await fetch(`/api/post/${postId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: data
|
body: data
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@ -307,21 +390,31 @@
|
|||||||
const next = params.get('next') || '/profile/posts';
|
const next = params.get('next') || '/profile/posts';
|
||||||
window.location.href = next;
|
window.location.href = next;
|
||||||
} else {
|
} else {
|
||||||
showToast({message: 'Ошибка при сохранение поста', status: "error", duration:2500});
|
showToast({ message: 'Ошибка при сохранении поста', status: 'error', duration: 2500 });
|
||||||
|
isSubmitting = false;
|
||||||
|
createBtn.disabled = false;
|
||||||
|
createBtn.classList.remove('disabled');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(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 => {
|
document.querySelectorAll('.floating-group input, .floating-group textarea').forEach(input => {
|
||||||
input.addEventListener('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>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
@ -329,6 +422,62 @@
|
|||||||
|
|
||||||
{% macro style() -%}
|
{% macro style() -%}
|
||||||
<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 {
|
.preview {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@ -124,7 +124,350 @@
|
|||||||
<span class="c_som9s">Опубликовать</span>
|
<span class="c_som9s">Опубликовать</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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.addEventListener('DOMContentLoaded', () => {
|
||||||
document.getElementById('genre').dataset.list=JSON.stringify([
|
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>
|
</script>
|
||||||
{{ footer.section() }}
|
{{ footer.section() }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -522,6 +596,62 @@
|
|||||||
{% block head %}
|
{% block head %}
|
||||||
{{ header.style() }}
|
{{ header.style() }}
|
||||||
<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_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_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; }
|
.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
|
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')
|
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_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 + центрировать
|
# 4️⃣ Переднее изображение – contain + центрировать
|
||||||
@ -427,6 +427,6 @@ async def build_composite(data, scale: float = 1.5, new_w: int = 500):
|
|||||||
# --------------------------------------------------------------
|
# --------------------------------------------------------------
|
||||||
out_bytes = BytesIO()
|
out_bytes = BytesIO()
|
||||||
# final_img.save(out_bytes, format='JPEG', quality=85)
|
# 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)
|
out_bytes.seek(0)
|
||||||
return out_bytes # JPEG‑байты готового композитного изображения
|
return out_bytes # JPEG‑байты готового композитного изображения
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user