diff --git a/.gitignore b/.gitignore index 537a84e..f650315 100644 --- a/.gitignore +++ b/.gitignore @@ -1,36 +1,27 @@ -# --- Backend (Python) --- -.env -__pycache__/ -*.py[cod] -database/calendar.db -database/calendar.db-journal -.vscode -.venv/ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. -# --- Frontend (Next.js / v0) --- -node_modules/ -.next/ -out/ -.vercel/ -*.tsbuildinfo -next-env.d.ts +# dependencies +/node_modules -# leftover v0/dev artifacts -dump.txt -*.old-mock -*.old-mock.tsx +# next.js +/.next/ +/out/ + +# production +/build + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* # env files -.env*.local -.env.production -.env.development +.env* -# OS / editor -.DS_Store -Thumbs.db +# vercel +.vercel -# logs -npm-debug.log* -pnpm-debug.log* -yarn-debug.log* -yarn-error.log* \ No newline at end of file +# typescript +*.tsbuildinfo +next-env.d.ts \ No newline at end of file diff --git a/backend/__pycache__/auth.cpython-313.pyc b/backend/__pycache__/auth.cpython-313.pyc new file mode 100644 index 0000000..e006d8e Binary files /dev/null and b/backend/__pycache__/auth.cpython-313.pyc differ diff --git a/backend/__pycache__/database.cpython-313.pyc b/backend/__pycache__/database.cpython-313.pyc new file mode 100644 index 0000000..f00343f Binary files /dev/null and b/backend/__pycache__/database.cpython-313.pyc differ diff --git a/backend/__pycache__/email_service.cpython-313.pyc b/backend/__pycache__/email_service.cpython-313.pyc new file mode 100644 index 0000000..d7a6301 Binary files /dev/null and b/backend/__pycache__/email_service.cpython-313.pyc differ diff --git a/backend/__pycache__/event_signups.cpython-313.pyc b/backend/__pycache__/event_signups.cpython-313.pyc new file mode 100644 index 0000000..a3301d1 Binary files /dev/null and b/backend/__pycache__/event_signups.cpython-313.pyc differ diff --git a/backend/__pycache__/events.cpython-313.pyc b/backend/__pycache__/events.cpython-313.pyc new file mode 100644 index 0000000..0b6861b Binary files /dev/null and b/backend/__pycache__/events.cpython-313.pyc differ diff --git a/backend/__pycache__/main.cpython-313.pyc b/backend/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..53c32cf Binary files /dev/null and b/backend/__pycache__/main.cpython-313.pyc differ diff --git a/backend/__pycache__/permissions.cpython-313.pyc b/backend/__pycache__/permissions.cpython-313.pyc new file mode 100644 index 0000000..939d082 Binary files /dev/null and b/backend/__pycache__/permissions.cpython-313.pyc differ diff --git a/backend/__pycache__/rooms.cpython-313.pyc b/backend/__pycache__/rooms.cpython-313.pyc new file mode 100644 index 0000000..ce2403f Binary files /dev/null and b/backend/__pycache__/rooms.cpython-313.pyc differ diff --git a/backend/email_service.py b/backend/email_service.py index 307f85b..50ada61 100644 --- a/backend/email_service.py +++ b/backend/email_service.py @@ -1,17 +1,19 @@ import hashlib -import secrets -import sqlite3 -from datetime import datetime, timedelta, timezone -from pathlib import Path - import os +import secrets import smtplib +import sqlite3 + +from datetime import datetime, timedelta, timezone from email.message import EmailMessage +from pathlib import Path from dotenv import load_dotenv + load_dotenv() + DB_NAME = Path(__file__).parent.parent / "database" / "calendar.db" @@ -28,7 +30,207 @@ def hash_token(token: str) -> str: ).hexdigest() -def update_email(user_id: int, new_email: str): +# ============================================================ +# EMAIL +# ============================================================ + +def send_email( + email: str, + subject: str, + plain_text: str, + html: str, +): + smtp_host = os.getenv("SMTP_HOST") + smtp_port = int(os.getenv("SMTP_PORT", "587")) + smtp_username = os.getenv("SMTP_USERNAME") + smtp_password = os.getenv("SMTP_PASSWORD") + smtp_from = os.getenv("SMTP_FROM") + + if not all([ + smtp_host, + smtp_username, + smtp_password, + smtp_from, + ]): + raise RuntimeError("SMTP configuration is incomplete") + + message = EmailMessage() + + message["From"] = smtp_from + message["To"] = email + message["Subject"] = subject + + # Plain-text fallback + message.set_content(plain_text) + + # HTML version + message.add_alternative( + html, + subtype="html", + ) + + print(f"Sending email to {email}") + + with smtplib.SMTP( + smtp_host, + smtp_port, + ) as smtp: + + print("SMTP connected") + + smtp.starttls() + + print("TLS started") + + smtp.login( + smtp_username, + smtp_password, + ) + + print("SMTP login successful") + + smtp.send_message(message) + + print("Email sent") + + +def send_verification_email( + email: str, + token: str, +): + verification_url = ( + "https://ben.de-roo.org" + f"/api/verify-email?token={token}" + ) + + plain_text = f"""Hello, + +You requested to use this email address for your Calendar account. + +Verify your email address using this link: + +{verification_url} + +This link expires after 24 hours. + +If you did not request this, you can ignore this email. +""" + + html = f"""\ + + + + + + + + +

Hello,

+ +

+ You requested to use this email address + for your Calendar account. +

+ +

+ + Verify your email address + +

+ +

+ This link expires after 24 hours. +

+ +

+ If you did not request this, + you can ignore this email. +

+ + + +""" + + send_email( + email=email, + subject="Verify your Calendar email address", + plain_text=plain_text, + html=html, + ) + + +def send_password_reset_email( + email: str, + token: str, +): + password_reset_url = ( + "https://ben.de-roo.org" + f"/api/update-password_page?token={token}" + ) + + plain_text = f"""Hello, + +You requested to reset your Calendar password. + +Reset your password using this link: + +{password_reset_url} + +This link expires after 1 hour. + +If you did not request this, you can ignore this email. +""" + + html = f"""\ + + + + + + + + +

Hello,

+ +

+ You requested to reset your Calendar password. +

+ +

+ + Reset your password + +

+ +

+ This link expires after 1 hour. +

+ +

+ If you did not request this, + you can ignore this email. +

+ + + +""" + + send_email( + email=email, + subject="Reset your password", + plain_text=plain_text, + html=html, + ) + + +# ============================================================ +# EMAIL ADDRESS VERIFICATION +# ============================================================ + +def update_email( + user_id: int, + new_email: str, +): new_email = new_email.strip().lower() conn = get_connection() @@ -57,7 +259,10 @@ def update_email(user_id: int, new_email: str): WHERE email = ? AND id != ? """, - (new_email, user_id), + ( + new_email, + user_id, + ), ) if cur.fetchone() is not None: @@ -73,7 +278,10 @@ def update_email(user_id: int, new_email: str): email_verified = 0 WHERE id = ? """, - (new_email, user_id), + ( + new_email, + user_id, + ), ) cur.execute( @@ -104,7 +312,9 @@ def update_email(user_id: int, new_email: str): conn.close() -def create_verification(user_id: int): +def create_verification( + user_id: int, +): token = secrets.token_urlsafe(32) token_hash = hash_token(token) @@ -149,7 +359,9 @@ def create_verification(user_id: int): conn.close() -def verify_email(token: str): +def verify_email( + token: str, +): token_hash = hash_token(token) conn = get_connection() @@ -181,6 +393,7 @@ def verify_email(token: str): ) if datetime.now(timezone.utc) >= expires_at: + cur.execute( """ DELETE FROM email_verifications @@ -222,121 +435,14 @@ def verify_email(token: str): finally: conn.close() -def send_verification_email(email: str, token: str): - smtp_host = os.getenv("SMTP_HOST") - smtp_port = int(os.getenv("SMTP_PORT", "587")) - smtp_username = os.getenv("SMTP_USERNAME") - smtp_password = os.getenv("SMTP_PASSWORD") - smtp_from = os.getenv("SMTP_FROM") - if not all([ - smtp_host, - smtp_username, - smtp_password, - smtp_from, - ]): - raise RuntimeError("SMTP configuration is incomplete") +# ============================================================ +# PASSWORD RESET +# ============================================================ - verification_url = ( - f"https://ben.de-roo.org/api/verify-email?token={token}" - ) - - message = EmailMessage() - - message["From"] = smtp_from - message["To"] = email - message["Subject"] = "Verify your Calendar email address" - - message.set_content( - f"""Hello, - -You requested to use this email address for your Calendar account. - -Verify your email address using this link: - -{verification_url} - -This link expires after 24 hours. - -If you did not request this, you can ignore this email. -""" - ) - - print(f"Sending verification email to {email}") - - with smtplib.SMTP(smtp_host, smtp_port) as smtp: - print("SMTP connected") - - smtp.starttls() - print("TLS started") - - smtp.login( - smtp_username, - smtp_password, - ) - print("SMTP login successful") - - smtp.send_message(message) - print("Verification email sent") - -def send_password_reset_email(email: str, token: str): - smtp_host = os.getenv("SMTP_HOST") - smtp_port = int(os.getenv("SMTP_PORT", "587")) - smtp_username = os.getenv("SMTP_USERNAME") - smtp_password = os.getenv("SMTP_PASSWORD") - smtp_from = os.getenv("SMTP_FROM") - - if not all([ - smtp_host, - smtp_username, - smtp_password, - smtp_from, - ]): - raise RuntimeError("SMTP configuration is incomplete") - - password_reset_url = ( - f"https://ben.de-roo.org/api/update-password_page?token={token}" - ) - - message = EmailMessage() - - message["From"] = smtp_from - message["To"] = email - message["Subject"] = "Reset your password" - - message.set_content( - f"""Hello, - -You requested to use this email address for reseting your Calendar password. - -Reset your password using this link: - -{password_reset_url} - -This link expires after 24 hours. - -If you did not request this, you can ignore this email. -""" - ) - - print(f"Sending password reset email to {email}") - - with smtplib.SMTP(smtp_host, smtp_port) as smtp: - print("SMTP connected") - - smtp.starttls() - print("TLS started") - - smtp.login( - smtp_username, - smtp_password, - ) - print("SMTP login successful") - - smtp.send_message(message) - print("Password reset email sent") - -def create_password_reset(user_id: int): +def create_password_reset( + user_id: int, +): token = secrets.token_urlsafe(32) token_hash = hash_token(token) @@ -378,4 +484,4 @@ def create_password_reset(user_id: int): return token finally: - conn.close() \ No newline at end of file + conn.close() diff --git a/frontend(experimental)/newchat/.gitignore b/frontend(experimental)/newchat/.gitignore deleted file mode 100644 index f650315..0000000 --- a/frontend(experimental)/newchat/.gitignore +++ /dev/null @@ -1,27 +0,0 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules - -# next.js -/.next/ -/out/ - -# production -/build - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* - -# env files -.env* - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts \ No newline at end of file diff --git a/frontend(experimental)/newchat/README.md b/frontend(experimental)/newchat/README.md deleted file mode 100644 index 14a4cd3..0000000 --- a/frontend(experimental)/newchat/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# 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 deleted file mode 100644 index 5791b0a..0000000 --- a/frontend(experimental)/newchat/app/globals.css +++ /dev/null @@ -1,62 +0,0 @@ -@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 deleted file mode 100644 index 95ec418..0000000 --- a/frontend(experimental)/newchat/app/layout.tsx +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index cec067b..0000000 --- a/frontend(experimental)/newchat/app/loading.tsx +++ /dev/null @@ -1,4 +0,0 @@ -export default function Loading() { - return null -} - diff --git a/frontend(experimental)/newchat/app/page.tsx b/frontend(experimental)/newchat/app/page.tsx deleted file mode 100644 index 9549c74..0000000 --- a/frontend(experimental)/newchat/app/page.tsx +++ /dev/null @@ -1,239 +0,0 @@ -"use client" - -import { useMemo, useState } from "react" -import { Menu, Plus, Loader2 } from "lucide-react" - -import { useAuth } from "@/lib/auth-context" -import { useRooms, useEvents, useMyResponses } 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 { AccountSettingsDialog } from "@/components/account-settings-dialog" -import { RoomBackground } from "@/components/room-background" -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: "edit-event"; event: CalendarEvent } - | { type: "event-detail"; event: CalendarEvent } - | { type: "create-room" } - | { type: "join-room" } - | { type: "room-settings" } - | { type: "account-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 { myResponses } = useMyResponses(events, user.id) - - 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} - onOpenAccountSettings={() => setDialog({ type: "account-settings" })} - /> -
- -
-
-
- -
-

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

-

{formatMonthYear(weekStart)}

-
-
- -
- {activeRoom && ( - - )} - -
-
- -
- {eventsLoading ? ( -
- -
- ) : ( - setDialog({ type: "event-detail", event })} - /> - )} -
-
-
- - {(dialog.type === "create-event" || dialog.type === "edit-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} - onEdit={() => setDialog({ type: "edit-event", event: dialog.event })} - /> - )} - - {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() - }} - /> - )} - - {dialog.type === "account-settings" && ( - setDialog({ type: "none" })} - onLogout={onLogout} - /> - )} -
- ) -} \ No newline at end of file diff --git a/frontend(experimental)/newchat/components.json b/frontend(experimental)/newchat/components.json deleted file mode 100644 index 4ee62ee..0000000 --- a/frontend(experimental)/newchat/components.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$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/account-settings-dialog.tsx b/frontend(experimental)/newchat/components/account-settings-dialog.tsx deleted file mode 100644 index 053309c..0000000 --- a/frontend(experimental)/newchat/components/account-settings-dialog.tsx +++ /dev/null @@ -1,74 +0,0 @@ -"use client" - -import { LogOut, Mail, MailCheck, MailWarning, UserRound } from "lucide-react" -import { GlassModal, fieldClass, labelClass } from "./glass-modal" -import type { User } from "@/lib/types" - -interface AccountSettingsDialogProps { - user: User - onClose: () => void - onLogout: () => void -} - -export function AccountSettingsDialog({ user, onClose, onLogout }: AccountSettingsDialogProps) { - return ( - -
-
-
- {user.username.charAt(0).toUpperCase()} -
-
-

{user.username}

-

{user.email ?? "No email on file"}

-
-
- -
- -
- - {user.username} -
-
- -
- -
- - {user.email ?? "—"} -
- {user.email && ( -

- {user.email_verified ? ( - <> - Verified - - ) : ( - <> - Not verified - - )} -

- )} -
- -
- - - -
-
-
- ) -} diff --git a/frontend(experimental)/newchat/components/auth-screen.tsx b/frontend(experimental)/newchat/components/auth-screen.tsx deleted file mode 100644 index 3bcc92c..0000000 --- a/frontend(experimental)/newchat/components/auth-screen.tsx +++ /dev/null @@ -1,161 +0,0 @@ -"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 deleted file mode 100644 index 214f9ee..0000000 --- a/frontend(experimental)/newchat/components/create-event-dialog.tsx +++ /dev/null @@ -1,354 +0,0 @@ -"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 { CalendarEvent, EventType, EventVisibility, Room } from "@/lib/types" -import { parseDateTime, toBackendDateTime, toDateParam, toInputTime } from "@/lib/date-utils" - -interface CreateEventDialogProps { - rooms: Room[] - defaultRoomId: number | null - defaultDate: Date - /** When set, the dialog edits this event instead of creating a new one. */ - editingEvent?: CalendarEvent - onClose: () => void - onCreated: () => void -} - -const TYPE_OPTIONS: { value: EventType; label: string }[] = [ - { value: "proposal", label: "Proposal (members vote to confirm)" }, - { value: "busy", label: "Busy — room sees it as busy, no details" }, - { value: "private", label: "Private — hidden from the room" }, -] - -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, - editingEvent, - onClose, - onCreated, -}: CreateEventDialogProps) { - const isEditing = !!editingEvent - const initialRoom = editingEvent?.room_id ?? defaultRoomId ?? rooms[0]?.id ?? 0 - - // Start time defaults to "now", rounded up to the next quarter hour. - const now = new Date() - const defaultStartTime = new Date(now) - defaultStartTime.setMinutes(Math.ceil(now.getMinutes() / 15) * 15, 0, 0) - const defaultEndTime = new Date(defaultStartTime) - defaultEndTime.setHours(defaultEndTime.getHours() + 1) - - const editStart = editingEvent ? parseDateTime(editingEvent.start_time) : null - const editEnd = editingEvent ? parseDateTime(editingEvent.end_time) : null - - const [roomId, setRoomId] = useState(initialRoom) - const [title, setTitle] = useState(editingEvent?.title ?? "") - const [description, setDescription] = useState(editingEvent?.description ?? "") - const [type, setType] = useState(editingEvent?.type ?? "proposal") - const [visibility, setVisibility] = useState(editingEvent?.visibility ?? "room") - const [date, setDate] = useState(toDateParam(editStart ?? defaultDate)) - const [multiDay, setMultiDay] = useState( - editStart && editEnd ? toDateParam(editStart) !== toDateParam(editEnd) : false, - ) - const [endDate, setEndDate] = useState(toDateParam(editEnd ?? editStart ?? defaultDate)) - const [startTime, setStartTime] = useState(toInputTime(editStart ?? defaultStartTime)) - const [endTime, setEndTime] = useState(toInputTime(editEnd ?? defaultEndTime)) - const [minPeople, setMinPeople] = useState(editingEvent?.min_people ?? 1) - const [error, setError] = useState(null) - const [submitting, setSubmitting] = useState(false) - - // Busy/private events are always visible to the room as just "busy" (no - // details) or not visible at all — that's implied by the type, so the - // visibility choice only makes sense for proposals. - const handleTypeChange = (value: EventType) => { - setType(value) - if (value === "busy") setVisibility("busy_only") - else if (value === "private") setVisibility("private") - else setVisibility("room") - } - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setError(null) - - if (!roomId) { - setError("Please select a room.") - return - } - const startDate = new Date(`${date}T${startTime}`) - const endDate2 = new Date(`${multiDay ? endDate : date}T${endTime}`) - if (endDate2 <= startDate) { - setError("End time must be after the start time.") - return - } - - const payload = { - room_id: roomId, - type, - title: title.trim(), - description: description.trim(), - start_time: toBackendDateTime(startDate), - end_time: toBackendDateTime(endDate2), - visibility: type === "proposal" ? visibility : type === "busy" ? "busy_only" : "private", - min_people: type === "proposal" ? minPeople : 0, - } as const - - setSubmitting(true) - try { - if (isEditing && editingEvent) { - await api.updateEvent(editingEvent.id, payload) - } else { - await api.createEvent({ ...payload, status: type === "proposal" ? "proposed" : "confirmed" }) - } - onCreated() - onClose() - } catch (err) { - setError( - err instanceof ApiError - ? err.message - : isEditing - ? "Could not save the changes." - : "Could not create the event.", - ) - } finally { - setSubmitting(false) - } - } - - const dialogTitle = isEditing ? "Edit event" : "Create event" - - 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" - /> -
- -
- -