feat(ui): add drop and paste image uploads

This commit is contained in:
JB
2026-07-25 12:51:13 -04:00
parent 7a12b1d740
commit 6c982f7ed5
5 changed files with 129 additions and 15 deletions
+7
View File
@@ -496,3 +496,10 @@ bookmarkable, and expose the selected state with both orange emphasis and
textually identifiable active states. Counts describe the full search scope,
not merely the visible page. Do not use aggregate facts that cannot filter the
affected registry—replace them with actionable subsets.
### Image uploads
All image uploads use the shared SHD drop-zone treatment and expose three equivalent
selection paths: a file picker, drag and drop, and clipboard paste. The control shows
the selected filename and a local preview, but server-side format, size, transparency,
and normalization checks remain authoritative.
+2
View File
@@ -61,6 +61,8 @@
.catalogue-page,.guides-page,.profile-page{padding-top:34px}.catalogue-hero{min-height:230px;display:flex;justify-content:space-between;align-items:center;padding:25px 32px;margin-bottom:12px;border:1px solid var(--shd-border);background:radial-gradient(circle at 80% 50%,#332714 0,transparent 30%),linear-gradient(120deg,#141718,#0d1011);clip-path:polygon(0 0,97% 0,100% 18%,100% 100%,3% 100%,0 82%)}.catalogue-hero h1{font-size:clamp(48px,6vw,82px);line-height:.82;letter-spacing:-3px;margin:14px 0}.catalogue-hero h1 em{font-style:normal;color:var(--shd-orange)}.catalogue-hero>div>p:last-child{max-width:580px;color:var(--shd-text-muted);font:15px/1.55 Georgia,serif}.catalogue-count{min-width:210px;display:grid;text-align:right}.catalogue-count strong{font-size:38px}.catalogue-count span{font-size:7px;color:#6d7476;letter-spacing:1.5px;margin:3px 0 18px}.catalogue-count .primary{justify-self:end}.filter-bar{display:grid;grid-template-columns:2fr 1fr 1fr auto;gap:7px;align-items:end;margin:12px 0 18px;padding:11px;background:#101314;border:1px solid #2d3233}.filter-bar label{font-size:7px;color:#72797b;letter-spacing:1.2px}.filter-bar input,.filter-bar select{height:38px;margin-top:6px;padding:0 10px;font-size:9px}.filter-bar button{height:38px;padding:0 14px}.catalogue-grid{max-width:none}.catalogue-grid article{position:relative}.build-open{width:100%;margin-top:15px;padding:10px;border:1px solid #3a4041;background:#101314;color:#f6a821;font-size:8px;font-weight:bold;letter-spacing:1px}.guide-layout{display:grid;grid-template-columns:1fr 330px;gap:12px}.guide-feature{min-height:360px;padding:35px;position:relative;border:1px solid #303536;border-top:2px solid #f6a821;background:linear-gradient(135deg,#151819,#0d1011)}.guide-code{position:absolute;right:25px;top:22px;color:#4f5658;font-size:8px;letter-spacing:1.5px}.guide-feature h2{font-size:42px;line-height:.95;margin:20px 0}.guide-feature>p:not(.eyebrow){max-width:650px;color:#858b8d;font:15px/1.6 Georgia,serif}.guide-meta{display:flex;gap:8px;position:absolute;left:35px;bottom:30px}.guide-meta span{display:grid;padding:8px 11px;border:1px solid #303536;color:#60686a;font-size:7px;letter-spacing:1px}.guide-meta b{color:#d7d9d5;font-size:9px;margin-top:4px}.guide-index{border:1px solid #303536;background:#111415;padding:20px}.guide-index>div{display:grid;grid-template-columns:30px 1fr auto;align-items:center;min-height:54px;border-top:1px solid #292e2f}.guide-index small{color:#535b5d;font-size:8px}.guide-index span{font-size:9px;letter-spacing:.8px}.guide-index b{color:#f6a821}
.profile-command{display:flex;justify-content:space-between;align-items:center;padding:0 0 24px;border-bottom:1px solid #303536}.profile-ident{display:flex;align-items:center;gap:18px}.profile-avatar{width:76px;height:76px;display:block;object-fit:cover;border:1px solid #f6a821;border-radius:50%;background:#0a0c0d;box-shadow:0 0 0 6px #0a0c0d,0 0 0 7px #f6a82155}.profile-ident h1{font-size:39px;margin:4px 0}.role-badge{display:inline-block;padding:4px 8px;border:1px solid #6e5226;color:#f6a821;font-size:7px;letter-spacing:1.3px}.profile-metrics{display:flex;gap:7px}.profile-metrics div{min-width:77px;height:59px;display:grid;place-items:center;padding:7px;background:#111415;border:1px solid #303536}.profile-metrics strong{font-size:20px}.profile-metrics span{font-size:7px;color:#697174;letter-spacing:1px}.account-alert{display:flex;gap:16px;margin:12px 0;padding:12px 15px;background:#2a2112;border:1px solid #795c2a;border-left:3px solid #f6a821;font-size:10px}.account-alert b{color:#f6a821}.account-alert span{color:#b8ad99}.profile-dashboard{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:12px;margin-top:12px}.profile-feed{display:grid;gap:12px}.profile-module,.account-console{background:#111415;border:1px solid #303536;padding:16px}.module-head{display:flex;justify-content:space-between;align-items:center}.module-head h2,.profile-module h2,.account-console h2{font-size:17px;margin:4px 0 12px}.module-head .secondary{padding:10px 12px}.profile-table{border:1px solid #292e2f}.profile-row{display:grid;grid-template-columns:1.5fr .7fr .7fr .3fr;align-items:center;gap:9px;min-height:47px;padding:7px 11px;border-top:1px solid #292e2f;font-size:9px}.profile-row.heading{min-height:30px;border:0;background:#0d1011;color:#636b6d;font-size:7px;letter-spacing:1px}.profile-row>div{display:grid}.profile-row small{font-size:7px;color:#596164}.profile-row>span{text-transform:uppercase;color:#858d8f}.profile-row button{border:0;background:transparent;color:#f6a821;font-size:8px}.profile-empty{text-align:center;padding:38px}.profile-empty>span{font-size:31px;color:#f6a821}.profile-empty b{display:block;font-size:10px;letter-spacing:1px;margin:10px}.profile-empty p{color:#71797b;font:13px Georgia,serif}.profile-empty a{color:#f6a821;font-size:8px;font-weight:bold}.profile-columns{display:grid;grid-template-columns:1fr 1fr;gap:12px}.large-count{font-size:32px}.count-label{display:block;font-size:7px;color:#687073;letter-spacing:1.2px}.account-console{height:max-content}.console-head{display:flex;justify-content:space-between;padding-bottom:11px;margin-bottom:15px;border-bottom:1px solid #303536;color:#747c7e;font-size:7px;letter-spacing:1.3px}.console-head i{width:6px;height:6px;border-radius:50%;background:#75c08c;box-shadow:0 0 6px #75c08c}.console-action{width:100%;margin-top:9px}.connection-card{display:grid;grid-template-columns:35px 1fr auto;align-items:center;padding:10px;border:1px solid #303536}.discord-node{color:#8993ff;font-size:21px}.connection-card div{display:grid}.connection-card b{font-size:9px}.connection-card small{font-size:7px;color:#656d6f;margin-top:3px}.connection-card a{color:#f6a821;font-size:8px;font-weight:bold}
.avatar-picker{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:10px}.avatar-preset-form fieldset{height:100%}.avatar-preset{display:grid;gap:5px;width:100%;height:100%;padding:4px;background:#0b0e0f;border:1px solid #303536;color:#747c7e}.avatar-preset:hover,.avatar-preset:focus-visible{border-color:#8a672b;color:#f6a821}.avatar-preset.selected{border-color:#f6a821;color:#f6a821;box-shadow:inset 0 -2px #f6a821}.avatar-preset img{display:block;width:100%;aspect-ratio:1;object-fit:cover}.avatar-preset span{font-size:6px;line-height:1.25;text-transform:uppercase;letter-spacing:.4px}.avatar-upload{margin-top:8px}
.image-drop-input{display:grid;grid-template-columns:64px 1fr auto;align-items:center;gap:10px;min-height:82px;padding:8px;border:1px dashed #4b5152;background:#0b0e0f;cursor:pointer;transition:border-color .15s,background .15s,transform .1s}.image-drop-input:hover,.image-drop-input:focus-visible,.image-drop-input.dragging{border-color:var(--shd-orange);background:#15130e;outline:none}.image-drop-input.dragging{transform:translateY(1px);box-shadow:inset 0 0 0 1px #f6a82155}.image-file-input{position:absolute!important;width:1px!important;height:1px!important;overflow:hidden!important;clip:rect(0 0 0 0)!important;white-space:nowrap!important}.image-upload-preview{display:grid;place-items:center;width:64px;height:64px;border:1px solid #343a3b;background:#070909;color:#62696b;font-size:6px;text-align:center}.image-upload-preview img{display:block;width:100%;height:100%;object-fit:contain}.image-drop-copy{display:grid;gap:4px;min-width:0}.image-drop-copy b{color:#c6cbcc;font-size:8px;letter-spacing:1px}.image-drop-copy span{overflow:hidden;color:#747c7e;font-size:7px;text-overflow:ellipsis;white-space:nowrap}.image-upload-actions{display:flex;gap:5px}.image-upload-actions button{min-width:92px;padding:8px}.image-upload-error{display:block;margin-top:5px;color:var(--shd-danger)}@media(max-width:700px){.image-drop-input{grid-template-columns:56px 1fr}.image-upload-preview{width:56px;height:56px}.image-upload-actions{grid-column:1/-1}.image-upload-actions button{flex:1}}
.icon-upload-field .image-drop-input{grid-template-columns:1fr;gap:6px;text-align:center}.icon-upload-field .image-upload-preview{width:100%;height:72px}.icon-upload-field .image-upload-preview img{width:64px;height:64px}.icon-upload-field .image-upload-actions{display:grid;grid-template-columns:1fr 1fr;width:100%}.icon-upload-field .image-upload-actions button{min-width:0;padding:7px 3px;font-size:6px}
.account-tabs{width:100%;height:auto;margin:0 0 16px;display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--shd-border)}.account-tabs button,.account-subtabs button{min-width:0;height:34px;padding:0 5px;border:0;border-left:1px solid var(--shd-border);border-bottom:2px solid transparent;background:#0d1011;color:#6f7779;font-size:7px;font-weight:900;letter-spacing:.8px;cursor:pointer;transition:var(--shd-transition)}.account-tabs button:first-child,.account-subtabs button:first-child{border-left:0}.account-tabs button:hover,.account-tabs button:focus-visible,.account-subtabs button:hover,.account-subtabs button:focus-visible{color:var(--shd-text);background:#171b1c}.account-tabs button.active,.account-subtabs button.active{border-bottom-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.account-tabs button:active,.account-subtabs button:active{transform:translateY(1px);filter:brightness(.85)}.account-tabs button:focus-visible,.account-subtabs button:focus-visible{position:relative;z-index:1;outline:2px solid var(--shd-orange);outline-offset:2px}.account-panel{min-height:290px}.account-subtabs{display:grid;grid-template-columns:1fr 1fr;margin:0 0 10px;border:1px solid var(--shd-border)}.account-subtabs button{height:31px}.access-subtabs{display:grid;grid-template-columns:repeat(4,1fr);margin:-2px 0 17px;border:1px solid var(--shd-border);background:#0b0e0f}.access-subtabs button{min-width:0;height:42px;display:grid;place-content:center;gap:3px;padding:4px;border:0;border-left:1px solid var(--shd-border);border-bottom:2px solid transparent;background:transparent;color:var(--shd-text-dim);cursor:pointer;transition:var(--shd-transition)}.access-subtabs button:first-child{border-left:0}.access-subtabs button span{font-size:6px;font-weight:900;letter-spacing:.7px}.access-subtabs button b{font-size:9px}.access-subtabs button:hover,.access-subtabs button:focus-visible{background:#171b1c;color:var(--shd-text)}.access-subtabs button.active{border-bottom-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.access-subtabs button:active{transform:translateY(1px);filter:brightness(.85)}.access-subtabs button:focus-visible{position:relative;z-index:1;outline:2px solid var(--shd-orange);outline-offset:2px}.account-retention{color:var(--shd-text-muted);font:12px/1.55 Georgia,serif}.danger-eyebrow{color:var(--shd-danger)}
.account-alert.error-alert{background:#261513;border-color:#75443b;border-left-color:#df8069}.account-alert.error-alert b{color:#df8069}.account-alert.error-alert span{color:#d2aaa1}
.danger-zone{margin-top:12px;border:1px solid #563734;background:#181112}.danger-zone summary{padding:10px;color:#df8069;cursor:pointer;font-size:8px;font-weight:bold;letter-spacing:1px}.danger-zone>p,.danger-zone>form{margin:0;padding:0 10px 10px}.danger-zone>p{color:#9f8580;font-size:8px;line-height:1.5}.danger-zone form{padding-bottom:10px}
+2 -14
View File
@@ -1,26 +1,14 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
let {
currentUrl = '',
label = 'ICON'
}: { currentUrl?: string | null; label?: string } = $props();
let previewUrl = $state('');
let fileName = $state('');
const choose = (event: Event) => {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = file && file.type !== 'image/svg+xml' ? URL.createObjectURL(file) : '';
fileName = file?.name ?? '';
};
onDestroy(() => { if (previewUrl) URL.revokeObjectURL(previewUrl); });
</script>
<fieldset class="icon-upload-field">
<legend>{label}</legend>
<div class="icon-upload-preview">
{#if previewUrl || currentUrl}<img src={previewUrl || currentUrl || ''} alt="Current icon preview"/>{:else}<span>NO ICON</span>{/if}
</div>
<label><input name="icon" type="file" accept="image/svg+xml,image/png,image/webp,image/avif" onchange={choose}/><span>{fileName || (currentUrl ? 'REPLACE ICON' : 'UPLOAD ICON')}</span></label>
<ImageUploadInput name="icon" accept="image/svg+xml,image/png,image/webp,image/avif" {currentUrl} emptyLabel="NO ICON" browseLabel={currentUrl ? 'REPLACE ICON' : 'CHOOSE ICON'}/>
{#if currentUrl}<label class="icon-remove"><input name="removeIcon" type="checkbox" value="1"/><span>REMOVE CURRENT ICON</span></label>{/if}
<small>TRANSPARENT SVG, PNG, WEBP, OR AVIF · 2 MiB MAX</small>
</fieldset>
+115
View File
@@ -0,0 +1,115 @@
<script lang="ts">
import { onDestroy } from 'svelte';
let {
name,
accept,
required = false,
currentUrl = '',
emptyLabel = 'NO IMAGE',
browseLabel = 'CHOOSE FILE'
}: {
name: string;
accept: string;
required?: boolean;
currentUrl?: string | null;
emptyLabel?: string;
browseLabel?: string;
} = $props();
let input: HTMLInputElement;
let previewUrl = $state('');
let fileName = $state('');
let dragging = $state(false);
let error = $state('');
function setFile(file: File | undefined) {
if (!file) return;
if (!file.type.startsWith('image/')) {
error = 'Clipboard or dropped content must be an image.';
return;
}
const transfer = new DataTransfer();
transfer.items.add(file);
input.files = transfer.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
}
function choose(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = file ? URL.createObjectURL(file) : '';
fileName = file?.name ?? '';
error = '';
}
function drop(event: DragEvent) {
event.preventDefault();
dragging = false;
setFile(Array.from(event.dataTransfer?.files ?? []).find((file) => file.type.startsWith('image/')));
}
function paste(event: ClipboardEvent) {
const file = Array.from(event.clipboardData?.files ?? []).find((item) => item.type.startsWith('image/'));
if (file) {
event.preventDefault();
setFile(file);
} else {
error = 'No image was found on the clipboard.';
}
}
async function pasteFromClipboard() {
error = '';
try {
const items = await navigator.clipboard.read();
for (const item of items) {
const type = item.types.find((candidate) => candidate.startsWith('image/'));
if (type) {
const blob = await item.getType(type);
setFile(new File([blob], `pasted-image.${type.split('/')[1] || 'png'}`, { type }));
return;
}
}
error = 'No image was found on the clipboard.';
} catch {
error = 'Clipboard access was blocked. Focus the drop zone and press Ctrl+V instead.';
}
}
onDestroy(() => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
});
</script>
<div
class="image-drop-input"
class:dragging
role="group"
aria-label="Drop an image here or paste one from the clipboard"
ondragenter={(event) => { event.preventDefault(); dragging = true; }}
ondragover={(event) => event.preventDefault()}
ondragleave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) dragging = false;
}}
ondrop={drop}
onpaste={paste}
>
<input bind:this={input} class="image-file-input" {name} type="file" {accept} {required} onchange={choose}/>
<div class="image-upload-preview">
{#if previewUrl || currentUrl}
<img src={previewUrl || currentUrl || ''} alt="Selected upload preview"/>
{:else}
<span>{emptyLabel}</span>
{/if}
</div>
<div class="image-drop-copy">
<b>{dragging ? 'RELEASE TO SELECT' : 'DROP IMAGE HERE'}</b>
<span>{fileName || 'OR USE AN UPLOAD METHOD'}</span>
</div>
<div class="image-upload-actions">
<button type="button" class="secondary" onclick={(event) => { event.stopPropagation(); input.click(); }}>{browseLabel}</button>
<button type="button" class="secondary" onclick={(event) => { event.stopPropagation(); pasteFromClipboard(); }}>PASTE IMAGE</button>
</div>
</div>
{#if error}<small class="image-upload-error" role="alert">{error}</small>{/if}
+3 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser';
import { onMount } from 'svelte';
let { data, form } = $props();
@@ -106,7 +107,8 @@
{#if form?.avatarChanged}<p class="success">Avatar updated.</p>{/if}
{:else}
<PendingForm action="?/uploadAvatar" enctype="multipart/form-data" class="avatar-upload">
<label class="field">CUSTOM AVATAR<input name="avatar" type="file" accept="image/jpeg,image/png,image/webp,image/avif" required /></label>
<p class="eyebrow">CUSTOM AVATAR</p>
<ImageUploadInput name="avatar" accept="image/jpeg,image/png,image/webp,image/avif" required emptyLabel="NO AVATAR SELECTED" browseLabel="CHOOSE AVATAR"/>
<small class="identity-note">JPEG, PNG, WebP, or AVIF · 5 MiB maximum · cropped to a 256 px square.</small>
{#if form?.avatarError}<p class="error">{form.avatarError}</p>{/if}
{#if form?.avatarChanged}<p class="success">Avatar updated.</p>{/if}