207 lines
5.3 KiB
Svelte
207 lines
5.3 KiB
Svelte
<script>
|
|
import { storage } from '../../firebase.js';
|
|
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage';
|
|
|
|
/** @type {{ photos: string[], onchange: (photos: string[]) => void }} */
|
|
let { photos, onchange } = $props();
|
|
|
|
let fileInput;
|
|
let uploading = $state(false);
|
|
let uploadError = $state('');
|
|
|
|
function remove(index) {
|
|
onchange(photos.filter((_, i) => i !== index));
|
|
uploadError = '';
|
|
}
|
|
|
|
async function addFiles(e) {
|
|
const files = Array.from(e.currentTarget.files ?? []);
|
|
if (!files.length) return;
|
|
uploading = true;
|
|
uploadError = '';
|
|
try {
|
|
const urls = await Promise.all(files.map(uploadPhoto));
|
|
onchange([...photos, ...urls]);
|
|
} catch (err) {
|
|
uploadError = err?.message ?? 'Upload failed. Check Firebase Storage rules.';
|
|
} finally {
|
|
uploading = false;
|
|
e.currentTarget.value = '';
|
|
}
|
|
}
|
|
|
|
/** @param {File} file */
|
|
async function uploadPhoto(file) {
|
|
const storageRef = ref(storage, `photos/${crypto.randomUUID()}`);
|
|
await uploadBytes(storageRef, file);
|
|
return getDownloadURL(storageRef);
|
|
}
|
|
</script>
|
|
|
|
<div class="photo-editor">
|
|
<div class="label-row">
|
|
<span class="label">Photos</span>
|
|
<input bind:this={fileInput} type="file" accept="image/*" multiple onchange={addFiles} hidden />
|
|
</div>
|
|
|
|
{#if photos.length === 0}
|
|
<button type="button" class="empty-zone" onclick={() => fileInput.click()} disabled={uploading}>
|
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2">
|
|
<rect x="3" y="3" width="18" height="18" rx="3"/>
|
|
<circle cx="8.5" cy="8.5" r="1.5"/>
|
|
<path d="M21 15l-5-5L5 21"/>
|
|
</svg>
|
|
<span>{uploading ? 'Uploading…' : 'Click to add photos'}</span>
|
|
</button>
|
|
{:else}
|
|
<div class="grid">
|
|
{#each photos as src, i (src + i)}
|
|
<div class="cell">
|
|
<img {src} alt="photo {i + 1}" />
|
|
<button type="button" class="remove-btn" onclick={() => remove(i)} aria-label="Remove photo">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
|
<path d="M18 6L6 18M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
|
|
<button type="button" class="add-cell" onclick={() => fileInput.click()} disabled={uploading}>
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
|
<path d="M12 5v14M5 12h14"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if uploadError}
|
|
<div class="upload-error">{uploadError}</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.photo-editor {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
}
|
|
|
|
.label-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.label {
|
|
font-size: 11px;
|
|
font-weight: 400;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
color: var(--text-sub);
|
|
flex: 1;
|
|
}
|
|
|
|
.add-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
font-family: var(--sans);
|
|
font-size: 12px;
|
|
font-weight: 300;
|
|
color: var(--accent);
|
|
background: var(--accent-bg);
|
|
border: 1px solid var(--accent-border);
|
|
border-radius: 6px;
|
|
padding: 4px 10px;
|
|
cursor: pointer;
|
|
transition: background 0.15s;
|
|
}
|
|
.add-btn:hover { background: rgba(124,58,237,0.12); }
|
|
|
|
.empty-zone {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 10px;
|
|
height: 120px;
|
|
border: 1.5px dashed var(--border-bright);
|
|
border-radius: 10px;
|
|
color: var(--text-sub);
|
|
font-family: var(--sans);
|
|
font-size: 13px;
|
|
font-weight: 300;
|
|
cursor: pointer;
|
|
background: var(--bg-subtle);
|
|
transition: border-color 0.15s, color 0.15s;
|
|
width: 100%;
|
|
}
|
|
.empty-zone:hover { border-color: var(--accent-border); color: var(--accent); }
|
|
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 6px;
|
|
}
|
|
|
|
.cell {
|
|
position: relative;
|
|
aspect-ratio: 1;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
background: var(--bg-subtle);
|
|
}
|
|
|
|
.cell img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
display: block;
|
|
}
|
|
|
|
.remove-btn {
|
|
position: absolute;
|
|
top: 5px;
|
|
right: 5px;
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 50%;
|
|
border: none;
|
|
background: rgba(0,0,0,0.55);
|
|
color: #fff;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
opacity: 0;
|
|
transition: opacity 0.15s, background 0.15s;
|
|
}
|
|
.cell:hover .remove-btn { opacity: 1; }
|
|
.remove-btn:hover { background: rgba(220,38,38,0.85); }
|
|
|
|
.add-cell {
|
|
aspect-ratio: 1;
|
|
border-radius: 8px;
|
|
border: 1.5px dashed var(--border-bright);
|
|
background: var(--bg-subtle);
|
|
color: var(--text-sub);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
transition: border-color 0.15s, color 0.15s;
|
|
}
|
|
.add-cell:hover { border-color: var(--accent-border); color: var(--accent); }
|
|
|
|
.upload-error {
|
|
font-size: 12px;
|
|
color: #ef4444;
|
|
background: #fef2f2;
|
|
border: 1px solid #fecaca;
|
|
border-radius: 6px;
|
|
padding: 8px 10px;
|
|
line-height: 1.4;
|
|
word-break: break-word;
|
|
}
|
|
</style>
|