Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "mood" AS ENUM ('happy', 'sad', 'angry', 'silly');

-- AlterTable
ALTER TABLE "quack" ADD COLUMN "mood" "mood";
10 changes: 10 additions & 0 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -19,6 +19,9 @@ export class QuackResponseDto {
@ApiProperty()
text!: string;

@ApiProperty({ enum: QUACK_MOODS, nullable: true })
mood!: QuackMood | null;

@ApiProperty()
userId!: string;

Expand All @@ -37,6 +40,7 @@ export class QuackResponseDto {
return {
id: quack.id,
text: quack.text,
mood: quack.mood,
userId: quack.userId,
createdAt: quack.createdAt,
user: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export class QuacksController {
): Promise<QuackResponseDto> {
const quack = await this.quacksService.createQuack(user, {
text: body.text,
mood: body.mood,
});
return QuackResponseDto.fromDomain(quack);
}
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/src/modules/quack/domain/quack.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -7,6 +10,7 @@ export type QuackAuthor = {
export type Quack = {
id: string;
text: string;
mood: QuackMood | null;
userId: string;
createdAt: Date;
updatedAt: Date;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ 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 = (
quack: PrismaQuack & { user?: PrismaUser },
): Quack => ({
id: quack.id,
text: quack.text,
mood: quack.mood,
userId: quack.userId,
createdAt: quack.createdAt,
updatedAt: quack.updatedAt,
Expand Down Expand Up @@ -41,11 +42,13 @@ export class QuackRepository {

async createQuack(createQuackData: {
text: string;
mood?: QuackMood;
userId: string;
}): Promise<Quack> {
const quack = await this.prisma.quack.create({
data: {
text: createQuackData.text,
mood: createQuackData.mood,
user: { connect: { id: createQuackData.userId } },
},
include: { user: true },
Expand Down
17 changes: 17 additions & 0 deletions apps/backend/src/modules/quack/services/quacks.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { QuacksService } from './quacks.service';
const aQuack = (overrides: Partial<Quack> = {}): 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'),
Expand Down Expand Up @@ -45,4 +46,20 @@ describe('QuacksService', () => {
userId: 'u1',
});
});

it('passes the mood through when one is given', async () => {
const repository = mock<QuackRepository>();
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',
});
});
});
5 changes: 3 additions & 2 deletions apps/backend/src/modules/quack/services/quacks.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -13,10 +13,11 @@ export class QuacksService {

async createQuack(
user: Identity,
quackData: { text: string },
quackData: { text: string; mood?: QuackMood },
): Promise<Quack> {
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,
});
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
82 changes: 82 additions & 0 deletions apps/frontend/src/components/ui/toggle-group.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof toggleVariants> & {
spacing?: number
}
>({
size: "default",
variant: "default",
spacing: 0,
})

function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md",
className,
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}

function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)

return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}

export { ToggleGroup, ToggleGroupItem }
42 changes: 42 additions & 0 deletions apps/frontend/src/components/ui/toggle.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}

export { Toggle, toggleVariants }
4 changes: 2 additions & 2 deletions apps/frontend/src/features/quack/api/addQuack.ts
Original file line number Diff line number Diff line change
@@ -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<Quack> {
export async function addQuack(input: { text: string; mood?: QuackMood }): Promise<Quack> {
const json = await api.post("quacks", { json: input }).json()
return quackSchema.parse(json)
}
4 changes: 4 additions & 0 deletions apps/frontend/src/features/quack/api/quackSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading