From cb209abdb76bf6c3e9821e9b948b6c97789ec64f Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Wed, 19 Aug 2026 15:55:05 +0200 Subject: [PATCH] feat(web): add open-source section to the marketing home page Scibly is AGPLv3 and self-hostable, but nothing on the landing page said so. This adds a dedicated section between compliance and comparison, plus a teaser chip in the hero that anchors down to it. The section pairs a license card with a four-step terminal that replays a real self-hosting transcript (clone, compose up, open localhost, file an issue). Steps autoplay and stop on click. The typewriter runs entirely in CSS -- `marketing-motion.css` owns the keyframes and a `@media (scripting: enabled)` gate, so the server ships finished markup and only hides it when JS can restore it. Reduced motion drops straight to the finished line. `packages/routes` gains `external.github` so the repo, issues, and blob links stop being spelled out at each call site. The transcript is deliberately untranslated: `docker compose up` prints English in every locale. Co-Authored-By: Claude Opus 5 --- .../hero/hero-open-source-chip.tsx | 39 +++ .../home-page-section/hero/hero.tsx | 5 + .../hero/i18n/hero.i18n.de.json | 4 + .../hero/i18n/hero.i18n.en.json | 4 + .../home-page-section/hero/i18n/hero.types.ts | 4 + .../open-source/i18n/open-source.i18n.de.json | 36 +++ .../open-source/i18n/open-source.i18n.en.json | 36 +++ .../open-source/i18n/open-source.types.ts | 22 ++ .../open-source/open-source-atmosphere.tsx | 43 +++ .../open-source/open-source-facts.ts | 72 +++++ .../open-source/open-source.tsx | 95 ++++++ .../open-source/self-host-terminal.tsx | 293 ++++++++++++++++++ apps/web/src/app/[lang]/page.tsx | 6 + apps/web/src/i18n/types.ts | 2 + apps/web/src/styles/marketing-motion.css | 68 ++++ packages/routes/src/index.ts | 8 + 16 files changed, 737 insertions(+) create mode 100644 apps/web/src/app/[lang]/components/home-page-section/hero/hero-open-source-chip.tsx create mode 100644 apps/web/src/app/[lang]/components/home-page-section/open-source/i18n/open-source.i18n.de.json create mode 100644 apps/web/src/app/[lang]/components/home-page-section/open-source/i18n/open-source.i18n.en.json create mode 100644 apps/web/src/app/[lang]/components/home-page-section/open-source/i18n/open-source.types.ts create mode 100644 apps/web/src/app/[lang]/components/home-page-section/open-source/open-source-atmosphere.tsx create mode 100644 apps/web/src/app/[lang]/components/home-page-section/open-source/open-source-facts.ts create mode 100644 apps/web/src/app/[lang]/components/home-page-section/open-source/open-source.tsx create mode 100644 apps/web/src/app/[lang]/components/home-page-section/open-source/self-host-terminal.tsx diff --git a/apps/web/src/app/[lang]/components/home-page-section/hero/hero-open-source-chip.tsx b/apps/web/src/app/[lang]/components/home-page-section/hero/hero-open-source-chip.tsx new file mode 100644 index 0000000..40990bc --- /dev/null +++ b/apps/web/src/app/[lang]/components/home-page-section/hero/hero-open-source-chip.tsx @@ -0,0 +1,39 @@ +import { chipClass } from "@scibly/ui/design-language"; +import { cn } from "@scibly/ui/utils"; +import { ArrowDown, Github } from "lucide-react"; + +import { PILLARS, tint } from "@/app/[lang]/components/marketing-tokens"; + +import { type HeroDictionary } from "./i18n/hero.types"; + +export function HeroOpenSourceChip({ t }: { t: HeroDictionary["openSource"] }) { + return ( + + + {t.label} + + + {t.license} + + + + ); +} diff --git a/apps/web/src/app/[lang]/components/home-page-section/hero/hero.tsx b/apps/web/src/app/[lang]/components/home-page-section/hero/hero.tsx index bbc6f0f..d3e65c3 100644 --- a/apps/web/src/app/[lang]/components/home-page-section/hero/hero.tsx +++ b/apps/web/src/app/[lang]/components/home-page-section/hero/hero.tsx @@ -1,6 +1,7 @@ import { type ReactNode } from "react"; import { HeroGlassFrame } from "./hero-glass-frame"; +import { HeroOpenSourceChip } from "./hero-open-source-chip"; import { type HeroDictionary } from "./i18n/hero.types"; interface HeroProps { @@ -34,6 +35,10 @@ export function Hero({ t, children }: HeroProps) {
+
+ +
+ {/* Measured in characters so the line length holds at every size. German compounds need the wider measure to keep the headline off a fourth line; phones stay tighter so it never runs edge to edge. */} diff --git a/apps/web/src/app/[lang]/components/home-page-section/hero/i18n/hero.i18n.de.json b/apps/web/src/app/[lang]/components/home-page-section/hero/i18n/hero.i18n.de.json index ced21b4..dce223c 100644 --- a/apps/web/src/app/[lang]/components/home-page-section/hero/i18n/hero.i18n.de.json +++ b/apps/web/src/app/[lang]/components/home-page-section/hero/i18n/hero.i18n.de.json @@ -5,6 +5,10 @@ "titleHighlight": "moderne", "titleSuffix": " Teams", "subtitle": "Wir verwandeln vorhandenes Unternehmenswissen in interaktive Kurse.", + "openSource": { + "label": "Scibly ist Open Source", + "license": "AGPLv3" + }, "complianceBadges": [ { "kind": "euServer", diff --git a/apps/web/src/app/[lang]/components/home-page-section/hero/i18n/hero.i18n.en.json b/apps/web/src/app/[lang]/components/home-page-section/hero/i18n/hero.i18n.en.json index d305ab4..d2bf19a 100644 --- a/apps/web/src/app/[lang]/components/home-page-section/hero/i18n/hero.i18n.en.json +++ b/apps/web/src/app/[lang]/components/home-page-section/hero/i18n/hero.i18n.en.json @@ -5,6 +5,10 @@ "titleHighlight": "modern", "titleSuffix": " teams", "subtitle": "We turn your existing company knowledge into interactive courses.", + "openSource": { + "label": "Scibly is open source", + "license": "AGPLv3" + }, "complianceBadges": [ { "kind": "euServer", diff --git a/apps/web/src/app/[lang]/components/home-page-section/hero/i18n/hero.types.ts b/apps/web/src/app/[lang]/components/home-page-section/hero/i18n/hero.types.ts index 3b2179b..8e87279 100644 --- a/apps/web/src/app/[lang]/components/home-page-section/hero/i18n/hero.types.ts +++ b/apps/web/src/app/[lang]/components/home-page-section/hero/i18n/hero.types.ts @@ -32,6 +32,10 @@ export type HeroDictionary = { titleHighlight: string; titleSuffix: string; subtitle: string; + openSource: { + label: string; + license: string; + }; complianceBadges: Array<{ kind: string; label: string; diff --git a/apps/web/src/app/[lang]/components/home-page-section/open-source/i18n/open-source.i18n.de.json b/apps/web/src/app/[lang]/components/home-page-section/open-source/i18n/open-source.i18n.de.json new file mode 100644 index 0000000..7fc90a3 --- /dev/null +++ b/apps/web/src/app/[lang]/components/home-page-section/open-source/i18n/open-source.i18n.de.json @@ -0,0 +1,36 @@ +{ + "openSource": { + "eyebrow": "Open Source", + "title": "Offener Code. Deine Wahl. Deine Kontrolle.", + "subtitle": "Der Code von Scibly ist Open Source auf GitHub. Klone das Repository, starte den Stack und sieh selbst, wie Scibly funktioniert.", + "actions": { + "repo": "Code auf GitHub ansehen", + "selfHost": "Selbst hosten" + }, + "licenseLabel": "AGPLv3", + "terminal": { + "transcriptLabel": "Terminal-Ausgabe", + "stepsLabel": "Terminal-Schritte" + }, + "steps": [ + { + "kind": "clone", + "label": "Repository klonen", + "caption": "Der komplette Code. Kein abgespecktes Template.", + "note": "AGPLv3. Forke es und liefere deinen eigenen Build aus." + }, + { + "kind": "boot", + "label": "Stack starten", + "caption": "Ein Befehl. Datenbank und Apps laufen.", + "note": "Ein Befehl, und der komplette Stack läuft auf deiner eigenen Infrastruktur." + }, + { + "kind": "issue", + "label": "Bringe deine Ideen ein", + "caption": "Bugs melden, Ideen teilen und neue Features vorschlagen.", + "note": "Landet im selben Tracker, aus dem wir planen." + } + ] + } +} diff --git a/apps/web/src/app/[lang]/components/home-page-section/open-source/i18n/open-source.i18n.en.json b/apps/web/src/app/[lang]/components/home-page-section/open-source/i18n/open-source.i18n.en.json new file mode 100644 index 0000000..cf66bd6 --- /dev/null +++ b/apps/web/src/app/[lang]/components/home-page-section/open-source/i18n/open-source.i18n.en.json @@ -0,0 +1,36 @@ +{ + "openSource": { + "eyebrow": "Open source", + "title": "Open code. Your choice. Your control.", + "subtitle": "Scibly's code is open source on GitHub. Clone the repository, start the stack, and see for yourself how Scibly works.", + "actions": { + "repo": "View the code on GitHub", + "selfHost": "Host it yourself" + }, + "licenseLabel": "AGPLv3", + "terminal": { + "transcriptLabel": "Terminal transcript", + "stepsLabel": "Terminal steps" + }, + "steps": [ + { + "kind": "clone", + "label": "Clone the repository", + "caption": "The complete code. Not a stripped-down template.", + "note": "AGPLv3. Fork it and ship your own build." + }, + { + "kind": "boot", + "label": "Start the stack", + "caption": "One command. Database and apps running.", + "note": "One command, and the whole stack runs on your own infrastructure." + }, + { + "kind": "issue", + "label": "Contribute your ideas", + "caption": "Report bugs, share ideas, suggest new features.", + "note": "Lands in the same tracker we plan from." + } + ] + } +} diff --git a/apps/web/src/app/[lang]/components/home-page-section/open-source/i18n/open-source.types.ts b/apps/web/src/app/[lang]/components/home-page-section/open-source/i18n/open-source.types.ts new file mode 100644 index 0000000..a28f430 --- /dev/null +++ b/apps/web/src/app/[lang]/components/home-page-section/open-source/i18n/open-source.types.ts @@ -0,0 +1,22 @@ +type OpenSourceStep = { + kind: string; + label: string; + caption: string; + note: string; +}; + +export type OpenSourceDictionary = { + eyebrow: string; + title: string; + subtitle: string; + actions: { + repo: string; + selfHost: string; + }; + licenseLabel: string; + terminal: { + transcriptLabel: string; + stepsLabel: string; + }; + steps: OpenSourceStep[]; +}; diff --git a/apps/web/src/app/[lang]/components/home-page-section/open-source/open-source-atmosphere.tsx b/apps/web/src/app/[lang]/components/home-page-section/open-source/open-source-atmosphere.tsx new file mode 100644 index 0000000..b43ec14 --- /dev/null +++ b/apps/web/src/app/[lang]/components/home-page-section/open-source/open-source-atmosphere.tsx @@ -0,0 +1,43 @@ +import { Braces, Container, GitFork, Terminal } from "lucide-react"; + +import { + type AtmosphereTile, + MarketingAtmosphere, +} from "@/app/[lang]/components/marketing-grid-field"; +import { PILLARS } from "@/app/[lang]/components/marketing-tokens"; + +const tiles: AtmosphereTile[] = [ + { + id: "terminal", + className: "top-[16%] left-[3.5%] hidden xl:flex", + icon: , + tone: PILLARS.import, + }, + { + id: "fork", + className: "top-[24%] right-[4%] hidden xl:flex", + icon: , + tone: PILLARS.byoai, + }, + { + id: "container", + className: "bottom-[18%] left-[5%] hidden 2xl:flex", + icon: , + tone: PILLARS.channels, + }, + { + id: "braces", + className: "right-[3.5%] bottom-[15%] hidden 2xl:flex", + icon: , + tone: PILLARS.learner, + }, +]; + +const indents = [ + "top-[54%] left-[4%] hidden xl:block", + "top-[62%] right-[5%] hidden xl:block", +]; + +export function OpenSourceAtmosphere() { + return ; +} diff --git a/apps/web/src/app/[lang]/components/home-page-section/open-source/open-source-facts.ts b/apps/web/src/app/[lang]/components/home-page-section/open-source/open-source-facts.ts new file mode 100644 index 0000000..d762b27 --- /dev/null +++ b/apps/web/src/app/[lang]/components/home-page-section/open-source/open-source-facts.ts @@ -0,0 +1,72 @@ +import { routes } from "@scibly/routes"; +import { CircleDot, Container, type LucideIcon, Terminal } from "lucide-react"; + +import { type Pillar, PILLARS } from "@/app/[lang]/components/marketing-tokens"; + +/* The transcript stays untranslated: `docker compose up` prints English in every locale. */ +export const MONO_FONT = 'ui-monospace, "SFMono-Regular", Menlo, monospace'; + +const SELF_HOST_STEP_IDS = ["clone", "boot", "issue"] as const; + +type SelfHostStepId = (typeof SELF_HOST_STEP_IDS)[number]; + +export const toSelfHostStepId = (id: string): SelfHostStepId | undefined => + SELF_HOST_STEP_IDS.find((known) => known === id); + +type TerminalLineTone = "log" | "ok" | "port"; + +export type TerminalLine = { + tone: TerminalLineTone; + text: string; + value?: string; +}; + +type SelfHostStep = { + pillar: Pillar; + icon: LucideIcon; + command: string; + lines: readonly TerminalLine[]; +}; + +export const SELF_HOST_STEPS = { + clone: { + pillar: PILLARS.import, + icon: Terminal, + command: "git clone https://github.com/scibly-dev/scibly", + lines: [ + { tone: "log", text: "Cloning into 'scibly'..." }, + { tone: "log", text: "remote: Counting objects: 100%, done." }, + { tone: "ok", text: "apps/ packages/ ee/ docs/" }, + ], + }, + boot: { + pillar: PILLARS.channels, + icon: Container, + command: "docker compose up -d --build", + lines: [ + { tone: "ok", text: "Container scibly-postgres-1 Started" }, + { tone: "port", text: "web", value: "http://localhost:3000" }, + { tone: "port", text: "app", value: "http://localhost:3001" }, + { tone: "port", text: "collab", value: "ws://localhost:4000" }, + ], + }, + issue: { + pillar: PILLARS.analytics, + icon: CircleDot, + command: "gh issue create --repo scibly-dev/scibly", + lines: [ + { tone: "log", text: "Creating issue in scibly-dev/scibly" }, + { tone: "log", text: "? Title SCORM export for course packages" }, + { tone: "ok", text: "? What's next? Submit" }, + ], + }, +} satisfies Record; + +export function transcriptOf(step: SelfHostStep): string { + return step.lines + .map((line) => [line.text, line.value].filter(Boolean).join(" ")) + .join(" · "); +} + +export const SELF_HOSTING_GUIDE_HREF = + routes.external.github.file("docs/docker.md"); diff --git a/apps/web/src/app/[lang]/components/home-page-section/open-source/open-source.tsx b/apps/web/src/app/[lang]/components/home-page-section/open-source/open-source.tsx new file mode 100644 index 0000000..b0a417a --- /dev/null +++ b/apps/web/src/app/[lang]/components/home-page-section/open-source/open-source.tsx @@ -0,0 +1,95 @@ +import { routes } from "@scibly/routes"; +import { + actionClass, + primaryActionClass, + secondaryActionClass, +} from "@scibly/ui/design-language"; +import { cn } from "@scibly/ui/utils"; +import { ArrowUpRight, Github, Server } from "lucide-react"; + +import { + MarketingSection, + MarketingSectionHeader, +} from "@/app/[lang]/components/marketing-section-content"; +import { + lipShadow, + PILLARS, + tint, +} from "@/app/[lang]/components/marketing-tokens"; + +import { type OpenSourceDictionary } from "./i18n/open-source.types"; +import { OpenSourceAtmosphere } from "./open-source-atmosphere"; +import { MONO_FONT, SELF_HOSTING_GUIDE_HREF } from "./open-source-facts"; +import { SelfHostTerminal } from "./self-host-terminal"; + +export function OpenSourceSection({ t }: { t: OpenSourceDictionary }) { + return ( + } + > + + {t.eyebrow} + {t.licenseLabel} + + } + title={t.title} + description={t.subtitle} + /> + + + +
+ +
+
+ ); +} + +function LicensePill({ children }: { children: React.ReactNode }) { + const tone = PILLARS.learner; + + return ( + + {children} + + ); +} diff --git a/apps/web/src/app/[lang]/components/home-page-section/open-source/self-host-terminal.tsx b/apps/web/src/app/[lang]/components/home-page-section/open-source/self-host-terminal.tsx new file mode 100644 index 0000000..24303b3 --- /dev/null +++ b/apps/web/src/app/[lang]/components/home-page-section/open-source/self-host-terminal.tsx @@ -0,0 +1,293 @@ +"use client"; + +import { cn } from "@scibly/ui/utils"; +import { GitBranch } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { + chosenKey, + IDLE_KEY, + keyStyle, +} from "@/app/[lang]/components/marketing-tokens"; +import { + useInViewOnce, + usePrefersReducedMotion, +} from "@/components/in-view-reveal"; + +import { type OpenSourceDictionary } from "./i18n/open-source.types"; +import { + MONO_FONT, + SELF_HOST_STEPS, + type TerminalLine, + toSelfHostStepId, + transcriptOf, +} from "./open-source-facts"; + +const PROMPT_GREEN = "#a3e163"; +const CHECK_GREEN = "#58cc02"; +const URL_BLUE = "#94c4ff"; + +const TYPE_MS_PER_CHAR = 24; +const TYPE_START_MS = 160; +const LINE_STAGGER_MS = 150; +const STEP_HOLD_MS = 1900; + +export function SelfHostTerminal({ t }: { t: OpenSourceDictionary }) { + const { ref, inView } = useInViewOnce(0.3); + const prefersReducedMotion = usePrefersReducedMotion(); + + const steps = useMemo( + () => + t.steps.flatMap((step) => { + const kind = toSelfHostStepId(step.kind); + return kind ? [{ ...step, ...SELF_HOST_STEPS[kind], kind }] : []; + }), + [t.steps], + ); + + const [activeIndex, setActiveIndex] = useState(0); + const [isAutoplaying, setIsAutoplaying] = useState(true); + + const active = steps[activeIndex] ?? steps[0]; + const lastIndex = steps.length - 1; + + const typeMs = active ? active.command.length * TYPE_MS_PER_CHAR : 0; + const outroMs = active + ? TYPE_START_MS + typeMs + (active.lines.length + 1) * LINE_STAGGER_MS + : 0; + + useEffect(() => { + if (!inView || !isAutoplaying || prefersReducedMotion) return; + if (activeIndex >= lastIndex) return; + + const timer = window.setTimeout( + () => setActiveIndex((index) => index + 1), + outroMs + STEP_HOLD_MS, + ); + return () => window.clearTimeout(timer); + }, [ + inView, + isAutoplaying, + prefersReducedMotion, + activeIndex, + lastIndex, + outroMs, + ]); + + if (!active) return null; + + return ( +
+
+
+ + + + + + + + scibly — bash + + + + + main + +
+ + {/* Keyed on the step so switching remounts the lines and replays their CSS timeline. */} +
+ {/* `div`, not `p`: the app's unlayered `:where(p, li, dd) { text-wrap: pretty }` outranks `whitespace-nowrap`. */} +
+ ${" "} + + {active.command} + +
+ + {active.lines.map((line, index) => ( + + ))} + +
+ # {active.note} +
+
+ +
    + {steps.map((step) => ( +
  1. + {step.label}: {step.command} — {transcriptOf(step)} —{" "} + {step.note} +
  2. + ))} +
+
+ +
    + {steps.map((step, index) => { + const isActive = index === activeIndex; + const Icon = step.icon; + + return ( +
  1. + +
  2. + ); + })} +
+
+ ); +} + +function OutputLine({ + line, + delayMs, +}: { + line: TerminalLine; + delayMs: number; +}) { + return ( +
+ {line.tone === "ok" ? ( + + ✔ + + ) : ( + · + )} + + {line.tone === "port" ? ( + <> + + {line.text} + + {line.value} + + ) : ( + {line.text} + )} +
+ ); +} + +function Dot({ color }: { color: string }) { + return ( + + ); +} diff --git a/apps/web/src/app/[lang]/page.tsx b/apps/web/src/app/[lang]/page.tsx index feb1632..769fc80 100644 --- a/apps/web/src/app/[lang]/page.tsx +++ b/apps/web/src/app/[lang]/page.tsx @@ -36,6 +36,11 @@ const ComplianceSection = dynamic(() => (m) => m.ComplianceSection, ), ); +const OpenSourceSection = dynamic(() => + import("./components/home-page-section/open-source/open-source").then( + (m) => m.OpenSourceSection, + ), +); const CtaSection = dynamic(() => import("./components/home-page-section/cta/cta").then((m) => m.CtaSection), ); @@ -88,6 +93,7 @@ export default async function Home(props: { + diff --git a/apps/web/src/i18n/types.ts b/apps/web/src/i18n/types.ts index e2a786a..642bd79 100644 --- a/apps/web/src/i18n/types.ts +++ b/apps/web/src/i18n/types.ts @@ -6,6 +6,7 @@ import { type FaqDictionary } from "@/app/[lang]/components/home-page-section/fa import { type FeatureShowcaseDictionary } from "@/app/[lang]/components/home-page-section/feature-showcase/i18n/feature-showcase.types"; import { type FounderDictionary } from "@/app/[lang]/components/home-page-section/founder/i18n/founder.types"; import { type HeroDictionary } from "@/app/[lang]/components/home-page-section/hero/i18n/hero.types"; +import { type OpenSourceDictionary } from "@/app/[lang]/components/home-page-section/open-source/i18n/open-source.types"; import { type QualityProofDictionary } from "@/app/[lang]/components/home-page-section/quality-proof/i18n/quality-proof.types"; import { type AppRoutesPage } from "@/app/[lang]/i18n/app-routes.types"; import { type BlogPage } from "@/app/[lang]/i18n/blog.types"; @@ -27,6 +28,7 @@ export type Dictionary = { compliance: ComplianceDictionary; demoTour: DemoTourDictionary; founder: FounderDictionary; + openSource: OpenSourceDictionary; }; }; diff --git a/apps/web/src/styles/marketing-motion.css b/apps/web/src/styles/marketing-motion.css index be44339..fff4c00 100644 --- a/apps/web/src/styles/marketing-motion.css +++ b/apps/web/src/styles/marketing-motion.css @@ -370,3 +370,71 @@ animation: none; } } + +@keyframes sc-type { + from { + width: 0; + } + to { + width: var(--sc-type-width); + } +} + +@keyframes sc-caret { + 0%, + 100% { + border-right-color: rgba(163, 225, 99, 0.95); + } + 50% { + border-right-color: transparent; + } +} + +[data-term-command] { + /* Content-box keeps `width: Nch` exactly N characters wide; under border-box the caret's 2px eats the last glyph. */ + box-sizing: content-box; + display: inline-block; + width: var(--sc-type-width); + overflow: hidden; + vertical-align: bottom; + white-space: nowrap; + border-right: 2px solid rgba(163, 225, 99, 0.95); +} + +@media (scripting: enabled) { + .sc-term:not(.sc-term-ready) [data-term-command] { + width: 0; + } + + .sc-term:not(.sc-term-ready) [data-term-anim] { + opacity: 0; + } +} + +.sc-term-ready [data-term-command] { + animation-name: sc-type, sc-caret; + animation-duration: var(--sc-type-duration), 1.1s; + animation-timing-function: steps(var(--sc-type-steps), end), step-end; + animation-delay: 160ms, 160ms; + animation-fill-mode: both, none; + animation-iteration-count: 1, infinite; +} + +.sc-term-ready [data-term-anim] { + animation: sc-rise 260ms cubic-bezier(0.22, 1, 0.36, 1) var(--sc-line-delay) + both; +} + +@media (prefers-reduced-motion: reduce) { + .sc-term [data-term-command] { + animation: none !important; + width: var(--sc-type-width) !important; + border-right-color: transparent; + } + + .sc-term [data-term-anim] { + animation: none !important; + opacity: 1 !important; + transform: none !important; + } +} diff --git a/packages/routes/src/index.ts b/packages/routes/src/index.ts index af9f85a..7dbe897 100644 --- a/packages/routes/src/index.ts +++ b/packages/routes/src/index.ts @@ -28,6 +28,8 @@ const toHomeUrl = (path: string) => const toAppUrl = (path: string) => env.NEXT_PUBLIC_APP_URL.concat(path.startsWith("/") ? path : `/${path}`); +const GITHUB_REPO_URL = "https://github.com/scibly-dev/scibly" as const; + const BASE_AUTH_PATH = "/auth" as const; const BASE_PROFILE_PATH = "/profile" as const; const BASE_API_PATH = "/api" as const; @@ -206,6 +208,12 @@ export const routes = { external: { docs: env.NEXT_PUBLIC_DOCS_URL, + + github: { + repo: GITHUB_REPO_URL, + issues: `${GITHUB_REPO_URL}/issues` as const, + file: (path: string) => `${GITHUB_REPO_URL}/blob/main/${path}` as const, + }, }, pythonBackend: {