UI:Refactor create find modal UI and update finds list/header layout

- Replace Modal with Sheet for create find modal - Redesign form fields
and file upload UI - Add responsive mobile support for modal - Update
FindsList to support optional title hiding - Move finds section header
to sticky header in main page - Adjust styles for improved layout and
responsiveness
This commit is contained in:
2025-10-21 14:44:25 +02:00
parent e1c5846fa4
commit aa9ed77499
3 changed files with 353 additions and 256 deletions

View File

@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import Modal from '$lib/components/Modal.svelte'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/sheet';
import Input from '$lib/components/Input.svelte'; import { Input } from '$lib/components/input';
import Button from '$lib/components/Button.svelte'; import { Label } from '$lib/components/label';
import { Button } from '$lib/components/button';
import { coordinates } from '$lib/stores/location'; import { coordinates } from '$lib/stores/location';
interface Props { interface Props {
@@ -12,7 +13,6 @@
let { isOpen, onClose, onFindCreated }: Props = $props(); let { isOpen, onClose, onFindCreated }: Props = $props();
// Form state
let title = $state(''); let title = $state('');
let description = $state(''); let description = $state('');
let latitude = $state(''); let latitude = $state('');
@@ -24,7 +24,6 @@
let isSubmitting = $state(false); let isSubmitting = $state(false);
let uploadedMedia = $state<Array<{ type: string; url: string; thumbnailUrl: string }>>([]); let uploadedMedia = $state<Array<{ type: string; url: string; thumbnailUrl: string }>>([]);
// Categories
const categories = [ const categories = [
{ value: 'cafe', label: 'Café' }, { value: 'cafe', label: 'Café' },
{ value: 'restaurant', label: 'Restaurant' }, { value: 'restaurant', label: 'Restaurant' },
@@ -35,21 +34,40 @@
{ value: 'other', label: 'Other' } { value: 'other', label: 'Other' }
]; ];
// Auto-fill location when modal opens let showModal = $state(true);
let isMobile = $state(false);
$effect(() => { $effect(() => {
if (isOpen && $coordinates) { if (typeof window === 'undefined') return;
const checkIsMobile = () => {
isMobile = window.innerWidth < 768;
};
checkIsMobile();
window.addEventListener('resize', checkIsMobile);
return () => window.removeEventListener('resize', checkIsMobile);
});
$effect(() => {
if (!showModal) {
onClose();
}
});
$effect(() => {
if (showModal && $coordinates) {
latitude = $coordinates.latitude.toString(); latitude = $coordinates.latitude.toString();
longitude = $coordinates.longitude.toString(); longitude = $coordinates.longitude.toString();
} }
}); });
// Handle file selection
function handleFileChange(event: Event) { function handleFileChange(event: Event) {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
selectedFiles = target.files; selectedFiles = target.files;
} }
// Upload media files
async function uploadMedia(): Promise<void> { async function uploadMedia(): Promise<void> {
if (!selectedFiles || selectedFiles.length === 0) return; if (!selectedFiles || selectedFiles.length === 0) return;
@@ -71,7 +89,6 @@
uploadedMedia = result.media; uploadedMedia = result.media;
} }
// Submit form
async function handleSubmit() { async function handleSubmit() {
const lat = parseFloat(latitude); const lat = parseFloat(latitude);
const lng = parseFloat(longitude); const lng = parseFloat(longitude);
@@ -83,12 +100,10 @@
isSubmitting = true; isSubmitting = true;
try { try {
// Upload media first if any
if (selectedFiles && selectedFiles.length > 0) { if (selectedFiles && selectedFiles.length > 0) {
await uploadMedia(); await uploadMedia();
} }
// Create the find
const response = await fetch('/api/finds', { const response = await fetch('/api/finds', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -110,11 +125,8 @@
throw new Error('Failed to create find'); throw new Error('Failed to create find');
} }
// Reset form and close modal
resetForm(); resetForm();
onClose(); showModal = false;
// Notify parent about new find creation
onFindCreated(new CustomEvent('findCreated', { detail: { reload: true } })); onFindCreated(new CustomEvent('findCreated', { detail: { reload: true } }));
} catch (error) { } catch (error) {
console.error('Error creating find:', error); console.error('Error creating find:', error);
@@ -133,7 +145,6 @@
selectedFiles = null; selectedFiles = null;
uploadedMedia = []; uploadedMedia = [];
// Reset file input
const fileInput = document.querySelector('#media-files') as HTMLInputElement; const fileInput = document.querySelector('#media-files') as HTMLInputElement;
if (fileInput) { if (fileInput) {
fileInput.value = ''; fileInput.value = '';
@@ -142,291 +153,341 @@
function closeModal() { function closeModal() {
resetForm(); resetForm();
onClose(); showModal = false;
} }
</script> </script>
{#if isOpen} {#if isOpen}
<Modal bind:showModal={isOpen} positioning="center"> <Sheet open={showModal} onOpenChange={(open) => (showModal = open)}>
{#snippet header()} <SheetContent side={isMobile ? 'bottom' : 'right'} class="create-find-sheet">
<h2>Create Find</h2> <SheetHeader>
{/snippet} <SheetTitle>Create Find</SheetTitle>
</SheetHeader>
<div class="form-container">
<form <form
onsubmit={(e) => { onsubmit={(e) => {
e.preventDefault(); e.preventDefault();
handleSubmit(); handleSubmit();
}} }}
class="form"
> >
<div class="form-group"> <div class="form-content">
<label for="title">Title *</label> <div class="field">
<Input name="title" placeholder="What did you find?" required bind:value={title} /> <Label for="title">What did you find?</Label>
<span class="char-count">{title.length}/100</span> <Input name="title" placeholder="Amazing coffee shop..." required bind:value={title} />
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
name="description"
placeholder="Tell us about this place..."
maxlength="500"
bind:value={description}
></textarea>
<span class="char-count">{description.length}/500</span>
</div>
<div class="form-group">
<label for="location-name">Location Name</label>
<Input
name="location-name"
placeholder="e.g., Café Belga, Brussels"
bind:value={locationName}
/>
</div>
<div class="form-row">
<div class="form-group">
<label for="latitude">Latitude *</label>
<Input name="latitude" type="text" required bind:value={latitude} />
</div> </div>
<div class="form-group">
<label for="longitude">Longitude *</label> <div class="field">
<Input name="longitude" type="text" required bind:value={longitude} /> <Label for="description">Tell us about it</Label>
<textarea
name="description"
placeholder="The best cappuccino in town..."
maxlength="500"
bind:value={description}
></textarea>
</div> </div>
</div>
<div class="form-group"> <div class="field">
<label for="category">Category</label> <Label for="location-name">Location name</Label>
<select name="category" bind:value={category}> <Input
{#each categories as cat (cat.value)} name="location-name"
<option value={cat.value}>{cat.label}</option> placeholder="Café Central, Brussels"
{/each} bind:value={locationName}
</select> />
</div> </div>
<div class="form-group"> <div class="field-group">
<label for="media-files">Photos/Videos (max 5)</label> <div class="field">
<input <Label for="category">Category</Label>
id="media-files" <select name="category" bind:value={category} class="select">
type="file" {#each categories as cat (cat.value)}
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime" <option value={cat.value}>{cat.label}</option>
multiple {/each}
max="5" </select>
onchange={handleFileChange}
/>
{#if selectedFiles && selectedFiles.length > 0}
<div class="file-preview">
{#each Array.from(selectedFiles) as file (file.name)}
<span class="file-name">{file.name}</span>
{/each}
</div> </div>
{/if} <div class="field">
<Label>Privacy</Label>
<label class="privacy-toggle">
<input type="checkbox" bind:checked={isPublic} />
<span>{isPublic ? 'Public' : 'Private'}</span>
</label>
</div>
</div>
<div class="field">
<Label for="media-files">Add photo or video</Label>
<div class="file-upload">
<input
id="media-files"
type="file"
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime"
onchange={handleFileChange}
class="file-input"
/>
<div class="file-content">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M14.5 4H6A2 2 0 0 0 4 6V18A2 2 0 0 0 6 20H18A2 2 0 0 0 20 18V9.5L14.5 4Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M14 4V10H20"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span>Click to upload</span>
</div>
</div>
{#if selectedFiles && selectedFiles.length > 0}
<div class="file-selected">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M14.5 4H6A2 2 0 0 0 4 6V18A2 2 0 0 0 6 20H18A2 2 0 0 0 20 18V9.5L14.5 4Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M14 4V10H20"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span>{selectedFiles[0].name}</span>
</div>
{/if}
</div>
<div class="field-group">
<div class="field">
<Label for="latitude">Latitude</Label>
<Input name="latitude" type="text" required bind:value={latitude} />
</div>
<div class="field">
<Label for="longitude">Longitude</Label>
<Input name="longitude" type="text" required bind:value={longitude} />
</div>
</div>
</div> </div>
<div class="form-group"> <div class="actions">
<label class="privacy-toggle"> <Button variant="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
<input type="checkbox" bind:checked={isPublic} />
<span class="checkmark"></span>
Make this find public
</label>
<p class="privacy-help">
{isPublic ? 'Everyone can see this find' : 'Only your friends can see this find'}
</p>
</div>
<div class="form-actions">
<button
type="button"
class="button secondary"
onclick={closeModal}
disabled={isSubmitting}
>
Cancel Cancel
</button> </Button>
<Button type="submit" disabled={isSubmitting || !title.trim()}> <Button type="submit" disabled={isSubmitting || !title.trim()}>
{isSubmitting ? 'Creating...' : 'Create Find'} {isSubmitting ? 'Creating...' : 'Create Find'}
</Button> </Button>
</div> </div>
</form> </form>
</div> </SheetContent>
</Modal> </Sheet>
{/if} {/if}
<style> <style>
.form-container { :global(.create-find-sheet) {
padding: 24px; padding: 0 !important;
max-height: 70vh; width: 100%;
max-width: 500px;
height: 100vh;
border-radius: 0;
}
@media (max-width: 767px) {
:global(.create-find-sheet) {
height: 90vh;
border-radius: 12px 12px 0 0;
}
}
.form {
height: 100%;
display: flex;
flex-direction: column;
}
.form-content {
flex: 1;
padding: 1.5rem;
overflow-y: auto; overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
} }
.form-group { .field {
margin-bottom: 20px; display: flex;
flex-direction: column;
gap: 0.5rem;
} }
.form-row { .field-group {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 16px; gap: 1rem;
} }
label { @media (max-width: 640px) {
display: block; .field-group {
margin-bottom: 8px; grid-template-columns: 1fr;
font-weight: 500; }
color: #333;
font-size: 0.9rem;
font-family: 'Washington', serif;
} }
textarea { textarea {
width: 100%; min-height: 80px;
padding: 1.25rem 1.5rem; padding: 0.75rem;
border: none; border: 1px solid hsl(var(--border));
border-radius: 2rem;
background-color: #e0e0e0;
font-size: 1rem;
color: #333;
outline: none;
transition: background-color 0.2s ease;
resize: vertical;
min-height: 100px;
font-family: inherit;
}
textarea:focus {
background-color: #d5d5d5;
}
textarea::placeholder {
color: #888;
}
select {
width: 100%;
padding: 1.25rem 1.5rem;
border: none;
border-radius: 2rem;
background-color: #e0e0e0;
font-size: 1rem;
color: #333;
outline: none;
transition: background-color 0.2s ease;
cursor: pointer;
}
select:focus {
background-color: #d5d5d5;
}
input[type='file'] {
width: 100%;
padding: 12px;
border: 2px dashed #ccc;
border-radius: 8px; border-radius: 8px;
background-color: #f9f9f9; background: hsl(var(--background));
cursor: pointer; font-family: inherit;
font-size: 0.875rem;
resize: vertical;
outline: none;
transition: border-color 0.2s ease; transition: border-color 0.2s ease;
} }
input[type='file']:hover { textarea:focus {
border-color: #999; border-color: hsl(var(--primary));
box-shadow: 0 0 0 1px hsl(var(--primary));
} }
.char-count { textarea::placeholder {
font-size: 0.8rem; color: hsl(var(--muted-foreground));
color: #666;
text-align: right;
display: block;
margin-top: 4px;
} }
.file-preview { .select {
margin-top: 8px; padding: 0.75rem;
display: flex; border: 1px solid hsl(var(--border));
flex-wrap: wrap; border-radius: 8px;
gap: 8px; background: hsl(var(--background));
font-size: 0.875rem;
outline: none;
transition: border-color 0.2s ease;
cursor: pointer;
} }
.file-name { .select:focus {
background-color: #f0f0f0; border-color: hsl(var(--primary));
padding: 4px 8px; box-shadow: 0 0 0 1px hsl(var(--primary));
border-radius: 12px;
font-size: 0.8rem;
color: #666;
} }
.privacy-toggle { .privacy-toggle {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem;
cursor: pointer; cursor: pointer;
font-weight: normal; font-size: 0.875rem;
padding: 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--background));
transition: background-color 0.2s ease;
}
.privacy-toggle:hover {
background: hsl(var(--muted));
} }
.privacy-toggle input[type='checkbox'] { .privacy-toggle input[type='checkbox'] {
margin-right: 8px; width: 16px;
width: 18px; height: 16px;
height: 18px; accent-color: hsl(var(--primary));
} }
.privacy-help { .file-upload {
font-size: 0.8rem; position: relative;
color: #666; border: 2px dashed hsl(var(--border));
margin-top: 4px; border-radius: 8px;
margin-left: 26px; background: hsl(var(--muted) / 0.3);
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 32px;
padding-top: 20px;
border-top: 1px solid #e5e5e5;
}
.button {
flex: 1;
padding: 1.25rem 2rem;
border: none;
border-radius: 2rem;
font-size: 1rem;
font-weight: 500;
transition: all 0.2s ease; transition: all 0.2s ease;
cursor: pointer; cursor: pointer;
height: 3.5rem;
} }
.button:disabled { .file-upload:hover {
opacity: 0.6; border-color: hsl(var(--primary));
cursor: not-allowed; background: hsl(var(--muted) / 0.5);
transform: none;
} }
.button:disabled:hover { .file-input {
transform: none; position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
} }
.button.secondary { .file-content {
background-color: #6c757d; display: flex;
color: white; flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
gap: 0.5rem;
color: hsl(var(--muted-foreground));
} }
.button.secondary:hover:not(:disabled) { .file-content span {
background-color: #5a6268; font-size: 0.875rem;
transform: translateY(-1px); }
.file-selected {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: hsl(var(--muted));
border: 1px solid hsl(var(--border));
border-radius: 8px;
font-size: 0.875rem;
margin-top: 0.5rem;
}
.file-selected svg {
color: hsl(var(--primary));
flex-shrink: 0;
}
.file-selected span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
display: flex;
gap: 1rem;
padding: 1.5rem;
border-top: 1px solid hsl(var(--border));
background: hsl(var(--background));
}
.actions :global(button) {
flex: 1;
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.form-container { .form-content {
padding: 16px; padding: 1rem;
gap: 1rem;
} }
.form-row { .actions {
grid-template-columns: 1fr; padding: 1rem;
}
.form-actions {
flex-direction: column; flex-direction: column;
} }
.actions :global(button) {
flex: none;
}
} }
</style> </style>

View File

@@ -26,6 +26,7 @@
title?: string; title?: string;
showEmpty?: boolean; showEmpty?: boolean;
emptyMessage?: string; emptyMessage?: string;
hideTitle?: boolean;
} }
let { let {
@@ -33,7 +34,8 @@
onFindExplore, onFindExplore,
title = 'Finds', title = 'Finds',
showEmpty = true, showEmpty = true,
emptyMessage = 'No finds to display' emptyMessage = 'No finds to display',
hideTitle = false
}: FindsListProps = $props(); }: FindsListProps = $props();
function handleFindExplore(id: string) { function handleFindExplore(id: string) {
@@ -42,9 +44,11 @@
</script> </script>
<section class="finds-feed"> <section class="finds-feed">
<div class="feed-header"> {#if !hideTitle}
<h2 class="feed-title">{title}</h2> <div class="feed-header">
</div> <h2 class="feed-title">{title}</h2>
</div>
{/if}
{#if finds.length > 0} {#if finds.length > 0}
<div class="feed-container"> <div class="feed-container">
@@ -98,6 +102,7 @@
<style> <style>
.finds-feed { .finds-feed {
width: 100%; width: 100%;
padding: 0 24px 24px 24px;
} }
.feed-header { .feed-header {

View File

@@ -220,19 +220,22 @@
</div> </div>
<div class="finds-section"> <div class="finds-section">
<div class="finds-header"> <div class="finds-sticky-header">
<div class="finds-title-section"> <div class="finds-header-content">
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} /> <div class="finds-title-section">
<h2 class="finds-title">Finds</h2>
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
</div>
<Button onclick={openCreateModal} class="create-find-button">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
</svg>
Create Find
</Button>
</div> </div>
<FindsList {finds} onFindExplore={handleFindExplore} />
<Button onclick={openCreateModal} class="create-find-button">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
</svg>
Create Find
</Button>
</div> </div>
<FindsList {finds} onFindExplore={handleFindExplore} hideTitle={true} />
</div> </div>
</main> </main>
@@ -288,27 +291,44 @@
.finds-section { .finds-section {
background: white; background: white;
border-radius: 12px; border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: relative; position: relative;
} }
.finds-header { .finds-sticky-header {
position: relative; position: sticky;
top: 0;
z-index: 50;
background: white;
border-bottom: 1px solid hsl(var(--border));
padding: 24px 24px 16px 24px;
border-radius: 12px 12px 0 0;
}
.finds-header-content {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
} }
.finds-title-section { .finds-title-section {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 20px; gap: 1rem;
flex: 1;
}
.finds-title {
font-family: 'Washington', serif;
font-size: 1.875rem;
font-weight: 700;
margin: 0;
color: hsl(var(--foreground));
} }
:global(.create-find-button) { :global(.create-find-button) {
position: absolute; flex-shrink: 0;
top: 0;
right: 0;
z-index: 10;
} }
.fab { .fab {
@@ -341,14 +361,25 @@
gap: 16px; gap: 16px;
} }
.finds-section { .finds-sticky-header {
padding: 16px; padding: 16px 16px 12px 16px;
}
.finds-header-content {
flex-direction: column;
align-items: stretch;
gap: 12px;
} }
.finds-title-section { .finds-title-section {
flex-direction: column; flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 12px; gap: 12px;
align-items: stretch; }
.finds-title {
font-size: 1.5rem;
} }
:global(.create-find-button) { :global(.create-find-button) {
@@ -369,8 +400,8 @@
padding: 12px; padding: 12px;
} }
.finds-section { .finds-sticky-header {
padding: 12px; padding: 12px 12px 8px 12px;
} }
} }
</style> </style>