diff --git a/frontend(experimental)/newchat/README.md b/frontend(experimental)/newchat/README.md new file mode 100644 index 0000000..14a4cd3 --- /dev/null +++ b/frontend(experimental)/newchat/README.md @@ -0,0 +1,33 @@ +# calendar-app + +This is a [Next.js](https://nextjs.org) project bootstrapped with [v0](https://v0.app). + +## Built with v0 + +This repository is linked to a [v0](https://v0.app) project. You can continue developing by visiting the link below -- start new chats to make changes, and v0 will push commits directly to this repo. Every merge to `main` will automatically deploy. + +[Continue working on v0 →](https://v0.app/chat/projects/prj_JicVuGp27VUV82GWT4csmGqXBtSV) + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +## Learn More + +To learn more, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +- [v0 Documentation](https://v0.app/docs) - learn about v0 and how to use it. diff --git a/frontend(experimental)/newchat/app/globals.css b/frontend(experimental)/newchat/app/globals.css new file mode 100644 index 0000000..5791b0a --- /dev/null +++ b/frontend(experimental)/newchat/app/globals.css @@ -0,0 +1,62 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 221.2 83.2% 53.3%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 221.2 83.2% 53.3%; + --radius: 0.5rem; +} + +.dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 217.2 91.2% 59.8%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 224.3 76.3% 48%; +} + +@layer utilities { + .bg-grid-slate-200\/50 { + background-image: linear-gradient(currentColor 1px, transparent 1px), + linear-gradient(to right, currentColor 1px, transparent 1px); + background-size: 20px 20px; + } +} + +.react-draggable { + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); +} + diff --git a/frontend(experimental)/newchat/app/layout.tsx b/frontend(experimental)/newchat/app/layout.tsx new file mode 100644 index 0000000..0778c98 --- /dev/null +++ b/frontend(experimental)/newchat/app/layout.tsx @@ -0,0 +1,27 @@ +import type React from "react" +import type { Metadata } from "next" +import { Inter } from "next/font/google" +import "./globals.css" +import { AuthProvider } from "@/lib/auth-context" + +const inter = Inter({ subsets: ["latin"] }) + +export const metadata: Metadata = { + title: "Calendar | Rooms & Events", + description: "A shared, room-based calendar for planning events together.", + generator: "v0.app", +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + + {children} + + + ) +} diff --git a/frontend(experimental)/newchat/app/loading.tsx b/frontend(experimental)/newchat/app/loading.tsx new file mode 100644 index 0000000..cec067b --- /dev/null +++ b/frontend(experimental)/newchat/app/loading.tsx @@ -0,0 +1,4 @@ +export default function Loading() { + return null +} + diff --git a/frontend(experimental)/newchat/app/page.tsx b/frontend(experimental)/newchat/app/page.tsx new file mode 100644 index 0000000..3114318 --- /dev/null +++ b/frontend(experimental)/newchat/app/page.tsx @@ -0,0 +1,229 @@ +"use client" + +import { useMemo, useState } from "react" +import Image from "next/image" +import { Menu, Plus, Loader2 } from "lucide-react" + +import { useAuth } from "@/lib/auth-context" +import { useRooms, useEvents } from "@/lib/hooks" +import { AuthScreen } from "@/components/auth-screen" +import { Sidebar } from "@/components/sidebar" +import { WeekView } from "@/components/week-view" +import { CreateEventDialog } from "@/components/create-event-dialog" +import { EventDetailDialog } from "@/components/event-detail-dialog" +import { CreateRoomDialog, JoinRoomDialog } from "@/components/room-dialogs" +import { RoomSettingsDialog } from "@/components/room-settings-dialog" +import { getRoomBackground } from "@/lib/room-theme" +import { addDays, startOfWeek, toDateParam, formatMonthYear } from "@/lib/date-utils" +import type { CalendarEvent, User } from "@/lib/types" + +type DialogState = + | { type: "none" } + | { type: "create-event" } + | { type: "event-detail"; event: CalendarEvent } + | { type: "create-room" } + | { type: "join-room" } + | { type: "room-settings" } + +export default function Home() { + const { user, loading, logout } = useAuth() + + if (loading) { + return ( +
+ +
+ ) + } + + if (!user) { + return + } + + return +} + +function CalendarApp({ user, onLogout }: { user: User; onLogout: () => void }) { + const [activeRoomId, setActiveRoomId] = useState(null) + const [selectedDate, setSelectedDate] = useState(() => new Date()) + const [sidebarOpen, setSidebarOpen] = useState(false) + const [dialog, setDialog] = useState({ type: "none" }) + + const { rooms, mutate: mutateRooms } = useRooms() + + const weekStart = useMemo(() => startOfWeek(selectedDate), [selectedDate]) + const weekEnd = useMemo(() => addDays(weekStart, 7), [weekStart]) + const from = useMemo(() => toDateParam(weekStart), [weekStart]) + const to = useMemo(() => toDateParam(weekEnd), [weekEnd]) + + const { + events, + isLoading: eventsLoading, + mutate: mutateEvents, + } = useEvents(activeRoomId, from, to) + + const activeRoom = rooms.find((r) => r.id === activeRoomId) + const background = getRoomBackground(activeRoomId) + + const refreshAll = () => { + mutateEvents() + mutateRooms() + } + + return ( +
+ +
+ +
+ {/* Mobile sidebar backdrop */} + {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + +
+ { + setActiveRoomId(id) + setSidebarOpen(false) + }} + selectedDate={selectedDate} + onSelectDate={setSelectedDate} + onCreateEvent={() => setDialog({ type: "create-event" })} + onCreateRoom={() => setDialog({ type: "create-room" })} + onJoinRoom={() => setDialog({ type: "join-room" })} + user={user} + onLogout={onLogout} + /> +
+ +
+
+
+ +
+

+ {activeRoom ? activeRoom.name : "All Rooms"} +

+

{formatMonthYear(weekStart)}

+
+
+ +
+ {activeRoom && ( + + )} + +
+
+ +
+ {eventsLoading ? ( +
+ +
+ ) : ( + setDialog({ type: "event-detail", event })} + /> + )} +
+
+
+ + {dialog.type === "create-event" && ( + setDialog({ type: "none" })} + onCreated={() => { + setDialog({ type: "none" }) + refreshAll() + }} + /> + )} + + {dialog.type === "event-detail" && ( + r.id === dialog.event.room_id)} + currentUserId={user.id} + onClose={() => setDialog({ type: "none" })} + onChanged={refreshAll} + /> + )} + + {dialog.type === "create-room" && ( + setDialog({ type: "none" })} + onCreated={(roomId: number) => { + setDialog({ type: "none" }) + mutateRooms() + setActiveRoomId(roomId) + }} + /> + )} + + {dialog.type === "join-room" && ( + setDialog({ type: "none" })} + onJoined={(roomId: number) => { + setDialog({ type: "none" }) + mutateRooms() + setActiveRoomId(roomId) + }} + /> + )} + + {dialog.type === "room-settings" && activeRoom && ( + setDialog({ type: "none" })} + onChanged={mutateRooms} + onLeftOrDeleted={() => { + setDialog({ type: "none" }) + setActiveRoomId(null) + mutateRooms() + }} + /> + )} +
+ ) +} diff --git a/frontend(experimental)/newchat/components.json b/frontend(experimental)/newchat/components.json new file mode 100644 index 0000000..4ee62ee --- /dev/null +++ b/frontend(experimental)/newchat/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/frontend(experimental)/newchat/components/auth-screen.tsx b/frontend(experimental)/newchat/components/auth-screen.tsx new file mode 100644 index 0000000..3bcc92c --- /dev/null +++ b/frontend(experimental)/newchat/components/auth-screen.tsx @@ -0,0 +1,161 @@ +"use client" + +import type React from "react" +import { useState } from "react" +import Image from "next/image" +import { Calendar, Loader2 } from "lucide-react" +import { useAuth } from "@/lib/auth-context" +import { ApiError } from "@/lib/api" +import { ALL_ROOMS_BACKGROUND } from "@/lib/room-theme" + +type Mode = "login" | "register" + +export function AuthScreen() { + const { login, register } = useAuth() + const [mode, setMode] = useState("login") + const [username, setUsername] = useState("") + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [error, setError] = useState(null) + const [notice, setNotice] = useState(null) + const [submitting, setSubmitting] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + setNotice(null) + setSubmitting(true) + try { + if (mode === "login") { + await login(username.trim(), password) + } else { + await register(username.trim(), password, email.trim()) + } + } catch (err) { + const message = + err instanceof ApiError ? err.message : "Something went wrong. Please try again." + setError(message) + } finally { + setSubmitting(false) + } + } + + const switchMode = (next: Mode) => { + setMode(next) + setError(null) + setNotice(null) + } + + return ( +
+ Aurora over a mountain lake +
+ +
+
+
+ +
+

+ {mode === "login" ? "Welcome back" : "Create your account"} +

+

+ {mode === "login" + ? "Sign in to your shared calendar" + : "Join rooms and plan events together"} +

+
+ +
+
+ + setUsername(e.target.value)} + className="rounded-xl border border-white/20 bg-white/10 px-4 py-2.5 text-white placeholder:text-white/50 focus:outline-none focus:ring-2 focus:ring-white/40" + placeholder="your_username" + /> +
+ + {mode === "register" && ( +
+ + setEmail(e.target.value)} + className="rounded-xl border border-white/20 bg-white/10 px-4 py-2.5 text-white placeholder:text-white/50 focus:outline-none focus:ring-2 focus:ring-white/40" + placeholder="you@example.com" + /> +
+ )} + +
+ + setPassword(e.target.value)} + className="rounded-xl border border-white/20 bg-white/10 px-4 py-2.5 text-white placeholder:text-white/50 focus:outline-none focus:ring-2 focus:ring-white/40" + placeholder={mode === "register" ? "At least 8 characters" : "••••••••"} + /> +
+ + {error && ( +
+ {error} +
+ )} + {notice && ( +
+ {notice} +
+ )} + + +
+ +

+ {mode === "login" ? "Don't have an account? " : "Already have an account? "} + +

+
+
+ ) +} diff --git a/frontend(experimental)/newchat/components/create-event-dialog.tsx b/frontend(experimental)/newchat/components/create-event-dialog.tsx new file mode 100644 index 0000000..385f513 --- /dev/null +++ b/frontend(experimental)/newchat/components/create-event-dialog.tsx @@ -0,0 +1,265 @@ +"use client" + +import type React from "react" +import { useState } from "react" +import { Loader2 } from "lucide-react" +import { GlassModal, fieldClass, labelClass } from "./glass-modal" +import * as api from "@/lib/api" +import { ApiError } from "@/lib/api" +import type { EventType, EventVisibility, Room } from "@/lib/types" +import { toBackendDateTime, toInputDateTime } from "@/lib/date-utils" + +interface CreateEventDialogProps { + rooms: Room[] + defaultRoomId: number | null + defaultDate: Date + onClose: () => void + onCreated: () => void +} + +const TYPE_OPTIONS: { value: EventType; label: string }[] = [ + { value: "proposal", label: "Proposal (members vote to confirm)" }, + { value: "busy", label: "Busy (blocked time)" }, + { value: "private", label: "Private" }, +] + +const VISIBILITY_OPTIONS: { value: EventVisibility; label: string }[] = [ + { value: "room", label: "Room — visible to all members" }, + { value: "busy_only", label: "Busy only — hide details" }, + { value: "private", label: "Private — only me" }, +] + +const optionClass = "bg-slate-800 text-white" + +export function CreateEventDialog({ + rooms, + defaultRoomId, + defaultDate, + onClose, + onCreated, +}: CreateEventDialogProps) { + const initialRoom = defaultRoomId ?? rooms[0]?.id ?? 0 + + const defaultStart = new Date(defaultDate) + defaultStart.setHours(9, 0, 0, 0) + const defaultEnd = new Date(defaultDate) + defaultEnd.setHours(10, 0, 0, 0) + + const [roomId, setRoomId] = useState(initialRoom) + const [title, setTitle] = useState("") + const [description, setDescription] = useState("") + const [type, setType] = useState("proposal") + const [visibility, setVisibility] = useState("room") + const [start, setStart] = useState(toInputDateTime(defaultStart)) + const [end, setEnd] = useState(toInputDateTime(defaultEnd)) + const [minPeople, setMinPeople] = useState(1) + const [error, setError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + + if (!roomId) { + setError("Please select a room.") + return + } + const startDate = new Date(start) + const endDate = new Date(end) + if (endDate <= startDate) { + setError("End time must be after the start time.") + return + } + + setSubmitting(true) + try { + await api.createEvent({ + room_id: roomId, + type, + title: title.trim(), + description: description.trim(), + start_time: toBackendDateTime(startDate), + end_time: toBackendDateTime(endDate), + visibility, + status: type === "proposal" ? "proposed" : "confirmed", + min_people: type === "proposal" ? minPeople : 0, + }) + onCreated() + onClose() + } catch (err) { + setError(err instanceof ApiError ? err.message : "Could not create the event.") + } finally { + setSubmitting(false) + } + } + + if (rooms.length === 0) { + return ( + +

+ You need to be in a room before creating an event. Create or join a room first. +

+
+ ) + } + + return ( + +
+
+ + +
+ +
+ + setTitle(e.target.value)} + className={fieldClass} + placeholder="e.g. Team dinner" + /> +
+ +
+ +