diff --git a/backend/main.py b/backend/main.py index c922ccd..9daab64 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,7 +14,7 @@ import sqlite3 from typing import Literal from fastapi.middleware.cors import CORSMiddleware -from events import delete_event +from events import delete_event, get_room_events class Login(BaseModel): username: str diff --git a/frontend/www/app.js b/frontend/www/app.js new file mode 100644 index 0000000..12de3fa --- /dev/null +++ b/frontend/www/app.js @@ -0,0 +1,1144 @@ +const API_BASE = + "http://localhost:8000"; + + + +function getToken(){ + + return localStorage.getItem("token"); + +} + + +function setToken(token){ + + localStorage.setItem("token", token); + +} + + +function clearToken(){ + + localStorage.removeItem("token"); + +} + + + +async function apiFetch(path, options = {}){ + + let headers = + options.headers + ? { ...options.headers } + : {}; + + + headers["Content-Type"] = "application/json"; + + + let token = + getToken(); + + + if(token){ + + headers["Authorization"] = "Bearer " + token; + + } + + + let response = + await fetch( + API_BASE + path, + { + ...options, + headers + } + ); + + + if(response.status === 401){ + + clearToken(); + + showLogin(); + + throw new Error("not authenticated"); + + } + + + let data = + await response.json() + .catch(() => null); + + + if(!response.ok){ + + let message = + (data && data.detail) + ? data.detail + : "request failed"; + + throw new Error(message); + + } + + + return data; + +} + + + +// ---------- auth ---------- + + +function showLogin(){ + + document.getElementById("app").style.display = "none"; + document.getElementById("login-screen").style.display = "flex"; + +} + + +function showApp(){ + + document.getElementById("login-screen").style.display = "none"; + document.getElementById("app").style.display = "grid"; + +} + + +async function handleLogin(event){ + + event.preventDefault(); + + + let username = + document.getElementById("login-username").value; + + let password = + document.getElementById("login-password").value; + + let errorBox = + document.getElementById("login-error"); + + + errorBox.textContent = ""; + + + try{ + + let result = + await apiFetch( + "/login", + { + method: "POST", + body: JSON.stringify({ username, password }) + } + ); + + + if(result.status !== "success"){ + + errorBox.textContent = "invalid username or password"; + return; + + } + + + setToken(result.access_token); + + showApp(); + + showPage("dashboard"); + + } catch(error){ + + errorBox.textContent = error.message; + + } + +} + + +async function handleRegister(event){ + + event.preventDefault(); + + + let username = + document.getElementById("login-username").value; + + let password = + document.getElementById("login-password").value; + + let errorBox = + document.getElementById("login-error"); + + + errorBox.textContent = ""; + + + try{ + + await apiFetch( + "/create-user", + { + method: "POST", + body: JSON.stringify({ username, password }) + } + ); + + + errorBox.style.color = "green"; + errorBox.textContent = "account created, you can log in now"; + + } catch(error){ + + errorBox.style.color = ""; + errorBox.textContent = error.message; + + } + +} + + +function logout(){ + + clearToken(); + + showLogin(); + +} + + + +// ---------- page router ---------- + + +function showPage(page){ + + let content = + document.getElementById("content"); + + + document.querySelectorAll("nav a") + .forEach(link => link.classList.remove("active")); + + let activeLink = + document.querySelector(`nav a[data-page="${page}"]`); + + if(activeLink){ + + activeLink.classList.add("active"); + + } + + + if(page === "dashboard"){ + + loadDashboard(); + + } + + + if(page === "rooms"){ + + loadRooms(); + + } + + + if(page === "events"){ + + loadEvents(); + + } + + + if(page === "settings"){ + + content.innerHTML = ` +

Settings

+ +
+ Account preferences. +

+ +
+ `; + + } + +} + + + +// ---------- dashboard ---------- + + +let calendarWeekOffset = 0; + +let cachedDashboardEvents = []; + +let currentUserId = null; + + +async function loadDashboard(){ + + let content = + document.getElementById("content"); + + + content.innerHTML = ` +

Dashboard

+
Loading...
+ `; + + + calendarWeekOffset = 0; + + + try{ + + let me = + await apiFetch("/me"); + + currentUserId = me.id; + + let roomsData = + await apiFetch("/rooms"); + + let rooms = + roomsData.rooms || []; + + + let eventsData = + await apiFetch("/events"); + + cachedDashboardEvents = + eventsData.events || []; + + + let roomsHtml = + rooms.length + ? rooms.map(room => ` +
+ ${room.room_name} +
+ `).join("") + : `

You have no rooms yet.

`; + + + content.innerHTML = ` +

Dashboard

+ +
+

Welcome back, ${me.username}

+

+ Overview of your calendar. +

+
+ +
+ ${buildWeekCalendarHtml(cachedDashboardEvents, calendarWeekOffset)} +
+ +
+

Your rooms

+ ${roomsHtml} +
+ +
+

Create a room

+ +
+ + + + + + + +
+ +

+
+ +
+

Join a room

+ +
+ + + + + +
+ +

+
+ `; + + } catch(error){ + + content.innerHTML = ` +

Dashboard

+
Could not load dashboard: ${error.message}
+ `; + + } + +} + + +async function handleCreateRoom(event){ + + event.preventDefault(); + + + let roomName = + document.getElementById("create-room-name").value; + + let inviteCode = + document.getElementById("create-room-code").value; + + let messageBox = + document.getElementById("create-room-message"); + + + try{ + + await apiFetch( + "/create-room", + { + method: "POST", + body: JSON.stringify({ + room_name: roomName, + invite_code: inviteCode + }) + } + ); + + + messageBox.textContent = "room created"; + + loadDashboard(); + + } catch(error){ + + messageBox.textContent = error.message; + + } + +} + + +async function handleJoinRoom(event){ + + event.preventDefault(); + + + let inviteCode = + document.getElementById("join-room-code").value; + + let messageBox = + document.getElementById("join-room-message"); + + + try{ + + await apiFetch( + "/join-room", + { + method: "POST", + body: JSON.stringify({ invite_code: inviteCode }) + } + ); + + + messageBox.textContent = "joined room"; + + loadDashboard(); + + } catch(error){ + + messageBox.textContent = error.message; + + } + +} + + + +// ---------- calendar (week view) ---------- + + +const CALENDAR_HOUR_HEIGHT = 48; + +const CALENDAR_START_HOUR = 0; + +const CALENDAR_END_HOUR = 24; + + +function isEventCancelled(event){ + + let status = + (event.status || "").toLowerCase(); + + return ( + status === "cancelled" + || status === "canceled" + || status === "declined" + ); + +} + + +function getEventColorClass(event, currentUserId){ + + let isOwnEvent = + event.creator_id === currentUserId; + + let isPrivateOther = + event.visibility === "private" && !isOwnEvent; + + + if(isPrivateOther){ + + return "event-private"; + + } + + + if((event.status || "").toLowerCase() === "confirmed"){ + + return "event-confirmed"; + + } + + + return "event-busy"; + +} + + +function getWeekStart(offset){ + + let today = + new Date(); + + let base = + new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() + (offset * 7) + ); + + let dayOfWeek = + base.getDay(); + + let mondayDiff = + (dayOfWeek === 0) ? -6 : (1 - dayOfWeek); + + + base.setDate(base.getDate() + mondayDiff); + + return base; + +} + + +function changeCalendarWeek(delta){ + + calendarWeekOffset += delta; + + + let calendarCard = + document.getElementById("calendar-card"); + + if(calendarCard){ + + calendarCard.innerHTML = + buildWeekCalendarHtml(cachedDashboardEvents, calendarWeekOffset); + + } + +} + + +function buildWeekCalendarHtml(events, offset){ + + let weekStart = + getWeekStart(offset); + + let today = + new Date(); + + let dayNames = + ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + + let days = []; + + + for(let i = 0; i < 7; i++){ + + let d = + new Date(weekStart); + + d.setDate(weekStart.getDate() + i); + + days.push(d); + + } + + + let rangeLabel = + days[0].toLocaleDateString("default", { day: "numeric", month: "short" }) + + " - " + + days[6].toLocaleDateString("default", { day: "numeric", month: "short", year: "numeric" }); + + + let totalMinutes = + (CALENDAR_END_HOUR - CALENDAR_START_HOUR) * 60; + + let trackHeight = + (CALENDAR_END_HOUR - CALENDAR_START_HOUR) * CALENDAR_HOUR_HEIGHT; + + + // time gutter + + let hourRows = ""; + + + for(let h = CALENDAR_START_HOUR; h < CALENDAR_END_HOUR; h++){ + + hourRows += ` +
+ ${String(h).padStart(2, "0")}:00 +
+ `; + + } + + + // day columns + + let dayColumns = + days.map(day => { + + let dayEvents = + events.filter(event => { + + if(!event.start_time || isEventCancelled(event)){ + + return false; + + } + + let eventStart = + new Date(event.start_time); + + return ( + eventStart.getFullYear() === day.getFullYear() + && eventStart.getMonth() === day.getMonth() + && eventStart.getDate() === day.getDate() + ); + + }) + .sort((a, b) => new Date(a.start_time) - new Date(b.start_time)); + + + // assign overlap layers: an event gets the lowest layer + // whose most recent block has already ended + + let layerEnds = []; + + let placed = + dayEvents.map(event => { + + let start = + new Date(event.start_time); + + let end = + event.end_time + ? new Date(event.end_time) + : new Date(start.getTime() + 30 * 60000); + + let startMinutes = + start.getHours() * 60 + start.getMinutes(); + + let endMinutes = + Math.max( + startMinutes + 15, + end.getHours() * 60 + end.getMinutes() + ); + + + let layer = 0; + + while(layerEnds[layer] !== undefined && layerEnds[layer] > startMinutes){ + + layer++; + + } + + layerEnds[layer] = endMinutes; + + + return { event, startMinutes, endMinutes, layer }; + + }); + + + let maxLayer = + placed.reduce((m, p) => Math.max(m, p.layer), 0); + + + let blocksHtml = + placed.map(p => { + + let topPct = + (p.startMinutes / totalMinutes) * 100; + + let heightPct = + ((p.endMinutes - p.startMinutes) / totalMinutes) * 100; + + let leftPct = + p.layer * 14; + + let widthPct = + 100 - (maxLayer * 14); + + let colorClass = + getEventColorClass(p.event, currentUserId); + + let timeLabel = + new Date(p.event.start_time) + .toLocaleTimeString("default", { hour: "2-digit", minute: "2-digit" }); + + + return ` +
+ ${timeLabel} + ${p.event.title} +
+ `; + + }).join(""); + + + let isToday = + ( + day.getFullYear() === today.getFullYear() + && day.getMonth() === today.getMonth() + && day.getDate() === today.getDate() + ); + + + return ` +
+ +
+
${dayNames[days.indexOf(day)]}
+
${day.getDate()}
+
+ +
+ ${blocksHtml} +
+ +
+ `; + + }).join(""); + + + return ` +
+ + + +

${rangeLabel}

+ + + +
+ +
+ + + confirmed + + + + busy + + + + private (others) + + +
+ +
+ +
+
+
+ ${hourRows} +
+
+ +
+ ${dayColumns} +
+ +
+ `; + +} + + + +// ---------- rooms page ---------- + + +async function loadRooms(){ + + let content = + document.getElementById("content"); + + + content.innerHTML = ` +

Rooms

+
Loading...
+ `; + + + try{ + + let roomsData = + await apiFetch("/rooms"); + + let rooms = + roomsData.rooms || []; + + + if(rooms.length === 0){ + + content.innerHTML = ` +

Rooms

+
+ Your shared rooms appear here. +
+ `; + + return; + + } + + + let roomsHtml = + rooms.map(room => ` +
+

${room.room_name}

+

invite code: ${room.invite_code || "-"}

+ +
+ `).join(""); + + + content.innerHTML = ` +

Rooms

+ ${roomsHtml} + `; + + } catch(error){ + + content.innerHTML = ` +

Rooms

+
Could not load rooms: ${error.message}
+ `; + + } + +} + + +async function loadRoomEvents(roomId){ + + let content = + document.getElementById("content"); + + + content.innerHTML = ` +

Room events

+
Loading...
+ `; + + + try{ + + let data = + await apiFetch(`/rooms/${roomId}/events`); + + let events = + data.events || []; + + + content.innerHTML = ` +

Room events

+ ${renderEventCards(events)} + `; + + } catch(error){ + + content.innerHTML = ` +

Room events

+
Could not load events: ${error.message}
+ `; + + } + +} + + + +// ---------- events page ---------- + + +async function loadEvents(){ + + let content = + document.getElementById("content"); + + + content.innerHTML = ` +

Events

+
Loading...
+ `; + + + try{ + + let data = + await apiFetch("/events"); + + let events = + data.events || []; + + + content.innerHTML = ` +

Events

+ ${renderEventCards(events)} + `; + + } catch(error){ + + content.innerHTML = ` +

Events

+
Could not load events: ${error.message}
+ `; + + } + +} + + +function renderEventCards(events){ + + if(events.length === 0){ + + return `
No events scheduled.
`; + + } + + + return events.map(event => ` + +
+ +

${event.title}

+ +

${event.description || ""}

+ +

+ ${formatEventTime(event.start_time, event.end_time)} +

+ +

+ status: ${event.status} +

+ +
+ + + + + + + +
+ +

+ +
+ + `).join(""); + +} + + +function formatEventTime(start, end){ + + if(!start){ + + return "time unknown"; + + } + + + let startDate = + new Date(start); + + let endDate = + end + ? new Date(end) + : null; + + + let startText = + startDate.toLocaleString(); + + if(!endDate){ + + return startText; + + } + + + return startText + " - " + endDate.toLocaleString(); + +} + + +async function handleRespond(eventId, status){ + + let messageBox = + document.getElementById(`event-response-${eventId}`); + + + try{ + + let result = + await apiFetch( + `/events/${eventId}/respond?status=${status}`, + { method: "POST" } + ); + + + messageBox.textContent = + `you responded: ${status} (${result.going} going)`; + + } catch(error){ + + messageBox.textContent = error.message; + + } + +} + + + +// ---------- theme ---------- + + +function toggleTheme(){ + + document.body.classList.toggle("dark"); + + + let theme = + document.body.classList.contains("dark") + ? "dark" + : "light"; + + + localStorage.setItem( + "theme", + theme + ); + +} + + + +// ---------- init ---------- + + +let savedTheme = + localStorage.getItem("theme"); + + +if(savedTheme === "dark"){ + + document.body.classList.add("dark"); + +} + + +if(getToken()){ + + showApp(); + + showPage("dashboard"); + +} else { + + showLogin(); + +} \ No newline at end of file diff --git a/frontend/www/events/index.html b/frontend/www/events/index.html deleted file mode 100644 index 8d4ad6d..0000000 --- a/frontend/www/events/index.html +++ /dev/null @@ -1 +0,0 @@ -hier komt de events pagina \ No newline at end of file diff --git a/frontend/www/index.html b/frontend/www/index.html index 46effad..b4f3f5b 100644 --- a/frontend/www/index.html +++ b/frontend/www/index.html @@ -1,26 +1,100 @@ - Calendar + Calendar Dashboard + -
- -
-

Dashboard

-
- Kalender komt hier -
-
+ +
+ + + + + + + +
+ + + +

+ +
+ + + +
+ + + + + +
+ +
+ + +
+ + + + \ No newline at end of file diff --git a/frontend/www/style.css b/frontend/www/style.css index 93ee329..94ea310 100644 --- a/frontend/www/style.css +++ b/frontend/www/style.css @@ -1,59 +1,632 @@ +:root { + + --bg: #f4f5f7; + --panel: white; + --sidebar: #ffffff; + --text: #111; + --muted: #666; + --border: #ddd; + --accent: #2563eb; + +} + + + +.dark { + + --bg: #080808; + --panel: #151515; + --sidebar: #101010; + --text: #eee; + --muted: #aaa; + --border: #333; + --accent: #38bdf8; + +} + + + * { box-sizing: border-box; } + body { + margin: 0; - background: #080808; - color: white; - font-family: monospace; + + background: var(--bg); + + color: var(--text); + + font-family: + Inter, + system-ui, + sans-serif; + + transition: 0.2s; + } + .layout { + display: grid; - grid-template-columns: 250px 1fr; + grid-template-columns: 260px 1fr; height: 100vh; + + display: none; + } .sidebar { - background: #111; - padding: 20px; - border-right: 1px solid #333; -} + background: var(--sidebar); + + border-right: 1px solid var(--border); + + padding: 25px; -.sidebar nav { display: flex; + flex-direction: column; - gap: 15px; + } -.sidebar a { + +.logo { + + display: flex; + + align-items: center; + + gap: 10px; + + margin-bottom: 40px; + +} + + + +.logo h2 { + + margin: 0; + +} + + + +nav { + + display: flex; + + flex-direction: column; + + gap: 8px; + +} + + + +nav a { + + cursor: pointer; + + padding: 12px 15px; + + border-radius: 10px; + + color: var(--muted); + + transition: 0.2s; + +} + + + +nav a:hover, +nav .active { + + background: var(--accent); + color: white; - text-decoration: none; + } -.sidebar a:hover { - color: cyan; + +.sidebar-bottom { + + margin-top: auto; + } + +button { + + width: 100%; + + padding: 12px; + + border-radius: 10px; + + border: 1px solid var(--border); + + background: var(--panel); + + color: var(--text); + + cursor: pointer; + + margin-top: 8px; + +} + + + +/* Content */ + .content { - padding: 30px; + + padding: 40px; + + overflow-y: auto; + } -.calendar { - background: #151515; - border: 1px solid #333; + +h1 { + + margin-top: 0; + +} + + + +.card { + + background: var(--panel); + + border: 1px solid var(--border); + + border-radius: 16px; + + padding: 25px; + + margin-top: 20px; + + box-shadow: + 0 10px 30px rgba(0,0,0,0.05); + +} + + + +/* forms */ + +input { + + width: 100%; + + padding: 12px; + border-radius: 10px; - height: 500px; - padding: 20px; + + border: 1px solid var(--border); + + background: var(--bg); + + color: var(--text); + + margin-top: 8px; + +} + + + +form { + + display: flex; + + flex-direction: column; + +} + + + +.muted { + + color: var(--muted); + +} + + + +.error { + + color: #e11d48; + +} + + + +/* list items */ + +.list-item { + + padding: 10px 15px; + + border: 1px solid var(--border); + + border-radius: 10px; + + margin-top: 10px; + +} + + + +/* event actions */ + +.event-actions { + + display: flex; + + gap: 10px; + + margin-top: 15px; + +} + + +.event-actions button { + + margin-top: 0; + +} + + + +/* calendar */ + +.calendar-header { + + display: flex; + + align-items: center; + + justify-content: space-between; + +} + + +.calendar-header h3 { + + margin: 0; + + text-transform: capitalize; + +} + + +.calendar-nav-btn { + + width: auto; + + padding: 8px 14px; + + margin-top: 0; + +} + + +.calendar-legend { + + display: flex; + + gap: 18px; + + margin-top: 14px; + + font-size: 12px; + + color: var(--muted); + +} + + +.legend-item { + + display: flex; + + align-items: center; + + gap: 6px; + +} + + +.legend-dot { + + width: 10px; + + height: 10px; + + border-radius: 50%; + + display: inline-block; + +} + + +/* week grid */ + +.calendar-week { + + display: flex; + + margin-top: 16px; + + overflow-x: auto; + +} + + +.calendar-gutter { + + display: flex; + + flex-direction: column; + + width: 55px; + + flex-shrink: 0; + +} + + +.calendar-hour-label { + + font-size: 11px; + + color: var(--muted); + + text-align: right; + + padding-right: 8px; + + box-sizing: border-box; + + transform: translateY(-6px); + +} + + +.calendar-days { + + display: flex; + + flex: 1; + + min-width: 700px; + +} + + +.calendar-day-column { + + flex: 1; + + display: flex; + + flex-direction: column; + + border-left: 1px solid var(--border); + +} + + +.calendar-day-header { + + text-align: center; + + padding: 6px 0 10px 0; + + font-size: 12px; + + color: var(--muted); + +} + + +.calendar-day-header.today .calendar-day-number { + + color: white; + + background: var(--accent); + + border-radius: 50%; + + padding: 2px 7px; + +} + + +.calendar-day-name { + + text-transform: uppercase; + + font-size: 10px; + + letter-spacing: 0.05em; + +} + + +.calendar-day-number { + + font-size: 14px; + + font-weight: 600; + + color: var(--text); + + margin-top: 2px; + + display: inline-block; + +} + + +.calendar-day-track { + + position: relative; + + background-image: + repeating-linear-gradient( + to bottom, + var(--border) 0, + var(--border) 1px, + transparent 1px, + transparent 48px + ); + +} + + +.calendar-event { + + position: absolute; + + border-radius: 6px; + + padding: 3px 6px; + + font-size: 11px; + + color: white; + + cursor: pointer; + + overflow: hidden; + + box-shadow: 0 2px 6px rgba(0,0,0,0.25); + + opacity: 0.92; + + border: 1px solid rgba(255,255,255,0.4); + + transition: opacity 0.15s; + +} + + +.calendar-event:hover { + + opacity: 1; + + z-index: 99 !important; + +} + + +.calendar-event-time { + + display: block; + + font-size: 10px; + + opacity: 0.85; + +} + + +.calendar-event-title { + + display: block; + + font-weight: 600; + + white-space: nowrap; + + overflow: hidden; + + text-overflow: ellipsis; + +} + + +.event-confirmed { + + background: #16a34a; + +} + + +.event-busy { + + background: #f97316; + +} + + +.event-private { + + background: #6b7280; + +} + + + +/* login screen */ + +.login-screen { + + height: 100vh; + + width: 100%; + + display: flex; + + align-items: center; + + justify-content: center; + +} + + + +.login-box { + + background: var(--panel); + + border: 1px solid var(--border); + + border-radius: 16px; + + padding: 40px; + + width: 320px; + + box-shadow: + 0 10px 30px rgba(0,0,0,0.05); + +} + + + +.login-box h2 { + + margin-top: 0; + + text-align: center; + } \ No newline at end of file