Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { AppShellHeader } from "@/components/app-shell-header";

export default function AppShellLayout({ children }: { children: ReactNode }) {
return (
<div style={{ minHeight: "100vh", fontFamily: "system-ui, sans-serif" }}>
<div className="min-h-screen">
<AppShellHeader />
{children}
</div>
Expand Down
67 changes: 67 additions & 0 deletions apps/web/app/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/* Studiqo authenticated app — tokens aligned with docs/frontend/studiqo-style-guide.md */

@import "tailwindcss";

@theme inline {
/* Semantic palette (warm education) */
--color-canvas: #f5f1ea;
--color-surface: #fffcf7;
--color-raised: #f0ebe3;
--color-line: #e4ddd3;
--color-line-strong: #c4b8a8;
--color-ink: #1c1917;
--color-ink-muted: #57534e;
--color-ink-faint: #78716c;
--color-accent: #2a5f5a;
--color-accent-hover: #1f4744;
--color-accent-soft: rgb(42 95 90 / 0.14);
--color-danger: #b91c1c;
--color-success: #15803d;

/* next/font variables are set on <html> */
--font-sans: var(--font-ui), "Source Sans 3", ui-sans-serif, system-ui, sans-serif;
--font-serif-display: var(--font-display), Georgia, "Times New Roman", serif;
}

@layer base {
*,
*::before,
*::after {
box-sizing: border-box;
}

html {
color-scheme: light;
}

body {
@apply m-0 min-h-screen bg-canvas font-sans text-base leading-normal text-ink antialiased;
}
}

@layer components {
.app-nav-link {
@apply inline-block rounded-lg px-3.5 py-2 text-sm leading-snug text-ink-muted transition-colors duration-200 ease-in-out hover:bg-raised hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-canvas motion-reduce:transition-none;
}

.app-nav-link[aria-current="page"] {
@apply bg-accent-soft font-semibold text-accent;
}

.app-brand-link {
@apply text-lg font-semibold leading-snug tracking-tight text-ink no-underline transition-colors duration-200 hover:text-accent focus-visible:rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-surface motion-reduce:transition-none;
}

.app-btn {
@apply inline-flex min-h-11 cursor-pointer items-center justify-center rounded-lg border border-line-strong bg-surface px-4 text-sm font-semibold leading-snug text-ink transition-colors duration-200 ease-in-out hover:bg-raised hover:border-ink-faint focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-surface motion-reduce:transition-none;
}

.app-btn-primary {
@apply border-accent bg-accent text-white hover:border-accent-hover hover:bg-accent-hover hover:text-white;
}

/* Focus ring offset matches header surface (nav row uses default canvas offset). */
.app-nav-link--on-surface {
@apply focus-visible:ring-offset-surface;
}
}
17 changes: 16 additions & 1 deletion apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
import { Fraunces, Source_Sans_3 } from "next/font/google";
import type { ReactNode } from "react";

import { Providers } from "./providers";

import "./globals.css";

const fraunces = Fraunces({
subsets: ["latin"],
variable: "--font-display",
display: "swap",
});

const sourceSans = Source_Sans_3({
subsets: ["latin"],
variable: "--font-ui",
display: "swap",
});

export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<html lang="en" className={`${fraunces.variable} ${sourceSans.variable}`}>
<body>
<Providers>{children}</Providers>
</body>
Expand Down
4 changes: 1 addition & 3 deletions apps/web/app/t/[tenantSlug]/(public)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,5 @@ import type { ReactNode } from "react";

/** Invitation flows are public (no session required). */
export default function TenantPublicLayout({ children }: { children: ReactNode }) {
return (
<div style={{ minHeight: "100vh", fontFamily: "system-ui, sans-serif" }}>{children}</div>
);
return <div className="min-h-screen">{children}</div>;
}
51 changes: 22 additions & 29 deletions apps/web/app/t/[tenantSlug]/tenant-chrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,38 +22,33 @@ export function TenantChrome({
);

return (
<div style={{ minHeight: "100vh", fontFamily: "system-ui, sans-serif" }}>
<header
style={{
padding: "12px 20px",
borderBottom: "1px solid #e5e5e5",
display: "flex",
flexWrap: "wrap",
gap: 12,
alignItems: "center",
justifyContent: "space-between",
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<strong style={{ fontSize: 16 }}>
<div className="min-h-screen">
<header className="flex flex-wrap items-start justify-between gap-4 border-b border-line bg-surface px-6 py-4 md:items-center md:px-8">
<div>
<p className="font-serif-display m-0 text-xl font-semibold leading-tight tracking-tight">
{activeOrg?.name ?? tenantSlug}
</strong>
</p>
{activeOrg ? (
<span style={{ fontSize: 12, opacity: 0.65 }}>{activeOrg.slug}</span>
<span className="mt-0.5 block text-xs leading-snug text-ink-faint">
{activeOrg.slug}
</span>
) : null}
<span style={{ fontSize: 13, opacity: 0.75 }}>
<p className="mt-1.5 text-[0.8125rem] leading-snug text-ink-muted">
{user?.email ?? "—"}
{user?.role ? ` · ${user.role}` : null}
{user?.isSuperadmin ? " · superadmin" : null}
</span>
</p>
</div>
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<a href={appShellUrl("/onboarding")} style={{ fontSize: 14 }}>
<div className="flex flex-wrap items-center gap-2.5">
<a
href={appShellUrl("/onboarding")}
className="app-nav-link app-nav-link--on-surface"
>
All organizations
</a>
<button
type="button"
style={{ padding: "8px 12px", fontSize: 14 }}
className="app-btn app-btn-primary"
onClick={() => {
void (async () => {
await logout();
Expand All @@ -65,14 +60,12 @@ export function TenantChrome({
</button>
</div>
</header>
<div style={{ padding: "0 20px" }}>
<TenantNav
tenantSlug={tenantSlug}
role={user?.role}
isSuperadmin={user?.isSuperadmin ?? false}
/>
</div>
<div style={{ padding: "16px 20px 32px" }}>{children}</div>
<TenantNav
tenantSlug={tenantSlug}
role={user?.role}
isSuperadmin={user?.isSuperadmin ?? false}
/>
<div className="px-6 py-4 pb-8 md:px-8 md:py-6 md:pb-10">{children}</div>
</div>
);
}
83 changes: 47 additions & 36 deletions apps/web/components/app-shell-header.tsx
Original file line number Diff line number Diff line change
@@ -1,64 +1,75 @@
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";

import { useSession } from "@/lib/auth/session";
import { appShellUrl } from "@/lib/urls";

export function AppShellHeader() {
const pathname = usePathname() ?? "";
const { user, authStatus, logout } = useSession();
const authed = authStatus === "authenticated";
const loading = authStatus === "loading";

return (
<header
style={{
padding: "12px 20px",
borderBottom: "1px solid #e5e5e5",
display: "flex",
flexWrap: "wrap",
gap: 12,
alignItems: "center",
justifyContent: "space-between",
}}
>
<div style={{ display: "flex", gap: 16, alignItems: "center" }}>
<Link
href="/"
style={{ fontWeight: 600, color: "#111", textDecoration: "none" }}
>
<header className="flex flex-wrap items-start justify-between gap-4 border-b border-line bg-surface px-6 py-4 md:items-center md:px-8">
<div className="flex flex-wrap items-center gap-5">
<Link href="/" className="app-brand-link font-serif-display">
Studiqo
</Link>
<nav style={{ display: "flex", gap: 12, fontSize: 14 }}>
<Link href="/onboarding">Organizations</Link>
{loading ? null : authed ? null : (
<>
<Link href="/login">Log in</Link>
<Link href="/register">Register</Link>
</>
)}
<nav aria-label="App">
<ul className="m-0 flex list-none flex-wrap gap-1.5 p-0">
<li className="m-0">
<Link
href="/onboarding"
className="app-nav-link app-nav-link--on-surface"
aria-current={
pathname === "/onboarding" || pathname.startsWith("/onboarding/")
? "page"
: undefined
}
>
Organizations
</Link>
</li>
{loading ? null : authed ? null : (
<>
<li className="m-0">
<Link
href="/login"
className="app-nav-link app-nav-link--on-surface"
aria-current={pathname === "/login" ? "page" : undefined}
>
Log in
</Link>
</li>
<li className="m-0">
<Link
href="/register"
className="app-nav-link app-nav-link--on-surface"
aria-current={pathname === "/register" ? "page" : undefined}
>
Register
</Link>
</li>
</>
)}
</ul>
</nav>
</div>
{loading ? (
<span style={{ fontSize: 13, opacity: 0.6 }}>Session…</span>
<span className="text-[0.8125rem] text-ink-faint">Session…</span>
) : authed ? (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 12,
alignItems: "center",
fontSize: 14,
}}
>
<span style={{ opacity: 0.85 }}>
<div className="flex flex-wrap items-center gap-2.5">
<span className="text-sm leading-snug text-ink-muted">
{user?.email ?? "—"}
{user?.role ? ` · ${user.role}` : null}
{user?.isSuperadmin ? " · superadmin" : null}
</span>
<button
type="button"
style={{ padding: "8px 12px", fontSize: 14 }}
className="app-btn app-btn-primary"
onClick={() => {
void (async () => {
await logout();
Expand Down
53 changes: 38 additions & 15 deletions apps/web/components/tenant-nav.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";

import type { components } from "@studiqo/api-client/generated";

type Role = components["schemas"]["OrganizationMembershipRole"] | undefined;

function pathMatchesNavItem(pathname: string, base: string, href: string): boolean {
if (href === base) {
return pathname === base || pathname === `${base}/`;
}
return pathname === href || pathname.startsWith(`${href}/`);
}

export function TenantNav({
tenantSlug,
role,
Expand All @@ -15,28 +23,43 @@ export function TenantNav({
role: Role;
isSuperadmin: boolean;
}) {
const pathname = usePathname() ?? "";
const base = `/t/${tenantSlug}`;
const showStudentsAndLessons =
role === "org_admin" ||
role === "tutor" ||
role === "parent" ||
isSuperadmin;

const items: { href: string; label: string }[] = [{ href: base, label: "Home" }];
if (showStudentsAndLessons) {
items.push({ href: `${base}/students`, label: "Students" });
items.push({ href: `${base}/lessons`, label: "Lessons" });
}
if (role === "org_admin" || isSuperadmin) {
items.push({ href: `${base}/invites`, label: "Invites" });
items.push({ href: `${base}/organization`, label: "Members" });
}

return (
<nav style={{ display: "flex", gap: 16, fontSize: 14, padding: "8px 0" }}>
<Link href={base}>Home</Link>
{showStudentsAndLessons ? (
<Link href={`${base}/students`}>Students</Link>
) : null}
{showStudentsAndLessons ? (
<Link href={`${base}/lessons`}>Lessons</Link>
) : null}
{role === "org_admin" || isSuperadmin ? (
<Link href={`${base}/invites`}>Invites</Link>
) : null}
{role === "org_admin" || isSuperadmin ? (
<Link href={`${base}/organization`}>Members</Link>
) : null}
</nav>
<div className="border-b border-line bg-canvas px-6 md:px-8">
<nav aria-label="Workspace">
<ul className="m-0 flex list-none flex-wrap gap-1.5 px-0 py-3">
{items.map(({ href, label }) => (
<li key={href} className="m-0">
<Link
href={href}
className="app-nav-link"
aria-current={
pathMatchesNavItem(pathname, base, href) ? "page" : undefined
}
>
{label}
</Link>
</li>
))}
</ul>
</nav>
</div>
);
}
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/postcss": "^4.2.2",
"@types/node": "^25.5.0",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"eslint": "^10.1.0",
"eslint-config-next": "16.2.2",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.4.0",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.2"
}
Expand Down
Loading
Loading