36
spa/src/components/entry/entry-card.tsx
Normal file
36
spa/src/components/entry/entry-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
170
spa/src/components/entry/entry-form.tsx
Normal file
170
spa/src/components/entry/entry-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
55
spa/src/components/entry/entry-list.tsx
Normal file
55
spa/src/components/entry/entry-list.tsx
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user