feat(04-03): create TagInput component and integrate into EntryCard
- Create TagInput.svelte with Svelecte for multi-select autocomplete - Support creating new tags inline via creatable mode - Display tags on collapsed entry cards (max 3 with +N indicator) - Add TagInput in expanded view for editing entry tags - Pass availableTags through EntryList to EntryCard - Handle tag changes with immediate save via updateTags action
This commit is contained in:
@@ -1,20 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Entry, Image } from '$lib/server/db/schema';
|
import type { Entry, Image, Tag } from '$lib/server/db/schema';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { invalidateAll } from '$app/navigation';
|
import { invalidateAll } from '$app/navigation';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import ImageGallery from './ImageGallery.svelte';
|
import ImageGallery from './ImageGallery.svelte';
|
||||||
import ImageUpload from './ImageUpload.svelte';
|
import ImageUpload from './ImageUpload.svelte';
|
||||||
|
import TagInput from './TagInput.svelte';
|
||||||
|
|
||||||
interface EntryWithImages extends Entry {
|
interface EntryWithData extends Entry {
|
||||||
images: Image[];
|
images: Image[];
|
||||||
|
tags: Tag[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
entry: EntryWithImages;
|
entry: EntryWithData;
|
||||||
|
availableTags: Tag[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let { entry }: Props = $props();
|
let { entry, availableTags }: Props = $props();
|
||||||
|
|
||||||
// Expand/collapse state
|
// Expand/collapse state
|
||||||
let expanded = $state(false);
|
let expanded = $state(false);
|
||||||
@@ -28,6 +31,7 @@
|
|||||||
let editContent = $state(entry.content);
|
let editContent = $state(entry.content);
|
||||||
let editType = $state(entry.type);
|
let editType = $state(entry.type);
|
||||||
let editDueDate = $state(entry.dueDate || '');
|
let editDueDate = $state(entry.dueDate || '');
|
||||||
|
let editTags = $state<Tag[]>(entry.tags || []);
|
||||||
|
|
||||||
// Sync edit state when entry changes (after invalidateAll)
|
// Sync edit state when entry changes (after invalidateAll)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -36,6 +40,7 @@
|
|||||||
editContent = entry.content;
|
editContent = entry.content;
|
||||||
editType = entry.type;
|
editType = entry.type;
|
||||||
editDueDate = entry.dueDate || '';
|
editDueDate = entry.dueDate || '';
|
||||||
|
editTags = entry.tags || [];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,6 +190,22 @@
|
|||||||
await invalidateAll();
|
await invalidateAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleTagsChange(newTags: Tag[]) {
|
||||||
|
editTags = newTags;
|
||||||
|
|
||||||
|
// Save immediately (no debounce for tags)
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('id', entry.id);
|
||||||
|
formData.append('tags', JSON.stringify(newTags.map((t) => t.name)));
|
||||||
|
|
||||||
|
await fetch('?/updateTags', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
await invalidateAll();
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCameraInput(e: Event) {
|
async function handleCameraInput(e: Event) {
|
||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
@@ -289,6 +310,18 @@
|
|||||||
>
|
>
|
||||||
{entry.content}
|
{entry.content}
|
||||||
</p>
|
</p>
|
||||||
|
{#if entry.tags?.length > 0}
|
||||||
|
<div class="flex flex-wrap gap-1 mt-1">
|
||||||
|
{#each entry.tags.slice(0, 3) as tag}
|
||||||
|
<span class="text-xs px-2 py-0.5 bg-gray-100 text-gray-600 rounded-full"
|
||||||
|
>{tag.name}</span
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
{#if entry.tags.length > 3}
|
||||||
|
<span class="text-xs text-gray-400">+{entry.tags.length - 3}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -467,6 +500,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags section -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Tags</label>
|
||||||
|
<TagInput {availableTags} selectedTags={editTags} onchange={handleTagsChange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<label for="edit-type-{entry.id}" class="block text-sm font-medium text-gray-700 mb-1"
|
<label for="edit-type-{entry.id}" class="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Entry, Image } from '$lib/server/db/schema';
|
import type { Entry, Image, Tag } from '$lib/server/db/schema';
|
||||||
import EntryCard from './EntryCard.svelte';
|
import EntryCard from './EntryCard.svelte';
|
||||||
|
|
||||||
interface EntryWithImages extends Entry {
|
interface EntryWithData extends Entry {
|
||||||
images: Image[];
|
images: Image[];
|
||||||
|
tags: Tag[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
entries: EntryWithImages[];
|
entries: EntryWithData[];
|
||||||
|
availableTags: Tag[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let { entries }: Props = $props();
|
let { entries, availableTags }: Props = $props();
|
||||||
|
|
||||||
// Separate entries into pinned and unpinned
|
// Separate entries into pinned and unpinned
|
||||||
let pinnedEntries = $derived(entries.filter(e => e.pinned));
|
let pinnedEntries = $derived(entries.filter(e => e.pinned));
|
||||||
@@ -28,7 +30,7 @@
|
|||||||
<h2 class="text-sm font-medium text-gray-500 uppercase tracking-wide mb-2 px-4 md:px-0">Pinned</h2>
|
<h2 class="text-sm font-medium text-gray-500 uppercase tracking-wide mb-2 px-4 md:px-0">Pinned</h2>
|
||||||
<div class="divide-y divide-gray-100 md:divide-y-0 md:space-y-3">
|
<div class="divide-y divide-gray-100 md:divide-y-0 md:space-y-3">
|
||||||
{#each pinnedEntries as entry (entry.id)}
|
{#each pinnedEntries as entry (entry.id)}
|
||||||
<EntryCard {entry} />
|
<EntryCard {entry} {availableTags} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,7 +39,7 @@
|
|||||||
{#if unpinnedEntries.length > 0}
|
{#if unpinnedEntries.length > 0}
|
||||||
<div class="divide-y divide-gray-100 md:divide-y-0 md:space-y-3">
|
<div class="divide-y divide-gray-100 md:divide-y-0 md:space-y-3">
|
||||||
{#each unpinnedEntries as entry (entry.id)}
|
{#each unpinnedEntries as entry (entry.id)}
|
||||||
<EntryCard {entry} />
|
<EntryCard {entry} {availableTags} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
64
src/lib/components/TagInput.svelte
Normal file
64
src/lib/components/TagInput.svelte
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Svelecte from 'svelecte';
|
||||||
|
import type { Tag } from '$lib/server/db/schema';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
availableTags: Tag[];
|
||||||
|
selectedTags: Tag[];
|
||||||
|
onchange: (tags: Tag[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { availableTags, selectedTags, onchange }: Props = $props();
|
||||||
|
|
||||||
|
// Transform tags to Svelecte format
|
||||||
|
let options = $derived(availableTags.map((t) => ({ value: t.id, label: t.name })));
|
||||||
|
|
||||||
|
// Internal state bound to Svelecte
|
||||||
|
let selectedValues = $state(selectedTags.map((t) => t.id));
|
||||||
|
|
||||||
|
// Sync selected values when selectedTags prop changes
|
||||||
|
$effect(() => {
|
||||||
|
selectedValues = selectedTags.map((t) => t.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleChange(selection: { value: string; label: string }[] | null) {
|
||||||
|
const selected = selection || [];
|
||||||
|
|
||||||
|
// Map back to Tag format, handling new tags (where value === label means new)
|
||||||
|
const tags: Tag[] = selected.map((item) => {
|
||||||
|
// If value matches an existing tag id, use that tag
|
||||||
|
const existing = availableTags.find((t) => t.id === item.value);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
// Otherwise it's a new tag - create placeholder (will be created on save)
|
||||||
|
return {
|
||||||
|
id: item.label, // Use label as temp id for new tags
|
||||||
|
name: item.label,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onchange(tags);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Svelecte
|
||||||
|
{options}
|
||||||
|
value={selectedValues}
|
||||||
|
multiple
|
||||||
|
creatable
|
||||||
|
valueField="value"
|
||||||
|
labelField="label"
|
||||||
|
placeholder="Add tags..."
|
||||||
|
onChange={handleChange}
|
||||||
|
class="tag-input"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(.tag-input) {
|
||||||
|
--sv-bg: white;
|
||||||
|
--sv-border: 1px solid #e5e7eb;
|
||||||
|
--sv-border-radius: 0.5rem;
|
||||||
|
--sv-min-height: 42px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="max-w-2xl mx-auto px-4 py-4">
|
<div class="max-w-2xl mx-auto px-4 py-4">
|
||||||
<EntryList entries={data.entries} />
|
<EntryList entries={data.entries} availableTags={data.allTags} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<QuickCapture />
|
<QuickCapture />
|
||||||
|
|||||||
Reference in New Issue
Block a user