feat(ui): add drop and paste image uploads
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user