feat(discovery): more work on discovery plus a few fixes
This commit is contained in:
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />,
|
||||
|
||||
Reference in New Issue
Block a user