Compare commits

1 Commits

Author SHA1 Message Date
7ac31ccf2c Initial ui redo progress (#1)
Complete UI rewrite using shadcn-svelte for consistency and better design

Reviewed-on: #1
Co-authored-by: June <self@breadone.net>
Co-committed-by: June <self@breadone.net>
2026-02-17 18:29:59 +13:00
12 changed files with 368 additions and 52 deletions

View File

@@ -1,57 +1,319 @@
<script lang="ts">
import { CalendarDate } from "@internationalized/date";
import {
CalendarDate,
today,
getLocalTimeZone,
} from "@internationalized/date";
import { onMount } from "svelte";
import { formatDate } from "$lib/date";
import { createEntry, updateEntry, deleteEntry } from "$lib/upload.ts";
import { createEntry, updateEntry, deleteEntry } from "$lib/upload";
import Button from "$lib/components/ui/button/button.svelte";
import { Textarea } from "$lib/components/ui/textarea";
import { Upload, X } from "@lucide/svelte";
import { marked } from "marked";
let {
dateValue = $bindable<CalendarDate | undefined>(
new CalendarDate(2026, 2, 1),
),
} = $props();
let { dateValue = $bindable<CalendarDate>(today(getLocalTimeZone())) } =
$props();
let edit = $state<boolean>(false);
let isLoading = $state<boolean>(false);
let error = $state<string>("");
let previewImage = $state<string | null>(null);
let fileInput = $state<HTMLInputElement | undefined>(undefined);
let entry = $state({
id: "",
date: dateValue.toString(),
image: "",
content: "",
});
let editingEntry = $state({
id: "",
date: dateValue.toString(),
image: "",
content: "",
});
onMount(async () => {
const res = await fetch(`/api/entry?date=${dateValue.toString()}`);
const data = await res.json();
if (data) {
entry = { date: dateValue.toString(), ...data[0] };
}
await loadEntry();
});
async function loadEntry() {
try {
error = "";
const res = await fetch(`/api/entry?date=${dateValue.toString()}`);
const data = await res.json();
if (data && data.length > 0) {
entry = { ...data[0], date: dateValue.toString() };
previewImage = entry.image || null;
} else {
entry = {
id: "",
date: dateValue.toString(),
image: "",
content: "",
};
previewImage = null;
}
} catch (err) {
error = "Failed to load entry";
console.error(err);
}
}
function startEdit() {
editingEntry = { ...entry };
previewImage = entry.image || null;
edit = true;
}
function cancelEdit() {
edit = false;
error = "";
previewImage = entry.image || null;
}
function handleImageSelect(e: Event) {
const target = e.target as HTMLInputElement;
const files = target.files;
if (files && files.length > 0) {
const file = files[0];
// Validate file type
if (!file.type.startsWith("image/")) {
error = "Please select a valid image file";
return;
}
// Validate file size (5MB max)
if (file.size > 5 * 1024 * 1024) {
error = "Image must be smaller than 5MB";
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const result = event.target?.result as string;
editingEntry.image = result;
previewImage = result;
error = "";
};
reader.onerror = () => {
error = "Failed to read image file";
};
reader.readAsDataURL(file);
}
}
function removeImage() {
editingEntry.image = "";
previewImage = null;
if (fileInput) {
fileInput.value = "";
}
}
async function handleSave() {
try {
isLoading = true;
error = "";
editingEntry.date = dateValue.toString();
if (entry.id) {
editingEntry.id = entry.id;
await updateEntry(editingEntry);
} else {
delete editingEntry.id;
await createEntry(editingEntry);
}
// Reload the entry to get updated data
await loadEntry();
edit = false;
} catch (err) {
error = "Failed to save entry. Please try again.";
console.error(err);
} finally {
isLoading = false;
}
}
async function handleDelete() {
if (!confirm("Are you sure you want to delete this entry?")) {
return;
}
try {
isLoading = true;
error = "";
await deleteEntry(entry);
await loadEntry();
edit = false;
} catch (err) {
error = "Failed to delete entry. Please try again.";
console.error(err);
} finally {
isLoading = false;
}
}
</script>
<div class="flex flex-row items-center space-x-2">
<h1 class="text-3xl mr-auto">{formatDate(entry.date)}</h1>
<div class="w-full max-w-2xl">
<!-- Header -->
<div class="flex flex-row items-center justify-between mb-6">
<h1 class="text-3xl font-bold">{formatDate(entry.date)}</h1>
<div class="flex gap-2">
{#if !edit}
<Button variant="secondary" onclick={startEdit}>Edit</Button>
{:else}
<Button
variant="destructive"
onclick={handleDelete}
disabled={isLoading}
>
Delete
</Button>
<Button
variant="default"
onclick={handleSave}
disabled={isLoading}
>
{isLoading ? "Saving..." : "Save"}
</Button>
<Button
variant="outline"
onclick={cancelEdit}
disabled={isLoading}
>
Cancel
</Button>
{/if}
</div>
</div>
<!-- Error Message -->
{#if error}
<div
class="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md text-destructive text-sm"
>
{error}
</div>
{/if}
<!-- View Mode -->
{#if !edit}
<button
class="btn bg-secondary py-2 px-3 rounded-lg hover:opacity-60 transition-brightness"
onclick={() => (edit = !edit)}>Edit</button
>
<div class="space-y-4">
<!-- Image Display -->
{#if entry.image}
<div class="rounded-lg overflow-hidden border border-input">
<img
src={entry.image}
alt="Entry"
class="w-full h-auto max-h-128 object-cover"
/>
</div>
{:else}
<div
class="rounded-lg border-2 border-dashed border-muted-foreground/25 bg-muted/50 h-48 flex items-center justify-center text-muted-foreground text-sm"
>
No image
</div>
{/if}
<!-- Content Display -->
{#if entry.content}
<div class="rounded-lg border border-input bg-muted/30 p-4">
<div class="text-base whitespace-pre-wrap text-foreground">
{@html marked(entry.content)}
</div>
</div>
{:else}
<div
class="rounded-lg border border-dashed border-muted-foreground/25 bg-muted/50 p-4 text-muted-foreground text-sm text-center"
>
No content yet
</div>
{/if}
</div>
<!-- Edit Mode -->
{:else}
<button
class="bg-destructive py-2 px-3 rounded-lg hover:opacity-60 transition-brightness"
onclick={deleteEntry}
>
delete
</button>
<button
class="bg-primary text-accent py-2 px-3 rounded-lg hover:opacity-60 transition-brightness"
>
save
</button>
<button
class="bg-secondary py-2 px-3 rounded-lg hover:opacity-60 transition-brightness"
onclick={() => (edit = false)}
>
cancel
</button>
<div class="space-y-6">
<!-- Image Picker -->
<div class="space-y-3">
<!-- <label for="image-input" class="text-sm font-medium">
Image
</label> -->
{#if previewImage}
<div
class="relative rounded-lg overflow-hidden border border-input"
>
<img
src={previewImage}
alt="Preview"
class="w-full h-auto max-h-128 object-cover"
/>
<button
type="button"
onclick={removeImage}
class="absolute top-2 right-2 p-2 bg-destructive text-white rounded-md hover:bg-destructive/90 transition-colors"
title="Remove image"
>
<X size={20} />
</button>
</div>
{:else}
<div
class="rounded-lg border-2 border-dashed border-muted-foreground/25 bg-muted/50 p-8 flex flex-col items-center justify-center gap-3 cursor-pointer hover:border-muted-foreground/50 transition-colors"
role="button"
tabindex={0}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
fileInput?.click();
}
}}
onclick={() => fileInput?.click()}
>
<Upload size={32} class="text-muted-foreground" />
<p class="text-sm font-medium text-muted-foreground">
Click to upload or drag and drop
</p>
<p class="text-xs text-muted-foreground">
PNG, JPG, GIF up to 5MB
</p>
</div>
{/if}
<input
bind:this={fileInput}
id="image-input"
type="file"
accept="image/*"
onchange={handleImageSelect}
class="hidden"
/>
</div>
<!-- Content Editor -->
<div class="space-y-3">
<!-- <label for="content-input" class="text-sm font-medium">
Content
</label> -->
<Textarea
id="content-input"
bind:value={editingEntry.content}
placeholder="How was your day?"
class="min-h-64"
/>
<p class="text-xs text-muted-foreground">
{editingEntry.content.length} characters
</p>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,2 @@
export { default as Input } from "./input.svelte";
export type { InputProps } from "./input.svelte";

View File

@@ -0,0 +1,25 @@
<script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLInputAttributes } from "svelte/elements";
export type InputProps = WithElementRef<HTMLInputAttributes>;
</script>
<script lang="ts">
let {
class: className,
type = "text",
ref = $bindable(null),
...restProps
}: InputProps = $props();
</script>
<input
bind:this={ref}
{type}
class={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,2 @@
export { default as Textarea } from "./textarea.svelte";
export type { TextareaProps } from "./textarea.svelte";

View File

@@ -0,0 +1,28 @@
<script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLTextareaAttributes } from "svelte/elements";
export type TextareaProps = WithElementRef<HTMLTextareaAttributes> & {
class?: string;
value?: string;
};
</script>
<script lang="ts">
let {
class: className,
value = $bindable(""),
ref = $bindable(null),
...restProps
}: TextareaProps = $props();
</script>
<textarea
bind:this={ref}
bind:value
class={cn(
"flex min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...restProps}
></textarea>

View File

@@ -57,17 +57,11 @@ export async function updateEntry(entry) {
}
export async function deleteEntry(entry) {
const res = await fetch(`/api/entry/delete`, {
await fetch(`/api/entry/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ id: entry.id })
});
if (res.ok) {
entry = null;
} else {
alert('Failed to delete entry');
}
}

4
src/params/date.ts Normal file
View File

@@ -0,0 +1,4 @@
export function match(value) {
return /^\d{4}-\d{2}-\d{2}$/.test(value)
}

View File

@@ -16,10 +16,13 @@
let { children, data } = $props();
let dateValue = $derived(data.date);
let entries = $derived(data.entries);
let title = $state("Memento")
$effect(() => {
// Navigate when dateValue changes
goto(`/${dateValue}`);
title = `Memento: ${dateValue}`
});
$effect(() => {
@@ -28,7 +31,10 @@
});
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
<svelte:head>
<title>{title}</title>
<link rel="icon" href={favicon} />
</svelte:head>
<ModeWatcher />
<div

View File

@@ -7,10 +7,4 @@
</script>
{#if data.entry}
<Editor dateValue={data.date} />
{:else}
No entry exists for {data.date}
{/if}
<Editor dateValue={data.date} />

View File

@@ -61,7 +61,7 @@ async function getEntryByMonth(monthString: string) {
const endDate = new Date(year, month, 1, 0, 0, 0, 0)
const entries = await db.select().from(entryTable).where(
sql`${entryTable.date} >= ${startDate.toISOString()}::timestamp AND ${entryTable.date} < ${endDate.toISOString()}::timestamp`
sql`${entryTable.date} >= ${startDate.toISOString()}::timestamp AND ${entryTable.date} < ${endDate.toISOString()}::timestamp ORDER BY ${entryTable.date} DESC`
)
return httpResponse(entries, 200)

View File

@@ -9,7 +9,6 @@ export async function POST({ request }) {
try {
const body = await request.json();
const entry: typeof entryTable.$inferInsert = body
entry.date = new Date(body.date)
await db.insert(entryTable).values(entry)