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
3 changes: 2 additions & 1 deletion apps/api/src/controllers/publicController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export async function listPosts(_req: Request, res: Response) {
items: posts.map((p) => toPostDTO(p, false, true)),
nextCursor: hasMore ? (posts[posts.length - 1]?._id.toString() ?? null) : null,
};
res.set("Cache-Control", "public, max-age=30");
// Everyone gets the same answer, so the CDN may serve it for a minute and refresh in the background.
res.set("Cache-Control", "public, max-age=30, s-maxage=60, stale-while-revalidate=300");
res.json(body);
}
50 changes: 44 additions & 6 deletions apps/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,19 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />

<title>Commune</title>
<title>Commune: an open source community platform</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" />

<!-- Google Adsense Implementation -->
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-7015477982506060"
crossorigin="anonymous"></script>

<!-- 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)" />

<!-- Google Adsense Implementation -->
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-7015477982506060"
crossorigin="anonymous"></script>

<!-- 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" />
Expand All @@ -33,16 +33,54 @@
<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" />
<link rel="canonical" href="https://commune-mu.vercel.app/" />
<meta property="og:url" content="https://commune-mu.vercel.app/" />

<!-- 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; }
.boot-header { display: flex; align-items: center; height: 57px; padding: 0 20px; background: #fff; border-bottom: 1px solid #e4e1d8; font: 700 1.2rem system-ui, sans-serif; color: #1c1b18; }
.boot-body { max-width: 620px; margin: 0 auto; padding: 24px 16px; font-family: system-ui, sans-serif; color: #5f5c55; }
.boot-body h1 { margin: 0 0 8px; font-size: 1.4rem; color: #1c1b18; }
@media (prefers-color-scheme: dark) { .boot-header { background: #1d1d1a; border-color: #2c2b27; color: #efede6; } .boot-body { color: #9a978d; } .boot-body h1 { color: #efede6; } }
</style>

<!--
Start the requests the first screen needs right now, in parallel with downloading the
app, instead of after it has started. The app takes each response over exactly once
(see takeEarly in src/lib/api.ts). Skipped on localhost, where the API is elsewhere.
-->
<script>
(function () {
try {
var h = location.hostname;
if (h === "localhost" || h === "127.0.0.1") return;
var early = (window.__early = {});
var start = function (key, url, init) {
var p = fetch(url, init);
p.catch(function () {});
early[key] = p;
};
var hint = localStorage.getItem("commune_session");
var path = location.pathname;
start("/news?topic=top", "/api/news?topic=top");
if (hint !== "0") start("/auth/refresh", "/api/auth/refresh", { method: "POST", credentials: "include" });
else if (path === "/" || path === "/explore") start("/public/posts", "/api/public/posts");
} catch (e) {}
})();
</script>

</head>
<body>
<div id="root"><p class="boot">Loading Commune…</p></div>
<div id="root">
<div class="boot-header">Commune</div>
<div class="boot-body">
<h1>Commune</h1>
<p>An open source community platform. Share posts, follow people, join communities and read the latest news.</p>
</div>
</div>
<noscript>Commune needs JavaScript to run. Please enable it in your browser settings.</noscript>
<script type="module" src="/src/main.tsx"></script>
</body>
Expand Down
7 changes: 7 additions & 0 deletions apps/web/public/robots.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
User-agent: *
Allow: /
Disallow: /api/
Disallow: /messages
Disallow: /admin

Sitemap: https://commune-mu.vercel.app/sitemap.xml
8 changes: 8 additions & 0 deletions apps/web/public/sitemap.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://commune-mu.vercel.app/</loc><changefreq>hourly</changefreq><priority>1.0</priority></url>
<url><loc>https://commune-mu.vercel.app/explore</loc><changefreq>hourly</changefreq><priority>0.9</priority></url>
<url><loc>https://commune-mu.vercel.app/news</loc><changefreq>hourly</changefreq><priority>0.7</priority></url>
<url><loc>https://commune-mu.vercel.app/contact</loc><changefreq>yearly</changefreq><priority>0.3</priority></url>
<url><loc>https://commune-mu.vercel.app/register</loc><changefreq>yearly</changefreq><priority>0.5</priority></url>
</urlset>
87 changes: 69 additions & 18 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,75 @@
import { Link, Navigate, Route, Routes, useLocation } from "react-router-dom";
import { Suspense, lazy, useEffect } from "react";
import type { ReactNode } from "react";
import { useAuth } from "./store/auth";
import Header, { AUTH_PATHS } from "./components/Header";
import FeedPage from "./pages/FeedPage";
import PostPage from "./pages/PostPage";
import LoginPage from "./pages/LoginPage";
import RegisterPage from "./pages/RegisterPage";
import ProfilePage from "./pages/ProfilePage";
import NotificationsPage from "./pages/NotificationsPage";
import AdminPage from "./pages/AdminPage";
import ForgotPasswordPage from "./pages/ForgotPasswordPage";
import ResetPasswordPage from "./pages/ResetPassword";
import VerifyEmailPage from "./pages/VerifyEmailPage";
import VerifyBanner from "./components/VerifyBanner";
import SearchPage from "./pages/SearchPage";
import PwaBanner from "./components/PwaBanner";
import ExplorePage from "./pages/ExplorePage";
import CommunitiesPage from "./pages/CommunitiesPage";
import CommunityPage from "./pages/CommunityPage";
import LeftNav from "./components/LeftNav";
import RightRail from "./components/RightRail";
import MessagesPage from "./pages/MessagesPage";
import NewsPage from "./pages/NewsPage";
import ContactPage from "./pages/ContactPage";
import PlusPage from "./pages/PlusPage";
import InsightsPage from "./pages/InsightsPage";

const PostPage = lazy(() => import("./pages/PostPage"));
const LoginPage = lazy(() => import("./pages/LoginPage"));
const RegisterPage = lazy(() => import("./pages/RegisterPage"));
const ProfilePage = lazy(() => import("./pages/ProfilePage"));
const NotificationsPage = lazy(() => import("./pages/NotificationsPage"));
const AdminPage = lazy(() => import("./pages/AdminPage"));
const ForgotPasswordPage = lazy(() => import("./pages/ForgotPasswordPage"));
const VerifyEmailPage = lazy(() => import("./pages/VerifyEmailPage"));
const SearchPage = lazy(() => import("./pages/SearchPage"));
const CommunitiesPage = lazy(() => import("./pages/CommunitiesPage"));
const CommunityPage = lazy(() => import("./pages/CommunityPage"));
const PlusPage = lazy(() => import("./pages/PlusPage"));
const InsightsPage = lazy(() => import("./pages/InsightsPage"));
const MessagesPage = lazy(() => import("./pages/MessagesPage"));
const NewsPage = lazy(() => import("./pages/NewsPage"));
const ContactPage = lazy(() => import("./pages/ContactPage"));
const ResetPasswordPage = lazy(() => import("./pages/ResetPassword"));

const TITLES: Record<string, string> = {
"/": "Commune: an open source community platform",
"/explore": "Explore",
"/news": "Latest news",
"/contact": "Contact",
"/plus": "Commune Plus",
"/insights": "Insights",
"/messages": "Messages",
"/communities": "Communities",
"/notifications": "Notifications",
"/search": "Search",
"/login": "Log in",
"/register": "Sign up",
"/forgot-password": "Forgot password",
"/reset-password": "Reset password",
"/admin": "Admin",
};

/** Per-page browser title and canonical link, so tabs, history and search engines get real names. */
function RouteMeta() {
const { pathname } = useLocation();
useEffect(() => {
const user = /^\/u\/([^/]+)/.exec(pathname)?.[1];
const community = /^\/c\/([^/]+)/.exec(pathname)?.[1];
const name =
TITLES[pathname] ??
(user ? `@${decodeURIComponent(user)}` : community ? `c/${decodeURIComponent(community)}` : pathname.startsWith("/messages") ? "Messages" : pathname.startsWith("/posts/") ? "Post" : "Commune");
document.title = pathname === "/" ? name : `${name} · Commune`;

let link = document.querySelector<HTMLLinkElement>('link[rel="canonical"]');
if (!link) {
link = document.createElement("link");
link.rel = "canonical";
document.head.appendChild(link);
}
link.href = `${window.location.origin}${pathname}`;

// Move keyboard and screen-reader focus to the page content after navigating.
document.getElementById("main")?.focus({ preventScroll: true });
}, [pathname]);
return null;
}

function Protected({ children }: { children: ReactNode }) {
const { user, loading } = useAuth();
Expand All @@ -47,17 +92,22 @@ export default function App() {
const wide = pathname.startsWith("/messages");
return (
<>
<a href="#main" className="skip-link">
Skip to content
</a>
<RouteMeta />
<Header />
{!authPage && <VerifyBanner />}
<PwaBanner />
<div className={authPage ? "shell-auth" : `shell${user ? " has-left" : ""}${wide ? " wide" : ""}`}>
{!authPage && user && <LeftNav />}
<main className={authPage ? "container container-auth" : "container"}>
<main id="main" tabIndex={-1} className={authPage ? "container container-auth" : "container"}>
{authPage && (
<Link to="/" className="brand auth-brand">
Commune
</Link>
)}
<Suspense fallback={<p className="muted center route-loading">Loading…</p>}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
Expand Down Expand Up @@ -159,6 +209,7 @@ export default function App() {
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</main>
{!authPage && !wide && <RightRail />}
</div>
Expand Down
29 changes: 24 additions & 5 deletions apps/web/src/components/AdSlot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,33 @@ declare global {

let scriptAdded = false;

/**
* The AdSense script is heavy, so it waits until the page has finished loading and the
* visitor has done something (scroll, tap, key press), or 8 seconds have passed. The page
* itself therefore loads and responds first.
*/
function loadAdSense(client: string) {
if (scriptAdded) return;
scriptAdded = true;
const script = document.createElement("script");
script.async = true;
script.crossOrigin = "anonymous";
script.src = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${encodeURIComponent(client)}`;
document.head.appendChild(script);
const add = () => {
const script = document.createElement("script");
script.async = true;
script.crossOrigin = "anonymous";
script.src = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${encodeURIComponent(client)}`;
document.head.appendChild(script);
};
const events = ["scroll", "pointerdown", "keydown", "touchstart"] as const;
let done = false;
const go = () => {
if (done) return;
done = true;
events.forEach((e) => window.removeEventListener(e, go));
add();
};
events.forEach((e) => window.addEventListener(e, go, { once: true, passive: true }));
const afterLoad = () => window.setTimeout(go, 8000);
if (document.readyState === "complete") afterLoad();
else window.addEventListener("load", afterLoad, { once: true });
}

export default function AdSlot({ slot }: { slot: string | undefined }) {
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/PostCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export default function PostCard({ post }: { post: PostDTO }) {
src={assetUrl(post.imageUrl)}
alt={`Image posted by @${post.author.username}`}
loading="lazy"
decoding="async"
/>
</a>
)}
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/components/PostList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@ export default function PostList({

return (
<>
{query.isLoading && <p className="muted center">Loading…</p>}
{query.isLoading && (
<div aria-busy="true" aria-label="Loading posts">
{[0, 1, 2].map((i) => (
<div key={i} className="card skeleton-post" />
))}
</div>
)}
{query.isError && <p className="error center">{query.error.message}</p>}
{query.isSuccess && posts.length === 0 && <p className="muted center">{emptyMessage}</p>}
{posts.map((post) => (
Expand Down
Loading
Loading