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:
@@ -5,6 +5,8 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: platform-frontend-v2
|
container_name: platform-frontend-v2
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./screenshots:/app/screenshots
|
||||||
environment:
|
environment:
|
||||||
- ORIGIN=${PLATFORM_V2_ORIGIN:-https://dash.quadjourney.com}
|
- ORIGIN=${PLATFORM_V2_ORIGIN:-https://dash.quadjourney.com}
|
||||||
- GATEWAY_URL=http://gateway:8100
|
- GATEWAY_URL=http://gateway:8100
|
||||||
|
|||||||
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