diff --git a/CLAUDE.md b/CLAUDE.md index 62479e7..0cc3b24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,10 @@ Need a control that isn't in `src/components/ui/`? Add it with `pnpm dlx shadcn@ The CLI puts `shadow-xs`/`shadow-sm` on inputs, textareas and cards. [`DESIGN.md`](DESIGN.md) keeps shadows for things that genuinely float — dialogs, dropdowns, toasts. Strip them. +### Icons come from lucide-react + +Whenever you need an icon, use `lucide-react` (already installed, and the `iconLibrary` in `components.json`). No emoji as icons, no other icon packages, no hand-written SVGs. Icon-only controls still need an `aria-label` per [`DESIGN.md`](DESIGN.md). + ### The app is already running Assume the dev servers are up. If something is listening on the app's ports, that is this application: use it. Don't start a second instance, don't restart it, don't run `pnpm dev`. diff --git a/apps/backend/prisma/migrations/20261006144241_add_quack_mood/migration.sql b/apps/backend/prisma/migrations/20261006144241_add_quack_mood/migration.sql new file mode 100644 index 0000000..3927547 --- /dev/null +++ b/apps/backend/prisma/migrations/20261006144241_add_quack_mood/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "mood" AS ENUM ('happy', 'sad', 'angry', 'silly'); + +-- AlterTable +ALTER TABLE "quack" ADD COLUMN "mood" "mood"; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 70d0525..842c055 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -22,9 +22,19 @@ enum UserRole { @@map("user_role") } +enum Mood { + happy + sad + angry + silly + + @@map("mood") +} + model Quack { id String @id @default(cuid()) text String @db.Text + mood Mood? user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId String createdAt DateTime @default(now()) diff --git a/apps/backend/src/modules/quack/controllers/dto/create-quack.dto.ts b/apps/backend/src/modules/quack/controllers/dto/create-quack.dto.ts index fa110aa..36e2970 100644 --- a/apps/backend/src/modules/quack/controllers/dto/create-quack.dto.ts +++ b/apps/backend/src/modules/quack/controllers/dto/create-quack.dto.ts @@ -1,5 +1,12 @@ +import { QUACK_MOODS, QuackMood } from '@/modules/quack/domain/quack'; import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; +import { + IsIn, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, +} from 'class-validator'; export class CreateQuackDto { @ApiProperty({ @@ -11,4 +18,13 @@ export class CreateQuackDto { @IsNotEmpty() @MaxLength(280) text!: string; + + @ApiProperty({ + description: 'How the quack feels. Optional.', + enum: QUACK_MOODS, + required: false, + }) + @IsOptional() + @IsIn(QUACK_MOODS) + mood?: QuackMood; } diff --git a/apps/backend/src/modules/quack/controllers/dto/quack.response.dto.ts b/apps/backend/src/modules/quack/controllers/dto/quack.response.dto.ts index 8ad9aa9..2e11482 100644 --- a/apps/backend/src/modules/quack/controllers/dto/quack.response.dto.ts +++ b/apps/backend/src/modules/quack/controllers/dto/quack.response.dto.ts @@ -1,4 +1,4 @@ -import { Quack } from '@/modules/quack/domain/quack'; +import { Quack, QUACK_MOODS, QuackMood } from '@/modules/quack/domain/quack'; import { ApiProperty } from '@nestjs/swagger'; class QuackUserDto { @@ -19,6 +19,9 @@ export class QuackResponseDto { @ApiProperty() text!: string; + @ApiProperty({ enum: QUACK_MOODS, nullable: true }) + mood!: QuackMood | null; + @ApiProperty() userId!: string; @@ -37,6 +40,7 @@ export class QuackResponseDto { return { id: quack.id, text: quack.text, + mood: quack.mood, userId: quack.userId, createdAt: quack.createdAt, user: { diff --git a/apps/backend/src/modules/quack/controllers/quacks.controller.ts b/apps/backend/src/modules/quack/controllers/quacks.controller.ts index d38415f..20c7604 100644 --- a/apps/backend/src/modules/quack/controllers/quacks.controller.ts +++ b/apps/backend/src/modules/quack/controllers/quacks.controller.ts @@ -56,6 +56,7 @@ export class QuacksController { ): Promise { const quack = await this.quacksService.createQuack(user, { text: body.text, + mood: body.mood, }); return QuackResponseDto.fromDomain(quack); } diff --git a/apps/backend/src/modules/quack/domain/quack.ts b/apps/backend/src/modules/quack/domain/quack.ts index a7ae02f..062c4d1 100644 --- a/apps/backend/src/modules/quack/domain/quack.ts +++ b/apps/backend/src/modules/quack/domain/quack.ts @@ -1,3 +1,6 @@ +export const QUACK_MOODS = ['happy', 'sad', 'angry', 'silly'] as const; +export type QuackMood = (typeof QUACK_MOODS)[number]; + export type QuackAuthor = { id: string; name: string; @@ -7,6 +10,7 @@ export type QuackAuthor = { export type Quack = { id: string; text: string; + mood: QuackMood | null; userId: string; createdAt: Date; updatedAt: Date; diff --git a/apps/backend/src/modules/quack/repositories/quack.repository.ts b/apps/backend/src/modules/quack/repositories/quack.repository.ts index 67abf82..3d12d0b 100644 --- a/apps/backend/src/modules/quack/repositories/quack.repository.ts +++ b/apps/backend/src/modules/quack/repositories/quack.repository.ts @@ -3,7 +3,7 @@ import { Quack as PrismaQuack, User as PrismaUser, } from '@/generated/prisma/client'; -import { Quack } from '@/modules/quack/domain/quack'; +import { Quack, QuackMood } from '@/modules/quack/domain/quack'; import { Injectable } from '@nestjs/common'; const mapPrismaQuackToDomain = ( @@ -11,6 +11,7 @@ const mapPrismaQuackToDomain = ( ): Quack => ({ id: quack.id, text: quack.text, + mood: quack.mood, userId: quack.userId, createdAt: quack.createdAt, updatedAt: quack.updatedAt, @@ -41,11 +42,13 @@ export class QuackRepository { async createQuack(createQuackData: { text: string; + mood?: QuackMood; userId: string; }): Promise { const quack = await this.prisma.quack.create({ data: { text: createQuackData.text, + mood: createQuackData.mood, user: { connect: { id: createQuackData.userId } }, }, include: { user: true }, diff --git a/apps/backend/src/modules/quack/services/quacks.service.spec.ts b/apps/backend/src/modules/quack/services/quacks.service.spec.ts index 640106f..64bd14a 100644 --- a/apps/backend/src/modules/quack/services/quacks.service.spec.ts +++ b/apps/backend/src/modules/quack/services/quacks.service.spec.ts @@ -9,6 +9,7 @@ import { QuacksService } from './quacks.service'; const aQuack = (overrides: Partial = {}): Quack => ({ id: 'q1', text: 'quack quack', + mood: null, userId: 'u1', createdAt: new Date('2026-01-01T12:00:00Z'), updatedAt: new Date('2026-01-01T12:00:00Z'), @@ -45,4 +46,20 @@ describe('QuacksService', () => { userId: 'u1', }); }); + + it('passes the mood through when one is given', async () => { + const repository = mock(); + repository.createQuack.mockResolvedValue(aQuack({ mood: 'silly' })); + + const service = new QuacksService(repository); + const user = { id: 'u1' } as Identity; + + await service.createQuack(user, { text: 'hello', mood: 'silly' }); + + expect(repository.createQuack).toHaveBeenCalledWith({ + text: 'hello', + mood: 'silly', + userId: 'u1', + }); + }); }); diff --git a/apps/backend/src/modules/quack/services/quacks.service.ts b/apps/backend/src/modules/quack/services/quacks.service.ts index 633772f..dfe9769 100644 --- a/apps/backend/src/modules/quack/services/quacks.service.ts +++ b/apps/backend/src/modules/quack/services/quacks.service.ts @@ -1,4 +1,4 @@ -import { Quack } from '@/modules/quack/domain/quack'; +import { Quack, QuackMood } from '@/modules/quack/domain/quack'; import { QuackRepository } from '@/modules/quack/repositories/quack.repository'; import { Identity } from '@/shared/auth/domain/identity'; import { Injectable } from '@nestjs/common'; @@ -13,10 +13,11 @@ export class QuacksService { async createQuack( user: Identity, - quackData: { text: string }, + quackData: { text: string; mood?: QuackMood }, ): Promise { return this.quackRepository.createQuack({ text: quackData.text, + mood: quackData.mood, // the author is taken from the session, never from the request body userId: user.id, }); diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 00bbad9..d79379b 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -24,6 +24,7 @@ "better-auth": "^1.7.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cn": "^0.4.0", "date-fns": "^4.4.0", "ky": "^2.0.2", "lucide-react": "^1.33.0", diff --git a/apps/frontend/src/components/ui/toggle-group.tsx b/apps/frontend/src/components/ui/toggle-group.tsx new file mode 100644 index 0000000..c36da54 --- /dev/null +++ b/apps/frontend/src/components/ui/toggle-group.tsx @@ -0,0 +1,82 @@ +"use client" + +import * as React from "react" +import { type VariantProps } from "class-variance-authority" +import { cn } from "cn" +import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui" + +import { toggleVariants } from "@/components/ui/toggle" + +const ToggleGroupContext = React.createContext< + VariantProps & { + spacing?: number + } +>({ + size: "default", + variant: "default", + spacing: 0, +}) + +function ToggleGroup({ + className, + variant, + size, + spacing = 0, + children, + ...props +}: React.ComponentProps & + VariantProps & { + spacing?: number + }) { + return ( + + + {children} + + + ) +} + +function ToggleGroupItem({ + className, + children, + variant, + size, + ...props +}: React.ComponentProps & VariantProps) { + const context = React.useContext(ToggleGroupContext) + + return ( + + {children} + + ) +} + +export { ToggleGroup, ToggleGroupItem } diff --git a/apps/frontend/src/components/ui/toggle.tsx b/apps/frontend/src/components/ui/toggle.tsx new file mode 100644 index 0000000..b651cf4 --- /dev/null +++ b/apps/frontend/src/components/ui/toggle.tsx @@ -0,0 +1,42 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "cn" +import { Toggle as TogglePrimitive } from "radix-ui" + +const toggleVariants = cva( + "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-transparent", + outline: "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-9 min-w-9 px-2", + sm: "h-8 min-w-8 px-1.5", + lg: "h-10 min-w-10 px-2.5", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +) + +function Toggle({ + className, + variant, + size, + ...props +}: React.ComponentProps & VariantProps) { + return ( + + ) +} + +export { Toggle, toggleVariants } diff --git a/apps/frontend/src/features/quack/api/addQuack.ts b/apps/frontend/src/features/quack/api/addQuack.ts index 5af3359..2bf61eb 100644 --- a/apps/frontend/src/features/quack/api/addQuack.ts +++ b/apps/frontend/src/features/quack/api/addQuack.ts @@ -1,8 +1,8 @@ import { api } from "@/lib/api-client" -import { quackSchema, type Quack } from "@/features/quack/api/quackSchemas" +import { quackSchema, type Quack, type QuackMood } from "@/features/quack/api/quackSchemas" -export async function addQuack(input: { text: string }): Promise { +export async function addQuack(input: { text: string; mood?: QuackMood }): Promise { const json = await api.post("quacks", { json: input }).json() return quackSchema.parse(json) } diff --git a/apps/frontend/src/features/quack/api/quackSchemas.ts b/apps/frontend/src/features/quack/api/quackSchemas.ts index 2035bd5..6f15041 100644 --- a/apps/frontend/src/features/quack/api/quackSchemas.ts +++ b/apps/frontend/src/features/quack/api/quackSchemas.ts @@ -8,9 +8,13 @@ export const quackUserSchema = z.object({ username: z.string(), }) +export const quackMoods = ["happy", "sad", "angry", "silly"] as const +export type QuackMood = (typeof quackMoods)[number] + export const quackSchema = z.object({ id: z.string(), text: z.string(), + mood: z.enum(quackMoods).nullable(), userId: z.string(), createdAt: z.coerce.date(), user: quackUserSchema, diff --git a/apps/frontend/src/features/quack/components/QuackForm.tsx b/apps/frontend/src/features/quack/components/QuackForm.tsx index 091e363..b657c95 100644 --- a/apps/frontend/src/features/quack/components/QuackForm.tsx +++ b/apps/frontend/src/features/quack/components/QuackForm.tsx @@ -1,5 +1,5 @@ import { zodResolver } from "@hookform/resolvers/zod" -import { Loader2 } from "lucide-react" +import { Angry, Frown, Laugh, Loader2, Smile, type LucideIcon } from "lucide-react" import { useForm, useWatch } from "react-hook-form" import { z } from "zod" @@ -14,20 +14,30 @@ import { FormMessage, } from "@/components/ui/form" import { Textarea } from "@/components/ui/textarea" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { cn } from "@/lib/utils" +import { quackMoods, type QuackMood } from "@/features/quack/api/quackSchemas" import { useAddQuack } from "@/features/quack/hooks/useAddQuack" // Mirrors the server-side DTO (MaxLength(280)) so the user is told before // the request is made — the server still validates independently. const MAX_LENGTH = 280 +const MOOD_ICON: Record = { + happy: Smile, + sad: Frown, + angry: Angry, + silly: Laugh, +} + const schema = z.object({ text: z .string() .trim() .min(1, "Write something first") .max(MAX_LENGTH, `Keep it under ${MAX_LENGTH} characters`), + mood: z.enum(quackMoods).optional(), }) type FormValues = z.infer @@ -38,14 +48,14 @@ export function QuackForm({ className }: QuackFormProps) { const addQuack = useAddQuack() const form = useForm({ resolver: zodResolver(schema), - defaultValues: { text: "" }, + defaultValues: { text: "", mood: undefined }, }) const text = useWatch({ control: form.control, name: "text" }) const length = text?.length ?? 0 const handleSubmit = (values: FormValues) => { - addQuack.mutate({ text: values.text }, { onSuccess: () => form.reset() }) + addQuack.mutate({ text: values.text, mood: values.mood }, { onSuccess: () => form.reset() }) } return ( @@ -80,6 +90,39 @@ export function QuackForm({ className }: QuackFormProps) { )} /> + ( + + Mood (optional) + field.onChange(value || undefined)} + disabled={addQuack.isPending} + spacing={2} + className="flex-wrap" + > + {quackMoods.map((mood) => { + const Icon = MOOD_ICON[mood] + return ( + + + + ) + })} + + + + )} + /> +
· + {quack.mood ? ( + <> + · + {quack.mood} + + ) : null}

{quack.text}

diff --git a/apps/frontend/src/features/quack/components/QuackList.test.tsx b/apps/frontend/src/features/quack/components/QuackList.test.tsx index 8a08682..68404b8 100644 --- a/apps/frontend/src/features/quack/components/QuackList.test.tsx +++ b/apps/frontend/src/features/quack/components/QuackList.test.tsx @@ -9,6 +9,7 @@ import { QuackList } from "@/features/quack/components/QuackList" const quack = (overrides: Partial = {}): Quack => ({ id: "q1", text: "quack quack", + mood: null, userId: "u1", createdAt: new Date("2026-01-01T12:00:00Z"), user: { id: "u1", name: "Caffeinated Duck", username: "CaffeinatedDuck" }, @@ -24,6 +25,20 @@ describe("QuackList", () => { expect(screen.getByText("@CaffeinatedDuck")).toBeInTheDocument() }) + it("shows the mood only when a quack has one", () => { + render( + , + ) + + expect(screen.getAllByText(/^(happy|sad|angry|silly)$/i)).toHaveLength(1) + expect(screen.getByText("silly")).toBeInTheDocument() + }) + it("shows an error with a working reload button", async () => { const onReload = vi.fn() render( diff --git a/apps/frontend/src/routes/index.tsx b/apps/frontend/src/routes/index.tsx index e886a74..513ef51 100644 --- a/apps/frontend/src/routes/index.tsx +++ b/apps/frontend/src/routes/index.tsx @@ -20,6 +20,7 @@ const SAMPLE_QUACKS: Quack[] = [ { id: "sample-1", text: "me: throws one crumb into the pond\nducks: assemble like the Avengers\ni fear i may have started something", + mood: null, userId: "sample-user-1", createdAt: new Date("2026-09-22T09:12:00"), user: { id: "sample-user-1", name: "Caffeinated Duck", username: "CaffeinatedDuck" }, @@ -27,6 +28,7 @@ const SAMPLE_QUACKS: Quack[] = [ { id: "sample-2", text: "If ducks wore pants, would they wear them on their legs or over their whole lower half like a cape?\nAsking for a friend. A feathery friend.", + mood: null, userId: "sample-user-2", createdAt: new Date("2026-09-22T08:40:00"), user: { id: "sample-user-2", name: "Deep Duck Thoughts", username: "DeepDuckThoughts" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8f0b71..2a868a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -263,6 +263,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + cn: + specifier: ^0.4.0 + version: 0.4.0 date-fns: specifier: ^4.4.0 version: 4.4.0 @@ -4112,6 +4115,11 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cn@0.4.0: + resolution: {integrity: sha512-qOAeUhPwPzBxx6jaeQdo646XIKliKHc2j1ZF7Ngb1o0pJZKH5gq8FkA0aVdUdPFUDXNXyUCXpCtoJISo6dhizg==} + engines: {node: '>=20'} + hasBin: true + co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -11345,6 +11353,8 @@ snapshots: clsx@2.1.1: {} + cn@0.4.0: {} + co@4.6.0: {} collect-v8-coverage@1.0.3: {}