feat: goals — "watch N movies in YEAR" with progress bar
Domain: Goal entity, UserSettings (federation toggle), RemoteGoalEntry.
Ports: GoalRepository, UserSettingsRepository, RemoteGoalRepository.
Adapters: sqlite + postgres repos, migrations, AP content query extensions.
Application: CRUD use cases (create/update/delete/get/list), settings use cases.
API: 7 endpoints (/goals CRUD, /users/{id}/goals, /settings) with utoipa docs.
Federation: GoalObject (Note + goal discriminator), outbound broadcast with
per-user toggle, inbound GoalObjectHandler in CompositeObjectHandler.
SPA: API client + hooks, GoalCard (shadcn Card+Progress+DropdownMenu),
GoalSheet (Drawer), profile integration (editable own, read-only others),
federation toggle in settings (Switch).
Classic HTML: glassmorphic goal card on profile, Frutiger Aero styling.
Progress computed from existing reviews — backwards compatible.
This commit is contained in:
70
spa/src/components/goal-card.tsx
Normal file
70
spa/src/components/goal-card.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Check, MoreHorizontal, Pencil, Target, Trash2 } from "lucide-react"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { GoalDto } from "@/lib/api/users"
|
||||
|
||||
type GoalCardProps = {
|
||||
goal: GoalDto
|
||||
editable?: boolean
|
||||
onEdit?: () => void
|
||||
onDelete?: () => void
|
||||
}
|
||||
|
||||
export function GoalCard({ goal, editable, onEdit, onDelete }: GoalCardProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-2 py-3 px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{t("goals.yearGoal", { year: goal.year })}
|
||||
</span>
|
||||
</div>
|
||||
{editable && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="size-7 p-0">
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<Pencil className="mr-2 size-3.5" />
|
||||
{t("common.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDelete} className="text-destructive">
|
||||
<Trash2 className="mr-2 size-3.5" />
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
<Progress value={goal.percentage} className="h-2" />
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{goal.current_count} / {goal.target_count} {t("goals.movies")}
|
||||
</span>
|
||||
<span>{Math.round(goal.percentage)}%</span>
|
||||
</div>
|
||||
{goal.is_complete && (
|
||||
<p className="text-xs text-green-500 flex items-center gap-1">
|
||||
<Check className="size-3" />
|
||||
{t("goals.reached")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
121
spa/src/components/goal-sheet.tsx
Normal file
121
spa/src/components/goal-sheet.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { VisuallyHidden } from "radix-ui"
|
||||
import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useCreateGoal, useUpdateGoal } from "@/hooks/use-goals"
|
||||
import { toast } from "sonner"
|
||||
|
||||
type GoalSheetProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
editYear?: number
|
||||
editTarget?: number
|
||||
}
|
||||
|
||||
export function GoalSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
editYear,
|
||||
editTarget,
|
||||
}: GoalSheetProps) {
|
||||
const { t } = useTranslation()
|
||||
const isEditing = editYear !== undefined
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
const [year, setYear] = useState(editYear ?? currentYear)
|
||||
const [target, setTarget] = useState(editTarget ?? 52)
|
||||
const createMutation = useCreateGoal()
|
||||
const updateMutation = useUpdateGoal()
|
||||
|
||||
function handleClose() {
|
||||
onOpenChange(false)
|
||||
if (!isEditing) {
|
||||
setYear(currentYear)
|
||||
setTarget(52)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (target < 1) return
|
||||
|
||||
if (isEditing) {
|
||||
updateMutation.mutate(
|
||||
{ year, data: { target_count: target } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t("goals.updated"))
|
||||
handleClose()
|
||||
},
|
||||
},
|
||||
)
|
||||
} else {
|
||||
createMutation.mutate(
|
||||
{ year, target_count: target },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t("goals.created"))
|
||||
handleClose()
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||
<DrawerContent className="px-4 pb-8">
|
||||
<VisuallyHidden.Root>
|
||||
<DrawerTitle>
|
||||
{isEditing ? t("goals.editGoal") : t("goals.setGoal")}
|
||||
</DrawerTitle>
|
||||
</VisuallyHidden.Root>
|
||||
|
||||
<div className="mx-auto w-full max-w-sm space-y-6 pt-4">
|
||||
<h2 className="text-lg font-semibold text-center">
|
||||
{isEditing ? t("goals.editGoal") : t("goals.setGoal")}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("goals.year")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={2020}
|
||||
max={2100}
|
||||
value={year}
|
||||
onChange={(e) => setYear(Number(e.target.value))}
|
||||
disabled={isEditing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("goals.targetMovies")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={9999}
|
||||
value={target}
|
||||
onChange={(e) => setTarget(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleSubmit}
|
||||
disabled={isPending || target < 1}
|
||||
>
|
||||
{isPending
|
||||
? t("common.saving")
|
||||
: isEditing
|
||||
? t("common.save")
|
||||
: t("goals.setGoal")}
|
||||
</Button>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
92
spa/src/hooks/use-goals.ts
Normal file
92
spa/src/hooks/use-goals.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import {
|
||||
getGoals,
|
||||
getUserGoals,
|
||||
createGoal,
|
||||
updateGoal,
|
||||
deleteGoal,
|
||||
getSettings,
|
||||
updateSettings,
|
||||
} from "@/lib/api/goals"
|
||||
import type {
|
||||
CreateGoalRequest,
|
||||
UpdateGoalRequest,
|
||||
UpdateUserSettingsRequest,
|
||||
} from "@/lib/api/goals"
|
||||
import { userKeys } from "@/hooks/use-users"
|
||||
|
||||
export const goalKeys = {
|
||||
all: ["goals"] as const,
|
||||
list: () => [...goalKeys.all, "list"] as const,
|
||||
user: (userId: string) => [...goalKeys.all, "user", userId] as const,
|
||||
}
|
||||
|
||||
export const settingsKeys = {
|
||||
all: ["settings"] as const,
|
||||
}
|
||||
|
||||
export function useGoals() {
|
||||
return useQuery({
|
||||
queryKey: goalKeys.list(),
|
||||
queryFn: getGoals,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUserGoals(userId: string) {
|
||||
return useQuery({
|
||||
queryKey: goalKeys.user(userId),
|
||||
queryFn: () => getUserGoals(userId),
|
||||
enabled: !!userId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateGoal() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateGoalRequest) => createGoal(data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: goalKeys.all })
|
||||
qc.invalidateQueries({ queryKey: userKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateGoal() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ year, data }: { year: number; data: UpdateGoalRequest }) =>
|
||||
updateGoal(year, data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: goalKeys.all })
|
||||
qc.invalidateQueries({ queryKey: userKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteGoal() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (year: number) => deleteGoal(year),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: goalKeys.all })
|
||||
qc.invalidateQueries({ queryKey: userKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useSettings() {
|
||||
return useQuery({
|
||||
queryKey: settingsKeys.all,
|
||||
queryFn: getSettings,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateSettings() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: UpdateUserSettingsRequest) => updateSettings(data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: settingsKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
54
spa/src/lib/api/goals.ts
Normal file
54
spa/src/lib/api/goals.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { z } from "zod"
|
||||
import { get, post, put, del } from "./client"
|
||||
import { goalDtoSchema } from "./users"
|
||||
|
||||
export const goalsResponseSchema = z.object({
|
||||
goals: z.array(goalDtoSchema),
|
||||
})
|
||||
export type GoalsResponse = z.infer<typeof goalsResponseSchema>
|
||||
|
||||
export type CreateGoalRequest = {
|
||||
year: number
|
||||
target_count: number
|
||||
}
|
||||
|
||||
export type UpdateGoalRequest = {
|
||||
target_count: number
|
||||
}
|
||||
|
||||
export const userSettingsDtoSchema = z.object({
|
||||
federate_goals: z.boolean(),
|
||||
})
|
||||
export type UserSettingsDto = z.infer<typeof userSettingsDtoSchema>
|
||||
|
||||
export type UpdateUserSettingsRequest = {
|
||||
federate_goals: boolean
|
||||
}
|
||||
|
||||
export function getGoals() {
|
||||
return get<GoalsResponse>("/goals")
|
||||
}
|
||||
|
||||
export function getUserGoals(userId: string) {
|
||||
return get<GoalsResponse>(`/users/${userId}/goals`)
|
||||
}
|
||||
|
||||
export function createGoal(data: CreateGoalRequest) {
|
||||
return post<z.infer<typeof goalDtoSchema>>("/goals", data)
|
||||
}
|
||||
|
||||
export function updateGoal(year: number, data: UpdateGoalRequest) {
|
||||
return put<z.infer<typeof goalDtoSchema>>(`/goals/${year}`, data)
|
||||
}
|
||||
|
||||
export function deleteGoal(year: number) {
|
||||
return del(`/goals/${year}`)
|
||||
}
|
||||
|
||||
export function getSettings() {
|
||||
return get<UserSettingsDto>("/settings")
|
||||
}
|
||||
|
||||
export function updateSettings(data: UpdateUserSettingsRequest) {
|
||||
return put("/settings", data)
|
||||
}
|
||||
@@ -63,6 +63,16 @@ export type MonthActivityDto = z.infer<typeof monthActivityDtoSchema>
|
||||
|
||||
const userDiaryResponseSchema = paginatedSchema(diaryEntryDtoSchema)
|
||||
|
||||
export const goalDtoSchema = z.object({
|
||||
year: z.number(),
|
||||
target_count: z.number(),
|
||||
current_count: z.number(),
|
||||
percentage: z.number(),
|
||||
is_complete: z.boolean(),
|
||||
goal_type: z.string(),
|
||||
})
|
||||
export type GoalDto = z.infer<typeof goalDtoSchema>
|
||||
|
||||
export const userProfileResponseSchema = z.object({
|
||||
user_id: z.string().uuid(),
|
||||
username: z.string(),
|
||||
@@ -74,6 +84,7 @@ export const userProfileResponseSchema = z.object({
|
||||
entries: userDiaryResponseSchema.optional(),
|
||||
history: z.array(monthActivityDtoSchema).optional(),
|
||||
trends: userTrendsDtoSchema.optional(),
|
||||
goals: z.array(goalDtoSchema).optional(),
|
||||
})
|
||||
export type UserProfileResponse = z.infer<typeof userProfileResponseSchema>
|
||||
|
||||
|
||||
@@ -159,7 +159,22 @@
|
||||
"admin": "Admin",
|
||||
"rebuildSearch": "Rebuild Search Index",
|
||||
"rebuildSearchDesc": "Re-index all movies and people",
|
||||
"rebuildSearchDone": "Reindex queued"
|
||||
"rebuildSearchDone": "Reindex queued",
|
||||
"privacy": "Privacy",
|
||||
"federateGoals": "Share goals on Fediverse",
|
||||
"federateGoalsDesc": "Broadcast goal progress to followers"
|
||||
},
|
||||
"goals": {
|
||||
"yearGoal": "{{year}} Goal",
|
||||
"movies": "movies",
|
||||
"reached": "Goal reached!",
|
||||
"setGoal": "Set Goal",
|
||||
"editGoal": "Edit Goal",
|
||||
"year": "Year",
|
||||
"targetMovies": "Target (movies)",
|
||||
"created": "Goal created",
|
||||
"updated": "Goal updated",
|
||||
"deleted": "Goal deleted"
|
||||
},
|
||||
"editProfile": {
|
||||
"title": "Edit Profile",
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ChevronDown, ChevronRight, Settings, Sparkles } from "lucide-react"
|
||||
import { ChevronDown, ChevronRight, Plus, Settings, Sparkles } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ProfileView, ProfileSkeleton } from "@/components/profile-view"
|
||||
import { useAuth } from "@/components/auth-provider"
|
||||
import { useWrapUps } from "@/hooks/use-wrapup"
|
||||
import { useUserProfile } from "@/hooks/use-users"
|
||||
import { useDeleteGoal } from "@/hooks/use-goals"
|
||||
import { GoalCard } from "@/components/goal-card"
|
||||
import { GoalSheet } from "@/components/goal-sheet"
|
||||
import { toast } from "sonner"
|
||||
import type { GoalDto } from "@/lib/api/users"
|
||||
|
||||
export const Route = createFileRoute("/_app/profile")({
|
||||
component: ProfilePage,
|
||||
@@ -36,6 +41,7 @@ function ProfilePage() {
|
||||
data={data}
|
||||
actions={
|
||||
<>
|
||||
<GoalSection goals={data.goals ?? []} />
|
||||
<Link to="/social" className="block">
|
||||
<Button variant="outline" size="sm" className="w-full justify-between">
|
||||
<span>{t("profile.followingFollowers")}</span>
|
||||
@@ -50,6 +56,58 @@ function ProfilePage() {
|
||||
)
|
||||
}
|
||||
|
||||
function GoalSection({ goals }: { goals: GoalDto[] }) {
|
||||
const { t } = useTranslation()
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [editGoal, setEditGoal] = useState<GoalDto | null>(null)
|
||||
const deleteMutation = useDeleteGoal()
|
||||
|
||||
function handleEdit(goal: GoalDto) {
|
||||
setEditGoal(goal)
|
||||
setSheetOpen(true)
|
||||
}
|
||||
|
||||
function handleDelete(year: number) {
|
||||
deleteMutation.mutate(year, {
|
||||
onSuccess: () => toast.success(t("goals.deleted")),
|
||||
})
|
||||
}
|
||||
|
||||
function handleSheetClose(open: boolean) {
|
||||
setSheetOpen(open)
|
||||
if (!open) setEditGoal(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{goals.map((g) => (
|
||||
<GoalCard
|
||||
key={g.year}
|
||||
goal={g}
|
||||
editable
|
||||
onEdit={() => handleEdit(g)}
|
||||
onDelete={() => handleDelete(g.year)}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setSheetOpen(true)}
|
||||
>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
{t("goals.setGoal")}
|
||||
</Button>
|
||||
<GoalSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={handleSheetClose}
|
||||
editYear={editGoal?.year}
|
||||
editTarget={editGoal?.target_count}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function wrapupYear(startDate: string): string {
|
||||
return startDate.slice(0, 4)
|
||||
}
|
||||
|
||||
@@ -10,11 +10,14 @@ import {
|
||||
RefreshCw,
|
||||
ShieldBan,
|
||||
Sparkles,
|
||||
Target,
|
||||
User,
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useAuth, useIsAdmin } from "@/components/auth-provider"
|
||||
import { reindexSearch } from "@/lib/api/users"
|
||||
import { useSettings, useUpdateSettings } from "@/hooks/use-goals"
|
||||
|
||||
export const Route = createFileRoute("/_app/settings/")({
|
||||
component: SettingsPage,
|
||||
@@ -94,6 +97,8 @@ function SettingsPage() {
|
||||
<SettingsGroup label={t("settings.integrations")} items={integrations} />
|
||||
<SettingsGroup label={t("settings.socialGroup")} items={social} />
|
||||
|
||||
<PrivacySection />
|
||||
|
||||
{isAdmin && <AdminActions />}
|
||||
|
||||
<button
|
||||
@@ -109,6 +114,40 @@ function SettingsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function PrivacySection() {
|
||||
const { t } = useTranslation()
|
||||
const { data: settings } = useSettings()
|
||||
const updateMutation = useUpdateSettings()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-1.5 px-1 text-xs font-medium text-muted-foreground">
|
||||
{t("settings.privacy")}
|
||||
</p>
|
||||
<div className="divide-y divide-border rounded-xl bg-card">
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
<span className="text-muted-foreground">
|
||||
<Target className="size-4" />
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{t("settings.federateGoals")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.federateGoalsDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings?.federate_goals ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateMutation.mutate({ federate_goals: checked })
|
||||
}
|
||||
disabled={updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AdminActions() {
|
||||
const { t } = useTranslation()
|
||||
const reindex = useMutation({
|
||||
|
||||
@@ -4,6 +4,7 @@ import { UserCheck, UserPlus } from "lucide-react"
|
||||
import { BackButton } from "@/components/back-button"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ProfileView, ProfileSkeleton } from "@/components/profile-view"
|
||||
import { GoalCard } from "@/components/goal-card"
|
||||
import { useAuth } from "@/components/auth-provider"
|
||||
import { useUserProfile } from "@/hooks/use-users"
|
||||
import { useFollow, useUnfollow, useFollowing } from "@/hooks/use-social"
|
||||
@@ -34,6 +35,15 @@ function UserProfilePage() {
|
||||
<ProfileView
|
||||
data={data}
|
||||
userId={id}
|
||||
actions={
|
||||
data.goals?.length ? (
|
||||
<div className="space-y-2">
|
||||
{data.goals.map((g) => (
|
||||
<GoalCard key={g.year} goal={g} />
|
||||
))}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
headerRight={
|
||||
!isSelf ? (
|
||||
isFollowing ? (
|
||||
|
||||
Reference in New Issue
Block a user