Link the user console from the nav, footer and add-model page
Browse filesParticipants can register and mint their own API keys in the console, so
/add-model now leads with the self-service path and keeps the email route
as a fallback. The console URL comes from CONSOLE_URL; unset means no
console and every entry point disappears.
- README.md +22 -0
- src/app/add-model/page.tsx +73 -20
- src/app/layout.tsx +5 -2
- src/components/Footer.tsx +19 -2
- src/components/Navigation.tsx +47 -10
- src/config/console.ts +22 -0
README.md
CHANGED
|
@@ -96,3 +96,25 @@ the Markdown is baked into the image, so a rebuild is what makes a new post appe
|
|
| 96 |
|
| 97 |
For local development, drop `.md` files straight into `content/news/` (it is gitignored)
|
| 98 |
and run `npm run dev`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
For local development, drop `.md` files straight into `content/news/` (it is gitignored)
|
| 98 |
and run `npm run dev`.
|
| 99 |
+
|
| 100 |
+
## User console
|
| 101 |
+
|
| 102 |
+
Participants create an account, manage their organization and mint their own API keys in
|
| 103 |
+
the **user console**, a separate app on its own domain (`console.ts-arena.live` for
|
| 104 |
+
TS-Arena itself). This site only links to it:
|
| 105 |
+
|
| 106 |
+
| Variable | Default | Meaning |
|
| 107 |
+
|---|---|---|
|
| 108 |
+
| `CONSOLE_URL` | *(empty)* | Base URL of the console, no trailing slash. Empty = no console. |
|
| 109 |
+
|
| 110 |
+
It is read on the server, but the pages that link to the console (`/`, `/add-model`) are
|
| 111 |
+
statically prerendered, so the value is baked in at **build** time. On Coolify it must
|
| 112 |
+
therefore be marked as a **build variable**, like `NEWS_CONTENT_REPO`, or the build will
|
| 113 |
+
not see it and the links will silently not appear. The URL differs per environment, which
|
| 114 |
+
is why it is not hardcoded: a baked-in production URL would send anyone testing a staging
|
| 115 |
+
deployment to the live console.
|
| 116 |
+
|
| 117 |
+
Leave it unset and every console entry point disappears β the nav item, the footer link,
|
| 118 |
+
and the self-service copy on `/add-model`, which then falls back to asking for an API key
|
| 119 |
+
by email. That is the right default for a fork: the console is a separate service you may
|
| 120 |
+
well not be running.
|
src/app/add-model/page.tsx
CHANGED
|
@@ -1,8 +1,14 @@
|
|
| 1 |
import Breadcrumbs from '@/src/components/Breadcrumbs';
|
| 2 |
-
import { Mail, Key, TrendingUp, Trophy, BarChart3, Users, CheckCircle } from 'lucide-react';
|
| 3 |
import { siGithub } from 'simple-icons/icons';
|
|
|
|
| 4 |
|
| 5 |
export default function InfoPage() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
return (
|
| 7 |
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 8 |
<Breadcrumbs items={[{ label: 'Add Model', href: '/add-model' }]} />
|
|
@@ -18,27 +24,40 @@ export default function InfoPage() {
|
|
| 18 |
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-lg border border-blue-200 p-4 sm:p-6 mb-8">
|
| 19 |
<h2 className="text-xl font-semibold text-gray-900 mb-3">Get Started</h2>
|
| 20 |
<p className="text-gray-700 mb-4">
|
| 21 |
-
Ready to test your forecasting models against the best? Participate actively in our benchmark challenges!
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
| 26 |
rel="noopener noreferrer"
|
| 27 |
className="text-blue-700 hover:text-blue-900 font-semibold underline"
|
| 28 |
>
|
| 29 |
Git repository
|
| 30 |
</a>.
|
| 31 |
</p>
|
| 32 |
-
|
| 33 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
href="mailto:DataAnalytics@wiwi.uni-paderborn.de?subject=TS-Arena Questions&body=Hello,%0D%0A%0D%0AI have questions about participating in the TS-Arena benchmark.%0D%0A%0D%0ABest regards"
|
| 43 |
className="inline-flex items-center justify-center gap-2 px-6 py-3 bg-white hover:bg-gray-50 text-gray-900 font-semibold rounded-lg border border-gray-300 transition-colors shadow-sm"
|
| 44 |
>
|
|
@@ -46,6 +65,16 @@ export default function InfoPage() {
|
|
| 46 |
Ask Questions
|
| 47 |
</a>
|
| 48 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
</div>
|
| 50 |
|
| 51 |
{/* How it Works */}
|
|
@@ -57,10 +86,28 @@ export default function InfoPage() {
|
|
| 57 |
1
|
| 58 |
</div>
|
| 59 |
<div>
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
</div>
|
| 65 |
</div>
|
| 66 |
|
|
@@ -154,7 +201,13 @@ export default function InfoPage() {
|
|
| 154 |
<div className="space-y-2">
|
| 155 |
<div className="flex gap-2 items-start">
|
| 156 |
<CheckCircle className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
</div>
|
| 159 |
<div className="flex gap-2 items-start">
|
| 160 |
<CheckCircle className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
|
|
|
|
| 1 |
import Breadcrumbs from '@/src/components/Breadcrumbs';
|
| 2 |
+
import { Mail, Key, TrendingUp, Trophy, BarChart3, Users, CheckCircle, UserPlus } from 'lucide-react';
|
| 3 |
import { siGithub } from 'simple-icons/icons';
|
| 4 |
+
import { getConsoleUrl } from '@/src/config/console';
|
| 5 |
|
| 6 |
export default function InfoPage() {
|
| 7 |
+
// With a console, signing up is self-service and the email address is only
|
| 8 |
+
// there for people who get stuck. Without one β a fork that does not run
|
| 9 |
+
// the console β the email route is the only way in, so it stays primary.
|
| 10 |
+
const consoleUrl = getConsoleUrl();
|
| 11 |
+
|
| 12 |
return (
|
| 13 |
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 14 |
<Breadcrumbs items={[{ label: 'Add Model', href: '/add-model' }]} />
|
|
|
|
| 24 |
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-lg border border-blue-200 p-4 sm:p-6 mb-8">
|
| 25 |
<h2 className="text-xl font-semibold text-gray-900 mb-3">Get Started</h2>
|
| 26 |
<p className="text-gray-700 mb-4">
|
| 27 |
+
Ready to test your forecasting models against the best? Participate actively in our benchmark challenges!{' '}
|
| 28 |
+
{consoleUrl
|
| 29 |
+
? 'Create an account in the console and mint your own API key β no waiting on us.'
|
| 30 |
+
: 'Simply send us an email and weβll provide you with an API key to get started.'}{' '}
|
| 31 |
+
Find detailed participation instructions in our{' '}
|
| 32 |
+
<a
|
| 33 |
+
href="https://github.com/DAG-UPB/ts-arena"
|
| 34 |
+
target="_blank"
|
| 35 |
rel="noopener noreferrer"
|
| 36 |
className="text-blue-700 hover:text-blue-900 font-semibold underline"
|
| 37 |
>
|
| 38 |
Git repository
|
| 39 |
</a>.
|
| 40 |
</p>
|
| 41 |
+
|
| 42 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 43 |
+
{consoleUrl ? (
|
| 44 |
+
<a
|
| 45 |
+
href={`${consoleUrl}/register`}
|
| 46 |
+
className="inline-flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-colors shadow-sm"
|
| 47 |
+
>
|
| 48 |
+
<UserPlus className="w-5 h-5" />
|
| 49 |
+
Create an Account
|
| 50 |
+
</a>
|
| 51 |
+
) : (
|
| 52 |
+
<a
|
| 53 |
+
href="mailto:DataAnalytics@wiwi.uni-paderborn.de?subject=TS-Arena API Key Request&body=Hello,%0D%0A%0D%0AI would like to participate in the TS-Arena benchmark.%0D%0A%0D%0AOrganization: [Please specify your organization]%0D%0A%0D%0ABest regards"
|
| 54 |
+
className="inline-flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-colors shadow-sm"
|
| 55 |
+
>
|
| 56 |
+
<Mail className="w-5 h-5" />
|
| 57 |
+
Request API Key
|
| 58 |
+
</a>
|
| 59 |
+
)}
|
| 60 |
+
<a
|
| 61 |
href="mailto:DataAnalytics@wiwi.uni-paderborn.de?subject=TS-Arena Questions&body=Hello,%0D%0A%0D%0AI have questions about participating in the TS-Arena benchmark.%0D%0A%0D%0ABest regards"
|
| 62 |
className="inline-flex items-center justify-center gap-2 px-6 py-3 bg-white hover:bg-gray-50 text-gray-900 font-semibold rounded-lg border border-gray-300 transition-colors shadow-sm"
|
| 63 |
>
|
|
|
|
| 65 |
Ask Questions
|
| 66 |
</a>
|
| 67 |
</div>
|
| 68 |
+
|
| 69 |
+
{consoleUrl && (
|
| 70 |
+
<p className="mt-4 text-sm text-gray-600">
|
| 71 |
+
Already have an account?{' '}
|
| 72 |
+
<a href={`${consoleUrl}/login`} className="text-blue-700 hover:text-blue-900 font-semibold underline">
|
| 73 |
+
Sign in to the console
|
| 74 |
+
</a>
|
| 75 |
+
. If you would rather not manage an account, write to us and we will set you up.
|
| 76 |
+
</p>
|
| 77 |
+
)}
|
| 78 |
</div>
|
| 79 |
|
| 80 |
{/* How it Works */}
|
|
|
|
| 86 |
1
|
| 87 |
</div>
|
| 88 |
<div>
|
| 89 |
+
{consoleUrl ? (
|
| 90 |
+
<>
|
| 91 |
+
<h3 className="font-medium text-gray-900 mb-1">Create an Account and an API Key</h3>
|
| 92 |
+
<p className="text-sm text-gray-600">
|
| 93 |
+
Register in the{' '}
|
| 94 |
+
<a href={`${consoleUrl}/register`} className="text-blue-700 hover:text-blue-900 underline">
|
| 95 |
+
console
|
| 96 |
+
</a>
|
| 97 |
+
, confirm your email address, then mint an API key there. Naming an organization
|
| 98 |
+
creates it with you as its admin and shows it on the leaderboard; leave it blank to
|
| 99 |
+
take part as an individual. If your organization already exists, ask its admin to
|
| 100 |
+
invite you instead.
|
| 101 |
+
</p>
|
| 102 |
+
</>
|
| 103 |
+
) : (
|
| 104 |
+
<>
|
| 105 |
+
<h3 className="font-medium text-gray-900 mb-1">Request an API Key</h3>
|
| 106 |
+
<p className="text-sm text-gray-600">
|
| 107 |
+
Email us with your organization name and we'll send you an API key.
|
| 108 |
+
</p>
|
| 109 |
+
</>
|
| 110 |
+
)}
|
| 111 |
</div>
|
| 112 |
</div>
|
| 113 |
|
|
|
|
| 201 |
<div className="space-y-2">
|
| 202 |
<div className="flex gap-2 items-start">
|
| 203 |
<CheckCircle className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
|
| 204 |
+
{/* An organization is optional in the console β individuals take part too,
|
| 205 |
+
so this must not read as a hard requirement any more. */}
|
| 206 |
+
<span className="text-gray-700">
|
| 207 |
+
{consoleUrl
|
| 208 |
+
? 'A verified email address; an organization is optional'
|
| 209 |
+
: 'Valid organization or affiliation'}
|
| 210 |
+
</span>
|
| 211 |
</div>
|
| 212 |
<div className="flex gap-2 items-start">
|
| 213 |
<CheckCircle className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
|
src/app/layout.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import "./globals.css";
|
|
| 4 |
import Navigation from "@/src/components/Navigation";
|
| 5 |
import Footer from "@/src/components/Footer";
|
| 6 |
import { hasNews } from "@/src/content/news";
|
|
|
|
| 7 |
|
| 8 |
const geistSans = Geist({
|
| 9 |
variable: "--font-geist-sans",
|
|
@@ -28,16 +29,18 @@ export default function RootLayout({
|
|
| 28 |
}: Readonly<{
|
| 29 |
children: React.ReactNode;
|
| 30 |
}>) {
|
|
|
|
|
|
|
| 31 |
return (
|
| 32 |
<html lang="en">
|
| 33 |
<body
|
| 34 |
className={`${geistSans.variable} ${geistMono.variable} antialiased flex flex-col min-h-screen`}
|
| 35 |
>
|
| 36 |
-
<Navigation showNews={hasNews()} />
|
| 37 |
<main className="flex-1">
|
| 38 |
{children}
|
| 39 |
</main>
|
| 40 |
-
<Footer />
|
| 41 |
{/* Self-hosted Umami on franzia. data-domains is an allowlist: any other
|
| 42 |
host serving this build (the Hugging Face Space mirror, a local dev
|
| 43 |
server) loads the script but reports nothing. */}
|
|
|
|
| 4 |
import Navigation from "@/src/components/Navigation";
|
| 5 |
import Footer from "@/src/components/Footer";
|
| 6 |
import { hasNews } from "@/src/content/news";
|
| 7 |
+
import { getConsoleUrl } from "@/src/config/console";
|
| 8 |
|
| 9 |
const geistSans = Geist({
|
| 10 |
variable: "--font-geist-sans",
|
|
|
|
| 29 |
}: Readonly<{
|
| 30 |
children: React.ReactNode;
|
| 31 |
}>) {
|
| 32 |
+
const consoleUrl = getConsoleUrl();
|
| 33 |
+
|
| 34 |
return (
|
| 35 |
<html lang="en">
|
| 36 |
<body
|
| 37 |
className={`${geistSans.variable} ${geistMono.variable} antialiased flex flex-col min-h-screen`}
|
| 38 |
>
|
| 39 |
+
<Navigation showNews={hasNews()} consoleUrl={consoleUrl} />
|
| 40 |
<main className="flex-1">
|
| 41 |
{children}
|
| 42 |
</main>
|
| 43 |
+
<Footer consoleUrl={consoleUrl} />
|
| 44 |
{/* Self-hosted Umami on franzia. data-domains is an allowlist: any other
|
| 45 |
host serving this build (the Hugging Face Space mirror, a local dev
|
| 46 |
server) loads the script but reports nothing. */}
|
src/components/Footer.tsx
CHANGED
|
@@ -2,7 +2,16 @@
|
|
| 2 |
|
| 3 |
import Link from 'next/link';
|
| 4 |
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
const currentYear = new Date().getFullYear();
|
| 7 |
|
| 8 |
return (
|
|
@@ -12,7 +21,15 @@ export default function Footer() {
|
|
| 12 |
<div className="text-sm text-gray-500">
|
| 13 |
Β© {currentYear} TS-Arena. All rights reserved.
|
| 14 |
</div>
|
| 15 |
-
<div className="flex
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
<Link
|
| 17 |
href="/impressum"
|
| 18 |
className="text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
|
|
|
| 2 |
|
| 3 |
import Link from 'next/link';
|
| 4 |
|
| 5 |
+
interface FooterProps {
|
| 6 |
+
/**
|
| 7 |
+
* Absolute URL of the user console, or null if this instance has no
|
| 8 |
+
* console. Resolved on the server in the root layout β see
|
| 9 |
+
* `getConsoleUrl()`.
|
| 10 |
+
*/
|
| 11 |
+
consoleUrl?: string | null;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export default function Footer({ consoleUrl = null }: FooterProps) {
|
| 15 |
const currentYear = new Date().getFullYear();
|
| 16 |
|
| 17 |
return (
|
|
|
|
| 21 |
<div className="text-sm text-gray-500">
|
| 22 |
Β© {currentYear} TS-Arena. All rights reserved.
|
| 23 |
</div>
|
| 24 |
+
<div className="flex flex-wrap justify-center gap-x-6 gap-y-2">
|
| 25 |
+
{consoleUrl && (
|
| 26 |
+
<a
|
| 27 |
+
href={consoleUrl}
|
| 28 |
+
className="text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
| 29 |
+
>
|
| 30 |
+
Console
|
| 31 |
+
</a>
|
| 32 |
+
)}
|
| 33 |
<Link
|
| 34 |
href="/impressum"
|
| 35 |
className="text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
src/components/Navigation.tsx
CHANGED
|
@@ -12,12 +12,25 @@ interface NavigationProps {
|
|
| 12 |
* not have, and an empty "News" tab is worse than no tab.
|
| 13 |
*/
|
| 14 |
showNews?: boolean;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
}
|
| 16 |
|
| 17 |
-
export default function Navigation({ showNews = false }: NavigationProps) {
|
| 18 |
const pathname = usePathname();
|
| 19 |
|
| 20 |
-
const navItems = [
|
| 21 |
{ href: '/', label: 'Rankings' },
|
| 22 |
{ href: '/challenges', label: 'Challenges' },
|
| 23 |
{ href: '/models', label: 'Models' },
|
|
@@ -25,6 +38,7 @@ export default function Navigation({ showNews = false }: NavigationProps) {
|
|
| 25 |
{ href: '/backtesting-archive', label: 'Backtesting Archive' },
|
| 26 |
...(showNews ? [{ href: '/news', label: 'News' }] : []),
|
| 27 |
{ href: '/about', label: 'About' },
|
|
|
|
| 28 |
];
|
| 29 |
|
| 30 |
return (
|
|
@@ -41,15 +55,24 @@ export default function Navigation({ showNews = false }: NavigationProps) {
|
|
| 41 |
</div>
|
| 42 |
<div className="hidden lg:ml-6 lg:flex lg:space-x-8">
|
| 43 |
{navItems.map((item) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
const isActive = pathname === item.href;
|
| 45 |
return (
|
| 46 |
<Link
|
| 47 |
key={item.href}
|
| 48 |
href={item.href}
|
| 49 |
-
className={`
|
| 50 |
-
isActive
|
| 51 |
-
? 'border-blue-500 text-gray-900'
|
| 52 |
-
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
| 53 |
}`}
|
| 54 |
>
|
| 55 |
{item.label}
|
|
@@ -91,6 +114,22 @@ export default function Navigation({ showNews = false }: NavigationProps) {
|
|
| 91 |
{({ close }) => (
|
| 92 |
<div className="py-2">
|
| 93 |
{navItems.map((item) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
const isActive = pathname === item.href;
|
| 95 |
return (
|
| 96 |
<Link
|
|
@@ -98,10 +137,8 @@ export default function Navigation({ showNews = false }: NavigationProps) {
|
|
| 98 |
href={item.href}
|
| 99 |
onClick={() => close()}
|
| 100 |
aria-current={isActive ? 'page' : undefined}
|
| 101 |
-
className={`
|
| 102 |
-
isActive
|
| 103 |
-
? 'border-blue-500 bg-blue-50 text-gray-900'
|
| 104 |
-
: 'border-transparent text-gray-500 hover:border-gray-300 hover:bg-gray-50 hover:text-gray-700'
|
| 105 |
}`}
|
| 106 |
>
|
| 107 |
{item.label}
|
|
|
|
| 12 |
* not have, and an empty "News" tab is worse than no tab.
|
| 13 |
*/
|
| 14 |
showNews?: boolean;
|
| 15 |
+
/**
|
| 16 |
+
* Absolute URL of the user console, or null if this instance has no
|
| 17 |
+
* console. Also resolved in the root layout β see `getConsoleUrl()`.
|
| 18 |
+
*/
|
| 19 |
+
consoleUrl?: string | null;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
interface NavItem {
|
| 23 |
+
href: string;
|
| 24 |
+
label: string;
|
| 25 |
+
/** Renders a plain <a> and never matches the active state β the console
|
| 26 |
+
lives on another domain, so next/link and pathname do not apply. */
|
| 27 |
+
external?: boolean;
|
| 28 |
}
|
| 29 |
|
| 30 |
+
export default function Navigation({ showNews = false, consoleUrl = null }: NavigationProps) {
|
| 31 |
const pathname = usePathname();
|
| 32 |
|
| 33 |
+
const navItems: NavItem[] = [
|
| 34 |
{ href: '/', label: 'Rankings' },
|
| 35 |
{ href: '/challenges', label: 'Challenges' },
|
| 36 |
{ href: '/models', label: 'Models' },
|
|
|
|
| 38 |
{ href: '/backtesting-archive', label: 'Backtesting Archive' },
|
| 39 |
...(showNews ? [{ href: '/news', label: 'News' }] : []),
|
| 40 |
{ href: '/about', label: 'About' },
|
| 41 |
+
...(consoleUrl ? [{ href: consoleUrl, label: 'Console', external: true }] : []),
|
| 42 |
];
|
| 43 |
|
| 44 |
return (
|
|
|
|
| 55 |
</div>
|
| 56 |
<div className="hidden lg:ml-6 lg:flex lg:space-x-8">
|
| 57 |
{navItems.map((item) => {
|
| 58 |
+
const baseClasses = 'inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium transition-colors';
|
| 59 |
+
const inactiveClasses = 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700';
|
| 60 |
+
|
| 61 |
+
if (item.external) {
|
| 62 |
+
return (
|
| 63 |
+
<a key={item.href} href={item.href} className={`${baseClasses} ${inactiveClasses}`}>
|
| 64 |
+
{item.label}
|
| 65 |
+
</a>
|
| 66 |
+
);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
const isActive = pathname === item.href;
|
| 70 |
return (
|
| 71 |
<Link
|
| 72 |
key={item.href}
|
| 73 |
href={item.href}
|
| 74 |
+
className={`${baseClasses} ${
|
| 75 |
+
isActive ? 'border-blue-500 text-gray-900' : inactiveClasses
|
|
|
|
|
|
|
| 76 |
}`}
|
| 77 |
>
|
| 78 |
{item.label}
|
|
|
|
| 114 |
{({ close }) => (
|
| 115 |
<div className="py-2">
|
| 116 |
{navItems.map((item) => {
|
| 117 |
+
const baseClasses = 'flex items-center min-h-[44px] py-2 pl-3 pr-4 border-l-4 text-base font-medium transition-colors';
|
| 118 |
+
const inactiveClasses = 'border-transparent text-gray-500 hover:border-gray-300 hover:bg-gray-50 hover:text-gray-700';
|
| 119 |
+
|
| 120 |
+
if (item.external) {
|
| 121 |
+
return (
|
| 122 |
+
<a
|
| 123 |
+
key={item.href}
|
| 124 |
+
href={item.href}
|
| 125 |
+
onClick={() => close()}
|
| 126 |
+
className={`${baseClasses} ${inactiveClasses}`}
|
| 127 |
+
>
|
| 128 |
+
{item.label}
|
| 129 |
+
</a>
|
| 130 |
+
);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
const isActive = pathname === item.href;
|
| 134 |
return (
|
| 135 |
<Link
|
|
|
|
| 137 |
href={item.href}
|
| 138 |
onClick={() => close()}
|
| 139 |
aria-current={isActive ? 'page' : undefined}
|
| 140 |
+
className={`${baseClasses} ${
|
| 141 |
+
isActive ? 'border-blue-500 bg-blue-50 text-gray-900' : inactiveClasses
|
|
|
|
|
|
|
| 142 |
}`}
|
| 143 |
>
|
| 144 |
{item.label}
|
src/config/console.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* The user console is a separate deployment on its own domain
|
| 3 |
+
* (`console.ts-arena.live` for TS-Arena itself), so its URL differs per
|
| 4 |
+
* environment and must not be hardcoded β a baked-in prod URL would send
|
| 5 |
+
* anyone testing the dev site to the production console.
|
| 6 |
+
*
|
| 7 |
+
* Resolved from `CONSOLE_URL` on the server and passed down as a prop, the
|
| 8 |
+
* same way `hasNews()` is. Note that the pages carrying the console links
|
| 9 |
+
* (`/`, `/add-model`) are statically prerendered, so this is read at *build*
|
| 10 |
+
* time β on Coolify `CONSOLE_URL` has to be marked as a build variable, like
|
| 11 |
+
* `NEWS_CONTENT_REPO`, or the build will not see it.
|
| 12 |
+
*
|
| 13 |
+
* Unset means this instance has no console β every console entry point then
|
| 14 |
+
* disappears and `/add-model` falls back to the email route. That is the
|
| 15 |
+
* right default for a fork: the console is a separate service a self-hoster
|
| 16 |
+
* may well not run.
|
| 17 |
+
*/
|
| 18 |
+
export function getConsoleUrl(): string | null {
|
| 19 |
+
const url = process.env.CONSOLE_URL?.trim();
|
| 20 |
+
if (!url) return null;
|
| 21 |
+
return url.replace(/\/+$/, '');
|
| 22 |
+
}
|