not-your-blog-v2/src/pages/upload.astro
2026-05-05 19:19:27 +03:00

508 lines
12 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
import DefaultLayout from '../layouts/DefaultLayout.astro';
const WORKER_URL = import.meta.env.PUBLIC_WORKER_URL;
const UPLOAD_SECRET = import.meta.env.PUBLIC_UPLOAD_SECRET;
const UPLOAD_PASSWORD = import.meta.env.PUBLIC_UPLOAD_PASSWORD;
---
<DefaultLayout>
<!-- Password gate — shown before upload UI -->
<div id="gate" class="gate">
<div class="gate-box">
<p class="gate-label">Password required</p>
<input id="gate-input" type="password" class="gate-input" placeholder="Enter password" />
<button id="gate-btn" class="gate-btn">Enter</button>
<p id="gate-error" class="gate-error"></p>
</div>
</div>
<section id="upload-section" class="upload-section hidden">
<h4>Upload</h4>
<div id="drop-zone" class="drop-zone">
<p class="drop-hint">Drag & drop files here or</p>
<label for="file-input" class="file-label">Choose files</label>
<input id="file-input" type="file" accept="video/*,image/*" multiple class="file-input" />
</div>
<div id="preview-grid" class="preview-grid"></div>
<button id="upload-btn" class="upload-btn" disabled>Upload all</button>
<div id="status" class="status"></div>
</section>
</DefaultLayout>
<script define:vars={{ UPLOAD_PASSWORD }}>
const gate = document.getElementById('gate');
const gateInput = document.getElementById('gate-input');
const gateBtn = document.getElementById('gate-btn');
const gateError = document.getElementById('gate-error');
const uploadSection = document.getElementById('upload-section');
function unlock() {
gate.classList.add('hidden');
uploadSection.classList.remove('hidden');
}
if (sessionStorage.getItem('upload_authed') === '1') {
unlock();
}
function attempt() {
if (gateInput.value === UPLOAD_PASSWORD) {
sessionStorage.setItem('upload_authed', '1');
unlock();
} else {
gateError.textContent = 'Wrong password.';
gateInput.value = '';
setTimeout(() => { window.location.href = '/'; }, 1500);
}
}
gateBtn.addEventListener('click', attempt);
gateInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') attempt(); });
</script>
<script define:vars={{ WORKER_URL, UPLOAD_SECRET }}>
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
const previewGrid = document.getElementById('preview-grid');
const uploadBtn = document.getElementById('upload-btn');
const statusEl = document.getElementById('status');
let selectedFiles = [];
function setStatus(msg, type) {
statusEl.textContent = msg;
statusEl.className = 'status ' + (type || '');
}
function addFiles(incoming) {
const valid = Array.from(incoming).filter(f =>
f.type.startsWith('video/') || f.type.startsWith('image/')
);
if (valid.length !== incoming.length) {
setStatus('Some files were skipped — only videos and images are accepted.', 'error');
} else {
setStatus('', '');
}
for (const file of valid) {
if (selectedFiles.find(f => f.name === file.name && f.size === file.size)) continue;
selectedFiles.push(file);
renderPreview(file);
}
uploadBtn.disabled = selectedFiles.length === 0;
}
function renderPreview(file) {
const objectUrl = URL.createObjectURL(file);
const card = document.createElement('div');
card.className = 'preview-card';
card.dataset.name = file.name;
let media;
if (file.type.startsWith('video/')) {
media = document.createElement('video');
media.src = objectUrl;
media.controls = true;
media.muted = true;
media.playsInline = true;
} else {
media = document.createElement('img');
media.src = objectUrl;
media.alt = file.name;
}
const info = document.createElement('div');
info.className = 'preview-info';
const name = document.createElement('span');
name.className = 'preview-name';
name.textContent = file.name;
const size = document.createElement('span');
size.className = 'preview-size';
size.textContent = formatSize(file.size);
const progress = document.createElement('div');
progress.className = 'preview-progress hidden';
progress.innerHTML = `<div class="preview-bar"></div><span class="preview-pct">0%</span>`;
const removeBtn = document.createElement('button');
removeBtn.className = 'preview-remove';
removeBtn.textContent = '×';
removeBtn.addEventListener('click', () => {
selectedFiles = selectedFiles.filter(f => f !== file);
URL.revokeObjectURL(objectUrl);
card.remove();
uploadBtn.disabled = selectedFiles.length === 0;
});
info.append(name, size, progress);
card.append(media, info, removeBtn);
previewGrid.appendChild(card);
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function setCardProgress(filename, pct, done, error) {
const card = previewGrid.querySelector(`[data-name="${CSS.escape(filename)}"]`);
if (!card) return;
const wrap = card.querySelector('.preview-progress');
const bar = card.querySelector('.preview-bar');
const pctEl = card.querySelector('.preview-pct');
wrap.classList.remove('hidden');
bar.style.width = pct + '%';
pctEl.textContent = pct + '%';
if (done) card.classList.add('done');
if (error) card.classList.add('errored');
}
fileInput.addEventListener('change', (e) => addFiles(e.target.files));
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
addFiles(e.dataTransfer.files);
});
uploadBtn.addEventListener('click', async () => {
if (selectedFiles.length === 0) return;
uploadBtn.disabled = true;
setStatus(`Uploading ${selectedFiles.length} file(s)…`, 'info');
const results = await Promise.allSettled(selectedFiles.map(uploadFile));
const failed = results.filter(r => r.status === 'rejected').length;
if (failed === 0) {
setStatus(`All ${selectedFiles.length} file(s) uploaded successfully.`, 'success');
} else {
setStatus(`${failed} file(s) failed. Check the previews.`, 'error');
}
uploadBtn.disabled = false;
});
async function uploadFile(file) {
const res = await fetch(
`${WORKER_URL}/presign?filename=${encodeURIComponent(file.name)}`,
{ headers: { Authorization: `Bearer ${UPLOAD_SECRET}` } }
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
setCardProgress(file.name, 0, false, true);
throw new Error(body.error || `Worker responded ${res.status}`);
}
const { url } = await res.json();
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', url);
xhr.setRequestHeader('Content-Type', file.type);
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
setCardProgress(file.name, Math.round((e.loaded / e.total) * 100), false, false);
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
setCardProgress(file.name, 100, true, false);
resolve();
} else {
setCardProgress(file.name, 0, false, true);
reject(new Error(`R2 upload failed: ${xhr.status}`));
}
});
xhr.addEventListener('error', () => {
setCardProgress(file.name, 0, false, true);
reject(new Error('Network error'));
});
xhr.send(file);
});
}
</script>
<style>
.gate {
display: flex;
align-items: center;
justify-content: center;
min-height: 40vh;
}
.gate.hidden {
display: none;
}
.gate-box {
display: flex;
flex-direction: column;
gap: 0.6em;
width: 100%;
max-width: 280px;
}
.gate-label {
margin: 0;
color: #aaa;
font-size: 0.9rem;
}
.gate-input {
background: #1e1e2e;
border: 1px solid #444;
border-radius: 4px;
color: #eeeeee;
padding: 0.5em 0.8em;
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
.gate-input:focus {
border-color: #4ecca3;
}
.gate-btn {
padding: 0.5em;
background: #4ecca3;
color: #1a1a2e;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.gate-btn:hover {
opacity: 0.85;
}
.gate-error {
margin: 0;
color: #e05c5c;
font-size: 0.85rem;
min-height: 1em;
}
.upload-section.hidden {
display: none;
}
section {
color: #eeeeee;
}
h4 {
font-size: 1.15rem;
margin: 0 0 1.2em 0;
color: #eeeeee;
}
.drop-zone {
border: 2px dashed #444;
border-radius: 6px;
padding: 2em 1.5em;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
margin-bottom: 1em;
}
.drop-zone.drag-over {
border-color: #4ecca3;
background: rgba(78, 204, 163, 0.05);
}
.drop-hint {
margin: 0 0 0.6em 0;
color: #aaa;
font-size: 0.95rem;
}
.file-input {
display: none;
}
.file-label {
display: inline-block;
padding: 0.4em 1em;
background: transparent;
border: 1px solid #4ecca3;
color: #4ecca3;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s, color 0.2s;
}
.file-label:hover {
background: #4ecca3;
color: #1a1a2e;
}
/* Preview grid */
.preview-grid {
display: flex;
flex-wrap: wrap;
gap: 1em;
margin-bottom: 1em;
}
.preview-card {
position: relative;
background: #1e1e2e;
border: 1px solid #333;
border-radius: 6px;
overflow: hidden;
display: flex;
flex-direction: column;
width: 400px;
max-width: 100%;
transition: border-color 0.2s;
}
.preview-card.done {
border-color: #4ecca3;
}
.preview-card.errored {
border-color: #e05c5c;
}
.preview-card video,
.preview-card img {
width: 400px;
height: 400px;
max-width: 100%;
object-fit: cover;
display: block;
background: #111;
}
.preview-info {
padding: 0.5em 0.6em;
display: flex;
flex-direction: column;
gap: 0.2em;
}
.preview-name {
font-size: 0.75rem;
color: #ccc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preview-size {
font-size: 0.7rem;
color: #666;
}
.preview-progress {
display: flex;
align-items: center;
gap: 0.4em;
margin-top: 0.3em;
}
.preview-progress.hidden {
display: none;
}
.preview-bar {
flex: 1;
height: 4px;
background: #4ecca3;
border-radius: 2px;
transition: width 0.2s;
}
.preview-pct {
font-size: 0.65rem;
color: #aaa;
min-width: 3ch;
}
.preview-remove {
position: absolute;
top: 4px;
right: 4px;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
border: none;
color: #fff;
font-size: 1rem;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.15s;
}
.preview-card:hover .preview-remove {
opacity: 1;
}
/* Upload button */
.upload-btn {
display: block;
padding: 0.5em 1.6em;
background: #4ecca3;
color: #1a1a2e;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
margin-bottom: 1em;
}
.upload-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.upload-btn:not(:disabled):hover {
opacity: 0.85;
}
.status {
font-size: 0.9rem;
min-height: 1.2em;
}
.status.info { color: #aaa; }
.status.success { color: #4ecca3; }
.status.error { color: #e05c5c; }
</style>