Files
taskplaner/src/lib/components/EntryCard.svelte
Thomas Richter ce07d79652 feat: parse hashtags from content instead of dedicated tag input
Tags are now automatically extracted from #hashtags in content text.
Removed TagInput from entry editing, keeping it only in FilterBar.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 21:59:04 +01:00

551 lines
15 KiB
Svelte

<script lang="ts">
import type { Entry, Image, Tag } from '$lib/server/db/schema';
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import { slide } from 'svelte/transition';
import ImageGallery from './ImageGallery.svelte';
import ImageUpload from './ImageUpload.svelte';
import { highlightText } from '$lib/utils/highlightText';
import { highlightHashtags } from '$lib/utils/parseHashtags';
interface EntryWithData extends Entry {
images: Image[];
tags: Tag[];
}
interface Props {
entry: EntryWithData;
searchQuery?: string;
}
let { entry, searchQuery = '' }: Props = $props();
// Expand/collapse state
let expanded = $state(false);
// Image management state
let editImagesMode = $state(false);
let cameraInput: HTMLInputElement;
// Edit state - use $derived to stay in sync with entry prop
let editTitle = $state(entry.title || '');
let editContent = $state(entry.content);
let editType = $state(entry.type);
let editDueDate = $state(entry.dueDate || '');
// Sync edit state when entry changes (after invalidateAll)
$effect(() => {
if (!expanded) {
editTitle = entry.title || '';
editContent = entry.content;
editType = entry.type;
editDueDate = entry.dueDate || '';
}
});
// Debounced auto-save
let saveTimeout: ReturnType<typeof setTimeout>;
let isSaving = $state(false);
// Swipe state
let swipeOffset = $state(0);
let isConfirmingDelete = $state(false);
let touchStartX = 0;
let isSwiping = $state(false);
function handleTouchStart(e: TouchEvent) {
// Ignore if touch started on a button or interactive element
const target = e.target as HTMLElement;
if (target.closest('button, input, textarea, select, a, [role="button"]')) {
return;
}
touchStartX = e.touches[0].clientX;
isSwiping = true;
}
function handleTouchMove(e: TouchEvent) {
if (!isSwiping) return;
const currentX = e.touches[0].clientX;
const diff = currentX - touchStartX;
// Only allow left swipe (negative diff), cap at -100px
if (diff < 0) {
swipeOffset = Math.max(diff, -100);
}
}
function handleTouchEnd() {
isSwiping = false;
// If swiped far enough, show confirmation
if (swipeOffset < -60) {
isConfirmingDelete = true;
swipeOffset = -100; // Snap to reveal delete area
} else {
swipeOffset = 0; // Snap back
}
}
function cancelDelete() {
isConfirmingDelete = false;
swipeOffset = 0;
}
async function confirmDelete() {
const formData = new FormData();
formData.append('id', entry.id);
await fetch('?/delete', {
method: 'POST',
body: formData
});
await invalidateAll();
}
async function debouncedSave() {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => {
isSaving = true;
const formData = new FormData();
formData.append('id', entry.id);
formData.append('title', editTitle);
formData.append('content', editContent);
formData.append('type', editType);
try {
await fetch('?/update', {
method: 'POST',
body: formData
});
// Refresh data so list shows updated values
await invalidateAll();
} finally {
isSaving = false;
}
}, 400);
}
function handleInput() {
debouncedSave();
}
function toggleExpand() {
if (expanded) {
// Collapsing - reset edit state to current entry values
editTitle = entry.title || '';
editContent = entry.content;
editType = entry.type;
}
expanded = !expanded;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleExpand();
}
}
async function handleDeleteImage(imageId: string) {
const formData = new FormData();
formData.append('imageId', imageId);
await fetch('?/deleteImage', {
method: 'POST',
body: formData
});
await invalidateAll();
}
function handleUploadComplete() {
// invalidateAll is called by ImageUpload, so nothing extra needed here
}
async function handleTogglePin() {
const formData = new FormData();
formData.append('id', entry.id);
await fetch('?/togglePin', {
method: 'POST',
body: formData
});
await invalidateAll();
}
async function handleDueDateChange(e: Event) {
const input = e.target as HTMLInputElement;
editDueDate = input.value;
const formData = new FormData();
formData.append('id', entry.id);
formData.append('dueDate', input.value);
await fetch('?/updateDueDate', {
method: 'POST',
body: formData
});
await invalidateAll();
}
async function handleCameraInput(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file || !file.type.startsWith('image/')) return;
const formData = new FormData();
formData.append('image', file);
formData.append('entryId', entry.id);
await fetch('?/uploadImage', {
method: 'POST',
body: formData
});
await invalidateAll();
input.value = '';
}
</script>
<div class="relative overflow-hidden">
<!-- Delete background revealed during swipe -->
<div
class="absolute inset-y-0 right-0 w-24 bg-red-500 flex items-center justify-center"
aria-hidden="true"
>
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</div>
<!-- Swipeable entry card -->
<article
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
style="transform: translateX({swipeOffset}px); transition: {isSwiping
? 'none'
: 'transform 0.2s'}"
class="p-4 border-b border-gray-100 md:border md:rounded-lg md:shadow-sm md:mb-3 bg-white relative"
>
<!-- Collapsed view - clickable header -->
<div class="flex items-start gap-3">
{#if entry.type === 'task'}
<form method="POST" action="?/toggleComplete" use:enhance>
<input type="hidden" name="id" value={entry.id} />
<button
type="submit"
class="w-6 h-6 rounded border-2 border-gray-300 flex items-center justify-center touch-target
{entry.status === 'done' ? 'bg-green-500 border-green-500' : 'hover:border-gray-400'}"
aria-label={entry.status === 'done' ? 'Mark as incomplete' : 'Mark as complete'}
>
{#if entry.status === 'done'}
<svg
class="w-4 h-4 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{/if}
</button>
</form>
{:else}
<span
class="w-6 h-6 rounded-full bg-purple-100 flex items-center justify-center flex-shrink-0"
>
<span class="text-purple-600 text-xs font-medium">T</span>
</span>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex-1 min-w-0 cursor-pointer"
role="button"
tabindex="0"
onclick={toggleExpand}
onkeydown={handleKeydown}
>
<h3
class="font-medium text-gray-900 text-base md:text-lg {entry.status === 'done'
? 'line-through text-gray-400'
: ''}"
>
{#if !expanded && searchQuery}
{@html highlightText(entry.title || 'Untitled', searchQuery)}
{:else}
{entry.title || 'Untitled'}
{/if}
</h3>
{#if !expanded}
<p
class="text-gray-600 text-sm md:text-base line-clamp-2 {entry.status === 'done'
? 'text-gray-400'
: ''}"
>
{#if searchQuery}
{@html highlightText(entry.content, searchQuery)}
{:else}
{@html highlightHashtags(entry.content)}
{/if}
</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}
</div>
<div class="flex items-center gap-2">
{#if isSaving}
<span class="text-xs text-gray-400">Saving...</span>
{/if}
{#if entry.pinned}
<svg class="w-4 h-4 text-yellow-500" fill="currentColor" viewBox="0 0 20 20" aria-label="Pinned">
<path d="M5 4a2 2 0 012-2h6a2 2 0 012 2v3.586l1.707 1.707A1 1 0 0117 10v1a1 1 0 01-1 1h-4v5a1 1 0 11-2 0v-5H6a1 1 0 01-1-1v-1a1 1 0 01.293-.707L7 7.586V4z" />
</svg>
{/if}
{#if entry.dueDate && !expanded}
<span class="text-xs text-gray-500">{entry.dueDate}</span>
{/if}
{#if entry.images?.length > 0 && !expanded}
<span
class="flex items-center gap-1 text-xs text-gray-500"
aria-label="{entry.images.length} image{entry.images.length > 1 ? 's' : ''}"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
{entry.images.length}
</span>
{/if}
<span
class="text-xs px-2 py-1 rounded-full {entry.type === 'task'
? 'bg-blue-100 text-blue-700'
: 'bg-purple-100 text-purple-700'}"
>
{entry.type}
</span>
<button
type="button"
onclick={toggleExpand}
class="p-1 hover:bg-gray-100 rounded"
aria-label={expanded ? 'Collapse entry' : 'Expand entry'}
>
<svg
class="w-5 h-5 text-gray-400 transform transition-transform {expanded
? 'rotate-180'
: ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
</div>
</div>
<!-- Expanded view - edit fields -->
{#if expanded}
<div transition:slide={{ duration: 200 }} class="mt-4 pl-9 space-y-3">
<div>
<label for="edit-title-{entry.id}" class="block text-sm font-medium text-gray-700 mb-1"
>Title</label
>
<input
id="edit-title-{entry.id}"
bind:value={editTitle}
oninput={handleInput}
placeholder="Add a title..."
class="w-full px-3 py-2 border border-gray-200 rounded-lg text-base focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label for="edit-content-{entry.id}" class="block text-sm font-medium text-gray-700 mb-1"
>Content</label
>
<textarea
id="edit-content-{entry.id}"
bind:value={editContent}
oninput={handleInput}
rows="4"
class="w-full px-3 py-2 border border-gray-200 rounded-lg text-base resize-y focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
></textarea>
</div>
<!-- Images section -->
{#if entry.images?.length > 0}
<div>
<div class="flex items-center justify-between mb-2">
<label class="text-sm font-medium text-gray-700">Images</label>
<button
type="button"
onclick={() => (editImagesMode = !editImagesMode)}
class="text-sm text-blue-600 hover:text-blue-700"
>
{editImagesMode ? 'Done' : 'Edit'}
</button>
</div>
<ImageGallery
images={entry.images}
entryId={entry.id}
editMode={editImagesMode}
onDelete={handleDeleteImage}
/>
</div>
{/if}
<!-- Add image section -->
<div>
<label class="text-sm font-medium text-gray-700 mb-2 block">Add Image</label>
<div class="flex gap-2 items-start">
<div class="flex-1">
<ImageUpload entryId={entry.id} onUploadComplete={handleUploadComplete} />
</div>
<button
type="button"
onclick={() => cameraInput?.click()}
class="px-4 py-2 border border-gray-300 rounded-lg flex items-center gap-2 hover:bg-gray-50 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
Camera
</button>
<input
bind:this={cameraInput}
type="file"
accept="image/*"
capture="environment"
onchange={handleCameraInput}
class="hidden"
/>
</div>
</div>
<!-- Pin and Due Date row -->
<div class="flex items-center gap-4">
<button
type="button"
onclick={handleTogglePin}
class="p-2 rounded-lg hover:bg-gray-100 {entry.pinned ? 'text-yellow-500' : 'text-gray-400'}"
aria-label={entry.pinned ? 'Unpin entry' : 'Pin entry'}
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M5 4a2 2 0 012-2h6a2 2 0 012 2v3.586l1.707 1.707A1 1 0 0117 10v1a1 1 0 01-1 1h-4v5a1 1 0 11-2 0v-5H6a1 1 0 01-1-1v-1a1 1 0 01.293-.707L7 7.586V4z" />
</svg>
</button>
<div>
<label for="due-date-{entry.id}" class="block text-sm font-medium text-gray-700 mb-1">Due Date</label>
<input
id="due-date-{entry.id}"
type="date"
value={editDueDate}
onchange={handleDueDateChange}
class="px-3 py-2 border border-gray-200 rounded-lg text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div class="flex items-center justify-between">
<div>
<label for="edit-type-{entry.id}" class="block text-sm font-medium text-gray-700 mb-1"
>Type</label
>
<select
id="edit-type-{entry.id}"
bind:value={editType}
onchange={handleInput}
class="px-3 py-2 border border-gray-200 rounded-lg text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="thought">Thought</option>
<option value="task">Task</option>
</select>
</div>
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={entry.id} />
<button
type="submit"
class="px-4 py-2 text-red-600 hover:bg-red-50 rounded-lg touch-target"
onclick={(e) => {
if (!confirm('Delete this entry?')) {
e.preventDefault();
}
}}
>
Delete
</button>
</form>
</div>
</div>
{/if}
</article>
<!-- Delete confirmation overlay -->
{#if isConfirmingDelete}
<div
class="absolute inset-0 bg-white/95 flex items-center justify-center gap-4 z-10"
transition:slide={{ duration: 150 }}
>
<button
onclick={cancelDelete}
class="px-4 py-2 text-gray-600 border border-gray-300 rounded-lg touch-target min-w-[44px] min-h-[44px]"
>
Cancel
</button>
<button
onclick={confirmDelete}
class="px-4 py-2 bg-red-600 text-white rounded-lg touch-target min-w-[44px] min-h-[44px]"
>
Delete
</button>
</div>
{/if}
</div>