init
Some checks failed
CI / ci (push) Failing after 1m48s

This commit is contained in:
2026-08-25 23:24:36 +02:00
commit 95739892de
466 changed files with 33918 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
import { format } from "date-fns"
import { Link } from "@tanstack/react-router"
import { Card, CardContent } from "@/components/ui/card"
import { MoodBadge } from "@/components/mood/mood-badge"
import type { EntryResponse } from "@/api/schema"
interface EntryCardProps {
entry: EntryResponse
}
export function EntryCard({ entry }: EntryCardProps) {
const date = new Date(entry.loggedAt)
return (
<Link to="/entry/$id" params={{ id: entry.id }}>
<Card className="cursor-pointer transition-colors hover:bg-muted/50">
<CardContent className="flex items-start gap-3 p-4">
<MoodBadge value={entry.mood} />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{entry.moodLabel}</span>
<span className="text-xs text-muted-foreground">
{format(date, "h:mm a")}
</span>
</div>
{entry.content && (
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{entry.content}
</p>
)}
</div>
</CardContent>
</Card>
</Link>
)
}

View File

@@ -0,0 +1,170 @@
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Textarea } from "@/components/ui/textarea"
import { MoodPicker } from "@/components/mood/mood-picker"
import { ActivityPicker } from "@/components/activity/activity-picker"
import { PhotoPicker } from "@/components/media/photo-picker"
import { VoiceMemoPicker } from "@/components/media/voice-memo-picker"
import { useActivities } from "@/hooks/use-activities"
import { media as mediaApi } from "@/api/client"
import { toast } from "@/components/ui/toast"
import type { MoodValue } from "@/lib/mood"
import type { MediaItem } from "@/lib/media"
import { getExistingIds, getPendingFiles } from "@/lib/media"
export interface EntryFormData {
mood: number
content?: string
activityIds?: string[]
photoIds?: string[]
voiceMemoIds?: string[]
}
interface EntryFormProps {
title: string
submitLabel: string
isPending: boolean
onSubmit: (data: EntryFormData) => void
initial?: {
mood?: number
content?: string
activities?: string[]
photos?: string[]
voiceMemos?: string[]
}
}
export function EntryForm({
title,
submitLabel,
isPending,
onSubmit,
initial,
}: EntryFormProps) {
const [mood, setMood] = useState<MoodValue | undefined>(
initial?.mood as MoodValue | undefined
)
const [content, setContent] = useState(initial?.content ?? "")
const [selectedActivities, setSelectedActivities] = useState<string[]>(
initial?.activities ?? []
)
const [photoItems, setPhotoItems] = useState<MediaItem[]>(
(initial?.photos ?? []).map((id) => ({ type: "existing" as const, id }))
)
const [voiceMemoItems, setVoiceMemoItems] = useState<MediaItem[]>(
(initial?.voiceMemos ?? []).map((id) => ({ type: "existing" as const, id }))
)
const [uploading, setUploading] = useState(false)
const { data: activities } = useActivities()
const toggleActivity = (id: string) => {
setSelectedActivities((prev) =>
prev.includes(id) ? prev.filter((a) => a !== id) : [...prev, id]
)
}
const handleSubmit = async () => {
if (!mood) return
setUploading(true)
try {
const photoIds = [...getExistingIds(photoItems)]
for (const file of getPendingFiles(photoItems)) {
const result = await mediaApi.uploadPhoto(file)
photoIds.push(result.id)
}
const voiceMemoIds = [...getExistingIds(voiceMemoItems)]
for (const file of getPendingFiles(voiceMemoItems)) {
const result = await mediaApi.uploadVoiceMemo(file)
voiceMemoIds.push(result.id)
}
onSubmit({
mood,
content: content.trim() || undefined,
activityIds: selectedActivities.length ? selectedActivities : undefined,
photoIds: photoIds.length ? photoIds : undefined,
voiceMemoIds: voiceMemoIds.length ? voiceMemoIds : undefined,
})
} catch {
toast.add({ title: "Failed to upload media", type: "error" })
} finally {
setUploading(false)
}
}
const busy = isPending || uploading
return (
<div className="flex flex-col gap-6">
<h1 className="text-center text-xl font-bold">{title}</h1>
<MoodPicker value={mood} onChange={setMood} size="lg" />
{activities?.length ? (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Activities</CardTitle>
</CardHeader>
<CardContent>
<ActivityPicker
activities={activities}
selected={selectedActivities}
onToggle={toggleActivity}
/>
</CardContent>
</Card>
) : null}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Note</CardTitle>
</CardHeader>
<CardContent>
<Textarea
placeholder="What's on your mind?"
value={content}
onChange={(e) => setContent(e.target.value)}
rows={3}
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Photos</CardTitle>
</CardHeader>
<CardContent>
<PhotoPicker items={photoItems} onChange={setPhotoItems} />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Voice memos</CardTitle>
</CardHeader>
<CardContent>
<VoiceMemoPicker
items={voiceMemoItems}
onChange={setVoiceMemoItems}
/>
</CardContent>
</Card>
<Button
size="lg"
onClick={handleSubmit}
disabled={!mood || busy}
className="w-full"
>
{uploading
? "Uploading media..."
: isPending
? "Saving..."
: submitLabel}
</Button>
</div>
)
}

View File

@@ -0,0 +1,55 @@
import { format, isToday, isYesterday } from "date-fns"
import type { EntryResponse } from "@/api/schema"
import { EntryCard } from "./entry-card"
interface EntryListProps {
entries: EntryResponse[]
limit?: number
}
export function EntryList({ entries, limit }: EntryListProps) {
const sliced = limit ? entries.slice(0, limit) : entries
const grouped = groupByDay(sliced)
return (
<div className="flex flex-col gap-4">
{grouped.map(({ label, entries }) => (
<div key={label} className="flex flex-col gap-2">
<h3 className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
{label}
</h3>
{entries.map((entry) => (
<EntryCard key={entry.id} entry={entry} />
))}
</div>
))}
</div>
)
}
function groupByDay(
entries: EntryResponse[]
): Array<{ label: string; entries: EntryResponse[] }> {
const groups = new Map<string, EntryResponse[]>()
for (const entry of entries) {
const date = new Date(entry.loggedAt)
const key = format(date, "yyyy-MM-dd")
if (!groups.has(key)) {
groups.set(key, [])
}
groups.get(key)!.push(entry)
}
return Array.from(groups.entries()).map(([key, entries]) => ({
label: formatDayLabel(new Date(key)),
entries,
}))
}
function formatDayLabel(date: Date): string {
if (isToday(date)) return "Today"
if (isYesterday(date)) return "Yesterday"
return format(date, "EEEE, MMMM d, yyyy")
}