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
15 changed files with 484 additions and 49 deletions

View File

@@ -1,30 +1,319 @@
<script lang="ts">
import { CalendarDate } from "@internationalized/date";
import {
CalendarDate,
today,
getLocalTimeZone,
} from "@internationalized/date";
import { onMount } from "svelte";
let {
dateValue = $bindable<CalendarDate | undefined>(
new CalendarDate(2026, 2, 1),
),
} = $props()
let edit = $state(false)
import { formatDate } from "$lib/date";
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>(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({
date: dateValue.toString(),
image: "",
content: ""
})
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()
entry = data
})
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>
{JSON.stringify(entry)}
<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 flex-row items-center justify-between"> -->
<h1 class="text-3xl">{entry["date"]}</h1>
<!-- </div> -->
<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}
<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}
<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

@@ -55,3 +55,13 @@ export async function updateEntry(entry) {
body: JSON.stringify(entry),
});
}
export async function deleteEntry(entry) {
await fetch(`/api/entry/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ id: entry.id })
});
}

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

@@ -1,14 +1,59 @@
<script lang="ts">
import './layout.css';
import favicon from '$lib/assets/favicon.svg';
import { ModeWatcher } from "mode-watcher"
import "./layout.css";
import favicon from "$lib/assets/favicon.svg";
import { ModeWatcher } from "mode-watcher";
import {
CalendarDate,
today,
getLocalTimeZone,
} from "@internationalized/date";
let { children } = $props();
import Header from "$lib/components/header.svelte";
import Calendar from "$lib/components/calendar.svelte";
import EntrySummaryView from "$lib/components/entrySummaryView.svelte";
import { goto } from "$app/navigation";
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(() => {
// Sync dateValue with data.date when it changes
dateValue = data.date;
});
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
<svelte:head>
<title>{title}</title>
<link rel="icon" href={favicon} />
</svelte:head>
<ModeWatcher />
<div class="md:max-w-3/4 mx-auto">
<ModeWatcher />
{@render children()}
<div
class="flex flex-col md:flex-row space-y-4 w-full mt-6 mx-auto md:max-w-3/4"
>
<div class="md:w-1/2 md:order-2">
<Header />
{#key dateValue}
{@render children()}
{/key}
</div>
<div class="md:w-1/2 lg:max-w-1/4 space-y-4 md:mr-4">
<Calendar bind:value={dateValue} />
<ul class="space-y-4">
{#each entries as entry}
<EntrySummaryView {entry} bind:value={dateValue} />
{/each}
</ul>
</div>
</div>

22
src/routes/+layout.ts Normal file
View File

@@ -0,0 +1,22 @@
import { today, getLocalTimeZone, CalendarDate } from "@internationalized/date";
export async function load({ params, fetch }) {
let dateValue;
if (params.date) {
const [year, month, day] = params.date.split("-").map(Number);
dateValue = new CalendarDate(year, month, day);
} else {
dateValue = today(getLocalTimeZone());
}
const res = await fetch(
`/api/entry?month=${dateValue.year}-${dateValue.month}`,
);
const entries = await res.json();
return {
entries: entries,
date: dateValue,
};
}

View File

@@ -13,8 +13,14 @@
</script>
<!-- <Editor {dateValue} /> -->
<div class="flex flex-col md:flex-row space-y-4 w-full mt-6">
<div>
Click on a date to view or edit an entry.
</div>
<!-- <div class="flex flex-col md:flex-row space-y-4 w-full mt-6">
<div class="md:w-1/2 md:order-2">
<Header />
@@ -23,7 +29,7 @@
{/key}
</div>
<div class="md:w-1/2 space-y-4 md:mr-4">
<div class="md:w-1/2 lg:max-w-1/4 space-y-4 md:mr-4">
<Calendar bind:value={dateValue} />
<ul class="space-y-4">
{#each data.all as entry}
@@ -34,4 +40,4 @@
{/each}
</ul>
</div>
</div>
</div> -->

View File

@@ -1,13 +0,0 @@
export const load = async ({ fetch, params }) => {
const res = await fetch("/api/entry/all");
const allEntries = await res.json();
const today = new Date().toISOString().split('T')[0];
const todayEntry = await fetch(`/api/entry?date=${today}`);
const todayEntryData = await todayEntry.json();
return {
all: allEntries,
today: todayEntryData,
};
};

View File

@@ -0,0 +1,10 @@
<script lang="ts">
import Editor from "$lib/components/editor.svelte";
let {
data
} = $props()
</script>
<Editor dateValue={data.date} />

View File

@@ -0,0 +1,6 @@
export async function load({ params, fetch }) {
const res = await fetch(`/api/entry?date=${params.date}`);
const entries = await res.json();
return { entry: entries[0] };
}

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)