inital commit, very early alpha stage
This commit is contained in:
8
frontend/Dockerfile
Normal file
8
frontend/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM node:22-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
EXPOSE 5173
|
||||
|
||||
12
frontend/README.md
Normal file
12
frontend/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# GroupHome Frontend
|
||||
|
||||
React, Vite, and TypeScript frontend for the GroupHome demo.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
npm run build
|
||||
npm run test
|
||||
```
|
||||
|
||||
The dev server proxies `/api` and `/.well-known` to `http://localhost:8000`.
|
||||
14
frontend/index.html
Normal file
14
frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#f7f4ee" />
|
||||
<title>GroupHome</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
3262
frontend/package-lock.json
generated
Normal file
3262
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
frontend/package.json
Normal file
30
frontend/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "grouphome-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc && vite build",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@testing-library/jest-dom": "^6.4.8",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.2",
|
||||
"vitest": "^2.0.5"
|
||||
}
|
||||
}
|
||||
59
frontend/src/api/client.ts
Normal file
59
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export type ApiError = {
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||
|
||||
function csrfToken() {
|
||||
return document.cookie
|
||||
.split("; ")
|
||||
.find((part) => part.startsWith("grouphome_csrf="))
|
||||
?.split("=")[1];
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(options.headers);
|
||||
const isForm = options.body instanceof FormData;
|
||||
if (!isForm && options.body && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
const csrf = csrfToken();
|
||||
if (csrf && !headers.has("X-CSRF-Token")) {
|
||||
headers.set("X-CSRF-Token", decodeURIComponent(csrf));
|
||||
}
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
credentials: "include",
|
||||
...options,
|
||||
headers
|
||||
});
|
||||
if (!response.ok) {
|
||||
let payload: ApiError = {};
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
payload = { error: { code: "request_failed", message: response.statusText } };
|
||||
}
|
||||
throw new Error(payload.error?.message ?? "Request failed");
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export function postJson<T>(path: string, body: unknown): Promise<T> {
|
||||
return api<T>(path, { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
export function patchJson<T>(path: string, body: unknown): Promise<T> {
|
||||
return api<T>(path, { method: "PATCH", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
export function deleteJson<T>(path: string): Promise<T> {
|
||||
return api<T>(path, { method: "DELETE" });
|
||||
}
|
||||
|
||||
114
frontend/src/api/types.ts
Normal file
114
frontend/src/api/types.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
export type Group = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
server_origin: string;
|
||||
legacy_channel_status: string;
|
||||
transition_deadline?: string | null;
|
||||
};
|
||||
|
||||
export type Member = {
|
||||
id: string;
|
||||
group_id: string;
|
||||
display_name: string;
|
||||
role: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ActionItem = {
|
||||
id: string;
|
||||
source_type: "local" | "remote";
|
||||
source_server_origin: string;
|
||||
source_group_id: string;
|
||||
source_group_name: string;
|
||||
type: string;
|
||||
priority: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
object_type: string;
|
||||
object_id: string;
|
||||
due_at?: string | null;
|
||||
};
|
||||
|
||||
export type EventItem = {
|
||||
id: string;
|
||||
group_id: string;
|
||||
group_name?: string;
|
||||
source_type?: string;
|
||||
source_server_origin?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
starts_at: string;
|
||||
ends_at?: string | null;
|
||||
location_name?: string | null;
|
||||
location_address?: string | null;
|
||||
rsvp_required: boolean;
|
||||
rsvp_status?: string | null;
|
||||
changed_at?: string | null;
|
||||
};
|
||||
|
||||
export type Announcement = {
|
||||
id: string;
|
||||
group_id: string;
|
||||
group_name?: string;
|
||||
title: string;
|
||||
body: string;
|
||||
priority: string;
|
||||
official: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type TaskItem = {
|
||||
id: string;
|
||||
group_id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
due_at?: string | null;
|
||||
status: string;
|
||||
assigned_to_member_id?: string | null;
|
||||
};
|
||||
|
||||
export type FileAsset = {
|
||||
id: string;
|
||||
group_id: string;
|
||||
group_name?: string;
|
||||
source_type?: string;
|
||||
source_server_origin?: string;
|
||||
filename_original: string;
|
||||
description?: string | null;
|
||||
size_bytes: number;
|
||||
download_url?: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type Poll = {
|
||||
id: string;
|
||||
group_id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
status: string;
|
||||
closes_at?: string | null;
|
||||
options: { id: string; label: string; position: number; vote_count: number }[];
|
||||
};
|
||||
|
||||
export type Thread = {
|
||||
id: string;
|
||||
group_id: string;
|
||||
group_name?: string;
|
||||
title: string;
|
||||
kind: string;
|
||||
updated_at?: string;
|
||||
latest_message?: ChatMessage | null;
|
||||
messages?: ChatMessage[];
|
||||
};
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string;
|
||||
thread_id: string;
|
||||
author_member_id: string;
|
||||
author_name: string;
|
||||
body: string;
|
||||
created_at: string;
|
||||
mine: boolean;
|
||||
low_signal: boolean;
|
||||
};
|
||||
93
frontend/src/app/App.tsx
Normal file
93
frontend/src/app/App.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { api, postJson } from "../api/client";
|
||||
import { Loading } from "../components/Loading";
|
||||
import { Layout } from "./Layout";
|
||||
import { CalendarPage } from "../routes/CalendarPage";
|
||||
import { ChatPage } from "../routes/ChatPage";
|
||||
import { DetailPage } from "../routes/DetailPage";
|
||||
import { FilesPage } from "../routes/FilesPage";
|
||||
import { GroupPage } from "../routes/GroupPage";
|
||||
import { GroupsPage } from "../routes/GroupsPage";
|
||||
import { HomePage } from "../routes/HomePage";
|
||||
import { JoinPage } from "../routes/JoinPage";
|
||||
import { MePage } from "../routes/MePage";
|
||||
|
||||
type MeResponse = {
|
||||
authenticated: boolean;
|
||||
dev_mode: boolean;
|
||||
};
|
||||
|
||||
export function App() {
|
||||
const location = useLocation();
|
||||
const [ready, setReady] = useState(location.pathname.startsWith("/join/"));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function bootstrap() {
|
||||
if (location.pathname.startsWith("/join/")) {
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const me = await api<MeResponse>("/api/me");
|
||||
if (!me.authenticated && me.dev_mode) {
|
||||
await postJson("/api/auth/dev/demo-session", {});
|
||||
}
|
||||
if (!cancelled) setReady(true);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : "Could not open the app.");
|
||||
setReady(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
setReady(false);
|
||||
bootstrap();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [location.pathname]);
|
||||
|
||||
if (!ready) {
|
||||
return <Loading label="Opening GroupHome" />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<main className="standalone">
|
||||
<div className="error-panel">
|
||||
<h1>GroupHome</h1>
|
||||
<p>{error}</p>
|
||||
<p>Run the backend seed command, then refresh this page.</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/join/:token" element={<JoinPage />} />
|
||||
<Route element={<Layout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="/calendar" element={<CalendarPage />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
<Route path="/groups" element={<GroupsPage />} />
|
||||
<Route path="/groups/:groupId" element={<GroupPage />} />
|
||||
<Route path="/groups/:groupId/admin" element={<GroupPage initialTab="admin" />} />
|
||||
<Route path="/groups/:groupId/discussions" element={<GroupPage initialTab="discussions" />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/me" element={<MePage />} />
|
||||
<Route path="/me/devices" element={<MePage initialPanel="devices" />} />
|
||||
<Route path="/me/notifications" element={<MePage initialPanel="notifications" />} />
|
||||
<Route path="/me/servers/connect" element={<MePage initialPanel="servers" />} />
|
||||
<Route path="/events/:id" element={<DetailPage kind="events" />} />
|
||||
<Route path="/announcements/:id" element={<DetailPage kind="announcements" />} />
|
||||
<Route path="/tasks/:id" element={<DetailPage kind="tasks" />} />
|
||||
<Route path="/polls/:id" element={<DetailPage kind="polls" />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
46
frontend/src/app/Layout.tsx
Normal file
46
frontend/src/app/Layout.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { CalendarDays, Folder, Home, MessageCircle, User, UsersRound } from "lucide-react";
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
|
||||
const nav = [
|
||||
{ to: "/", label: "Home", icon: Home },
|
||||
{ to: "/calendar", label: "Calendar", icon: CalendarDays },
|
||||
{ to: "/chat", label: "Chat", icon: MessageCircle, primary: true },
|
||||
{ to: "/groups", label: "Groups", icon: UsersRound },
|
||||
{ to: "/files", label: "Files", icon: Folder },
|
||||
{ to: "/me", label: "Me", icon: User }
|
||||
];
|
||||
|
||||
export function Layout() {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="side-nav" aria-label="Main navigation">
|
||||
<div className="brand-lockup">
|
||||
<span className="brand-mark">GH</span>
|
||||
<div>
|
||||
<strong>GroupHome</strong>
|
||||
<small>Command center</small>
|
||||
</div>
|
||||
</div>
|
||||
<nav>
|
||||
{nav.map((item) => (
|
||||
<NavLink key={item.to} to={item.to} end={item.to === "/"} className={({ isActive }) => `${item.primary ? "nav-item primary" : "nav-item"}${isActive ? " active" : ""}`}>
|
||||
<item.icon size={19} aria-hidden />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
<main className="main-panel">
|
||||
<Outlet />
|
||||
</main>
|
||||
<nav className="bottom-nav" aria-label="Main navigation">
|
||||
{nav.map((item) => (
|
||||
<NavLink key={item.to} to={item.to} end={item.to === "/"} className={({ isActive }) => `${item.primary ? "bottom-item primary" : "bottom-item"}${isActive ? " active" : ""}`}>
|
||||
<item.icon size={20} aria-hidden />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
frontend/src/app/format.ts
Normal file
25
frontend/src/app/format.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export function formatDateTime(value?: string | null) {
|
||||
if (!value) return "";
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function formatDate(value?: string | null) {
|
||||
if (!value) return "";
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric"
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function fileSize(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
10
frontend/src/components/Badge.tsx
Normal file
10
frontend/src/components/Badge.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type BadgeProps = {
|
||||
children: ReactNode;
|
||||
tone?: "neutral" | "urgent" | "good" | "remote" | "quiet";
|
||||
};
|
||||
|
||||
export function Badge({ children, tone = "neutral" }: BadgeProps) {
|
||||
return <span className={`badge badge-${tone}`}>{children}</span>;
|
||||
}
|
||||
10
frontend/src/components/Button.tsx
Normal file
10
frontend/src/components/Button.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
|
||||
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||
};
|
||||
|
||||
export function Button({ variant = "primary", className = "", ...props }: ButtonProps) {
|
||||
return <button className={`button button-${variant} ${className}`} {...props} />;
|
||||
}
|
||||
|
||||
10
frontend/src/components/Card.tsx
Normal file
10
frontend/src/components/Card.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type CardProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Card({ children, className = "" }: CardProps) {
|
||||
return <article className={`card ${className}`}>{children}</article>;
|
||||
}
|
||||
12
frontend/src/components/EmptyState.tsx
Normal file
12
frontend/src/components/EmptyState.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Inbox } from "lucide-react";
|
||||
|
||||
export function EmptyState({ title, body }: { title: string; body?: string }) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<Inbox size={22} aria-hidden />
|
||||
<strong>{title}</strong>
|
||||
{body ? <p>{body}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
23
frontend/src/components/FormRow.tsx
Normal file
23
frontend/src/components/FormRow.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { InputHTMLAttributes, ReactNode, TextareaHTMLAttributes } from "react";
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function FormRow({ label, children }: Props) {
|
||||
return (
|
||||
<label className="form-row">
|
||||
<span>{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function TextInput(props: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return <input className="input" {...props} />;
|
||||
}
|
||||
|
||||
export function TextArea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
return <textarea className="input textarea" {...props} />;
|
||||
}
|
||||
9
frontend/src/components/Loading.tsx
Normal file
9
frontend/src/components/Loading.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
export function Loading({ label = "Loading" }: { label?: string }) {
|
||||
return (
|
||||
<div className="loading" role="status" aria-live="polite">
|
||||
<span className="skeleton skeleton-dot" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
23
frontend/src/components/Section.tsx
Normal file
23
frontend/src/components/Section.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type SectionProps = {
|
||||
title: string;
|
||||
eyebrow?: string;
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function Section({ title, eyebrow, action, children }: SectionProps) {
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
{eyebrow ? <span className="eyebrow">{eyebrow}</span> : null}
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
14
frontend/src/main.tsx
Normal file
14
frontend/src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { App } from "./app/App";
|
||||
import "./styles/base.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
54
frontend/src/routes/CalendarPage.tsx
Normal file
54
frontend/src/routes/CalendarPage.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { MapPin } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import type { EventItem } from "../api/types";
|
||||
import { formatDateTime } from "../app/format";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Card } from "../components/Card";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { Loading } from "../components/Loading";
|
||||
import { Section } from "../components/Section";
|
||||
|
||||
export function CalendarPage() {
|
||||
const [events, setEvents] = useState<EventItem[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api<{ events: EventItem[] }>("/api/home/calendar").then((data) => setEvents(data.events));
|
||||
}, []);
|
||||
|
||||
if (!events) return <Loading label="Loading calendar" />;
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<header className="page-header">
|
||||
<span className="eyebrow">Calendar</span>
|
||||
<h1>Upcoming across groups</h1>
|
||||
<p>Events from local memberships and connected group servers.</p>
|
||||
</header>
|
||||
<Section title="Agenda">
|
||||
<div className="timeline">
|
||||
{events.length === 0 ? <EmptyState title="No events scheduled" /> : null}
|
||||
{events.map((event) => (
|
||||
<Card key={`${event.source_type}-${event.id}`} className="timeline-card">
|
||||
<div className="date-block">{formatDateTime(event.starts_at)}</div>
|
||||
<div>
|
||||
<div className="card-topline">
|
||||
<Badge tone={event.source_type === "remote" ? "remote" : "neutral"}>{event.group_name}</Badge>
|
||||
{event.changed_at ? <Badge tone="urgent">Changed</Badge> : null}
|
||||
</div>
|
||||
<h3>{event.title}</h3>
|
||||
{event.location_name ? (
|
||||
<p className="inline-meta">
|
||||
<MapPin size={15} /> {event.location_name}
|
||||
</p>
|
||||
) : null}
|
||||
{event.rsvp_required ? <p>RSVP: {event.rsvp_status ?? "unknown"}</p> : null}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
298
frontend/src/routes/ChatPage.tsx
Normal file
298
frontend/src/routes/ChatPage.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import { CalendarPlus, CheckSquare, ChevronDown, Link2, Megaphone, MessageCircle, Plus, Send, Vote } from "lucide-react";
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { api, postJson } from "../api/client";
|
||||
import type { ChatMessage, Group, Member, Thread } from "../api/types";
|
||||
import { formatDateTime } from "../app/format";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Button } from "../components/Button";
|
||||
import { FormRow, TextInput } from "../components/FormRow";
|
||||
import { Loading } from "../components/Loading";
|
||||
|
||||
type ChatResponse = {
|
||||
groups: { group: Group; member: Member }[];
|
||||
active_group: Group | null;
|
||||
active_member: Member | null;
|
||||
members: Member[];
|
||||
threads: Thread[];
|
||||
active_thread: Thread | null;
|
||||
current_member_id: string | null;
|
||||
};
|
||||
|
||||
type StructureKind = "message" | "poll" | "event" | "task" | "announcement" | "invite" | "feedback";
|
||||
|
||||
const structureOptions: { kind: StructureKind; label: string; icon: typeof MessageCircle }[] = [
|
||||
{ kind: "message", label: "Message", icon: MessageCircle },
|
||||
{ kind: "poll", label: "Poll", icon: Vote },
|
||||
{ kind: "event", label: "Event", icon: CalendarPlus },
|
||||
{ kind: "task", label: "Task", icon: CheckSquare },
|
||||
{ kind: "announcement", label: "Announcement", icon: Megaphone },
|
||||
{ kind: "invite", label: "Invite", icon: Link2 },
|
||||
{ kind: "feedback", label: "Feedback", icon: MessageCircle }
|
||||
];
|
||||
|
||||
function titleFromDraft(draft: string) {
|
||||
const firstLine = draft.trim().split("\n").find(Boolean) || "New item";
|
||||
return firstLine.replace(/^[-*]\s*/, "").slice(0, 140);
|
||||
}
|
||||
|
||||
function pollOptionsFromDraft(draft: string) {
|
||||
const lines = draft
|
||||
.split("\n")
|
||||
.map((line) => line.trim().replace(/^[-*]\s*/, ""))
|
||||
.filter(Boolean);
|
||||
const optionLines = lines.slice(1).filter((line) => line.length <= 80);
|
||||
if (optionLines.length >= 2) return optionLines.slice(0, 8).map((label) => ({ label }));
|
||||
if (draft.includes(",")) {
|
||||
const parts = draft
|
||||
.split(":")
|
||||
.pop()
|
||||
?.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
if (parts && parts.length >= 2) return parts.slice(0, 8).map((label) => ({ label }));
|
||||
}
|
||||
return [{ label: "Yes" }, { label: "No" }];
|
||||
}
|
||||
|
||||
function guessedKind(draft: string): StructureKind {
|
||||
const text = draft.toLowerCase();
|
||||
if (!text.trim()) return "message";
|
||||
if (/(invite|join link|new people|qr)/.test(text)) return "invite";
|
||||
if (/(poll|vote|choose|which option|yes\/no)/.test(text)) return "poll";
|
||||
if (/(training|match|meeting|appointment|event|tomorrow|tonight|rescheduled|moved|changed)/.test(text)) return "event";
|
||||
if (/(todo|task|can someone|please bring|need someone|driver|bring)/.test(text)) return "task";
|
||||
if (/(announce|official|important|urgent|from now)/.test(text)) return "announcement";
|
||||
if (/(feedback|idea|concern|issue)/.test(text)) return "feedback";
|
||||
return "message";
|
||||
}
|
||||
|
||||
function defaultStartsAt(draft: string) {
|
||||
const lower = draft.toLowerCase();
|
||||
const date = new Date();
|
||||
if (lower.includes("tomorrow")) {
|
||||
date.setDate(date.getDate() + 1);
|
||||
} else {
|
||||
date.setDate(date.getDate() + 2);
|
||||
}
|
||||
date.setHours(lower.includes("morning") ? 9 : lower.includes("afternoon") ? 15 : 18, 0, 0, 0);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function displayMessages(messages: ChatMessage[], foldNoise: boolean) {
|
||||
if (!foldNoise) return messages.map((message) => ({ type: "message" as const, message }));
|
||||
const items: ({ type: "message"; message: ChatMessage } | { type: "fold"; count: number; names: string[] })[] = [];
|
||||
let folded: ChatMessage[] = [];
|
||||
const flush = () => {
|
||||
if (folded.length) {
|
||||
items.push({ type: "fold", count: folded.length, names: [...new Set(folded.map((message) => message.author_name))].slice(0, 3) });
|
||||
folded = [];
|
||||
}
|
||||
};
|
||||
for (const message of messages) {
|
||||
if (message.low_signal && !message.mine) {
|
||||
folded.push(message);
|
||||
} else {
|
||||
flush();
|
||||
items.push({ type: "message", message });
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return items;
|
||||
}
|
||||
|
||||
export function ChatPage() {
|
||||
const [data, setData] = useState<ChatResponse | null>(null);
|
||||
const [groupId, setGroupId] = useState<string>("");
|
||||
const [threadId, setThreadId] = useState<string>("");
|
||||
const [draft, setDraft] = useState("");
|
||||
const [selectedKind, setSelectedKind] = useState<StructureKind>("message");
|
||||
const [newThreadTitle, setNewThreadTitle] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [foldNoise, setFoldNoise] = useState(() => localStorage.getItem("grouphome.foldNoise") === "true");
|
||||
|
||||
const suggestedKind = useMemo(() => guessedKind(draft), [draft]);
|
||||
const activeGroupId = data?.active_group?.id ?? groupId;
|
||||
const activeThreadId = data?.active_thread?.id ?? threadId;
|
||||
|
||||
async function load(nextGroupId = groupId, nextThreadId = threadId) {
|
||||
const params = new URLSearchParams();
|
||||
if (nextGroupId) params.set("group_id", nextGroupId);
|
||||
if (nextThreadId) params.set("thread_id", nextThreadId);
|
||||
const result = await api<ChatResponse>(`/api/chat${params.toString() ? `?${params.toString()}` : ""}`);
|
||||
setData(result);
|
||||
setGroupId(result.active_group?.id ?? "");
|
||||
setThreadId(result.active_thread?.id ?? "");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("grouphome.foldNoise", String(foldNoise));
|
||||
}, [foldNoise]);
|
||||
|
||||
async function chooseGroup(nextGroupId: string) {
|
||||
setGroupId(nextGroupId);
|
||||
setThreadId("");
|
||||
await load(nextGroupId, "");
|
||||
}
|
||||
|
||||
async function chooseThread(nextThreadId: string) {
|
||||
setThreadId(nextThreadId);
|
||||
await load(groupId, nextThreadId);
|
||||
}
|
||||
|
||||
async function createThread(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!activeGroupId || !newThreadTitle.trim()) return;
|
||||
const result = await postJson<{ thread: Thread }>(`/api/chat/threads?group_id=${activeGroupId}`, { title: newThreadTitle, kind: "discussion" });
|
||||
setNewThreadTitle("");
|
||||
await load(activeGroupId, result.thread.id);
|
||||
}
|
||||
|
||||
async function sendMessage(body: string) {
|
||||
await postJson(`/api/chat/threads/${activeThreadId}/messages`, { body });
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!draft.trim() || !activeGroupId || !activeThreadId) return;
|
||||
const kind = selectedKind;
|
||||
const title = titleFromDraft(draft);
|
||||
try {
|
||||
if (kind === "message") {
|
||||
await sendMessage(draft);
|
||||
} else if (kind === "poll") {
|
||||
await postJson(`/api/groups/${activeGroupId}/polls`, { title, description: draft, options: pollOptionsFromDraft(draft) });
|
||||
await sendMessage(`Created poll: ${title}`);
|
||||
} else if (kind === "event") {
|
||||
await postJson(`/api/groups/${activeGroupId}/events`, { title, description: draft, starts_at: defaultStartsAt(draft), rsvp_required: true, location_name: "TBD" });
|
||||
await sendMessage(`Created event: ${title}`);
|
||||
} else if (kind === "task") {
|
||||
await postJson(`/api/groups/${activeGroupId}/tasks`, { title, description: draft });
|
||||
await sendMessage(`Created task: ${title}`);
|
||||
} else if (kind === "announcement") {
|
||||
const canPostOfficial = ["owner", "admin", "moderator"].includes(data?.active_member?.role ?? "");
|
||||
await postJson(`/api/groups/${activeGroupId}/announcements`, { title, body: draft, official: canPostOfficial, priority: /urgent|important/i.test(draft) ? "urgent" : "normal" });
|
||||
await sendMessage(`${canPostOfficial ? "Posted official announcement" : "Posted announcement"}: ${title}`);
|
||||
} else if (kind === "invite") {
|
||||
const invite = await postJson<{ invite_url: string }>(`/api/groups/${activeGroupId}/invites`, { label: title, max_uses: 50 });
|
||||
await sendMessage(`Invite link: ${invite.invite_url}`);
|
||||
} else if (kind === "feedback") {
|
||||
await sendMessage(`Feedback: ${draft}`);
|
||||
}
|
||||
setDraft("");
|
||||
setSelectedKind("message");
|
||||
setNotice("");
|
||||
await load(activeGroupId, activeThreadId);
|
||||
} catch (err) {
|
||||
setNotice(err instanceof Error ? err.message : "Could not create that item.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) return <Loading label="Opening chat" />;
|
||||
if (!data.active_group || !data.active_thread) {
|
||||
return <div className="error-panel">Join a group to start chatting.</div>;
|
||||
}
|
||||
|
||||
const messages = data.active_thread.messages ?? [];
|
||||
const renderedMessages = displayMessages(messages, foldNoise);
|
||||
|
||||
return (
|
||||
<div className="chat-page">
|
||||
<aside className="chat-sidebar">
|
||||
<div className="chat-sidebar-header">
|
||||
<div>
|
||||
<span className="eyebrow">Chat</span>
|
||||
<h1>Messages</h1>
|
||||
</div>
|
||||
<Badge tone="good">{data.groups.length} groups</Badge>
|
||||
</div>
|
||||
<FormRow label="Group">
|
||||
<select className="input" value={data.active_group.id} onChange={(event) => chooseGroup(event.target.value)}>
|
||||
{data.groups.map(({ group }) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
<form className="inline-form" onSubmit={createThread}>
|
||||
<TextInput value={newThreadTitle} onChange={(event) => setNewThreadTitle(event.target.value)} placeholder="New thread" />
|
||||
<Button type="submit" variant="secondary" aria-label="Create thread">
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</form>
|
||||
<div className="chat-thread-list">
|
||||
{data.threads.map((thread) => (
|
||||
<button key={thread.id} type="button" className={thread.id === data.active_thread?.id ? "chat-thread active" : "chat-thread"} onClick={() => chooseThread(thread.id)}>
|
||||
<strong>{thread.title}</strong>
|
||||
<span>{thread.latest_message?.body ?? "No messages yet"}</span>
|
||||
<small>{thread.latest_message?.created_at ? formatDateTime(thread.latest_message.created_at) : thread.kind}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="chat-panel">
|
||||
<header className="chat-header">
|
||||
<div>
|
||||
<span className="eyebrow">{data.active_group.name}</span>
|
||||
<h2>{data.active_thread.title}</h2>
|
||||
</div>
|
||||
<label className="toggle-row">
|
||||
<input type="checkbox" checked={foldNoise} onChange={(event) => setFoldNoise(event.target.checked)} />
|
||||
<span>Fold short replies</span>
|
||||
</label>
|
||||
</header>
|
||||
|
||||
<div className="message-list">
|
||||
{renderedMessages.map((item, index) =>
|
||||
item.type === "fold" ? (
|
||||
<div className="folded-noise" key={`fold-${index}`}>
|
||||
<ChevronDown size={15} />
|
||||
{item.count} short replies folded from {item.names.join(", ")}
|
||||
</div>
|
||||
) : (
|
||||
<div key={item.message.id} className={item.message.mine ? "message-row mine" : "message-row"}>
|
||||
<div className={item.message.mine ? "bubble mine" : "bubble"}>
|
||||
<div className="bubble-meta">
|
||||
<strong>{item.message.author_name}</strong>
|
||||
<span>{formatDateTime(item.message.created_at)}</span>
|
||||
</div>
|
||||
<p>{item.message.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form className="chat-composer" onSubmit={submit}>
|
||||
{notice ? <div className="notice">{notice}</div> : null}
|
||||
<div className="structure-strip">
|
||||
{structureOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const active = selectedKind === option.kind;
|
||||
return (
|
||||
<button key={option.kind} type="button" className={active ? "structure-chip active" : "structure-chip"} onClick={() => setSelectedKind(option.kind)}>
|
||||
<Icon size={15} />
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{suggestedKind !== "message" && selectedKind === "message" ? (
|
||||
<div className="composer-hint">Looks like a {suggestedKind}. Send normally or create the structured item.</div>
|
||||
) : null}
|
||||
<div className="composer-row">
|
||||
<textarea className="input chat-input" value={draft} onChange={(event) => setDraft(event.target.value)} placeholder="Write a message, poll, event, task, invite, or feedback..." />
|
||||
<Button type="submit" aria-label="Send">
|
||||
<Send size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
frontend/src/routes/DetailPage.tsx
Normal file
102
frontend/src/routes/DetailPage.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Check, Server } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { api, patchJson, postJson } from "../api/client";
|
||||
import { formatDateTime } from "../app/format";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Button } from "../components/Button";
|
||||
import { Card } from "../components/Card";
|
||||
import { Loading } from "../components/Loading";
|
||||
import { Section } from "../components/Section";
|
||||
|
||||
export function DetailPage({ kind }: { kind: "events" | "announcements" | "tasks" | "polls" }) {
|
||||
const { id } = useParams();
|
||||
const [item, setItem] = useState<any | null>(null);
|
||||
|
||||
async function load() {
|
||||
if (!id) return;
|
||||
const data = await api<any>(`/api/${kind}/${id}`);
|
||||
const key = kind === "events" ? "event" : kind === "announcements" ? "announcement" : kind === "tasks" ? "task" : "poll";
|
||||
setItem(data[key]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [id, kind]);
|
||||
|
||||
if (!item) return <Loading label="Loading detail" />;
|
||||
|
||||
async function rsvp(status: "yes" | "maybe" | "no") {
|
||||
await postJson(`/api/events/${item.id}/rsvp`, { status });
|
||||
await load();
|
||||
}
|
||||
|
||||
async function completeTask() {
|
||||
await patchJson(`/api/tasks/${item.id}`, { status: "done" });
|
||||
await load();
|
||||
}
|
||||
|
||||
async function vote(optionId: string) {
|
||||
await postJson(`/api/polls/${item.id}/vote`, { option_id: optionId });
|
||||
await load();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<header className="page-header">
|
||||
<span className="eyebrow">{kind}</span>
|
||||
<h1>{item.title}</h1>
|
||||
<p>{item.description || item.body || item.group_name}</p>
|
||||
</header>
|
||||
<Section title="Details">
|
||||
<Card>
|
||||
<div className="card-topline">
|
||||
{item.group_name ? <Badge tone="neutral">{item.group_name}</Badge> : null}
|
||||
{item.source_type === "remote" ? (
|
||||
<Badge tone="remote">
|
||||
<Server size={13} /> Remote
|
||||
</Badge>
|
||||
) : null}
|
||||
{item.priority ? <Badge tone={item.priority === "urgent" ? "urgent" : "good"}>{item.priority}</Badge> : null}
|
||||
</div>
|
||||
{item.starts_at ? <p>{formatDateTime(item.starts_at)}</p> : null}
|
||||
{item.due_at ? <p>Due {formatDateTime(item.due_at)}</p> : null}
|
||||
{item.location_name ? <p>{item.location_name}</p> : null}
|
||||
{kind === "events" ? (
|
||||
<div className="button-row">
|
||||
<Button type="button" onClick={() => rsvp("yes")}>
|
||||
Yes
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => rsvp("maybe")}>
|
||||
Maybe
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => rsvp("no")}>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{kind === "tasks" && item.status === "open" ? (
|
||||
<Button type="button" onClick={completeTask}>
|
||||
<Check size={16} /> Complete task
|
||||
</Button>
|
||||
) : null}
|
||||
{kind === "polls" ? (
|
||||
<div className="stack-tight">
|
||||
{item.options.map((option: any) => (
|
||||
<Button key={option.id} type="button" variant="secondary" onClick={() => vote(option.id)}>
|
||||
{option.label} · {option.vote_count}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{item.group_id ? (
|
||||
<Link className="text-link" to={`/groups/${item.group_id}`}>
|
||||
Back to group
|
||||
</Link>
|
||||
) : null}
|
||||
</Card>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
95
frontend/src/routes/FilesPage.tsx
Normal file
95
frontend/src/routes/FilesPage.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Download, Upload } from "lucide-react";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import type { FileAsset, Group, Member } from "../api/types";
|
||||
import { fileSize } from "../app/format";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Button } from "../components/Button";
|
||||
import { Card } from "../components/Card";
|
||||
import { FormRow, TextInput } from "../components/FormRow";
|
||||
import { Loading } from "../components/Loading";
|
||||
import { Section } from "../components/Section";
|
||||
|
||||
export function FilesPage() {
|
||||
const [files, setFiles] = useState<FileAsset[] | null>(null);
|
||||
const [groups, setGroups] = useState<{ group: Group; member: Member }[]>([]);
|
||||
const [groupId, setGroupId] = useState("");
|
||||
const [upload, setUpload] = useState<File | null>(null);
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
async function load() {
|
||||
const [fileData, groupData] = await Promise.all([api<{ files: FileAsset[] }>("/api/home/files"), api<{ groups: { group: Group; member: Member }[] }>("/api/groups")]);
|
||||
setFiles(fileData.files);
|
||||
setGroups(groupData.groups);
|
||||
setGroupId((current) => current || groupData.groups[0]?.group.id || "");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!upload || !groupId) return;
|
||||
const form = new FormData();
|
||||
form.append("upload", upload);
|
||||
form.append("description", description);
|
||||
await api(`/api/groups/${groupId}/files`, { method: "POST", body: form });
|
||||
setUpload(null);
|
||||
setDescription("");
|
||||
await load();
|
||||
}
|
||||
|
||||
if (!files) return <Loading label="Loading files" />;
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<header className="page-header">
|
||||
<span className="eyebrow">Files</span>
|
||||
<h1>Shared documents</h1>
|
||||
<p>Global file list with source group and server visible.</p>
|
||||
</header>
|
||||
<Section title="Upload local file">
|
||||
<Card>
|
||||
<form className="form-grid" onSubmit={submit}>
|
||||
<FormRow label="Group">
|
||||
<select className="input" value={groupId} onChange={(event) => setGroupId(event.target.value)}>
|
||||
{groups.map((item) => (
|
||||
<option key={item.group.id} value={item.group.id}>
|
||||
{item.group.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
<FormRow label="Description">
|
||||
<TextInput value={description} onChange={(event) => setDescription(event.target.value)} placeholder="What members should know" />
|
||||
</FormRow>
|
||||
<FormRow label="File">
|
||||
<input className="input" type="file" onChange={(event) => setUpload(event.target.files?.[0] ?? null)} />
|
||||
</FormRow>
|
||||
<Button type="submit" disabled={!upload || !groupId}>
|
||||
<Upload size={16} /> Upload
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</Section>
|
||||
<Section title="All files">
|
||||
<div className="list-panel">
|
||||
{files.map((file) => (
|
||||
<a className="list-row" href={file.download_url ?? "#"} key={`${file.source_type}-${file.id}`}>
|
||||
<div>
|
||||
<strong>{file.filename_original}</strong>
|
||||
<span>{file.description || file.group_name}</span>
|
||||
</div>
|
||||
<div className="row-tail">
|
||||
<Badge tone={file.source_type === "remote" ? "remote" : "neutral"}>{file.group_name}</Badge>
|
||||
<span>{fileSize(file.size_bytes)}</span>
|
||||
<Download size={16} />
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
473
frontend/src/routes/GroupPage.tsx
Normal file
473
frontend/src/routes/GroupPage.tsx
Normal file
@@ -0,0 +1,473 @@
|
||||
import { Bell, CalendarPlus, Check, Clipboard, FileUp, MessageSquare, Plus, Vote } from "lucide-react";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { api, patchJson, postJson } from "../api/client";
|
||||
import type { ActionItem, Announcement, EventItem, FileAsset, Group, Member, Poll, TaskItem, Thread } from "../api/types";
|
||||
import { formatDateTime } from "../app/format";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Button } from "../components/Button";
|
||||
import { Card } from "../components/Card";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { FormRow, TextArea, TextInput } from "../components/FormRow";
|
||||
import { Loading } from "../components/Loading";
|
||||
import { Section } from "../components/Section";
|
||||
|
||||
type GroupResponse = {
|
||||
group: Group;
|
||||
member: Member;
|
||||
members: Member[];
|
||||
dashboard: {
|
||||
important_now: ActionItem[];
|
||||
upcoming: EventItem[];
|
||||
open_actions: ActionItem[];
|
||||
announcements: Announcement[];
|
||||
tasks: TaskItem[];
|
||||
polls: Poll[];
|
||||
files: FileAsset[];
|
||||
discussions: Thread[];
|
||||
};
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initialTab?: "dashboard" | "compose" | "discussions" | "admin";
|
||||
};
|
||||
|
||||
export function GroupPage({ initialTab = "dashboard" }: Props) {
|
||||
const { groupId } = useParams();
|
||||
const [data, setData] = useState<GroupResponse | null>(null);
|
||||
const [tab, setTab] = useState(initialTab);
|
||||
const [notice, setNotice] = useState("");
|
||||
|
||||
async function load() {
|
||||
if (!groupId) return;
|
||||
setData(await api<GroupResponse>(`/api/groups/${groupId}`));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setTab(initialTab);
|
||||
}, [initialTab]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [groupId]);
|
||||
|
||||
if (!data) return <Loading label="Loading group" />;
|
||||
|
||||
const { group, member, dashboard, members } = data;
|
||||
const canAdmin = ["owner", "admin"].includes(member.role);
|
||||
const canModerate = ["owner", "admin", "moderator"].includes(member.role);
|
||||
|
||||
async function rsvp(eventId: string, status: "yes" | "maybe" | "no") {
|
||||
await postJson(`/api/events/${eventId}/rsvp`, { status });
|
||||
await load();
|
||||
}
|
||||
|
||||
async function completeTask(taskId: string) {
|
||||
await patchJson(`/api/tasks/${taskId}`, { status: "done" });
|
||||
await load();
|
||||
}
|
||||
|
||||
async function vote(pollId: string, optionId: string) {
|
||||
await postJson(`/api/polls/${pollId}/vote`, { option_id: optionId });
|
||||
await load();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<header className="page-header">
|
||||
<span className="eyebrow">Group</span>
|
||||
<h1>{group.name}</h1>
|
||||
<p>{group.description}</p>
|
||||
<div className="button-row">
|
||||
<Badge tone="good">{member.role}</Badge>
|
||||
<Badge tone="quiet">{group.legacy_channel_status}</Badge>
|
||||
{group.transition_deadline ? <Badge tone="urgent">Transition {group.transition_deadline}</Badge> : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="segmented" role="tablist">
|
||||
{["dashboard", "compose", "discussions", "admin"].map((item) => (
|
||||
<button key={item} type="button" className={tab === item ? "active" : ""} onClick={() => setTab(item as typeof tab)} disabled={item === "admin" && !canAdmin}>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{notice ? <div className="notice">{notice}</div> : null}
|
||||
|
||||
{tab === "dashboard" ? (
|
||||
<>
|
||||
<Section title="Important now">
|
||||
<div className="card-grid">
|
||||
{dashboard.important_now.length === 0 ? <EmptyState title="Nothing urgent" /> : null}
|
||||
{dashboard.important_now.map((action) => (
|
||||
<Card key={action.id}>
|
||||
<Badge tone={action.priority === "urgent" ? "urgent" : "neutral"}>{action.type.replaceAll("_", " ")}</Badge>
|
||||
<h3>{action.title}</h3>
|
||||
<p>{action.summary}</p>
|
||||
{action.object_type === "event" ? (
|
||||
<div className="button-row">
|
||||
<Button type="button" onClick={() => rsvp(action.object_id, "yes")}>
|
||||
<Check size={16} /> Yes
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => rsvp(action.object_id, "maybe")}>
|
||||
Maybe
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Link className="text-link" to={`/${action.object_type}s/${action.object_id}`}>
|
||||
Open
|
||||
</Link>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Upcoming events">
|
||||
<div className="list-panel">
|
||||
{dashboard.upcoming.map((event) => (
|
||||
<div className="list-row" key={event.id}>
|
||||
<div>
|
||||
<strong>{event.title}</strong>
|
||||
<span>{event.location_name || event.description}</span>
|
||||
</div>
|
||||
<div className="row-tail">
|
||||
<span>{formatDateTime(event.starts_at)}</span>
|
||||
{event.rsvp_required ? <Badge tone={event.rsvp_status === "yes" ? "good" : "urgent"}>RSVP {event.rsvp_status}</Badge> : null}
|
||||
</div>
|
||||
{event.rsvp_required ? (
|
||||
<div className="button-row compact">
|
||||
<Button type="button" variant="secondary" onClick={() => rsvp(event.id, "yes")}>
|
||||
Yes
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => rsvp(event.id, "no")}>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className="two-column">
|
||||
<Section title="Official announcements">
|
||||
<div className="list-panel">
|
||||
{dashboard.announcements.map((item) => (
|
||||
<Link className="list-row" to={`/announcements/${item.id}`} key={item.id}>
|
||||
<div>
|
||||
<strong>{item.title}</strong>
|
||||
<span>{item.body}</span>
|
||||
</div>
|
||||
<Badge tone={item.priority === "urgent" ? "urgent" : "good"}>{item.official ? "Official" : "Post"}</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
<Section title="Files">
|
||||
<div className="list-panel">
|
||||
{dashboard.files.map((file) => (
|
||||
<a href={file.download_url} className="list-row" key={file.id}>
|
||||
<strong>{file.filename_original}</strong>
|
||||
<span>{file.description}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<Section title="Tasks and polls">
|
||||
<div className="card-grid">
|
||||
{dashboard.tasks.map((task) => (
|
||||
<Card key={task.id}>
|
||||
<Badge tone={task.status === "open" ? "urgent" : "good"}>{task.status}</Badge>
|
||||
<h3>{task.title}</h3>
|
||||
<p>{task.description}</p>
|
||||
{task.due_at ? <p>{formatDateTime(task.due_at)}</p> : null}
|
||||
{task.status === "open" ? (
|
||||
<Button type="button" variant="secondary" onClick={() => completeTask(task.id)}>
|
||||
Complete
|
||||
</Button>
|
||||
) : null}
|
||||
</Card>
|
||||
))}
|
||||
{dashboard.polls.map((poll) => (
|
||||
<Card key={poll.id}>
|
||||
<Badge tone="neutral">Poll</Badge>
|
||||
<h3>{poll.title}</h3>
|
||||
<p>{poll.description}</p>
|
||||
<div className="stack-tight">
|
||||
{poll.options.map((option) => (
|
||||
<Button key={option.id} type="button" variant="secondary" onClick={() => vote(poll.id, option.id)}>
|
||||
{option.label} · {option.vote_count}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{tab === "compose" ? (
|
||||
<ComposePanel group={group} members={members} canModerate={canModerate} onDone={load} setNotice={setNotice} />
|
||||
) : null}
|
||||
|
||||
{tab === "discussions" ? (
|
||||
<DiscussionsPanel group={group} threads={dashboard.discussions} onDone={load} />
|
||||
) : null}
|
||||
|
||||
{tab === "admin" && canAdmin ? <AdminPanel group={group} onDone={load} setNotice={setNotice} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComposePanel({
|
||||
group,
|
||||
members,
|
||||
canModerate,
|
||||
onDone,
|
||||
setNotice
|
||||
}: {
|
||||
group: Group;
|
||||
members: Member[];
|
||||
canModerate: boolean;
|
||||
onDone: () => Promise<void>;
|
||||
setNotice: (value: string) => void;
|
||||
}) {
|
||||
const [announcementTitle, setAnnouncementTitle] = useState("");
|
||||
const [announcementBody, setAnnouncementBody] = useState("");
|
||||
const [eventTitle, setEventTitle] = useState("");
|
||||
const [taskTitle, setTaskTitle] = useState("");
|
||||
const [assignedTo, setAssignedTo] = useState("");
|
||||
const [pollTitle, setPollTitle] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
|
||||
async function submitAnnouncement(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
await postJson(`/api/groups/${group.id}/announcements`, { title: announcementTitle, body: announcementBody, priority: "normal", official: canModerate, requires_ack: false });
|
||||
setAnnouncementTitle("");
|
||||
setAnnouncementBody("");
|
||||
setNotice("Announcement created.");
|
||||
await onDone();
|
||||
}
|
||||
|
||||
async function submitEvent(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const startsAt = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString();
|
||||
await postJson(`/api/groups/${group.id}/events`, { title: eventTitle, starts_at: startsAt, rsvp_required: true, location_name: "TBD" });
|
||||
setEventTitle("");
|
||||
setNotice("Event created.");
|
||||
await onDone();
|
||||
}
|
||||
|
||||
async function submitTask(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
await postJson(`/api/groups/${group.id}/tasks`, { title: taskTitle, assigned_to_member_id: assignedTo || null });
|
||||
setTaskTitle("");
|
||||
setNotice("Task created.");
|
||||
await onDone();
|
||||
}
|
||||
|
||||
async function submitPoll(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
await postJson(`/api/groups/${group.id}/polls`, { title: pollTitle, options: [{ label: "Yes" }, { label: "No" }] });
|
||||
setPollTitle("");
|
||||
setNotice("Poll created.");
|
||||
await onDone();
|
||||
}
|
||||
|
||||
async function submitFile(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!file) return;
|
||||
const form = new FormData();
|
||||
form.append("upload", file);
|
||||
form.append("description", "Uploaded from group dashboard");
|
||||
await api(`/api/groups/${group.id}/files`, { method: "POST", body: form });
|
||||
setFile(null);
|
||||
setNotice("File uploaded.");
|
||||
await onDone();
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title="Create structured content" eyebrow="Composer">
|
||||
<div className="card-grid">
|
||||
<Card>
|
||||
<form className="form-grid" onSubmit={submitAnnouncement}>
|
||||
<h3>
|
||||
<Bell size={17} /> Announcement
|
||||
</h3>
|
||||
<FormRow label="Title">
|
||||
<TextInput value={announcementTitle} onChange={(event) => setAnnouncementTitle(event.target.value)} required />
|
||||
</FormRow>
|
||||
<FormRow label="Body">
|
||||
<TextArea value={announcementBody} onChange={(event) => setAnnouncementBody(event.target.value)} />
|
||||
</FormRow>
|
||||
<Button type="submit">Post</Button>
|
||||
</form>
|
||||
</Card>
|
||||
<Card>
|
||||
<form className="form-grid" onSubmit={submitEvent}>
|
||||
<h3>
|
||||
<CalendarPlus size={17} /> Event
|
||||
</h3>
|
||||
<FormRow label="Title">
|
||||
<TextInput value={eventTitle} onChange={(event) => setEventTitle(event.target.value)} required />
|
||||
</FormRow>
|
||||
<Button type="submit">Create event</Button>
|
||||
</form>
|
||||
</Card>
|
||||
<Card>
|
||||
<form className="form-grid" onSubmit={submitTask}>
|
||||
<h3>
|
||||
<Check size={17} /> Task
|
||||
</h3>
|
||||
<FormRow label="Title">
|
||||
<TextInput value={taskTitle} onChange={(event) => setTaskTitle(event.target.value)} required />
|
||||
</FormRow>
|
||||
<FormRow label="Assigned to">
|
||||
<select className="input" value={assignedTo} onChange={(event) => setAssignedTo(event.target.value)}>
|
||||
<option value="">No one yet</option>
|
||||
{members.map((item) => (
|
||||
<option value={item.id} key={item.id}>
|
||||
{item.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
<Button type="submit">Create task</Button>
|
||||
</form>
|
||||
</Card>
|
||||
<Card>
|
||||
<form className="form-grid" onSubmit={submitPoll}>
|
||||
<h3>
|
||||
<Vote size={17} /> Poll
|
||||
</h3>
|
||||
<FormRow label="Question">
|
||||
<TextInput value={pollTitle} onChange={(event) => setPollTitle(event.target.value)} required />
|
||||
</FormRow>
|
||||
<Button type="submit">Create yes/no poll</Button>
|
||||
</form>
|
||||
</Card>
|
||||
<Card>
|
||||
<form className="form-grid" onSubmit={submitFile}>
|
||||
<h3>
|
||||
<FileUp size={17} /> File
|
||||
</h3>
|
||||
<FormRow label="Upload">
|
||||
<input className="input" type="file" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
|
||||
</FormRow>
|
||||
<Button type="submit" disabled={!file}>Upload file</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function DiscussionsPanel({ group, threads, onDone }: { group: Group; threads: Thread[]; onDone: () => Promise<void> }) {
|
||||
const [title, setTitle] = useState("");
|
||||
|
||||
async function createThread(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
await postJson(`/api/groups/${group.id}/threads`, { title, kind: "discussion" });
|
||||
setTitle("");
|
||||
await onDone();
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title="Discussions" eyebrow="Secondary">
|
||||
<Card>
|
||||
<form className="inline-form" onSubmit={createThread}>
|
||||
<TextInput value={title} onChange={(event) => setTitle(event.target.value)} required placeholder="New discussion title" />
|
||||
<Button type="submit">
|
||||
<MessageSquare size={16} /> Start
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
<div className="list-panel">
|
||||
{threads.map((thread) => (
|
||||
<div className="list-row" key={thread.id}>
|
||||
<div>
|
||||
<strong>{thread.title}</strong>
|
||||
<span>{thread.kind === "archive" ? "Read-only archive" : "Discussion thread"}</span>
|
||||
</div>
|
||||
<Badge tone={thread.kind === "archive" ? "quiet" : "neutral"}>{thread.kind}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminPanel({ group, onDone, setNotice }: { group: Group; onDone: () => Promise<void>; setNotice: (value: string) => void }) {
|
||||
const [migration, setMigration] = useState<any | null>(null);
|
||||
const [inviteUrl, setInviteUrl] = useState("");
|
||||
const [copy, setCopy] = useState("");
|
||||
|
||||
async function loadMigration() {
|
||||
setMigration(await api(`/api/groups/${group.id}/migration`));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadMigration();
|
||||
}, [group.id]);
|
||||
|
||||
async function createInvite() {
|
||||
const result = await postJson<{ invite_url: string }>(`/api/groups/${group.id}/invites`, { label: "Member invite", max_uses: 100 });
|
||||
setInviteUrl(result.invite_url);
|
||||
}
|
||||
|
||||
async function reminder() {
|
||||
const result = await postJson<{ copy: string; invite_url: string }>(`/api/groups/${group.id}/migration/reminder-copy`, { frontend_origin: window.location.origin });
|
||||
setCopy(result.copy);
|
||||
setInviteUrl(result.invite_url);
|
||||
await loadMigration();
|
||||
}
|
||||
|
||||
async function markLegacy() {
|
||||
await patchJson(`/api/groups/${group.id}`, { legacy_channel_status: "legacy" });
|
||||
setNotice("Legacy channel status updated.");
|
||||
await onDone();
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title="Migration dashboard" eyebrow="Admin">
|
||||
<div className="card-grid">
|
||||
<Card>
|
||||
<h3>Member migration</h3>
|
||||
{migration ? (
|
||||
<ul className="metric-list">
|
||||
{Object.entries(migration.stats).map(([key, value]) => (
|
||||
<li key={key}>
|
||||
<strong>{String(value)}</strong>
|
||||
<span>{key.replaceAll("_", " ")}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<Loading label="Loading migration" />
|
||||
)}
|
||||
</Card>
|
||||
<Card>
|
||||
<h3>Invite and reminder</h3>
|
||||
<div className="button-row">
|
||||
<Button type="button" onClick={createInvite}>
|
||||
<Plus size={16} /> Invite link
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={reminder}>
|
||||
<Clipboard size={16} /> Reminder copy
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={markLegacy}>
|
||||
Mark legacy
|
||||
</Button>
|
||||
</div>
|
||||
{inviteUrl ? <pre className="copy-box">{inviteUrl}</pre> : null}
|
||||
{copy ? <pre className="copy-box">{copy}</pre> : null}
|
||||
</Card>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
89
frontend/src/routes/GroupsPage.tsx
Normal file
89
frontend/src/routes/GroupsPage.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Plus } from "lucide-react";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api, postJson } from "../api/client";
|
||||
import type { Group, Member } from "../api/types";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Button } from "../components/Button";
|
||||
import { Card } from "../components/Card";
|
||||
import { FormRow, TextArea, TextInput } from "../components/FormRow";
|
||||
import { Loading } from "../components/Loading";
|
||||
import { Section } from "../components/Section";
|
||||
|
||||
type GroupRow = {
|
||||
group: Group;
|
||||
member: Member;
|
||||
dashboard: { important_now: unknown[]; upcoming: unknown[]; files: unknown[] };
|
||||
};
|
||||
|
||||
export function GroupsPage() {
|
||||
const [groups, setGroups] = useState<GroupRow[] | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
async function load() {
|
||||
const data = await api<{ groups: GroupRow[] }>("/api/groups");
|
||||
setGroups(data.groups);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function createGroup(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
await postJson("/api/groups", { name, description, visibility: "private", owner_display_name: "Group admin" });
|
||||
setName("");
|
||||
setDescription("");
|
||||
await load();
|
||||
}
|
||||
|
||||
if (!groups) return <Loading label="Loading groups" />;
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<header className="page-header">
|
||||
<span className="eyebrow">Groups</span>
|
||||
<h1>Structured group spaces</h1>
|
||||
<p>Dashboards, members, files, announcements, tasks, and discussions grouped by organization.</p>
|
||||
</header>
|
||||
|
||||
<Section title="My groups">
|
||||
<div className="card-grid">
|
||||
{groups.map(({ group, member, dashboard }) => (
|
||||
<Link to={`/groups/${group.id}`} className="card link-card" key={group.id}>
|
||||
<div className="card-topline">
|
||||
<Badge tone="good">{member.role}</Badge>
|
||||
<Badge tone="quiet">{group.legacy_channel_status}</Badge>
|
||||
</div>
|
||||
<h3>{group.name}</h3>
|
||||
<p>{group.description}</p>
|
||||
<ul className="mini-stats">
|
||||
<li>{dashboard.important_now.length} open actions</li>
|
||||
<li>{dashboard.upcoming.length} upcoming</li>
|
||||
<li>{dashboard.files.length} files</li>
|
||||
</ul>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Create a group" eyebrow="Admin">
|
||||
<Card>
|
||||
<form className="form-grid" onSubmit={createGroup}>
|
||||
<FormRow label="Group name">
|
||||
<TextInput value={name} onChange={(event) => setName(event.target.value)} required placeholder="Choir parents" />
|
||||
</FormRow>
|
||||
<FormRow label="Purpose">
|
||||
<TextArea value={description} onChange={(event) => setDescription(event.target.value)} placeholder="Announcements, events, files, and coordination." />
|
||||
</FormRow>
|
||||
<Button type="submit">
|
||||
<Plus size={16} /> Create group
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
146
frontend/src/routes/HomePage.tsx
Normal file
146
frontend/src/routes/HomePage.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Check, ExternalLink } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api, postJson } from "../api/client";
|
||||
import type { ActionItem, Announcement, EventItem } from "../api/types";
|
||||
import { formatDateTime } from "../app/format";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Button } from "../components/Button";
|
||||
import { Card } from "../components/Card";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { Loading } from "../components/Loading";
|
||||
import { Section } from "../components/Section";
|
||||
|
||||
type HomeResponse = {
|
||||
profile: { primary_display_name: string };
|
||||
sections: {
|
||||
needs_me: ActionItem[];
|
||||
today: EventItem[];
|
||||
changed: ActionItem[];
|
||||
official_updates: Announcement[];
|
||||
catch_up: { label: string; count: number }[];
|
||||
};
|
||||
connections: { id: string; server_name: string; status: string }[];
|
||||
};
|
||||
|
||||
export function HomePage() {
|
||||
const [data, setData] = useState<HomeResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setData(await api<HomeResponse>("/api/home"));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not load home.");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function rsvp(action: ActionItem, status: "yes" | "maybe" | "no") {
|
||||
await postJson(`/api/events/${action.object_id}/rsvp`, { status });
|
||||
await load();
|
||||
}
|
||||
|
||||
if (error) return <div className="error-panel">{error}</div>;
|
||||
if (!data) return <Loading label="Loading home" />;
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Home</span>
|
||||
<h1>What needs attention</h1>
|
||||
<p>{data.profile.primary_display_name}'s groups, actions, and official updates in one place.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Section title="Needs me" eyebrow="Actionable">
|
||||
<div className="card-grid">
|
||||
{data.sections.needs_me.length === 0 ? <EmptyState title="No open actions" body="You are caught up across your groups." /> : null}
|
||||
{data.sections.needs_me.map((action) => (
|
||||
<Card key={action.id}>
|
||||
<div className="card-topline">
|
||||
<Badge tone={action.priority === "urgent" ? "urgent" : action.source_type === "remote" ? "remote" : "neutral"}>{action.type.replaceAll("_", " ")}</Badge>
|
||||
{action.source_type === "remote" ? <Badge tone="remote">Remote</Badge> : null}
|
||||
</div>
|
||||
<h3>{action.title}</h3>
|
||||
<p>{action.summary}</p>
|
||||
<div className="meta-row">
|
||||
<span>{action.source_group_name}</span>
|
||||
{action.due_at ? <span>{formatDateTime(action.due_at)}</span> : null}
|
||||
</div>
|
||||
{action.object_type === "event" && action.source_type !== "remote" ? (
|
||||
<div className="button-row">
|
||||
<Button type="button" onClick={() => rsvp(action, "yes")}>
|
||||
<Check size={16} /> Yes
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => rsvp(action, "maybe")}>
|
||||
Maybe
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => rsvp(action, "no")}>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Link className="text-link" to={action.source_type === "remote" ? "/me/servers/connect" : `/groups/${action.source_group_id}`}>
|
||||
Open source <ExternalLink size={14} />
|
||||
</Link>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Today / Upcoming" eyebrow="Agenda">
|
||||
<div className="list-panel">
|
||||
{data.sections.today.map((event) => (
|
||||
<Link key={`${event.source_type}-${event.id}`} to={event.source_type === "remote" ? "/me/servers/connect" : `/events/${event.id}`} className="list-row">
|
||||
<div>
|
||||
<strong>{event.title}</strong>
|
||||
<span>{event.group_name}</span>
|
||||
</div>
|
||||
<div className="row-tail">
|
||||
{event.changed_at ? <Badge tone="urgent">Changed</Badge> : null}
|
||||
<span>{formatDateTime(event.starts_at)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className="two-column">
|
||||
<Section title="Official updates" eyebrow="Not chatter">
|
||||
<div className="list-panel">
|
||||
{data.sections.official_updates.map((item) => (
|
||||
<Link key={item.id} to={`/announcements/${item.id}`} className="list-row">
|
||||
<div>
|
||||
<strong>{item.title}</strong>
|
||||
<span>{item.group_name}</span>
|
||||
</div>
|
||||
<Badge tone={item.priority === "urgent" ? "urgent" : "good"}>{item.official ? "Official" : "Discussion"}</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Catch up" eyebrow="Since last visit">
|
||||
<Card>
|
||||
<p>While you were away:</p>
|
||||
<ul className="metric-list">
|
||||
{data.sections.catch_up.map((item) => (
|
||||
<li key={item.label}>
|
||||
<strong>{item.count}</strong>
|
||||
<span>{item.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
133
frontend/src/routes/JoinPage.tsx
Normal file
133
frontend/src/routes/JoinPage.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Check, Smartphone } from "lucide-react";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { api, postJson } from "../api/client";
|
||||
import type { Announcement, EventItem, Group } from "../api/types";
|
||||
import { formatDateTime } from "../app/format";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Button } from "../components/Button";
|
||||
import { Card } from "../components/Card";
|
||||
import { FormRow, TextInput } from "../components/FormRow";
|
||||
import { Loading } from "../components/Loading";
|
||||
|
||||
type Preview = {
|
||||
group: Group;
|
||||
invite: { label: string; role: string };
|
||||
preview: { announcements: Announcement[]; events: EventItem[] };
|
||||
};
|
||||
|
||||
export function JoinPage() {
|
||||
const { token } = useParams();
|
||||
const [preview, setPreview] = useState<Preview | null>(null);
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [claimed, setClaimed] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
api<Preview>(`/api/join/${token}/preview`)
|
||||
.then((data) => {
|
||||
setPreview(data);
|
||||
setDisplayName("");
|
||||
})
|
||||
.catch((err) => setError(err instanceof Error ? err.message : "This invite is not available."));
|
||||
}, [token]);
|
||||
|
||||
async function claim(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!token) return;
|
||||
await postJson(`/api/auth/invite/${token}/claim`, { display_name: displayName, device_label: "This browser" });
|
||||
setClaimed(true);
|
||||
}
|
||||
|
||||
async function rsvp(eventId: string, status: "yes" | "maybe" | "no") {
|
||||
await postJson(`/api/events/${eventId}/rsvp`, { status });
|
||||
setClaimed(true);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<main className="join-shell">
|
||||
<Card>
|
||||
<h1>Invite unavailable</h1>
|
||||
<p>{error}</p>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
if (!preview) return <Loading label="Opening invite" />;
|
||||
|
||||
return (
|
||||
<main className="join-shell">
|
||||
<section className="join-card">
|
||||
<div className="brand-lockup">
|
||||
<span className="brand-mark">GH</span>
|
||||
<div>
|
||||
<strong>GroupHome</strong>
|
||||
<small>Open group</small>
|
||||
</div>
|
||||
</div>
|
||||
<Badge tone="good">{preview.invite.role}</Badge>
|
||||
<h1>{preview.group.name}</h1>
|
||||
<p>{preview.group.description}</p>
|
||||
<p className="pitch">Get updates, RSVP, files, and decisions here without reading every group chat.</p>
|
||||
|
||||
{!claimed ? (
|
||||
<form className="form-grid" onSubmit={claim}>
|
||||
<FormRow label="Display name">
|
||||
<TextInput value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="Anna Müller" required autoFocus />
|
||||
</FormRow>
|
||||
<Button type="submit">
|
||||
<Smartphone size={16} /> Join this group
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="notice">
|
||||
<Check size={17} /> You joined. You can act now and save access later.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="join-preview">
|
||||
<h2>Official updates</h2>
|
||||
<div className="card-grid">
|
||||
{preview.preview.announcements.map((item) => (
|
||||
<Card key={item.id}>
|
||||
<Badge tone={item.priority === "urgent" ? "urgent" : "good"}>Official</Badge>
|
||||
<h3>{item.title}</h3>
|
||||
<p>{item.body}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<h2>Upcoming</h2>
|
||||
<div className="card-grid">
|
||||
{preview.preview.events.map((event) => (
|
||||
<Card key={event.id}>
|
||||
<Badge tone="neutral">{formatDateTime(event.starts_at)}</Badge>
|
||||
<h3>{event.title}</h3>
|
||||
<p>{event.location_name || event.description}</p>
|
||||
{claimed && event.rsvp_required ? (
|
||||
<div className="button-row">
|
||||
<Button type="button" onClick={() => rsvp(event.id, "yes")}>
|
||||
Yes
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => rsvp(event.id, "maybe")}>
|
||||
Maybe
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => rsvp(event.id, "no")}>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{claimed ? (
|
||||
<Link className="button button-primary" to={`/groups/${preview.group.id}`}>
|
||||
Open group dashboard
|
||||
</Link>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
362
frontend/src/routes/MePage.tsx
Normal file
362
frontend/src/routes/MePage.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import { Bell, KeyRound, Link2, RefreshCw, Server, ShieldCheck, Smartphone, Trash2 } from "lucide-react";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { api, deleteJson, patchJson, postJson } from "../api/client";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Button } from "../components/Button";
|
||||
import { Card } from "../components/Card";
|
||||
import { FormRow, TextInput } from "../components/FormRow";
|
||||
import { Loading } from "../components/Loading";
|
||||
import { Section } from "../components/Section";
|
||||
|
||||
type MePageProps = {
|
||||
initialPanel?: "profile" | "devices" | "notifications" | "servers";
|
||||
};
|
||||
|
||||
export function MePage({ initialPanel = "profile" }: MePageProps) {
|
||||
const [panel, setPanel] = useState(initialPanel);
|
||||
const [me, setMe] = useState<any | null>(null);
|
||||
const [devices, setDevices] = useState<any[]>([]);
|
||||
const [prefs, setPrefs] = useState<Record<string, string>>({});
|
||||
const [notifications, setNotifications] = useState<any[]>([]);
|
||||
const [servers, setServers] = useState<any[]>([]);
|
||||
const [notice, setNotice] = useState("");
|
||||
|
||||
async function load() {
|
||||
const [meData, deviceData, prefData, notificationData, serverData] = await Promise.all([
|
||||
api<any>("/api/me"),
|
||||
api<any>("/api/me/devices"),
|
||||
api<any>("/api/me/notification-preferences"),
|
||||
api<any>("/api/me/notifications"),
|
||||
api<any>("/api/remote/servers")
|
||||
]);
|
||||
setMe(meData);
|
||||
setDevices(deviceData.devices);
|
||||
setPrefs(prefData.preferences);
|
||||
setNotifications(notificationData.notifications);
|
||||
setServers(serverData.servers);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setPanel(initialPanel);
|
||||
}, [initialPanel]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
if (!me) return <Loading label="Loading profile" />;
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<header className="page-header">
|
||||
<span className="eyebrow">Me</span>
|
||||
<h1>{me.profile?.primary_display_name ?? "Your access"}</h1>
|
||||
<p>Save access, link devices, tune notifications, and connect group servers.</p>
|
||||
</header>
|
||||
<div className="segmented" role="tablist">
|
||||
{[
|
||||
["profile", "Profile"],
|
||||
["devices", "Devices"],
|
||||
["notifications", "Notifications"],
|
||||
["servers", "Servers"]
|
||||
].map(([key, label]) => (
|
||||
<button key={key} type="button" className={panel === key ? "active" : ""} onClick={() => setPanel(key as typeof panel)}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{notice ? <div className="notice">{notice}</div> : null}
|
||||
|
||||
{panel === "profile" ? <ProfilePanel setNotice={setNotice} /> : null}
|
||||
{panel === "devices" ? <DevicesPanel devices={devices} reload={load} setNotice={setNotice} /> : null}
|
||||
{panel === "notifications" ? <NotificationsPanel prefs={prefs} notifications={notifications} reload={load} setNotice={setNotice} /> : null}
|
||||
{panel === "servers" ? <ServersPanel servers={servers} reload={load} setNotice={setNotice} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfilePanel({ setNotice }: { setNotice: (value: string) => void }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [recoveryCode, setRecoveryCode] = useState("");
|
||||
const [devCode, setDevCode] = useState("");
|
||||
|
||||
async function requestRecovery(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const result = await postJson<any>("/api/auth/recovery/request", { email });
|
||||
setDevCode(result.dev_code ?? "");
|
||||
setNotice("Recovery access prepared.");
|
||||
}
|
||||
|
||||
async function consumeRecovery(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
await postJson("/api/auth/recovery/consume", { recovery_code: recoveryCode, device_label: "Recovered browser" });
|
||||
setNotice("Access recovered on this browser.");
|
||||
}
|
||||
|
||||
async function passkeyReady() {
|
||||
await postJson("/api/auth/passkeys/register/options", { display_name: "GroupHome member" });
|
||||
await postJson("/api/auth/passkeys/register/verify", { development: true });
|
||||
setNotice("Passkey-ready protection is wired for development.");
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title="Save access" eyebrow="Progressive identity">
|
||||
<div className="card-grid">
|
||||
<Card>
|
||||
<h3>
|
||||
<ShieldCheck size={17} /> Recovery email
|
||||
</h3>
|
||||
<form className="form-grid" onSubmit={requestRecovery}>
|
||||
<FormRow label="Email">
|
||||
<TextInput type="email" value={email} onChange={(event) => setEmail(event.target.value)} required placeholder="anna@example.org" />
|
||||
</FormRow>
|
||||
<Button type="submit">Send recovery link</Button>
|
||||
</form>
|
||||
{devCode ? <pre className="copy-box">{devCode}</pre> : null}
|
||||
</Card>
|
||||
<Card>
|
||||
<h3>
|
||||
<KeyRound size={17} /> Recover access
|
||||
</h3>
|
||||
<form className="form-grid" onSubmit={consumeRecovery}>
|
||||
<FormRow label="Recovery code">
|
||||
<TextInput value={recoveryCode} onChange={(event) => setRecoveryCode(event.target.value)} required />
|
||||
</FormRow>
|
||||
<Button type="submit">Recover access</Button>
|
||||
</form>
|
||||
</Card>
|
||||
<Card>
|
||||
<h3>Protect access</h3>
|
||||
<p>Development passkey routes are available behind a pluggable provider.</p>
|
||||
<Button type="button" variant="secondary" onClick={passkeyReady}>
|
||||
<KeyRound size={16} /> Make passkey-ready
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function DevicesPanel({ devices, reload, setNotice }: { devices: any[]; reload: () => Promise<void>; setNotice: (value: string) => void }) {
|
||||
const [startedCode, setStartedCode] = useState("");
|
||||
const [approveCode, setApproveCode] = useState("");
|
||||
const [completeCode, setCompleteCode] = useState("");
|
||||
|
||||
async function start() {
|
||||
const result = await postJson<any>("/api/auth/device-link/start", { device_label: "Second browser" });
|
||||
setStartedCode(result.code);
|
||||
setCompleteCode(result.code);
|
||||
}
|
||||
|
||||
async function approve(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
await postJson("/api/auth/device-link/approve", { code: approveCode || startedCode });
|
||||
setNotice("Device link approved.");
|
||||
}
|
||||
|
||||
async function complete(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
await postJson("/api/auth/device-link/complete", { code: completeCode, device_label: "Linked browser" });
|
||||
setNotice("Device linked.");
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function revoke(id: string) {
|
||||
await deleteJson(`/api/me/devices/${id}`);
|
||||
await reload();
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title="Devices" eyebrow="Link another device">
|
||||
<div className="card-grid">
|
||||
<Card>
|
||||
<h3>
|
||||
<Smartphone size={17} /> Pairing
|
||||
</h3>
|
||||
<div className="button-row">
|
||||
<Button type="button" onClick={start}>
|
||||
<Link2 size={16} /> Start code
|
||||
</Button>
|
||||
</div>
|
||||
{startedCode ? <pre className="pairing-code">{startedCode}</pre> : null}
|
||||
<form className="form-grid" onSubmit={approve}>
|
||||
<FormRow label="Approve code">
|
||||
<TextInput value={approveCode} onChange={(event) => setApproveCode(event.target.value)} placeholder={startedCode || "ABC123"} />
|
||||
</FormRow>
|
||||
<Button type="submit" variant="secondary">Approve</Button>
|
||||
</form>
|
||||
<form className="form-grid" onSubmit={complete}>
|
||||
<FormRow label="Complete code">
|
||||
<TextInput value={completeCode} onChange={(event) => setCompleteCode(event.target.value)} placeholder="Code from new device" />
|
||||
</FormRow>
|
||||
<Button type="submit" variant="secondary">Complete on this browser</Button>
|
||||
</form>
|
||||
</Card>
|
||||
<Card>
|
||||
<h3>Known devices</h3>
|
||||
<div className="list-panel flush">
|
||||
{devices.map((device) => (
|
||||
<div className="list-row" key={device.id}>
|
||||
<div>
|
||||
<strong>{device.label}</strong>
|
||||
<span>{device.trust_level}</span>
|
||||
</div>
|
||||
{device.current ? <Badge tone="good">Current</Badge> : null}
|
||||
{!device.revoked_at ? (
|
||||
<Button type="button" variant="ghost" onClick={() => revoke(device.id)} aria-label={`Revoke ${device.label}`}>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
) : (
|
||||
<Badge tone="quiet">Revoked</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationsPanel({
|
||||
prefs,
|
||||
notifications,
|
||||
reload,
|
||||
setNotice
|
||||
}: {
|
||||
prefs: Record<string, string>;
|
||||
notifications: any[];
|
||||
reload: () => Promise<void>;
|
||||
setNotice: (value: string) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(prefs);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(prefs);
|
||||
}, [prefs]);
|
||||
|
||||
async function save() {
|
||||
await patchJson("/api/me/notification-preferences", { preferences: draft });
|
||||
setNotice("Notification preferences saved.");
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function markRead(id: string) {
|
||||
await patchJson(`/api/me/notifications/${id}/read`, {});
|
||||
await reload();
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title="Notifications" eyebrow="Mute the noise, not the group">
|
||||
<div className="card-grid">
|
||||
<Card>
|
||||
<h3>
|
||||
<Bell size={17} /> Preferences
|
||||
</h3>
|
||||
<div className="form-grid">
|
||||
{Object.entries(draft).map(([category, delivery]) => (
|
||||
<FormRow key={category} label={category.replaceAll("_", " ")}>
|
||||
<select className="input" value={delivery} onChange={(event) => setDraft({ ...draft, [category]: event.target.value })}>
|
||||
<option value="immediate">Immediate</option>
|
||||
<option value="digest">Digest</option>
|
||||
<option value="muted">Muted</option>
|
||||
</select>
|
||||
</FormRow>
|
||||
))}
|
||||
<Button type="button" onClick={save}>Save preferences</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<h3>Inbox</h3>
|
||||
<div className="list-panel flush">
|
||||
{notifications.map((item) => (
|
||||
<div className="list-row" key={item.id}>
|
||||
<div>
|
||||
<strong>{item.title}</strong>
|
||||
<span>{item.body}</span>
|
||||
</div>
|
||||
{item.read_at ? <Badge tone="quiet">Read</Badge> : <Button type="button" variant="ghost" onClick={() => markRead(item.id)}>Read</Button>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function ServersPanel({ servers, reload, setNotice }: { servers: any[]; reload: () => Promise<void>; setNotice: (value: string) => void }) {
|
||||
const [serverUrl, setServerUrl] = useState("http://localhost:8000");
|
||||
const [connectionCode, setConnectionCode] = useState("");
|
||||
const [generatedCode, setGeneratedCode] = useState("");
|
||||
|
||||
async function createCode() {
|
||||
const result = await postJson<any>("/api/connection-tokens", { label: "Connect my home server", scopes: ["sync:read"] });
|
||||
setGeneratedCode(result.connection_code_display_once);
|
||||
setConnectionCode(result.connection_code_display_once);
|
||||
}
|
||||
|
||||
async function connect(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
await postJson("/api/remote/servers/connect", { server_url: serverUrl, connection_code: connectionCode });
|
||||
setNotice("Connected server synced.");
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function sync(id: string) {
|
||||
await postJson(`/api/remote/servers/${id}/sync`, {});
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
await deleteJson(`/api/remote/servers/${id}`);
|
||||
await reload();
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title="Connected servers" eyebrow="No full federation">
|
||||
<div className="card-grid">
|
||||
<Card>
|
||||
<h3>
|
||||
<Server size={17} /> Connect another group server
|
||||
</h3>
|
||||
<div className="button-row">
|
||||
<Button type="button" variant="secondary" onClick={createCode}>
|
||||
Create connection code
|
||||
</Button>
|
||||
</div>
|
||||
{generatedCode ? <pre className="copy-box">{generatedCode}</pre> : null}
|
||||
<form className="form-grid" onSubmit={connect}>
|
||||
<FormRow label="Server URL">
|
||||
<TextInput value={serverUrl} onChange={(event) => setServerUrl(event.target.value)} required />
|
||||
</FormRow>
|
||||
<FormRow label="Connection code">
|
||||
<TextInput value={connectionCode} onChange={(event) => setConnectionCode(event.target.value)} required />
|
||||
</FormRow>
|
||||
<Button type="submit">Connect and sync</Button>
|
||||
</form>
|
||||
</Card>
|
||||
<Card>
|
||||
<h3>Servers</h3>
|
||||
<div className="list-panel flush">
|
||||
{servers.map((server) => (
|
||||
<div className="list-row" key={server.id}>
|
||||
<div>
|
||||
<strong>{server.server_name}</strong>
|
||||
<span>{server.server_origin}</span>
|
||||
</div>
|
||||
<Badge tone={server.status === "active" ? "good" : "urgent"}>{server.status}</Badge>
|
||||
<Button type="button" variant="ghost" onClick={() => sync(server.id)} aria-label={`Sync ${server.server_name}`}>
|
||||
<RefreshCw size={16} />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => remove(server.id)} aria-label={`Remove ${server.server_name}`}>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
907
frontend/src/styles/base.css
Normal file
907
frontend/src/styles/base.css
Normal file
@@ -0,0 +1,907 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f7f4ee;
|
||||
--surface: #fffdf8;
|
||||
--surface-strong: #ffffff;
|
||||
--ink: #20211f;
|
||||
--muted: #65675f;
|
||||
--line: #ded9cf;
|
||||
--teal: #0f766e;
|
||||
--teal-ink: #073b37;
|
||||
--coral: #c4513d;
|
||||
--amber: #8a5b00;
|
||||
--green: #2f7d32;
|
||||
--indigo: #3b4a8f;
|
||||
--shadow: 0 12px 28px rgba(38, 31, 22, 0.1);
|
||||
--radius: 8px;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
padding-bottom: 76px;
|
||||
}
|
||||
|
||||
.main-panel {
|
||||
width: min(1120px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 18px 14px 28px;
|
||||
}
|
||||
|
||||
.side-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.brand-lockup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.brand-lockup small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
place-items: center;
|
||||
border-radius: var(--radius);
|
||||
background: var(--teal);
|
||||
color: white;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
min-height: 68px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: rgba(255, 253, 248, 0.96);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.bottom-item,
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bottom-item {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bottom-item.active,
|
||||
.nav-item.active {
|
||||
color: var(--teal-ink);
|
||||
}
|
||||
|
||||
.bottom-item.primary {
|
||||
position: relative;
|
||||
transform: translateY(-12px);
|
||||
}
|
||||
|
||||
.bottom-item.primary svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: var(--teal);
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 22px rgba(15, 118, 110, 0.28);
|
||||
}
|
||||
|
||||
.bottom-item.primary span {
|
||||
color: var(--teal-ink);
|
||||
}
|
||||
|
||||
.nav-item.primary {
|
||||
background: var(--teal);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-item.primary.active {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.page-stack,
|
||||
.standalone {
|
||||
display: grid;
|
||||
gap: 1.1rem;
|
||||
}
|
||||
|
||||
.standalone {
|
||||
min-height: 100vh;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0 0.25rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--teal);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-strong);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.link-card {
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.link-card:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--teal);
|
||||
}
|
||||
|
||||
.card-topline,
|
||||
.meta-row,
|
||||
.button-row,
|
||||
.inline-meta,
|
||||
.row-tail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.meta-row,
|
||||
.row-tail {
|
||||
color: var(--muted);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.row-tail {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.button-row.compact {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
width: fit-content;
|
||||
min-height: 24px;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: #f4f0e8;
|
||||
color: var(--muted);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 800;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.badge-urgent {
|
||||
border-color: rgba(196, 81, 61, 0.35);
|
||||
background: #fff0ed;
|
||||
color: var(--coral);
|
||||
}
|
||||
|
||||
.badge-good {
|
||||
border-color: rgba(47, 125, 50, 0.28);
|
||||
background: #edf7ee;
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.badge-remote {
|
||||
border-color: rgba(59, 74, 143, 0.28);
|
||||
background: #eef1ff;
|
||||
color: var(--indigo);
|
||||
}
|
||||
|
||||
.badge-quiet {
|
||||
background: #f6f1df;
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
min-height: 42px;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background: var(--teal);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
border-color: rgba(15, 118, 110, 0.28);
|
||||
background: #e7f4f2;
|
||||
color: var(--teal-ink);
|
||||
}
|
||||
|
||||
.button-ghost {
|
||||
border-color: var(--line);
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.button-danger {
|
||||
background: var(--coral);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.text-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
color: var(--teal);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.list-panel {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-strong);
|
||||
}
|
||||
|
||||
.list-panel.flush {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.list-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.6rem;
|
||||
min-width: 0;
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.list-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.list-row > div:first-child {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list-row strong,
|
||||
.list-row span {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.list-row span {
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.timeline-card {
|
||||
grid-template-columns: 8.5rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.date-block {
|
||||
color: var(--teal);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.form-row span {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 104px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.segmented button {
|
||||
min-height: 38px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.segmented button.active {
|
||||
background: var(--teal);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.segmented button:disabled {
|
||||
color: #b8afa2;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.two-column {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.metric-list,
|
||||
.mini-stats {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.metric-list li,
|
||||
.mini-stats li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.metric-list strong {
|
||||
color: var(--ink);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.stack-tight {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.copy-box,
|
||||
.pairing-code {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
border: 1px dashed var(--teal);
|
||||
border-radius: var(--radius);
|
||||
background: #f0faf8;
|
||||
color: var(--teal-ink);
|
||||
font-weight: 800;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.pairing-code {
|
||||
text-align: center;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.notice,
|
||||
.error-panel,
|
||||
.empty-state,
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-strong);
|
||||
}
|
||||
|
||||
.error-panel {
|
||||
display: grid;
|
||||
max-width: 520px;
|
||||
color: var(--coral);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 140px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading {
|
||||
width: fit-content;
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
.skeleton-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--teal);
|
||||
animation: pulse 1s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.join-shell {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
width: min(1040px, 100%);
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.join-card,
|
||||
.join-preview {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
align-content: start;
|
||||
padding: 1.1rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-strong);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.pitch {
|
||||
color: var(--teal-ink);
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.chat-page {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
min-height: calc(100vh - 116px);
|
||||
}
|
||||
|
||||
.chat-sidebar,
|
||||
.chat-panel {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-strong);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.chat-sidebar {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
align-content: start;
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.chat-sidebar-header,
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.chat-sidebar h1 {
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
.chat-thread-list {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
max-height: 28vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.chat-thread {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
width: 100%;
|
||||
min-height: 72px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.chat-thread.active {
|
||||
border-color: rgba(15, 118, 110, 0.28);
|
||||
background: #e7f4f2;
|
||||
}
|
||||
|
||||
.chat-thread span,
|
||||
.chat-thread small {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(320px, 1fr) auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.chat-header h2 {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
min-height: 0;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
background:
|
||||
linear-gradient(rgba(247, 244, 238, 0.86), rgba(247, 244, 238, 0.86)),
|
||||
repeating-linear-gradient(45deg, rgba(15, 118, 110, 0.04) 0 2px, transparent 2px 14px);
|
||||
}
|
||||
|
||||
.message-row {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.message-row.mine {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
max-width: min(78%, 620px);
|
||||
padding: 0.7rem 0.8rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px 14px 14px 4px;
|
||||
background: white;
|
||||
box-shadow: 0 8px 18px rgba(38, 31, 22, 0.07);
|
||||
}
|
||||
|
||||
.bubble.mine {
|
||||
border-color: rgba(15, 118, 110, 0.22);
|
||||
border-radius: 14px 14px 4px 14px;
|
||||
background: #dff3ef;
|
||||
}
|
||||
|
||||
.bubble p {
|
||||
color: var(--ink);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.bubble-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.bubble-meta strong {
|
||||
color: var(--teal-ink);
|
||||
}
|
||||
|
||||
.folded-noise {
|
||||
align-self: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
max-width: 90%;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 253, 248, 0.88);
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.chat-composer {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--surface-strong);
|
||||
}
|
||||
|
||||
.structure-strip {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.structure-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
min-height: 34px;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.structure-chip.active {
|
||||
border-color: rgba(15, 118, 110, 0.35);
|
||||
background: #e7f4f2;
|
||||
color: var(--teal-ink);
|
||||
}
|
||||
|
||||
.composer-hint {
|
||||
width: fit-content;
|
||||
padding: 0.35rem 0.55rem;
|
||||
border-radius: var(--radius);
|
||||
background: #f6f1df;
|
||||
color: var(--amber);
|
||||
font-size: 0.83rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.composer-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.55rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
min-height: 54px;
|
||||
max-height: 160px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
@media (min-width: 680px) {
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.list-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.join-shell {
|
||||
grid-template-columns: minmax(280px, 0.8fr) minmax(0, 1.2fr);
|
||||
align-items: start;
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
h1 {
|
||||
font-size: 2.4rem;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 236px minmax(0, 1fr);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.side-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
height: 100vh;
|
||||
padding: 1.25rem;
|
||||
border-right: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.side-nav nav {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
justify-content: flex-start;
|
||||
min-height: 42px;
|
||||
padding: 0 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: #e7f4f2;
|
||||
}
|
||||
|
||||
.bottom-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main-panel {
|
||||
margin: 0;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.chat-page {
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
min-height: calc(100vh - 4rem);
|
||||
}
|
||||
|
||||
.chat-thread-list {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.chat-panel {
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.two-column {
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(280px, 0.8fr);
|
||||
}
|
||||
}
|
||||
30
frontend/src/test/App.test.tsx
Normal file
30
frontend/src/test/App.test.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { App } from "../app/App";
|
||||
|
||||
describe("App", () => {
|
||||
it("renders the shell after dev bootstrap", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ authenticated: true, dev_mode: true }), { status: 200 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
profile: { primary_display_name: "Anna Müller" },
|
||||
sections: { needs_me: [], today: [], changed: [], official_updates: [], catch_up: [] },
|
||||
connections: []
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
);
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByText("What needs attention")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
2
frontend/src/test/setup.ts
Normal file
2
frontend/src/test/setup.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2021"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": []
|
||||
}
|
||||
18
frontend/vite.config.ts
Normal file
18
frontend/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": "http://localhost:8000",
|
||||
"/.well-known": "http://localhost:8000"
|
||||
}
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/test/setup.ts"
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user