new frontend
ik ben zo cool
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
import type {
|
||||
CalendarEvent,
|
||||
EventResponse,
|
||||
EventStatus,
|
||||
EventType,
|
||||
EventVisibility,
|
||||
Room,
|
||||
RoomMember,
|
||||
RoomPermissions,
|
||||
RoomRole,
|
||||
SignupStatus,
|
||||
User,
|
||||
} from "./types"
|
||||
|
||||
export const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL?.replace(/\/$/, "") || "https://ben.de-roo.org/api"
|
||||
|
||||
const TOKEN_KEY = "calendar_token"
|
||||
|
||||
export function getToken(): string | null {
|
||||
if (typeof window === "undefined") return null
|
||||
return window.localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
window.localStorage.setItem(TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
window.localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.status = status
|
||||
this.name = "ApiError"
|
||||
}
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
method?: string
|
||||
body?: unknown
|
||||
auth?: boolean
|
||||
query?: Record<string, string | number | undefined>
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { method = "GET", body, auth = true, query } = options
|
||||
|
||||
let url = `${API_BASE_URL}${path}`
|
||||
if (query) {
|
||||
const params = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
params.append(key, String(value))
|
||||
}
|
||||
}
|
||||
const qs = params.toString()
|
||||
if (qs) url += `?${qs}`
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
if (body !== undefined) headers["Content-Type"] = "application/json"
|
||||
|
||||
if (auth) {
|
||||
const token = getToken()
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
} catch (err) {
|
||||
throw new ApiError(
|
||||
"Could not reach the server. Check your connection and the API URL.",
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
clearToken()
|
||||
throw new ApiError("Your session has expired. Please sign in again.", 401)
|
||||
}
|
||||
|
||||
let data: any = null
|
||||
const text = await res.text()
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch {
|
||||
data = text
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const detail =
|
||||
(data && typeof data === "object" && (data.detail || data.message)) ||
|
||||
(typeof data === "string" ? data : null) ||
|
||||
`Request failed (${res.status})`
|
||||
throw new ApiError(
|
||||
Array.isArray(detail) ? detail.map((d: any) => d.msg).join(", ") : String(detail),
|
||||
res.status,
|
||||
)
|
||||
}
|
||||
|
||||
return data as T
|
||||
}
|
||||
|
||||
// ---------- Auth ----------
|
||||
|
||||
export async function login(username: string, password: string) {
|
||||
const res = await request<{
|
||||
status: string
|
||||
access_token?: string
|
||||
token_type?: string
|
||||
}>("/login", { method: "POST", body: { username, password }, auth: false })
|
||||
|
||||
if (res.status !== "success" || !res.access_token) {
|
||||
throw new ApiError("Invalid username or password.", 401)
|
||||
}
|
||||
setToken(res.access_token)
|
||||
return res.access_token
|
||||
}
|
||||
|
||||
export async function register(username: string, password: string, email: string) {
|
||||
return request<{ status: string; user_id: number; email_verification: string }>(
|
||||
"/create-user",
|
||||
{ method: "POST", body: { username, password, email }, auth: false },
|
||||
)
|
||||
}
|
||||
|
||||
export async function getMe() {
|
||||
return request<User>("/me")
|
||||
}
|
||||
|
||||
// ---------- Rooms ----------
|
||||
|
||||
export async function getRooms() {
|
||||
const res = await request<{ rooms: Room[] }>("/rooms")
|
||||
return res.rooms
|
||||
}
|
||||
|
||||
export async function createRoom(room_name: string, invite_code: string) {
|
||||
return request<{ status: string; room_id: number; member_id: number }>("/create-room", {
|
||||
method: "POST",
|
||||
body: { room_name, invite_code },
|
||||
})
|
||||
}
|
||||
|
||||
export async function searchRoom(invite_code: string) {
|
||||
return request<{ status: string; room_name?: string; reason?: string }>("/search-room", {
|
||||
method: "POST",
|
||||
body: { invite_code },
|
||||
})
|
||||
}
|
||||
|
||||
export async function joinRoom(invite_code: string) {
|
||||
return request<{ status: string; room_id?: number; reason?: string }>("/join-room", {
|
||||
method: "POST",
|
||||
body: { invite_code },
|
||||
})
|
||||
}
|
||||
|
||||
export async function getRoomMembers(roomId: number) {
|
||||
const res = await request<{ members: RoomMember[] }>(`/rooms/${roomId}/members`)
|
||||
return res.members
|
||||
}
|
||||
|
||||
export async function roomWhoami(roomId: number) {
|
||||
return request<RoomPermissions>(`/rooms/${roomId}/whoami`)
|
||||
}
|
||||
|
||||
export async function renameRoom(roomId: number, new_name: string) {
|
||||
return request(`/rooms/${roomId}`, { method: "PATCH", body: { new_name } })
|
||||
}
|
||||
|
||||
export async function regenerateInviteCode(roomId: number) {
|
||||
return request<{ status: string; room_id: number; invite_code: string }>(
|
||||
`/rooms/${roomId}/invite-code`,
|
||||
{ method: "POST" },
|
||||
)
|
||||
}
|
||||
|
||||
export async function leaveRoom(roomId: number) {
|
||||
return request(`/rooms/${roomId}/members/me`, { method: "DELETE" })
|
||||
}
|
||||
|
||||
export async function deleteRoom(roomId: number) {
|
||||
return request(`/rooms/${roomId}`, { method: "DELETE" })
|
||||
}
|
||||
|
||||
export async function removeMember(roomId: number, userId: number) {
|
||||
return request(`/rooms/${roomId}/members/${userId}`, { method: "DELETE" })
|
||||
}
|
||||
|
||||
export async function updateMemberRole(roomId: number, userId: number, role: RoomRole) {
|
||||
return request(`/rooms/${roomId}/members/${userId}`, {
|
||||
method: "PATCH",
|
||||
body: { role },
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- Events ----------
|
||||
|
||||
export async function getUserEvents(from?: string, to?: string) {
|
||||
const res = await request<{ events: CalendarEvent[] }>("/events", {
|
||||
query: { from, to },
|
||||
})
|
||||
return res.events
|
||||
}
|
||||
|
||||
export async function getRoomEvents(roomId: number, from?: string, to?: string) {
|
||||
const res = await request<{ events: CalendarEvent[] }>(`/rooms/${roomId}/events`, {
|
||||
query: { from, to },
|
||||
})
|
||||
return res.events
|
||||
}
|
||||
|
||||
export async function getEvent(eventId: number) {
|
||||
return request<CalendarEvent>(`/events/${eventId}`)
|
||||
}
|
||||
|
||||
export interface CreateEventInput {
|
||||
room_id: number
|
||||
type: EventType
|
||||
title: string
|
||||
description: string
|
||||
start_time: string
|
||||
end_time: string
|
||||
visibility: EventVisibility
|
||||
status: EventStatus
|
||||
min_people: number
|
||||
}
|
||||
|
||||
export async function createEvent(input: CreateEventInput) {
|
||||
return request<{ status: string; event_id: number }>("/events", {
|
||||
method: "POST",
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateEvent(eventId: number, input: Partial<CreateEventInput>) {
|
||||
return request(`/events/${eventId}`, { method: "PATCH", body: input })
|
||||
}
|
||||
|
||||
export async function deleteEvent(eventId: number) {
|
||||
return request(`/events/${eventId}`, { method: "DELETE" })
|
||||
}
|
||||
|
||||
export async function confirmEvent(eventId: number) {
|
||||
return request(`/events/${eventId}/confirm`, { method: "POST" })
|
||||
}
|
||||
|
||||
export async function cancelEvent(eventId: number) {
|
||||
return request(`/events/${eventId}/cancel`, { method: "POST" })
|
||||
}
|
||||
|
||||
export async function respondToEvent(eventId: number, status: SignupStatus) {
|
||||
return request<{ status: string; going: number }>(`/events/${eventId}/respond`, {
|
||||
method: "POST",
|
||||
query: { status },
|
||||
})
|
||||
}
|
||||
|
||||
export async function getEventResponses(eventId: number) {
|
||||
const res = await request<{ responses: EventResponse[] }>(`/events/${eventId}/responses`)
|
||||
return res.responses
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"
|
||||
import * as api from "./api"
|
||||
import type { User } from "./types"
|
||||
|
||||
interface AuthContextValue {
|
||||
user: User | null
|
||||
loading: boolean
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
register: (username: string, password: string, email: string) => Promise<void>
|
||||
logout: () => void
|
||||
refreshUser: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined)
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadUser = useCallback(async () => {
|
||||
if (!api.getToken()) {
|
||||
setUser(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const me = await api.getMe()
|
||||
setUser(me)
|
||||
} catch {
|
||||
api.clearToken()
|
||||
setUser(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadUser()
|
||||
}, [loadUser])
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
await api.login(username, password)
|
||||
const me = await api.getMe()
|
||||
setUser(me)
|
||||
}, [])
|
||||
|
||||
const register = useCallback(
|
||||
async (username: string, password: string, email: string) => {
|
||||
await api.register(username, password, email)
|
||||
// Backend requires email verification, but login works immediately.
|
||||
await api.login(username, password)
|
||||
const me = await api.getMe()
|
||||
setUser(me)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const logout = useCallback(() => {
|
||||
api.clearToken()
|
||||
setUser(null)
|
||||
}, [])
|
||||
|
||||
const refreshUser = useCallback(async () => {
|
||||
const me = await api.getMe()
|
||||
setUser(me)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout, refreshUser }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error("useAuth must be used within AuthProvider")
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Helpers for parsing backend datetimes and computing week grids.
|
||||
|
||||
// Backend stores start_time / end_time as strings like "2026-08-10 19:00"
|
||||
// (sometimes ISO "2026-08-10T19:00"). Parse both robustly as local time.
|
||||
export function parseDateTime(value: string): Date {
|
||||
if (!value) return new Date(NaN)
|
||||
const normalized = value.trim().replace(" ", "T")
|
||||
const d = new Date(normalized)
|
||||
return d
|
||||
}
|
||||
|
||||
export function startOfWeek(date: Date): Date {
|
||||
const d = new Date(date)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
const day = d.getDay() // 0 = Sunday
|
||||
d.setDate(d.getDate() - day)
|
||||
return d
|
||||
}
|
||||
|
||||
export function addDays(date: Date, days: number): Date {
|
||||
const d = new Date(date)
|
||||
d.setDate(d.getDate() + days)
|
||||
return d
|
||||
}
|
||||
|
||||
export function isSameDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
)
|
||||
}
|
||||
|
||||
export function toDateParam(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, "0")
|
||||
const d = String(date.getDate()).padStart(2, "0")
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
// Format a Date into the "YYYY-MM-DD HH:MM" string the backend expects.
|
||||
export function toBackendDateTime(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const mo = String(date.getMonth() + 1).padStart(2, "0")
|
||||
const d = String(date.getDate()).padStart(2, "0")
|
||||
const h = String(date.getHours()).padStart(2, "0")
|
||||
const mi = String(date.getMinutes()).padStart(2, "0")
|
||||
return `${y}-${mo}-${d} ${h}:${mi}`
|
||||
}
|
||||
|
||||
// For <input type="datetime-local"> values ("YYYY-MM-DDTHH:MM").
|
||||
export function toInputDateTime(date: Date): string {
|
||||
return toBackendDateTime(date).replace(" ", "T")
|
||||
}
|
||||
|
||||
export function formatTime(date: Date): string {
|
||||
if (isNaN(date.getTime())) return "--:--"
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
export function formatDayLabel(date: Date): string {
|
||||
return date.toLocaleDateString([], { weekday: "long", month: "long", day: "numeric" })
|
||||
}
|
||||
|
||||
export function formatMonthYear(date: Date): string {
|
||||
return date.toLocaleDateString([], { month: "long", year: "numeric" })
|
||||
}
|
||||
|
||||
export const WEEKDAY_LABELS = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"]
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client"
|
||||
|
||||
import useSWR from "swr"
|
||||
import * as api from "./api"
|
||||
|
||||
export function useRooms() {
|
||||
const { data, error, isLoading, mutate } = useSWR("rooms", () => api.getRooms())
|
||||
return { rooms: data ?? [], error, isLoading, mutate }
|
||||
}
|
||||
|
||||
// activeRoomId === null => all rooms combined via /events
|
||||
export function useEvents(activeRoomId: number | null, from?: string, to?: string) {
|
||||
const key = activeRoomId === null ? ["events", from, to] : ["room-events", activeRoomId, from, to]
|
||||
const { data, error, isLoading, mutate } = useSWR(key, () =>
|
||||
activeRoomId === null
|
||||
? api.getUserEvents(from, to)
|
||||
: api.getRoomEvents(activeRoomId, from, to),
|
||||
)
|
||||
return { events: data ?? [], error, isLoading, mutate }
|
||||
}
|
||||
|
||||
export function useRoomMembers(roomId: number | null) {
|
||||
const { data, error, isLoading, mutate } = useSWR(
|
||||
roomId === null ? null : ["room-members", roomId],
|
||||
() => api.getRoomMembers(roomId as number),
|
||||
)
|
||||
return { members: data ?? [], error, isLoading, mutate }
|
||||
}
|
||||
|
||||
export function useRoomPermissions(roomId: number | null) {
|
||||
const { data, error, isLoading, mutate } = useSWR(
|
||||
roomId === null ? null : ["room-whoami", roomId],
|
||||
() => api.roomWhoami(roomId as number),
|
||||
)
|
||||
return { permissions: data, error, isLoading, mutate }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Deterministic per-room theming: background image + accent color.
|
||||
|
||||
const ROOM_BACKGROUNDS = [
|
||||
"/backgrounds/room-1.png",
|
||||
"/backgrounds/room-2.png",
|
||||
"/backgrounds/room-3.png",
|
||||
"/backgrounds/room-4.png",
|
||||
"/backgrounds/room-5.png",
|
||||
]
|
||||
|
||||
export const ALL_ROOMS_BACKGROUND = "/backgrounds/all-rooms.png"
|
||||
|
||||
// Tailwind background classes used to color-code events per room.
|
||||
const ROOM_COLORS = [
|
||||
{ bg: "bg-blue-500", dot: "bg-blue-500", ring: "ring-blue-400" },
|
||||
{ bg: "bg-emerald-500", dot: "bg-emerald-500", ring: "ring-emerald-400" },
|
||||
{ bg: "bg-purple-500", dot: "bg-purple-500", ring: "ring-purple-400" },
|
||||
{ bg: "bg-orange-500", dot: "bg-orange-500", ring: "ring-orange-400" },
|
||||
{ bg: "bg-pink-500", dot: "bg-pink-500", ring: "ring-pink-400" },
|
||||
{ bg: "bg-cyan-500", dot: "bg-cyan-500", ring: "ring-cyan-400" },
|
||||
{ bg: "bg-amber-500", dot: "bg-amber-500", ring: "ring-amber-400" },
|
||||
{ bg: "bg-teal-500", dot: "bg-teal-500", ring: "ring-teal-400" },
|
||||
]
|
||||
|
||||
export function getRoomBackground(roomId: number | null): string {
|
||||
if (roomId === null) return ALL_ROOMS_BACKGROUND
|
||||
return ROOM_BACKGROUNDS[roomId % ROOM_BACKGROUNDS.length]
|
||||
}
|
||||
|
||||
export function getRoomColor(roomId: number) {
|
||||
return ROOM_COLORS[roomId % ROOM_COLORS.length]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Types mirroring the FastAPI backend responses.
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
email: string | null
|
||||
email_verified: number
|
||||
}
|
||||
|
||||
export type RoomRole = "owner" | "admin" | "member"
|
||||
|
||||
export interface Room {
|
||||
id: number
|
||||
name: string
|
||||
owner_id: number
|
||||
invite_code: string
|
||||
role: RoomRole
|
||||
}
|
||||
|
||||
export interface RoomMember {
|
||||
id: number
|
||||
username: string
|
||||
role: RoomRole
|
||||
}
|
||||
|
||||
export type EventType = "private" | "busy" | "proposal"
|
||||
export type EventVisibility = "private" | "busy_only" | "room"
|
||||
export type EventStatus = "draft" | "proposed" | "confirmed" | "cancelled"
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: number
|
||||
room_id: number
|
||||
creator_id: number
|
||||
type: EventType
|
||||
title: string
|
||||
description: string | null
|
||||
start_time: string
|
||||
end_time: string
|
||||
visibility: EventVisibility
|
||||
status: EventStatus
|
||||
min_people: number
|
||||
invitation_mode?: string
|
||||
}
|
||||
|
||||
export type SignupStatus = "going" | "maybe" | "declined"
|
||||
|
||||
export interface EventResponse {
|
||||
event_id: number
|
||||
user_id: number
|
||||
status: SignupStatus
|
||||
username?: string
|
||||
}
|
||||
|
||||
export interface RoomPermissions {
|
||||
room_id: number
|
||||
user_id: number
|
||||
member: boolean
|
||||
role: RoomRole | null
|
||||
is_admin: boolean
|
||||
is_owner: boolean
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user