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
39 changes: 35 additions & 4 deletions apps/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,44 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Commune, an open source community platform" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />

<title>Commune</title>
<meta name="description" content="Commune is an open source community platform. Share posts, follow people and join the conversation." />
<meta name="application-name" content="Commune" />

<!-- Browser UI colours follow the user's light or dark setting. -->
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#f6f5f1" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#141412" media="(prefers-color-scheme: dark)" />

<!-- Icons and installable web app (PWA) -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Commune" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="format-detection" content="telephone=no" />

<!-- Link previews. Add og:image (an absolute https URL to a 1200x630 image) once you have one. -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Commune" />
<meta property="og:title" content="Commune" />
<meta property="og:description" content="An open source community platform. Share posts, follow people and join the conversation." />
<meta name="twitter:card" content="summary" />

<!-- Paint the right background immediately, so there is no white flash before the CSS loads. -->
<style>
html, body { margin: 0; background: #f6f5f1; }
@media (prefers-color-scheme: dark) { html, body { background: #141412; color: #efede6; } }
.boot { font-family: system-ui, sans-serif; text-align: center; padding: 40vh 16px 0; color: #6f6c64; }
</style>
</head>
<body>
<div id="root"></div>
<div id="root"><p class="boot">Loading Commune…</p></div>
<noscript>Commune needs JavaScript to run. Please enable it in your browser settings.</noscript>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Binary file added apps/web/public/icons/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/icons/icon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/icons/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/icons/maskable-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 25 additions & 0 deletions apps/web/public/manifest.webmanifest
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"id": "/",
"name": "Commune",
"short_name": "Commune",
"description": "An open source community platform: post, follow and chat with your community.",
"lang": "en",
"dir": "ltr",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#f6f5f1",
"theme_color": "#1f6f5c",
"categories": ["social"],
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
],
"shortcuts": [
{ "name": "Search", "url": "/search", "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }] },
{ "name": "Notifications", "url": "/notifications", "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }] }
]
}
36 changes: 36 additions & 0 deletions apps/web/public/offline.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<title>Offline · Commune</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<style>
:root { --bg: #f6f5f1; --text: #1c1b18; --muted: #6f6c64; --accent: #1f6f5c; }
@media (prefers-color-scheme: dark) {
:root { --bg: #141412; --text: #efede6; --muted: #9a978d; --accent: #4fb89c; }
}
body {
margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 24px;
background: var(--bg); color: var(--text);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif; text-align: center;
}
img { width: 72px; height: 72px; }
h1 { font-size: 22px; margin: 16px 0 8px; }
p { color: var(--muted); margin: 0 0 20px; }
button {
padding: 10px 20px; border: 0; border-radius: 10px; background: var(--accent);
color: #fff; font: inherit; font-weight: 600; cursor: pointer;
}
</style>
</head>
<body>
<main>
<img src="/favicon.svg" alt="" />
<h1>You're offline</h1>
<p>Commune needs a connection to load your feed. Check your network and try again.</p>
<button onclick="location.reload()">Try again</button>
</main>
</body>
</html>
105 changes: 105 additions & 0 deletions apps/web/public/sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* Commune service worker. Plain JavaScript on purpose: it is served as-is from /sw.js.
*
* What it does:
* - Makes the app installable and gives it a branded offline page.
* - Caches the app's own static files so repeat visits and slow networks are fast.
*
* What it deliberately does NOT do:
* - It never touches /api/* or /uploads/*, or any non-GET or cross-origin request. Account
* data, tokens and paid content are never stored in a cache.
*
* Bump VERSION to force every client to drop its old caches.
*/
const VERSION = "v1";
const STATIC_CACHE = `commune-static-${VERSION}`;
const SHELL_CACHE = `commune-shell-${VERSION}`;
const KNOWN_CACHES = [STATIC_CACHE, SHELL_CACHE];

const OFFLINE_URL = "/offline.html";
const SHELL_KEY = "/__shell";
const PRECACHE = [OFFLINE_URL, "/favicon.svg", "/icons/icon-192.png", "/icons/icon-512.png"];
const NAVIGATION_TIMEOUT_MS = 4000;

self.addEventListener("install", (event) => {
// No skipWaiting here: a new version waits until the user taps "Reload" in the app, so a
// page is never swapped to new code underneath someone who is typing.
event.waitUntil(caches.open(STATIC_CACHE).then((cache) => cache.addAll(PRECACHE)));
});

self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const names = await caches.keys();
await Promise.all(names.filter((n) => !KNOWN_CACHES.includes(n)).map((n) => caches.delete(n)));
await self.clients.claim();
})(),
);
});

self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") self.skipWaiting();
});

self.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET") return;

const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/uploads/")) return;
if (url.pathname === "/sw.js") return;

if (request.mode === "navigate") {
event.respondWith(handleNavigation(request));
return;
}
// Vite fingerprints everything in /assets, so a given URL never changes: cache first.
if (url.pathname.startsWith("/assets/")) {
event.respondWith(cacheFirst(request));
return;
}
event.respondWith(staleWhileRevalidate(request));
});

/** Network first (always the freshest app), with the cached shell or offline page as fallback. */
async function handleNavigation(request) {
try {
const response = await fetchWithTimeout(request, NAVIGATION_TIMEOUT_MS);
if (response.ok && (response.headers.get("content-type") || "").includes("text/html")) {
const cache = await caches.open(SHELL_CACHE);
await cache.put(SHELL_KEY, response.clone());
}
return response;
} catch {
const shell = await (await caches.open(SHELL_CACHE)).match(SHELL_KEY);
return shell || (await caches.match(OFFLINE_URL)) || Response.error();
}
}

function fetchWithTimeout(request, ms) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
return fetch(request, { signal: controller.signal }).finally(() => clearTimeout(timer));
}

async function cacheFirst(request) {
const cache = await caches.open(STATIC_CACHE);
const cached = await cache.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) await cache.put(request, response.clone());
return response;
}

async function staleWhileRevalidate(request) {
const cache = await caches.open(STATIC_CACHE);
const cached = await cache.match(request);
const refresh = fetch(request)
.then((response) => {
if (response.ok) cache.put(request, response.clone());
return response;
})
.catch(() => undefined);
return cached || (await refresh) || Response.error();
}
15 changes: 10 additions & 5 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import VerifyEmailPage from "./pages/VerifyEmailPage";
import VerifyBanner from "./components/VerifyBanner";
import SearchPage from "./pages/SearchPage";
import SearchBox from "./components/SearchBox";
import PwaBanner from "./components/PwaBanner";

function Protected({ children }: { children: ReactNode }) {
const { user, loading } = useAuth();
Expand All @@ -33,20 +34,24 @@ function Header() {
<Link to="/" className="brand">
Commune
</Link>
{user && <SearchBox/>} { user && (
{user && <SearchBox />}{" "}
{user && (
<nav className="header-right">
{user.role === "admin" && (
<Link to="/admin" className="nav-link">
Admin
</Link>
)}
<Link to="/notifications" className="nav-link" aria-label={`Notifications, ${count} unread`}>
<Link
to="/notifications"
className="nav-link"
aria-label={`Notifications, ${count} unread`}
>
🔔
{count > 0 && <span className="badge">{count > 99 ? "99+" : count}</span>}
</Link>
<Link to={`/u/${user.username}`} className="nav-link nav-profile">
<Avatar username={user.username} url={user.avatarUrl} size={26} />
@{user.username}
<Avatar username={user.username} url={user.avatarUrl} size={26} />@{user.username}
</Link>
<button className="btn btn-ghost" onClick={() => void logout()}>
Log out
Expand All @@ -61,7 +66,7 @@ export default function App() {
return (
<>
<Header />
<VerifyBanner />
<VerifyBanner /> <PwaBanner />
<main className="container">
<Routes>
<Route path="/login" element={<LoginPage />} />
Expand Down
62 changes: 62 additions & 0 deletions apps/web/src/components/PwaBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { useState } from "react";
import { applyUpdate, promptInstall, useOnline, usePwa } from "../lib/pwa";

const DISMISS_KEY = "commune-install-dismissed";

function wasDismissed(): boolean {
try {
return localStorage.getItem(DISMISS_KEY) === "1";
} catch {
return false;
}
}

/** Offline notice, "new version" prompt and "install app" prompt, stacked at the bottom. */
export default function PwaBanner() {
const { updateReady, canInstall } = usePwa();
const online = useOnline();
const [installHidden, setInstallHidden] = useState(wasDismissed);

const dismissInstall = () => {
setInstallHidden(true);
try {
localStorage.setItem(DISMISS_KEY, "1");
} catch {
/* private mode: just hide it for this visit */
}
};

const showInstall = canInstall && !installHidden;
if (online && !updateReady && !showInstall) return null;

return (
<div className="pwa-bars" role="region" aria-label="App status">
{!online && (
<div className="pwa-bar pwa-offline" role="status">
You're offline. Some things won't load until you're back.
</div>
)}
{updateReady && (
<div className="pwa-bar" role="status">
<span>A new version of Commune is ready.</span>
<button className="btn" onClick={applyUpdate}>
Reload
</button>
</div>
)}
{showInstall && (
<div className="pwa-bar">
<span>Install Commune for quick access from your home screen.</span>
<span className="row">
<button className="btn" onClick={() => void promptInstall()}>
Install
</button>
<button className="btn btn-ghost" onClick={dismissInstall} aria-label="Dismiss">
Not now
</button>
</span>
</div>
)}
</div>
);
}
Loading
Loading