508 lines
12 KiB
Plaintext
508 lines
12 KiB
Plaintext
---
|
||
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>
|