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
35 changes: 35 additions & 0 deletions frontend/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,47 @@ server {
root /usr/share/nginx/html;
index index.html;

# The SPA bundle is the reason this is here: several hundred kilobytes of
# JavaScript and CSS that compress to roughly a third of their size.
gzip on;
gzip_vary on;
gzip_comp_level 6;
# Below this, the gzip header and trailer eat the saving.
gzip_min_length 1024;
gzip_proxied any;
gzip_types
application/javascript
application/json
application/manifest+json
application/xml
image/svg+xml
text/css
text/plain
text/xml;

location = /healthz {
access_log off;
add_header Content-Type text/plain;
return 204;
}

# Vite fingerprints these filenames with a content hash, so a given URL can
# never change what it serves: a new build produces new names. That makes
# them safe to cache permanently, which is what turns a repeat visit from a
# revalidation of every asset into no requests at all.
location /assets/ {
access_log off;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}

# index.html carries the references to those hashed filenames, so it is the
# one file that must never be cached -- a stale copy points the browser at
# assets the last deploy removed.
location = /index.html {
add_header Cache-Control "no-cache";
}

location / {
try_files $uri $uri/ /index.html;
}
Expand Down
133 changes: 77 additions & 56 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,47 @@
import { lazy, Suspense } from "react"
import { useCurrentUserRole } from "./hooks/useCurrentUserRole"
import { Routes, Route, useLocation, Navigate } from "react-router-dom"
import { useSessionAuth } from "./auth/sessionAuthContext"
import Header from "./components/Header"
import Sidebar from "./components/Sidebar"

// The three screens a session can *start* on stay in the main bundle. Splitting
// a landing route only moves its download from the bundle to a second round
// trip the user waits through on a blank page.
import DashboardPage from "./pages/DashboardPage"
import LoginPage from "./pages/LoginPage"
import AuthCallback from "./pages/AuthCallback"
import ArticleView from "./pages/articleView"
import DevelopingStoriesView from "./pages/developingStoriesView"
import EditArticleView from "./pages/editArticleView"
import MediaView from "./pages/mediaView"
import AuthorsView from "./pages/authorsView"
import SectionsView from "./pages/sectionsView"
import UsersView from "./pages/usersView"
import CommentsView from "./pages/commentsView"
import ClassifiedsView from "./pages/classifiedsView"
import ActivityView from "./pages/activityView"
import NewsletterView from "./pages/newsletterView"
import SeoView from "./pages/seoView"
import SettingsPage from "./pages/settingsPage"
import PollView from "./pages/pollView"

// Everything else is reached by a click from one of those, so its chunk
// downloads while the editor is already looking at a rendered page. The article
// editor matters most: it pulls in Trix, which no other route touches.
const ArticleView = lazy(() => import("./pages/articleView"))
const DevelopingStoriesView = lazy(() => import("./pages/developingStoriesView"))
const EditArticleView = lazy(() => import("./pages/editArticleView"))
const MediaView = lazy(() => import("./pages/mediaView"))
const AuthorsView = lazy(() => import("./pages/authorsView"))
const SectionsView = lazy(() => import("./pages/sectionsView"))
const UsersView = lazy(() => import("./pages/usersView"))
const CommentsView = lazy(() => import("./pages/commentsView"))
const ClassifiedsView = lazy(() => import("./pages/classifiedsView"))
const ActivityView = lazy(() => import("./pages/activityView"))
const NewsletterView = lazy(() => import("./pages/newsletterView"))
const SeoView = lazy(() => import("./pages/seoView"))
const SettingsPage = lazy(() => import("./pages/settingsPage"))
const PollView = lazy(() => import("./pages/pollView"))

const AUTH_ROUTES = ["/login"]

// Rendered inside AppShell, so the sidebar and header stay put while a route
// chunk loads and only the content area changes.
function RouteFallback() {
return (
<div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">Loading…</p>
</div>
)
}

function AppShell({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen overflow-hidden bg-background">
Expand Down Expand Up @@ -98,48 +117,50 @@ export default function App() {

return (
<AppShell>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/articles" element={<ArticleView excludeType="developing-stories" />} />
<Route path="/articles/:slug/edit" element={<EditArticleView />} />
<Route path="/developing-stories" element={<DevelopingStoriesView />} />
<Route path="/developing-stories/:slug/edit" element={<EditArticleView />} />
<Route path="/articles/new" element={<EditArticleView />} />
<Route path="/developing-stories/new" element={<ComingSoon page="New Developing Story" />} />
<Route path="/newsletter" element={<NewsletterView />} />
<Route path="/media" element={<MediaView />} />
<Route path="/poll" element={<PollView />} />
<Route path="/authors" element={<AuthorsView />} />
<Route path="/sections" element={<SectionsView />} />
<Route path="/comments" element={<CommentsView />} />
<Route path="/classifieds" element={<ClassifiedsView />} />
<Route path="/seo" element={<SeoView />} />
<Route
path="/activity"
element={(
<AdminOnlyRoute>
<ActivityView />
</AdminOnlyRoute>
)}
/>
<Route
path="/users"
element={(
<AdminOnlyRoute>
<UsersView />
</AdminOnlyRoute>
)}
/>
<Route path="/user-settings" element={<ComingSoon page="Profile Settings" />} />
<Route
path="/settings"
element={(
<AdminOnlyRoute>
<SettingsPage />
</AdminOnlyRoute>
)}
/>
</Routes>
<Suspense fallback={<RouteFallback />}>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/articles" element={<ArticleView excludeType="developing-stories" />} />
<Route path="/articles/:slug/edit" element={<EditArticleView />} />
<Route path="/developing-stories" element={<DevelopingStoriesView />} />
<Route path="/developing-stories/:slug/edit" element={<EditArticleView />} />
<Route path="/articles/new" element={<EditArticleView />} />
<Route path="/developing-stories/new" element={<ComingSoon page="New Developing Story" />} />
<Route path="/newsletter" element={<NewsletterView />} />
<Route path="/media" element={<MediaView />} />
<Route path="/poll" element={<PollView />} />
<Route path="/authors" element={<AuthorsView />} />
<Route path="/sections" element={<SectionsView />} />
<Route path="/comments" element={<CommentsView />} />
<Route path="/classifieds" element={<ClassifiedsView />} />
<Route path="/seo" element={<SeoView />} />
<Route
path="/activity"
element={(
<AdminOnlyRoute>
<ActivityView />
</AdminOnlyRoute>
)}
/>
<Route
path="/users"
element={(
<AdminOnlyRoute>
<UsersView />
</AdminOnlyRoute>
)}
/>
<Route path="/user-settings" element={<ComingSoon page="Profile Settings" />} />
<Route
path="/settings"
element={(
<AdminOnlyRoute>
<SettingsPage />
</AdminOnlyRoute>
)}
/>
</Routes>
</Suspense>
</AppShell>
)
}
27 changes: 27 additions & 0 deletions frontend/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,33 @@ export default defineConfig({
url: path.resolve(__dirname, "node_modules/url/url.js"),
},
},
build: {
rollupOptions: {
output: {
// Vendor code changes only when a dependency is upgraded, while app
// code changes every deploy. Splitting them means a routine deploy
// invalidates the app chunk and leaves React and the UI kit in the
// browser cache, which is what the immutable caching in nginx.conf is
// there to exploit.
manualChunks: {
react: ["react", "react-dom", "react-router-dom"],
// Radix and the two icon sets: large, stable, and pulled in by nearly
// every route, so they belong in neither the app chunk nor a route's.
ui: [
"@radix-ui/react-avatar",
"@radix-ui/react-checkbox",
"@radix-ui/react-dropdown-menu",
"@radix-ui/react-label",
"@radix-ui/react-popover",
"@radix-ui/react-separator",
"@radix-ui/react-slot",
"@phosphor-icons/react",
"lucide-react",
],
},
},
},
},
test: {
environment: "jsdom",
globals: true,
Expand Down
Loading
Loading