This commit is contained in:
vladislav 2026-08-30 23:18:26 -04:00
parent 9ec170678e
commit 26f4dbbd92
Signed by: Vladislav
GPG Key ID: D97C9E5E4860EE45
4 changed files with 573 additions and 277 deletions

View File

@ -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")

View File

@ -3306,16 +3306,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"
}, },

87
main.py
View File

@ -2464,18 +2464,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,

View File

@ -120,11 +120,357 @@
</div> </div>
</div> </div>
</div> </div>
{{ header.section(user) }}
<form class="c_sbrxk" id="postForm">
<div class="c_dtjoh">
<button class="c_aq9et ba" id="createBtn" type="button"> <button class="c_aq9et ba" id="createBtn" type="button">
<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 +591,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 +599,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; }