Compare commits
4 Commits
53740dc694
...
792816c6c8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
792816c6c8 | ||
|
|
a46dfde6d2 | ||
|
|
e8cd7e9562 | ||
|
|
2e42d67196 |
@@ -100,7 +100,7 @@ router.get('/', async (req: Request, res: Response): Promise<void> => {
|
||||
prisma.bugReport.count({ where }),
|
||||
]);
|
||||
|
||||
res.json({ bugs: bugs.map(formatBug), total, page, pages: Math.ceil(total / limit) });
|
||||
res.json({ data: bugs.map(formatBug), total, page, pages: Math.ceil(total / limit) });
|
||||
});
|
||||
|
||||
// GET /api/bugs/:id
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { SettingsProvider, useSettings } from './contexts/SettingsContext';
|
||||
import { ProtectedRoute } from './components/shared/ProtectedRoute';
|
||||
import { PublicLayout } from './components/layout/PublicLayout';
|
||||
import { PageLoader } from './components/shared/PageLoader';
|
||||
@@ -19,36 +20,49 @@ const LoginPage = lazy(() => import('./pages/public/LoginPage'));
|
||||
const RegisterPage = lazy(() => import('./pages/public/RegisterPage'));
|
||||
const NotFoundPage = lazy(() => import('./pages/public/NotFoundPage'));
|
||||
|
||||
// ── Routes (needs SettingsContext) ────────────────────────────────────────────
|
||||
|
||||
function AppRoutes() {
|
||||
const { forumEnabled, bugsEnabled, loaded } = useSettings();
|
||||
|
||||
if (!loaded) return <PageLoader />;
|
||||
|
||||
return (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
<Route element={<PublicLayout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="studio" element={<StudioPage />} />
|
||||
<Route path="events" element={<EventsPage />} />
|
||||
<Route path="forum" element={forumEnabled ? <ForumPage /> : <NotFoundPage />} />
|
||||
<Route path="forum/thread/:id" element={forumEnabled ? <ThreadPage /> : <NotFoundPage />} />
|
||||
<Route path="bugs" element={bugsEnabled ? <BugReportPage /> : <NotFoundPage />} />
|
||||
<Route path="bugs/:id" element={bugsEnabled ? <BugDetailPage /> : <NotFoundPage />} />
|
||||
<Route
|
||||
path="account"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AccountPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="login" element={<LoginPage />} />
|
||||
<Route path="register" element={<RegisterPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// ── App ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
{/* Public Routes */}
|
||||
<Route element={<PublicLayout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="studio" element={<StudioPage />} />
|
||||
<Route path="events" element={<EventsPage />} />
|
||||
<Route path="forum" element={<ForumPage />} />
|
||||
<Route path="forum/thread/:id" element={<ThreadPage />} />
|
||||
<Route path="bugs" element={<BugReportPage />} />
|
||||
<Route path="bugs/:id" element={<BugDetailPage />} />
|
||||
<Route
|
||||
path="account"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AccountPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="login" element={<LoginPage />} />
|
||||
<Route path="register" element={<RegisterPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
<SettingsProvider>
|
||||
<AppRoutes />
|
||||
</SettingsProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useSettings } from '../../contexts/SettingsContext';
|
||||
|
||||
export function Footer() {
|
||||
const year = new Date().getFullYear();
|
||||
const { forumEnabled, bugsEnabled } = useSettings();
|
||||
|
||||
return (
|
||||
<footer
|
||||
@@ -37,11 +39,11 @@ export function Footer() {
|
||||
<div className="section-label" style={{ marginBottom: '0.75rem' }}>Navigate</div>
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||
{[
|
||||
{ to: '/', label: 'Home' },
|
||||
{ to: '/studio', label: 'Studio' },
|
||||
{ to: '/forum', label: 'Forum' },
|
||||
{ to: '/bugs', label: 'Bug Reports' },
|
||||
].map(({ to, label }) => (
|
||||
{ to: '/', label: 'Home', show: true },
|
||||
{ to: '/studio', label: 'Studio', show: true },
|
||||
{ to: '/forum', label: 'Forum', show: forumEnabled },
|
||||
{ to: '/bugs', label: 'Bug Reports', show: bugsEnabled },
|
||||
].filter((item) => item.show).map(({ to, label }) => (
|
||||
<li key={to}>
|
||||
<Link
|
||||
to={to}
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Link, NavLink, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useSettings } from '../../contexts/SettingsContext';
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ to: '/', label: 'Home', end: true },
|
||||
{ to: '/studio', label: 'Studio', end: false },
|
||||
{ to: '/events', label: 'Events', end: false },
|
||||
{ to: '/forum', label: 'Forum', end: false },
|
||||
{ to: '/bugs', label: 'Bugs', end: false },
|
||||
const BASE_NAV_LINKS = [
|
||||
{ to: '/', label: 'Home', end: true, feature: null as null | 'forum' | 'bugs' },
|
||||
{ to: '/studio', label: 'Studio', end: false, feature: null },
|
||||
{ to: '/events', label: 'Events', end: false, feature: null },
|
||||
{ to: '/forum', label: 'Forum', end: false, feature: 'forum' as const },
|
||||
{ to: '/bugs', label: 'Bugs', end: false, feature: 'bugs' as const },
|
||||
];
|
||||
|
||||
export function Navbar() {
|
||||
const { user, isAuthenticated, logout } = useAuth();
|
||||
const { forumEnabled, bugsEnabled } = useSettings();
|
||||
const navigate = useNavigate();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const navLinks = BASE_NAV_LINKS.filter(({ feature }) => {
|
||||
if (feature === 'forum') return forumEnabled;
|
||||
if (feature === 'bugs') return bugsEnabled;
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
logout();
|
||||
setMenuOpen(false);
|
||||
@@ -85,7 +93,7 @@ export function Navbar() {
|
||||
|
||||
{/* Desktop Nav */}
|
||||
<div className="hidden md:flex items-center gap-6">
|
||||
{NAV_LINKS.map(({ to, label, end }) => (
|
||||
{navLinks.map(({ to, label, end }) => (
|
||||
<NavLink key={to} to={to} end={end} style={navLinkStyle}>
|
||||
{label}
|
||||
</NavLink>
|
||||
@@ -148,7 +156,7 @@ export function Navbar() {
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.85rem' }}>
|
||||
{NAV_LINKS.map(({ to, label, end }) => (
|
||||
{navLinks.map(({ to, label, end }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
|
||||
31
nest-front/src/contexts/SettingsContext.tsx
Normal file
31
nest-front/src/contexts/SettingsContext.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { settingsApi } from '../utils/api';
|
||||
|
||||
interface SettingsContextValue {
|
||||
forumEnabled: boolean;
|
||||
bugsEnabled: boolean;
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
const SettingsContext = createContext<SettingsContextValue>({
|
||||
forumEnabled: true,
|
||||
bugsEnabled: true,
|
||||
loaded: false,
|
||||
});
|
||||
|
||||
export function SettingsProvider({ children }: { children: React.ReactNode }) {
|
||||
const [value, setValue] = useState<SettingsContextValue>({ forumEnabled: true, bugsEnabled: true, loaded: false });
|
||||
|
||||
useEffect(() => {
|
||||
settingsApi
|
||||
.get()
|
||||
.then((s) => setValue({ forumEnabled: s.forumEnabled, bugsEnabled: s.bugsEnabled, loaded: true }))
|
||||
.catch(() => setValue({ forumEnabled: true, bugsEnabled: true, loaded: true }));
|
||||
}, []);
|
||||
|
||||
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSettings(): SettingsContextValue {
|
||||
return useContext(SettingsContext);
|
||||
}
|
||||
@@ -88,7 +88,7 @@ export default function BugDetailPage() {
|
||||
}, [id]);
|
||||
|
||||
const alreadyVoted = useMemo(
|
||||
() => !!user && !!bug && bug.meTooBugs.includes(user.id),
|
||||
() => !!user && !!bug && (bug.meTooBugs ?? []).includes(user.id),
|
||||
[user, bug]
|
||||
);
|
||||
const isOwnReport = useMemo(
|
||||
@@ -100,7 +100,7 @@ export default function BugDetailPage() {
|
||||
if (!user || !bug || alreadyVoted || isOwnReport) return;
|
||||
try {
|
||||
await bugsApi.toggleMeToo(bug.id);
|
||||
setBug((prev) => prev ? { ...prev, meTooBugs: [...prev.meTooBugs, user.id] } : prev);
|
||||
setBug((prev) => prev ? { ...prev, meTooBugs: [...(prev.meTooBugs ?? []), user.id] } : prev);
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
@@ -137,7 +137,7 @@ export default function BugDetailPage() {
|
||||
return <Navigate to="/bugs" replace />;
|
||||
}
|
||||
|
||||
const metooCount = bug.meTooBugs.length;
|
||||
const metooCount = (bug.meTooBugs ?? []).length;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '860px', margin: '0 auto', padding: '3rem 1.5rem' }}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { bugsApi, settingsApi } from '../../utils/api';
|
||||
import { bugsApi } from '../../utils/api';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { timeAgo } from '../../utils/format';
|
||||
import type { BugReport, BugSeverity, BugStatus, BugReportFormData } from '../../types';
|
||||
@@ -78,7 +78,7 @@ function BugCard({ bug, highlight }: BugCardProps) {
|
||||
borderRadius: '3px',
|
||||
}}
|
||||
>
|
||||
▶ {bug.meTooBugs.length} {bug.meTooBugs.length === 1 ? 'user' : 'users'} have this
|
||||
▶ {(bug.meTooBugs ?? []).length} {(bug.meTooBugs ?? []).length === 1 ? 'user' : 'users'} have this
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -278,19 +278,14 @@ export default function BugReportPage() {
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const [bugs, setBugs] = useState<BugReport[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [bugsEnabled, setBugsEnabled] = useState(true);
|
||||
const [statusFilter, setStatusFilter] = useState<BugStatus | 'all'>('all');
|
||||
const [severityFilter, setSeverityFilter] = useState<BugSeverity | 'all'>('all');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
settingsApi.get().then((s) => setBugsEnabled(s.bugsEnabled)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const fetchBugs = useCallback(() => {
|
||||
setLoading(true);
|
||||
bugsApi.getBugs({ status: statusFilter, severity: severityFilter, limit: 100 })
|
||||
.then((res) => setBugs(res.data))
|
||||
.then((res) => setBugs(Array.isArray(res?.data) ? res.data : []))
|
||||
.catch(() => setBugs([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [statusFilter, severityFilter]);
|
||||
@@ -300,7 +295,7 @@ export default function BugReportPage() {
|
||||
const { myBugs, otherBugs } = useMemo(() => {
|
||||
const my: BugReport[] = [];
|
||||
const other: BugReport[] = [];
|
||||
bugs.forEach((b) => {
|
||||
(bugs ?? []).forEach((b) => {
|
||||
if (user && b.submittedById === user.id) my.push(b);
|
||||
else other.push(b);
|
||||
});
|
||||
@@ -317,20 +312,6 @@ export default function BugReportPage() {
|
||||
const inProgressCount = bugs.filter((b) => b.status === 'in_progress').length;
|
||||
const resolvedCount = bugs.filter((b) => b.status === 'resolved').length;
|
||||
|
||||
if (!bugsEnabled) {
|
||||
return (
|
||||
<div style={{ maxWidth: '960px', margin: '0 auto', padding: '3rem 1.5rem', textAlign: 'center' }}>
|
||||
<div className="section-label">Issue Tracker</div>
|
||||
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-red)', fontSize: 'clamp(2rem, 6vw, 3.5rem)', marginTop: '0.25rem' }}>
|
||||
BUG REPORTS UNAVAILABLE
|
||||
</h1>
|
||||
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.78rem', marginTop: '1rem', fontFamily: 'var(--font-mono)' }}>
|
||||
Bug reporting has been temporarily disabled by an administrator.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '960px', margin: '0 auto', padding: '3rem 1.5rem' }}>
|
||||
{/* Header */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { forumApi, settingsApi } from '../../utils/api';
|
||||
import { forumApi } from '../../utils/api';
|
||||
import { timeAgo } from '../../utils/format';
|
||||
import type { ForumCategory, ForumThread } from '../../types';
|
||||
|
||||
@@ -132,20 +132,17 @@ export default function ForumPage() {
|
||||
const [threads, setThreads] = useState<ForumThread[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [forumEnabled, setForumEnabled] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
|
||||
Promise.all([
|
||||
settingsApi.get(),
|
||||
forumApi.getCategories(),
|
||||
forumApi.getThreads({ limit: 200 }),
|
||||
])
|
||||
.then(([settings, cats, threadRes]) => {
|
||||
.then(([cats, threadRes]) => {
|
||||
if (cancelled) return;
|
||||
setForumEnabled(settings.forumEnabled);
|
||||
setCategories(cats);
|
||||
setThreads(threadRes.data);
|
||||
})
|
||||
@@ -169,20 +166,6 @@ export default function ForumPage() {
|
||||
);
|
||||
}, [search, categories, threads]);
|
||||
|
||||
if (!loading && !forumEnabled) {
|
||||
return (
|
||||
<div style={{ maxWidth: '960px', margin: '0 auto', padding: '4rem 1.5rem', textAlign: 'center' }}>
|
||||
<div className="section-label">Community</div>
|
||||
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-red)', fontSize: 'clamp(2rem, 5vw, 3rem)', marginTop: '0.5rem' }}>
|
||||
FORUM UNAVAILABLE
|
||||
</h1>
|
||||
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8rem', marginTop: '1rem', fontFamily: 'var(--font-mono)' }}>
|
||||
The forum has been temporarily disabled by an administrator.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '960px', margin: '0 auto', padding: '4rem 1.5rem' }}>
|
||||
{/* Header */}
|
||||
|
||||
Reference in New Issue
Block a user