feat(discovery): more work on discovery plus a few fixes

This commit is contained in:
Hampus Kraft
2026-02-17 15:41:08 +00:00
parent b19e9fb243
commit 302c0d2a0c
137 changed files with 7116 additions and 2047 deletions

View File

@@ -30,7 +30,6 @@ import {createMarketingContextFactory} from '@fluxer/marketing/src/app/Marketing
import {applyMarketingMiddlewareStack} from '@fluxer/marketing/src/app/MarketingMiddlewareStack';
import {registerMarketingRoutes} from '@fluxer/marketing/src/app/MarketingRouteRegistrar';
import {applyMarketingStaticAssets} from '@fluxer/marketing/src/app/MarketingStaticAssets';
import {createBadgeCache, productHuntFeaturedUrl, productHuntTopPostUrl} from '@fluxer/marketing/src/BadgeProxy';
import type {MarketingConfig} from '@fluxer/marketing/src/MarketingConfig';
import {createMarketingMetricsMiddleware} from '@fluxer/marketing/src/MarketingTelemetry';
import {initializeMarketingCsrf} from '@fluxer/marketing/src/middleware/Csrf';
@@ -59,14 +58,9 @@ export function createMarketingApp(options: CreateMarketingAppOptions): Marketin
const publicDir = resolve(publicDirOption ?? fileURLToPath(new URL('../public', import.meta.url)));
const app = new Hono();
const badgeFeaturedCache = createBadgeCache(productHuntFeaturedUrl);
const badgeTopPostCache = createBadgeCache(productHuntTopPostUrl);
const contextFactory = createMarketingContextFactory({
config,
publicDir,
badgeFeaturedCache,
badgeTopPostCache,
});
initializeMarketingCsrf(config.secretKeyBase, config.env === 'production');
@@ -93,8 +87,6 @@ export function createMarketingApp(options: CreateMarketingAppOptions): Marketin
app,
config,
contextFactory,
badgeFeaturedCache,
badgeTopPostCache,
});
const shutdown = (): void => {

View File

@@ -1,110 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {readMarketingResponseAsText, sendMarketingRequest} from '@fluxer/marketing/src/MarketingHttpClient';
import type {Context} from 'hono';
export const productHuntFeaturedUrl =
'https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1057558&theme=light';
export const productHuntTopPostUrl =
'https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=1057558&theme=light&period=daily&t=1767529639613';
const staleAfterMs = 300_000;
const fetchTimeoutMs = 4_500;
export interface BadgeCache {
getBadge(): Promise<string | null>;
}
interface CacheEntry {
svg: string;
fetchedAt: number;
}
export function createBadgeCache(url: string): BadgeCache {
let cache: CacheEntry | null = null;
let isRefreshing = false;
async function refreshBadge(): Promise<void> {
if (isRefreshing) return;
isRefreshing = true;
try {
const svg = await fetchBadgeSvg(url);
if (svg) {
cache = {svg, fetchedAt: Date.now()};
}
} finally {
isRefreshing = false;
}
}
return {
async getBadge() {
const now = Date.now();
if (!cache) {
const svg = await fetchBadgeSvg(url);
if (svg) {
cache = {svg, fetchedAt: now};
}
return svg;
}
const isStale = now - cache.fetchedAt > staleAfterMs;
if (isStale) {
void refreshBadge();
}
return cache.svg;
},
};
}
export async function createBadgeResponse(cache: BadgeCache, c: Context): Promise<Response> {
const svg = await cache.getBadge();
if (!svg) {
c.header('content-type', 'text/plain');
c.header('retry-after', '60');
return c.text('Badge temporarily unavailable', 503);
}
c.header('content-type', 'image/svg+xml');
c.header('cache-control', 'public, max-age=300, stale-while-revalidate=600');
c.header('vary', 'Accept');
return c.body(svg, 200);
}
async function fetchBadgeSvg(url: string): Promise<string | null> {
try {
const response = await sendMarketingRequest({
url,
method: 'GET',
headers: {
Accept: 'image/svg+xml',
},
timeout: fetchTimeoutMs,
serviceName: 'marketing_badges',
});
if (response.status < 200 || response.status >= 300) return null;
return await readMarketingResponseAsText(response.stream);
} catch {
return null;
}
}

View File

@@ -21,7 +21,6 @@
/** @jsxImportSource hono/jsx */
import type {LocaleCode} from '@fluxer/constants/src/Locales';
import type {BadgeCache} from '@fluxer/marketing/src/BadgeProxy';
import type {MarketingI18nService} from '@fluxer/marketing/src/marketing_i18n/MarketingI18nService';
export type MarketingPlatform = 'windows' | 'macos' | 'linux' | 'ios' | 'android' | 'unknown';
@@ -40,8 +39,6 @@ export interface MarketingContext {
platform: MarketingPlatform;
architecture: MarketingArchitecture;
releaseChannel: string;
badgeFeaturedCache: BadgeCache;
badgeTopPostCache: BadgeCache;
csrfToken: string;
isDev: boolean;
}

View File

@@ -21,7 +21,6 @@
/** @jsxImportSource hono/jsx */
import {CdnEndpoints} from '@fluxer/constants/src/CdnEndpoints';
import type {BadgeCache} from '@fluxer/marketing/src/BadgeProxy';
import {createI18n} from '@fluxer/marketing/src/I18n';
import type {MarketingConfig} from '@fluxer/marketing/src/MarketingConfig';
import type {MarketingContext} from '@fluxer/marketing/src/MarketingContext';
@@ -33,8 +32,6 @@ import type {Context as HonoContext} from 'hono';
export interface CreateMarketingContextFactoryOptions {
config: MarketingConfig;
publicDir: string;
badgeFeaturedCache: BadgeCache;
badgeTopPostCache: BadgeCache;
}
export type MarketingContextFactory = (c: HonoContext) => Promise<MarketingContext>;
@@ -62,8 +59,6 @@ export function createMarketingContextFactory(options: CreateMarketingContextFac
platform: requestInfo.platform,
architecture: requestInfo.architecture,
releaseChannel: options.config.releaseChannel,
badgeFeaturedCache: options.badgeFeaturedCache,
badgeTopPostCache: options.badgeTopPostCache,
csrfToken,
isDev: options.config.env === 'development',
};

View File

@@ -26,7 +26,6 @@ import {HttpStatus, MimeType} from '@fluxer/constants/src/HttpConstants';
import {isPressAssetId, PressAssets} from '@fluxer/constants/src/PressAssets';
import {createSession} from '@fluxer/hono/src/Session';
import {getLocaleFromCode} from '@fluxer/locale/src/LocaleService';
import {type BadgeCache, createBadgeResponse} from '@fluxer/marketing/src/BadgeProxy';
import type {MarketingConfig} from '@fluxer/marketing/src/MarketingConfig';
import {sendMarketingRequest} from '@fluxer/marketing/src/MarketingHttpClient';
import {renderCareersPage} from '@fluxer/marketing/src/pages/CareersPage';
@@ -54,8 +53,6 @@ export interface RegisterMarketingRoutesOptions {
app: Hono;
config: MarketingConfig;
contextFactory: MarketingContextFactory;
badgeFeaturedCache: BadgeCache;
badgeTopPostCache: BadgeCache;
}
interface LocaleCookieSession {
@@ -88,7 +85,6 @@ const PAGE_ROUTE_DEFINITIONS: ReadonlyArray<{
];
export function registerMarketingRoutes(options: RegisterMarketingRoutesOptions): void {
registerBadgeRoutes(options.app, options.badgeFeaturedCache, options.badgeTopPostCache);
registerLocaleRoute(options.app, options.config);
registerExternalRedirects(options.app);
registerSystemContentRoutes(options.app, options.contextFactory);
@@ -99,16 +95,6 @@ export function registerMarketingRoutes(options: RegisterMarketingRoutesOptions)
registerNotFoundRoute(options.app, options.contextFactory);
}
function registerBadgeRoutes(app: Hono, badgeFeaturedCache: BadgeCache, badgeTopPostCache: BadgeCache): void {
app.get('/api/badges/product-hunt', async (c) => {
return await createBadgeResponse(badgeFeaturedCache, c);
});
app.get('/api/badges/product-hunt-top-post', async (c) => {
return await createBadgeResponse(badgeTopPostCache, c);
});
}
function registerLocaleRoute(app: Hono, config: MarketingConfig): void {
app.post('/_locale', async (c) => {
const body = await c.req.parseBody();

View File

@@ -20,11 +20,12 @@
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {Locales} from '@fluxer/constants/src/Locales';
import {FlagSvg} from '@fluxer/marketing/src/components/Flags';
import {HackernewsBanner} from '@fluxer/marketing/src/components/HackernewsBanner';
import {MadeInSwedenBadge} from '@fluxer/marketing/src/components/MadeInSwedenBadge';
import {ArrowRightIcon} from '@fluxer/marketing/src/components/icons/ArrowRightIcon';
import {renderSecondaryButton, renderWithOverlay} from '@fluxer/marketing/src/components/PlatformDownloadButton';
import type {MarketingContext} from '@fluxer/marketing/src/MarketingContext';
import {href} from '@fluxer/marketing/src/UrlUtils';
interface HeroProps {
ctx: MarketingContext;
@@ -41,13 +42,35 @@ export function Hero(props: HeroProps): JSX.Element {
<span class="font-bold text-3xl text-white">Fluxer</span>
</div>
) : null}
<div class="flex flex-wrap justify-center gap-3 pb-2">
<span class="rounded-full bg-white px-4 py-1.5 font-medium text-[#4641D9] text-sm">
{ctx.i18n.getMessage('beta_and_access.public_beta', ctx.locale)}
</span>
<MadeInSwedenBadge ctx={ctx} />
<div class="flex flex-wrap items-center justify-center gap-3 pb-2">
<a
href="https://blog.fluxer.app/how-i-built-fluxer-a-discord-like-chat-app/"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 rounded-full bg-white/10 px-4 py-1.5 font-medium text-sm text-white transition-colors hover:bg-white/20"
>
{ctx.i18n.getMessage('launch.heading', ctx.locale)}
<ArrowRightIcon class="h-3.5 w-3.5" />
</a>
<a
href="https://blog.fluxer.app/roadmap-2026"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 rounded-full bg-white/10 px-4 py-1.5 font-medium text-sm text-white transition-colors hover:bg-white/20"
>
{ctx.i18n.getMessage('launch.view_full_roadmap', ctx.locale)}
<ArrowRightIcon class="h-3.5 w-3.5" />
</a>
</div>
<h1 class="hero">{ctx.i18n.getMessage('general.tagline', ctx.locale)}</h1>
<div class="-mt-4 flex items-center justify-center gap-2 font-medium text-sm text-white/80">
<span>{ctx.i18n.getMessage('beta_and_access.public_beta', ctx.locale)}</span>
<span class="text-white/40">·</span>
<span class="inline-flex items-center gap-1.5">
<FlagSvg locale={Locales.SV_SE} ctx={ctx} class="h-3.5 w-3.5 rounded-sm" />
{ctx.i18n.getMessage('general.made_in_sweden', ctx.locale)}
</span>
</div>
<p class="lead mx-auto max-w-2xl text-white/90">
{ctx.i18n.getMessage('product_positioning.intro', ctx.locale)}
</p>
@@ -105,32 +128,6 @@ export function Hero(props: HeroProps): JSX.Element {
</picture>
</div>
</div>
<div class="mt-10 flex flex-wrap items-center justify-center gap-4 md:mt-12">
<a
href="https://www.producthunt.com/products/fluxer?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-fluxer"
target="_blank"
rel="noopener noreferrer"
>
<img
alt="Fluxer - Open-source Discord-like instant messaging & VoIP platform | Product Hunt"
width="250"
height="54"
src={href(ctx, '/api/badges/product-hunt')}
/>
</a>
<a
href="https://www.producthunt.com/products/fluxer?embed=true&utm_source=badge-top-post-badge&utm_medium=badge&utm_campaign=badge-fluxer"
target="_blank"
rel="noopener noreferrer"
>
<img
alt={ctx.i18n.getMessage('misc_labels.product_hunt_badge_title', ctx.locale)}
width="250"
height="54"
src={href(ctx, '/api/badges/product-hunt-top-post')}
/>
</a>
</div>
</main>
);
}

View File

@@ -1,67 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {ArrowRightIcon} from '@fluxer/marketing/src/components/icons/ArrowRightIcon';
import {MarketingButton, MarketingButtonSecondary} from '@fluxer/marketing/src/components/MarketingButton';
import {Section} from '@fluxer/marketing/src/components/Section';
import type {MarketingContext} from '@fluxer/marketing/src/MarketingContext';
interface LaunchBlogSectionProps {
ctx: MarketingContext;
}
export function LaunchBlogSection(props: LaunchBlogSectionProps): JSX.Element {
const {ctx} = props;
return (
<Section
variant="light"
title={ctx.i18n.getMessage('launch.heading', ctx.locale)}
description={ctx.i18n.getMessage('launch.description', ctx.locale)}
>
<div class="mx-auto max-w-4xl">
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row sm:items-stretch">
<MarketingButton
href="https://blog.fluxer.app/how-i-built-fluxer-a-discord-like-chat-app/"
size="large"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 md:text-xl"
>
{ctx.i18n.getMessage('launch.read_more', ctx.locale)}
<ArrowRightIcon class="h-5 w-5 md:h-6 md:w-6" />
</MarketingButton>
<MarketingButtonSecondary
href="https://blog.fluxer.app/roadmap-2026"
size="large"
target="_blank"
rel="noopener noreferrer"
class="md:text-xl"
>
{ctx.i18n.getMessage('launch.view_full_roadmap', ctx.locale)}
<ArrowRightIcon class="h-5 w-5 md:h-6 md:w-6" />
</MarketingButtonSecondary>
</div>
</div>
</Section>
);
}

View File

@@ -1,40 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {Locales} from '@fluxer/constants/src/Locales';
import {FlagSvg} from '@fluxer/marketing/src/components/Flags';
import type {MarketingContext} from '@fluxer/marketing/src/MarketingContext';
interface MadeInSwedenBadgeProps {
ctx: MarketingContext;
}
export function MadeInSwedenBadge(props: MadeInSwedenBadgeProps): JSX.Element {
const {ctx} = props;
return (
<span class="inline-flex items-center gap-2 rounded-full bg-white px-4 py-1.5 font-medium text-[#4641D9] text-sm">
<FlagSvg locale={Locales.SV_SE} ctx={ctx} class="h-4 w-4 rounded-sm" />
<span>{ctx.i18n.getMessage('general.made_in_sweden', ctx.locale)}</span>
</span>
);
}

View File

@@ -24,7 +24,6 @@ import {CurrentFeaturesSection} from '@fluxer/marketing/src/components/CurrentFe
import {FinalCtaSection} from '@fluxer/marketing/src/components/FinalCtaSection';
import {GetInvolvedSection} from '@fluxer/marketing/src/components/GetInvolvedSection';
import {Hero} from '@fluxer/marketing/src/components/Hero';
import {LaunchBlogSection} from '@fluxer/marketing/src/components/LaunchBlogSection';
import type {MarketingContext} from '@fluxer/marketing/src/MarketingContext';
import {renderLayout} from '@fluxer/marketing/src/pages/Layout';
import {defaultPageMeta} from '@fluxer/marketing/src/pages/layout/Meta';
@@ -34,7 +33,6 @@ export async function renderHomePage(c: Context, ctx: MarketingContext): Promise
const getInvolved = await GetInvolvedSection({ctx});
const content: ReadonlyArray<JSX.Element> = [
<Hero ctx={ctx} />,
<LaunchBlogSection ctx={ctx} />,
<CurrentFeaturesSection ctx={ctx} />,
getInvolved,
<FinalCtaSection ctx={ctx} />,