feat: quick screenshot upload at /upload
- Drop zone: drag and drop files - Paste: Ctrl+V pastes clipboard screenshots directly - Browse: file picker button - Saves to platform/screenshots/ with timestamp filename - Mounted as volume in frontend container - Accessible from any device at dash.quadjourney.com/upload Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
117
frontend-v2/src/routes/(app)/upload/+page.svelte
Normal file
117
frontend-v2/src/routes/(app)/upload/+page.svelte
Normal file
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
let uploading = $state(false);
|
||||
let lastFile = $state('');
|
||||
let dragOver = $state(false);
|
||||
|
||||
async function upload(file: File) {
|
||||
uploading = true;
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
try {
|
||||
const res = await fetch('/upload', { method: 'POST', body: fd, credentials: 'include' });
|
||||
const data = await res.json();
|
||||
lastFile = data.filename || '';
|
||||
} catch { lastFile = 'Error'; }
|
||||
uploading = false;
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragOver = false;
|
||||
const file = e.dataTransfer?.files[0];
|
||||
if (file) upload(file);
|
||||
}
|
||||
|
||||
function handlePaste(e: ClipboardEvent) {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
const file = item.getAsFile();
|
||||
if (file) upload(file);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files?.[0]) upload(input.files[0]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onpaste={handlePaste} />
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="upload-page"
|
||||
ondragover={(e) => { e.preventDefault(); dragOver = true; }}
|
||||
ondragleave={() => dragOver = false}
|
||||
ondrop={handleDrop}
|
||||
>
|
||||
<div class="upload-zone" class:dragover={dragOver}>
|
||||
{#if uploading}
|
||||
<div class="upload-status">Uploading...</div>
|
||||
{:else if lastFile}
|
||||
<div class="upload-done">
|
||||
<div class="upload-check">Saved</div>
|
||||
<div class="upload-filename">{lastFile}</div>
|
||||
<button class="upload-another" onclick={() => lastFile = ''}>Upload another</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="upload-prompt">
|
||||
<div class="upload-icon">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
</div>
|
||||
<div class="upload-text">Drop a file, paste a screenshot, or click to upload</div>
|
||||
<label class="upload-btn">
|
||||
Browse files
|
||||
<input type="file" accept="image/*,.pdf,.txt,.md" onchange={handleFileInput} hidden />
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.upload-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 80vh;
|
||||
padding: 24px;
|
||||
}
|
||||
.upload-zone {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
min-height: 280px;
|
||||
border: 2px dashed rgba(35,26,17,0.15);
|
||||
border-radius: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
transition: all 200ms;
|
||||
background: rgba(255,252,248,0.5);
|
||||
}
|
||||
.upload-zone.dragover {
|
||||
border-color: rgba(179,92,50,0.4);
|
||||
background: rgba(255,248,242,0.8);
|
||||
}
|
||||
.upload-prompt { text-align: center; }
|
||||
.upload-icon { color: #8c7b69; margin-bottom: 16px; }
|
||||
.upload-text { color: #5c5046; font-size: 1rem; margin-bottom: 16px; line-height: 1.5; }
|
||||
.upload-btn {
|
||||
display: inline-block; padding: 10px 24px; border-radius: 999px;
|
||||
background: #1e1812; color: white; font-size: 0.88rem; font-weight: 600;
|
||||
cursor: pointer; transition: opacity 160ms;
|
||||
}
|
||||
.upload-btn:hover { opacity: 0.9; }
|
||||
.upload-status { font-size: 1.1rem; color: #5c5046; }
|
||||
.upload-done { text-align: center; }
|
||||
.upload-check { font-size: 1.2rem; font-weight: 700; color: #059669; margin-bottom: 8px; }
|
||||
.upload-filename { font-size: 0.88rem; color: #5c5046; font-family: var(--mono); margin-bottom: 16px; }
|
||||
.upload-another {
|
||||
padding: 8px 18px; border-radius: 999px; border: 1px solid rgba(35,26,17,0.12);
|
||||
background: none; color: #5c5046; font-size: 0.85rem; cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
25
frontend-v2/src/routes/(app)/upload/+server.ts
Normal file
25
frontend-v2/src/routes/(app)/upload/+server.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const UPLOAD_DIR = '/app/screenshots';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File;
|
||||
if (!file) return json({ error: 'No file' }, { status: 400 });
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const ext = file.name.split('.').pop() || 'png';
|
||||
const filename = `${timestamp}.${ext}`;
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(UPLOAD_DIR, filename), buffer);
|
||||
|
||||
return json({ ok: true, filename, path: `/app/screenshots/${filename}` });
|
||||
};
|
||||
Reference in New Issue
Block a user