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>
551 lines
15 KiB
Svelte
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>
|
|
|