mmcux commited on
Commit
d710c29
Β·
1 Parent(s): 009cee1

Link the user console from the nav, footer and add-model page

Browse files

Participants 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 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
- Simply send us an email and we&apos;ll provide you with an API key to get started. Find detailed participation instructions in our{' '}
23
- <a
24
- href="https://github.com/DAG-UPB/ts-arena"
25
- target="_blank"
 
 
 
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
- <a
35
- 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"
36
- 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"
37
- >
38
- <Mail className="w-5 h-5" />
39
- Request API Key
40
- </a>
41
- <a
 
 
 
 
 
 
 
 
 
 
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
- <h3 className="font-medium text-gray-900 mb-1">Request an API Key</h3>
61
- <p className="text-sm text-gray-600">
62
- Email us with your organization name and we&apos;ll send you an API key.
63
- </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- <span className="text-gray-700">Valid organization or affiliation</span>
 
 
 
 
 
 
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&apos;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
- export default function Footer() {
 
 
 
 
 
 
 
 
 
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 space-x-6">
 
 
 
 
 
 
 
 
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={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium transition-colors ${
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={`flex items-center min-h-[44px] py-2 pl-3 pr-4 border-l-4 text-base font-medium transition-colors ${
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
+ }