Suchinthana Wijesundara commited on
Commit
f64a3bf
·
1 Parent(s): c66c9dd

Init commit with project codes

Browse files
.dockerignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .next
3
+ node_modules
4
+ npm-debug.log
5
+ .env
6
+ .env.*
7
+ !.env.example
8
+ README.md
.env.example ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # Leave both BigQuery values empty to use the local in-memory demo directory.
2
+ BIGQUERY_PROJECT_ID=
3
+ BIGQUERY_DATASET=
4
+
5
+ # Public canonical site URL, used for metadata and sitemap generation.
6
+ NEXT_PUBLIC_SITE_URL=http://localhost:3000
.gitignore ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ lerna-debug.log*
8
+ .pnpm-debug.log*
9
+
10
+ # Diagnostic reports (https://nodejs.org/api/report.html)
11
+ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
12
+
13
+ # Runtime data
14
+ pids
15
+ *.pid
16
+ *.seed
17
+ *.pid.lock
18
+
19
+ # Directory for instrumented libs generated by jscoverage/JSCover
20
+ lib-cov
21
+
22
+ # Coverage directory used by tools like istanbul
23
+ coverage
24
+ *.lcov
25
+
26
+ # nyc test coverage
27
+ .nyc_output
28
+
29
+ # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
30
+ .grunt
31
+
32
+ # Bower dependency directory (https://bower.io/)
33
+ bower_components
34
+
35
+ # node-waf configuration
36
+ .lock-wscript
37
+
38
+ # Compiled binary addons (https://nodejs.org/api/addons.html)
39
+ build/Release
40
+
41
+ # Dependency directories
42
+ node_modules/
43
+ jspm_packages/
44
+
45
+ # Snowpack dependency directory (https://snowpack.dev/)
46
+ web_modules/
47
+
48
+ # TypeScript cache
49
+ *.tsbuildinfo
50
+
51
+ # Optional npm cache directory
52
+ .npm
53
+
54
+ # Optional eslint cache
55
+ .eslintcache
56
+
57
+ # Optional stylelint cache
58
+ .stylelintcache
59
+
60
+ # Microbundle cache
61
+ .rpt2_cache/
62
+ .rts2_cache_cjs/
63
+ .rts2_cache_es/
64
+ .rts2_cache_umd/
65
+
66
+ # Optional REPL history
67
+ .node_repl_history
68
+
69
+ # Output of 'npm pack'
70
+ *.tgz
71
+
72
+ # Yarn Integrity file
73
+ .yarn-integrity
74
+
75
+ # dotenv environment variable files
76
+ .env*
77
+ !.env.example
78
+
79
+ # parcel-bundler cache (https://parceljs.org/)
80
+ .cache
81
+ .parcel-cache
82
+
83
+ # Next.js build output
84
+ .next
85
+ out
86
+
87
+ # Nuxt.js build / generate output
88
+ .nuxt
89
+ dist
90
+
91
+ # Gatsby files
92
+ .cache/
93
+ # Comment in the public line in if your project uses Gatsby and not Next.js
94
+ # https://nextjs.org/blog/next-9-1#public-directory-support
95
+ # public
96
+
97
+ # vuepress build output
98
+ .vuepress/dist
99
+
100
+ # vuepress v2.x temp and cache directory
101
+ .temp
102
+ .cache
103
+
104
+ # vitepress build output
105
+ **/.vitepress/dist
106
+
107
+ # vitepress cache directory
108
+ **/.vitepress/cache
109
+
110
+ # Docusaurus cache and generated files
111
+ .docusaurus
112
+
113
+ # Serverless directories
114
+ .serverless/
115
+
116
+ # FuseBox cache
117
+ .fusebox/
118
+
119
+ # DynamoDB Local files
120
+ .dynamodb/
121
+
122
+ # TernJS port file
123
+ .tern-port
124
+
125
+ # Stores VSCode versions used for testing VSCode extensions
126
+ .vscode-test
127
+
128
+ # yarn v2
129
+ .yarn/cache
130
+ .yarn/unplugged
131
+ .yarn/build-state.yml
132
+ .yarn/install-state.gz
133
+ .pnp.*
AGENTS.md ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ <!-- BEGIN:nextjs-agent-rules -->
2
+
3
+ ## This is NOT the Next.js you know
4
+
5
+ This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
6
+
7
+ This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
8
+
9
+ <!-- END:nextjs-agent-rules -->
Dockerfile ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:22-alpine AS dependencies
2
+ WORKDIR /app
3
+ COPY package.json package-lock.json ./
4
+ RUN npm ci
5
+
6
+ FROM node:22-alpine AS builder
7
+ WORKDIR /app
8
+ ENV NEXT_TELEMETRY_DISABLED=1
9
+ COPY --from=dependencies /app/node_modules ./node_modules
10
+ COPY . .
11
+ RUN npm run build
12
+
13
+ FROM node:22-alpine AS runner
14
+ WORKDIR /app
15
+ ENV NODE_ENV=production
16
+ ENV NEXT_TELEMETRY_DISABLED=1
17
+ ENV PORT=8080
18
+ ENV HOSTNAME=0.0.0.0
19
+ COPY --from=builder /app/public ./public
20
+ COPY --from=builder /app/.next/standalone ./
21
+ COPY --from=builder /app/.next/static ./.next/static
22
+ RUN chown -R node:node /app
23
+ USER node
24
+ EXPOSE 8080
25
+ CMD ["node", "server.js"]
README.md ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Chainkit
2
+
3
+ Chainkit is an SEO-friendly directory for discovering and sharing public, open-source AI tools used in supply chain planning, procurement, logistics, warehousing, and visibility. Tool detail pages are rendered on the server and included in the generated sitemap.
4
+
5
+ ## Features
6
+
7
+ - Search and filter the directory by supply-chain category.
8
+ - Sort tools by rating, review count, and recency.
9
+ - Submit a project using its public GitHub repository URL.
10
+ - Rate tools from one to five stars; a browser can update its own rating.
11
+ - Per-tool metadata, canonical URLs, Open Graph tags, `robots.txt`, and `sitemap.xml`.
12
+ - BigQuery persistence on Google Cloud, with an in-memory sample directory for local development.
13
+ - Container deployment to Cloud Run.
14
+
15
+ The local demo directory uses real public GitHub repository links as examples; they are marked as starter data and have no pre-filled community ratings.
16
+
17
+ ## Run locally
18
+
19
+ Requirements: Node.js 22+ and npm.
20
+
21
+ ```sh
22
+ npm ci
23
+ copy .env.example .env.local
24
+ npm run dev
25
+ ```
26
+
27
+ Open [http://localhost:3000](http://localhost:3000). Without BigQuery settings the app starts in local demo mode. Local submissions and ratings are held in server memory and reset when the server restarts.
28
+
29
+ ## BigQuery
30
+
31
+ 1. Create a BigQuery dataset in the region you plan to use.
32
+ 2. Replace `YOUR_PROJECT_ID` and `YOUR_DATASET` in `infra/bigquery_schema.sql`, then run the SQL in the BigQuery console.
33
+ 3. Configure Application Default Credentials locally, or attach a runtime service account to Cloud Run.
34
+ 4. Set `BIGQUERY_PROJECT_ID`, `BIGQUERY_DATASET`, and `NEXT_PUBLIC_SITE_URL`.
35
+
36
+ The runtime service account needs `roles/bigquery.jobUser` on the project and `roles/bigquery.dataEditor` on the dataset. The app uses parameterized values for BigQuery writes; project and dataset identifiers are validated before being inserted into query text.
37
+
38
+ ## Deploy to Cloud Run
39
+
40
+ Build and deploy the container from the repository root. Supply your canonical
41
+ domain if you already have one; otherwise omit `NEXT_PUBLIC_SITE_URL` on the
42
+ first deployment:
43
+
44
+ ```sh
45
+ gcloud run deploy chainkit \
46
+ --source . \
47
+ --region us-central1 \
48
+ --allow-unauthenticated \
49
+ --service-account chainkit-runtime@YOUR_PROJECT_ID.iam.gserviceaccount.com \
50
+ --set-env-vars BIGQUERY_PROJECT_ID=YOUR_PROJECT_ID,BIGQUERY_DATASET=YOUR_DATASET
51
+ ```
52
+
53
+ After deployment, set `NEXT_PUBLIC_SITE_URL` to the Cloud Run URL or your
54
+ canonical domain so canonical links and the sitemap match the live site:
55
+
56
+ ```sh
57
+ gcloud run services describe chainkit --region us-central1 --format="value(status.url)"
58
+ gcloud run services update chainkit \
59
+ --region us-central1 \
60
+ --update-env-vars NEXT_PUBLIC_SITE_URL=https://YOUR_CANONICAL_DOMAIN
61
+ ```
62
+
63
+ The deployer needs permission to build/deploy the service and to act as the
64
+ runtime service account.
65
+
66
+ ## Important production considerations
67
+
68
+ - Submissions are immediately public in this starter. Add moderation or an approval workflow before opening submissions to an untrusted audience.
69
+ - Ratings use a random browser-local identifier, not an authenticated user identity. It supports repeat edits from the same browser but is not abuse-resistant; add sign-in and rate limiting before treating ratings as verified.
70
+ - The repository URL is validated and stored as a link. Chainkit does not clone, fetch, or execute submitted code.
71
+ - BigQuery is optimized for analytics rather than high-volume transactional writes. For substantial rating or submission traffic, consider a transactional store and retain BigQuery for analytics.
72
+
73
+ ## Project structure
74
+
75
+ ```text
76
+ src/app/ Next.js pages, SEO routes, and API endpoints
77
+ src/lib/tools.ts BigQuery adapter and local demo data
78
+ infra/ BigQuery table schema
79
+ Dockerfile Cloud Run container
80
+ ```
eslint.config.mjs ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import nextVitals from "eslint-config-next/core-web-vitals";
3
+ import nextTs from "eslint-config-next/typescript";
4
+
5
+ const eslintConfig = defineConfig([
6
+ ...nextVitals,
7
+ ...nextTs,
8
+ // Override default ignores of eslint-config-next.
9
+ globalIgnores([
10
+ // Default ignores of eslint-config-next:
11
+ ".next/**",
12
+ "out/**",
13
+ "build/**",
14
+ "next-env.d.ts",
15
+ ]),
16
+ ]);
17
+
18
+ export default eslintConfig;
infra/bigquery_schema.sql ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ CREATE TABLE IF NOT EXISTS `YOUR_PROJECT_ID.YOUR_DATASET.tools` (
2
+ tool_id STRING NOT NULL,
3
+ slug STRING NOT NULL,
4
+ name STRING NOT NULL,
5
+ summary STRING NOT NULL,
6
+ category STRING NOT NULL,
7
+ repo_url STRING NOT NULL,
8
+ website_url STRING,
9
+ submitted_by STRING NOT NULL,
10
+ created_at TIMESTAMP NOT NULL
11
+ )
12
+ PARTITION BY DATE(created_at)
13
+ CLUSTER BY category, slug;
14
+
15
+ CREATE TABLE IF NOT EXISTS `YOUR_PROJECT_ID.YOUR_DATASET.tool_ratings` (
16
+ tool_id STRING NOT NULL,
17
+ voter_id STRING NOT NULL,
18
+ rating INT64 NOT NULL,
19
+ created_at TIMESTAMP NOT NULL,
20
+ updated_at TIMESTAMP NOT NULL
21
+ )
22
+ PARTITION BY DATE(created_at)
23
+ CLUSTER BY tool_id, voter_id;
next-env.d.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+ import "./.next/types/routes.d.ts";
4
+ import "./.next/types/root-params.d.ts";
5
+
6
+ // NOTE: This file should not be edited
7
+ // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
next.config.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ output: "standalone",
5
+ turbopack: {
6
+ rules: {
7
+ "*.css": {
8
+ loaders: ["@tailwindcss/turbopack"],
9
+ as: "*.css",
10
+ },
11
+ },
12
+ },
13
+ };
14
+
15
+ export default nextConfig;
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "chainkit",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "prestart": "node scripts/prepare-standalone.mjs",
9
+ "start": "node .next/standalone/server.js",
10
+ "lint": "eslint",
11
+ "typecheck": "tsc --noEmit"
12
+ },
13
+ "dependencies": {
14
+ "@google-cloud/bigquery": "^9.6.0",
15
+ "lucide-react": "^1.70.0",
16
+ "next": "16.4.5",
17
+ "react": "19.3.0",
18
+ "react-dom": "19.3.0",
19
+ "zod": "^4.6.7"
20
+ },
21
+ "devDependencies": {
22
+ "@tailwindcss/turbopack": "^4",
23
+ "@types/node": "^20",
24
+ "@types/react": "^19",
25
+ "@types/react-dom": "^19",
26
+ "eslint": "^9",
27
+ "eslint-config-next": "16.4.5",
28
+ "tailwindcss": "^4",
29
+ "typescript": "^5"
30
+ }
31
+ }
public/file.svg ADDED
public/globe.svg ADDED
public/next.svg ADDED
public/vercel.svg ADDED
public/window.svg ADDED
scripts/prepare-standalone.mjs ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import { cp, mkdir } from "node:fs/promises";
2
+
3
+ for (const path of ["public", ".next/static"]) {
4
+ const destination = `.next/standalone/${path}`;
5
+ await mkdir(destination, { recursive: true });
6
+ await cp(path, destination, { recursive: true, force: true });
7
+ }
src/app/api/ratings/route.ts ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from "next/server";
2
+ import { z } from "zod";
3
+ import { rateTool } from "@/lib/tools";
4
+
5
+ const ratingSchema = z.object({
6
+ toolId: z.string().min(1).max(80),
7
+ voterId: z.string().uuid(),
8
+ rating: z.number().int().min(1).max(5),
9
+ });
10
+
11
+ export async function POST(request: Request) {
12
+ let body: unknown;
13
+ try {
14
+ body = await request.json();
15
+ } catch {
16
+ return NextResponse.json({ error: "Request body must be valid JSON." }, { status: 400 });
17
+ }
18
+
19
+ const parsed = ratingSchema.safeParse(body);
20
+ if (!parsed.success) {
21
+ return NextResponse.json({ error: "A valid tool and a 1–5 star rating are required." }, { status: 400 });
22
+ }
23
+
24
+ try {
25
+ const tool = await rateTool(
26
+ parsed.data.toolId,
27
+ parsed.data.voterId,
28
+ parsed.data.rating,
29
+ );
30
+ if (!tool) {
31
+ return NextResponse.json({ error: "Tool not found." }, { status: 404 });
32
+ }
33
+ return NextResponse.json({
34
+ averageRating: tool.averageRating,
35
+ ratingCount: tool.ratingCount,
36
+ });
37
+ } catch (error) {
38
+ console.error("Tool rating failed", error);
39
+ return NextResponse.json(
40
+ { error: "Your rating could not be saved. Please try again later." },
41
+ { status: 500 },
42
+ );
43
+ }
44
+ }
src/app/api/tools/route.ts ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from "next/server";
2
+ import { z } from "zod";
3
+ import { categories } from "@/lib/tool-types";
4
+ import { submitTool } from "@/lib/tools";
5
+
6
+ const githubRepoUrl = z
7
+ .string()
8
+ .url()
9
+ .refine((value) => {
10
+ try {
11
+ const url = new URL(value);
12
+ return (
13
+ url.protocol === "https:" &&
14
+ url.hostname === "github.com" &&
15
+ !url.username &&
16
+ !url.password &&
17
+ !url.search &&
18
+ !url.hash &&
19
+ /^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?\/?$/.test(url.pathname)
20
+ );
21
+ } catch {
22
+ return false;
23
+ }
24
+ }, "Enter a public GitHub repository URL.");
25
+
26
+ const optionalWebsiteUrl = z
27
+ .union([z.literal(""), z.string().url()])
28
+ .refine((value) => {
29
+ if (!value) return true;
30
+ const url = new URL(value);
31
+ return url.protocol === "https:" && !url.username && !url.password;
32
+ }, "Website must be a public HTTPS URL.");
33
+
34
+ const submissionSchema = z.object({
35
+ name: z.string().trim().min(2).max(80),
36
+ summary: z.string().trim().min(20).max(300),
37
+ category: z.enum(categories),
38
+ repoUrl: githubRepoUrl,
39
+ websiteUrl: optionalWebsiteUrl,
40
+ submittedBy: z.string().trim().min(2).max(60),
41
+ });
42
+
43
+ export async function POST(request: Request) {
44
+ let body: unknown;
45
+ try {
46
+ body = await request.json();
47
+ } catch {
48
+ return NextResponse.json({ error: "Request body must be valid JSON." }, { status: 400 });
49
+ }
50
+
51
+ const parsed = submissionSchema.safeParse(body);
52
+ if (!parsed.success) {
53
+ return NextResponse.json(
54
+ { error: "Please review the submission fields and try again.", fields: parsed.error.flatten().fieldErrors },
55
+ { status: 400 },
56
+ );
57
+ }
58
+
59
+ try {
60
+ const tool = await submitTool(parsed.data);
61
+ return NextResponse.json({ tool }, { status: 201 });
62
+ } catch (error) {
63
+ console.error("Tool submission failed", error);
64
+ return NextResponse.json(
65
+ { error: "The tool could not be saved. Please try again later." },
66
+ { status: 500 },
67
+ );
68
+ }
69
+ }
src/app/directory.tsx ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useEffect, useMemo, useRef, useState } from "react";
5
+ import {
6
+ ArrowDownUp,
7
+ ArrowRight,
8
+ ArrowUpRight,
9
+ Boxes,
10
+ Check,
11
+ ChevronDown,
12
+ CircleHelp,
13
+ GitFork,
14
+ Globe2,
15
+ Moon,
16
+ Plus,
17
+ Search,
18
+ Sparkles,
19
+ Sun,
20
+ X,
21
+ } from "lucide-react";
22
+ import { categories, type SupplyChainTool, type ToolCategory } from "@/lib/tool-types";
23
+ import { ToolRating } from "@/app/tool-rating";
24
+
25
+ type Props = { initialTools: SupplyChainTool[] };
26
+
27
+ type FormFields = {
28
+ name: string;
29
+ summary: string;
30
+ category: ToolCategory;
31
+ repoUrl: string;
32
+ websiteUrl: string;
33
+ submittedBy: string;
34
+ };
35
+
36
+ function ThemeToggle() {
37
+ useEffect(() => {
38
+ const savedTheme = window.localStorage.getItem("chainkit-theme");
39
+ const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
40
+ const shouldUseDark = savedTheme ? savedTheme === "dark" : prefersDark;
41
+ document.documentElement.classList.toggle("theme-dark", shouldUseDark);
42
+ }, []);
43
+
44
+ function toggleTheme() {
45
+ const nextDark = !document.documentElement.classList.contains("theme-dark");
46
+ document.documentElement.classList.toggle("theme-dark", nextDark);
47
+ window.localStorage.setItem("chainkit-theme", nextDark ? "dark" : "light");
48
+ }
49
+
50
+ return (
51
+ <button className="theme-toggle" onClick={toggleTheme} aria-label="Toggle color theme">
52
+ <Moon size={16} className="moon-icon" />
53
+ <Sun size={16} className="sun-icon" />
54
+ </button>
55
+ );
56
+ }
57
+
58
+ const emptyForm: FormFields = {
59
+ name: "",
60
+ summary: "",
61
+ category: "Planning",
62
+ repoUrl: "",
63
+ websiteUrl: "",
64
+ submittedBy: "",
65
+ };
66
+
67
+ export function Directory({ initialTools }: Props) {
68
+ const [tools, setTools] = useState(initialTools);
69
+ const [search, setSearch] = useState("");
70
+ const searchRef = useRef<HTMLInputElement>(null);
71
+ const [category, setCategory] = useState("All tools");
72
+ const [sort, setSort] = useState("Featured");
73
+ const [showForm, setShowForm] = useState(false);
74
+ const [form, setForm] = useState(emptyForm);
75
+ const [submitting, setSubmitting] = useState(false);
76
+ const [submitError, setSubmitError] = useState("");
77
+ const [submitted, setSubmitted] = useState(false);
78
+
79
+ useEffect(() => {
80
+ function focusSearch(event: KeyboardEvent) {
81
+ const activeElement = document.activeElement;
82
+ const isEditing =
83
+ activeElement instanceof HTMLElement &&
84
+ (activeElement.isContentEditable ||
85
+ ["INPUT", "TEXTAREA", "SELECT"].includes(activeElement.tagName));
86
+ if (event.key === "/" && !isEditing) {
87
+ event.preventDefault();
88
+ searchRef.current?.focus();
89
+ }
90
+ }
91
+
92
+ window.addEventListener("keydown", focusSearch);
93
+ return () => window.removeEventListener("keydown", focusSearch);
94
+ }, []);
95
+
96
+ const filteredTools = useMemo(() => {
97
+ const query = search.trim().toLowerCase();
98
+ const filtered = tools.filter((tool) => {
99
+ const matchesCategory = category === "All tools" || tool.category === category;
100
+ const matchesQuery =
101
+ !query ||
102
+ [tool.name, tool.summary, tool.category, tool.repoUrl]
103
+ .join(" ")
104
+ .toLowerCase()
105
+ .includes(query);
106
+ return matchesCategory && matchesQuery;
107
+ });
108
+
109
+ return [...filtered].sort((a, b) => {
110
+ if (sort === "Top rated") return b.averageRating - a.averageRating;
111
+ if (sort === "Most reviewed") return b.ratingCount - a.ratingCount;
112
+ if (sort === "Recently added")
113
+ return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
114
+ return b.ratingCount - a.ratingCount || b.averageRating - a.averageRating;
115
+ });
116
+ }, [category, search, sort, tools]);
117
+
118
+ async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
119
+ event.preventDefault();
120
+ setSubmitting(true);
121
+ setSubmitError("");
122
+ try {
123
+ const response = await fetch("/api/tools", {
124
+ method: "POST",
125
+ headers: { "Content-Type": "application/json" },
126
+ body: JSON.stringify(form),
127
+ });
128
+ const data = (await response.json()) as {
129
+ tool?: SupplyChainTool;
130
+ error?: string;
131
+ fields?: Record<string, string[] | undefined>;
132
+ };
133
+ if (!response.ok) {
134
+ const detail = Object.values(data.fields ?? {}).flat().find(Boolean);
135
+ throw new Error(detail || data.error || "Your submission could not be saved.");
136
+ }
137
+ const newTool = data.tool;
138
+ if (newTool) {
139
+ setTools((current) => [newTool, ...current]);
140
+ setSearch("");
141
+ setCategory("All tools");
142
+ setSort("Recently added");
143
+ }
144
+ setSubmitted(true);
145
+ setForm(emptyForm);
146
+ } catch (error) {
147
+ setSubmitError(error instanceof Error ? error.message : "Something went wrong. Please try again.");
148
+ } finally {
149
+ setSubmitting(false);
150
+ }
151
+ }
152
+
153
+ const updateForm = <K extends keyof FormFields>(key: K, value: FormFields[K]) =>
154
+ setForm((current) => ({ ...current, [key]: value }));
155
+
156
+ return (
157
+ <>
158
+ <header className="site-header">
159
+ <div className="header-inner">
160
+ <Link className="brand" href="/" aria-label="Chainkit home">
161
+ <span className="brand-icon"><Boxes size={19} strokeWidth={2.2} /></span>
162
+ <span>chainkit<span className="brand-period">.</span></span>
163
+ </Link>
164
+ <nav className="header-nav" aria-label="Main navigation">
165
+ <a href="#directory" className="nav-link is-active">Explore tools</a>
166
+ <a href="#how-it-works" className="nav-link">About</a>
167
+ </nav>
168
+ <div className="header-actions">
169
+ <ThemeToggle />
170
+ <button className="button button-small button-primary header-submit" onClick={() => { setShowForm(true); setSubmitted(false); }}>
171
+ <Plus size={16} aria-hidden="true" /> Submit a tool
172
+ </button>
173
+ </div>
174
+ </div>
175
+ </header>
176
+
177
+ <main>
178
+ <section className="hero">
179
+ <div className="hero-glow" aria-hidden="true" />
180
+ <div className="hero-content">
181
+ <div className="eyebrow"><span className="eyebrow-dot" /> THE OPEN SUPPLY CHAIN AI INDEX</div>
182
+ <h1>Find the AI tools<br />moving supply chains <span>forward.</span></h1>
183
+ <p className="hero-copy">
184
+ A community-built directory of open-source AI projects for planning, procurement, logistics, and beyond.
185
+ </p>
186
+ <div className="hero-actions">
187
+ <a className="button button-primary hero-cta" href="#directory">
188
+ Explore the directory <ArrowRight size={16} aria-hidden="true" />
189
+ </a>
190
+ <button className="button button-quiet" onClick={() => { setShowForm(true); setSubmitted(false); }}>
191
+ <Plus size={16} aria-hidden="true" /> Add your project
192
+ </button>
193
+ </div>
194
+ <div className="hero-proof">
195
+ <div className="avatar-stack" aria-hidden="true">
196
+ <span>AI</span><span>SC</span><span>ML</span><span>+</span>
197
+ </div>
198
+ <span>Built by practitioners, for practitioners</span>
199
+ </div>
200
+ </div>
201
+ <div className="hero-art" aria-hidden="true">
202
+ <div className="art-orbit orbit-outer" />
203
+ <div className="art-orbit orbit-inner" />
204
+ <div className="art-core"><Sparkles size={26} /></div>
205
+ <div className="art-node node-top"><span className="node-mark"><Sparkles size={15} /></span><span>AI PLANNING</span></div>
206
+ <div className="art-node node-right"><span className="node-mark"><GitFork size={15} /></span><span>OPEN SOURCE</span></div>
207
+ <div className="art-node node-bottom"><span className="node-mark"><Globe2 size={15} /></span><span>GLOBAL NETWORK</span></div>
208
+ <div className="art-node node-left"><span className="node-mark"><Boxes size={15} /></span><span>SUPPLY CHAIN</span></div>
209
+ </div>
210
+ <div className="hero-bottom-line">
211
+ <span>Discover what’s being built</span>
212
+ <span className="line" />
213
+ <span>01 / 05</span>
214
+ </div>
215
+ </section>
216
+
217
+ <section className="directory-section" id="directory">
218
+ <div className="section-heading">
219
+ <div>
220
+ <div className="section-kicker">THE DIRECTORY</div>
221
+ <h2>Explore the ecosystem<span className="heading-dot">.</span></h2>
222
+ <p>Find your next building block in the supply chain AI landscape.</p>
223
+ </div>
224
+ <div className="directory-count">
225
+ <span className="count-number">{tools.length.toString().padStart(2, "0")}</span>
226
+ <span>indexed<br />projects</span>
227
+ </div>
228
+ </div>
229
+
230
+ <div className="directory-toolbar">
231
+ <div className="search-field">
232
+ <Search size={17} aria-hidden="true" />
233
+ <input
234
+ ref={searchRef}
235
+ type="search"
236
+ placeholder="Search projects, use cases..."
237
+ value={search}
238
+ onChange={(event) => setSearch(event.target.value)}
239
+ aria-label="Search projects"
240
+ />
241
+ <kbd>/</kbd>
242
+ </div>
243
+ <label className="sort-control">
244
+ <ArrowDownUp size={15} aria-hidden="true" />
245
+ <span className="sort-label">Sort:</span>
246
+ <select value={sort} onChange={(event) => setSort(event.target.value)} aria-label="Sort projects">
247
+ <option>Featured</option>
248
+ <option>Top rated</option>
249
+ <option>Most reviewed</option>
250
+ <option>Recently added</option>
251
+ </select>
252
+ <ChevronDown size={14} className="select-chevron" aria-hidden="true" />
253
+ </label>
254
+ </div>
255
+
256
+ <div className="category-list" role="group" aria-label="Filter by category">
257
+ {["All tools", ...categories].map((item) => (
258
+ <button
259
+ className={`category-tab${category === item ? " selected" : ""}`}
260
+ key={item}
261
+ onClick={() => setCategory(item)}
262
+ aria-pressed={category === item}
263
+ >
264
+ {item}
265
+ {item === "All tools" ? <span className="tab-count">{tools.length}</span> : null}
266
+ </button>
267
+ ))}
268
+ </div>
269
+
270
+ {filteredTools.length ? (
271
+ <div className="tool-grid">
272
+ {filteredTools.map((tool, index) => (
273
+ <article className="tool-card" key={tool.id}>
274
+ <div className="card-top">
275
+ <span className={`tool-mark mark-${index % 5}`} aria-hidden="true">
276
+ {index % 5 === 0 ? <Sparkles size={18} /> : index % 5 === 1 ? <GitFork size={18} /> : index % 5 === 2 ? <Boxes size={18} /> : index % 5 === 3 ? <Globe2 size={18} /> : <CircleHelp size={18} />}
277
+ </span>
278
+ <span className="category-chip">{tool.category}</span>
279
+ <a className="external-icon" href={tool.repoUrl} target="_blank" rel="noopener noreferrer" aria-label={`Open ${tool.name} GitHub repository`}>
280
+ <ArrowUpRight size={17} />
281
+ </a>
282
+ </div>
283
+ <Link className="card-title-link" href={`/tools/${tool.slug}`}>
284
+ <h3>{tool.name}</h3>
285
+ </Link>
286
+ <p className="card-summary">{tool.summary}</p>
287
+ <div className="repo-line">
288
+ <span className="github-mark" aria-hidden="true">⌘</span>
289
+ <span>{tool.repoUrl.replace("https://github.com/", "").replace(/\/$/, "")}</span>
290
+ </div>
291
+ <div className="card-footer">
292
+ <ToolRating
293
+ toolId={tool.id}
294
+ initialRating={tool.averageRating}
295
+ initialCount={tool.ratingCount}
296
+ />
297
+ <Link href={`/tools/${tool.slug}`} className="card-view-link">
298
+ View details <ArrowRight size={14} />
299
+ </Link>
300
+ </div>
301
+ </article>
302
+ ))}
303
+ </div>
304
+ ) : (
305
+ <div className="empty-state">
306
+ <Search size={22} />
307
+ <h3>No matching projects</h3>
308
+ <p>Try a different search or category.</p>
309
+ <button className="button button-secondary" onClick={() => { setSearch(""); setCategory("All tools"); }}>
310
+ Clear filters
311
+ </button>
312
+ </div>
313
+ )}
314
+ <div className="directory-foot">
315
+ <span>Showing <strong>{filteredTools.length}</strong> of <strong>{tools.length}</strong> projects</span>
316
+ <button className="button button-outline" onClick={() => { setShowForm(true); setSubmitted(false); }}>
317
+ Share a project <ArrowUpRight size={15} />
318
+ </button>
319
+ </div>
320
+ </section>
321
+
322
+ <section className="community-section" id="how-it-works">
323
+ <div className="community-icon"><Sparkles size={19} /></div>
324
+ <div className="community-copy">
325
+ <div className="section-kicker">A BETTER SUPPLY CHAIN, TOGETHER</div>
326
+ <h2>Good tools deserve to be found.</h2>
327
+ <p>Submit an open-source project, share what works, and help the community find its next idea.</p>
328
+ </div>
329
+ <button className="button button-primary community-cta" onClick={() => { setShowForm(true); setSubmitted(false); }}>
330
+ Submit a tool <ArrowUpRight size={16} />
331
+ </button>
332
+ </section>
333
+ </main>
334
+
335
+ <footer className="site-footer">
336
+ <Link className="brand footer-brand" href="/">
337
+ <span className="brand-icon"><Boxes size={17} /></span>
338
+ <span>chainkit<span className="brand-period">.</span></span>
339
+ </Link>
340
+ <span>Open source meets the supply chain.</span>
341
+ <a href="https://github.com/" target="_blank" rel="noopener noreferrer">Built for the community <ArrowUpRight size={13} /></a>
342
+ </footer>
343
+
344
+ {showForm ? (
345
+ <div
346
+ className="modal-backdrop"
347
+ onKeyDown={(event) => { if (event.key === "Escape") setShowForm(false); }}
348
+ onMouseDown={(event) => { if (event.target === event.currentTarget) setShowForm(false); }}
349
+ >
350
+ <section className="submit-modal" role="dialog" aria-modal="true" aria-labelledby="modal-heading">
351
+ <button className="modal-close" onClick={() => setShowForm(false)} aria-label="Close submission form">
352
+ <X size={19} />
353
+ </button>
354
+ {submitted ? (
355
+ <div className="success-panel">
356
+ <div className="success-icon"><Check size={23} /></div>
357
+ <div className="section-kicker">YOU’RE IN THE INDEX</div>
358
+ <h2 id="modal-heading">Thanks for sharing.</h2>
359
+ <p>Your project has been added to the community directory.</p>
360
+ <button className="button button-primary" onClick={() => setShowForm(false)}>Back to exploring</button>
361
+ </div>
362
+ ) : (
363
+ <>
364
+ <div className="section-kicker">GROW THE ECOSYSTEM</div>
365
+ <h2 id="modal-heading">Submit a tool<span className="heading-dot">.</span></h2>
366
+ <p className="modal-intro">Share an open-source project that helps move supply chains forward.</p>
367
+ <form className="submit-form" onSubmit={(event) => void handleSubmit(event)}>
368
+ <label>
369
+ Project name
370
+ <input required minLength={2} maxLength={80} value={form.name} onChange={(event) => updateForm("name", event.target.value)} placeholder="e.g. Demand Forecaster" />
371
+ </label>
372
+ <label>
373
+ What does it do?
374
+ <textarea required minLength={20} maxLength={300} value={form.summary} onChange={(event) => updateForm("summary", event.target.value)} placeholder="A short, clear description of the project..." rows={3} />
375
+ </label>
376
+ <div className="form-row">
377
+ <label>
378
+ Category
379
+ <select value={form.category} onChange={(event) => updateForm("category", event.target.value as ToolCategory)}>
380
+ {categories.map((item) => <option key={item}>{item}</option>)}
381
+ </select>
382
+ </label>
383
+ <label>
384
+ Your name or handle
385
+ <input required minLength={2} maxLength={60} value={form.submittedBy} onChange={(event) => updateForm("submittedBy", event.target.value)} placeholder="How should we credit you?" />
386
+ </label>
387
+ </div>
388
+ <label>
389
+ Public GitHub repository
390
+ <input required type="url" value={form.repoUrl} onChange={(event) => updateForm("repoUrl", event.target.value)} placeholder="https://github.com/owner/repository" />
391
+ </label>
392
+ <label>
393
+ Project website <span className="optional-label">optional</span>
394
+ <input type="url" value={form.websiteUrl} onChange={(event) => updateForm("websiteUrl", event.target.value)} placeholder="https://your-project.com" />
395
+ </label>
396
+ {submitError ? <p className="form-error" role="alert">{submitError}</p> : null}
397
+ <button className="button button-primary form-submit" type="submit" disabled={submitting}>
398
+ {submitting ? "Submitting..." : "Submit project"} {!submitting ? <ArrowUpRight size={16} /> : null}
399
+ </button>
400
+ <p className="form-disclaimer">Only public GitHub repositories are accepted. Please submit projects you have permission to share.</p>
401
+ </form>
402
+ </>
403
+ )}
404
+ </section>
405
+ </div>
406
+ ) : null}
407
+ </>
408
+ );
409
+ }
src/app/globals.css ADDED
@@ -0,0 +1,396 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+
3
+ :root {
4
+ color-scheme: light;
5
+ --page: #f9f9f7;
6
+ --surface: #ffffff;
7
+ --surface-soft: #f5f5f2;
8
+ --ink: #17231e;
9
+ --ink-soft: #55645d;
10
+ --muted: #78827c;
11
+ --line: #e4e9e5;
12
+ --accent: #c5f36b;
13
+ --accent-dark: #1d392c;
14
+ --green: #34674d;
15
+ --green-soft: #eef5eb;
16
+ --shadow: 0 14px 42px rgba(23, 45, 32, 0.06);
17
+ }
18
+
19
+ html.theme-dark {
20
+ color-scheme: dark;
21
+ --page: #101713;
22
+ --surface: #17211b;
23
+ --surface-soft: #202b23;
24
+ --ink: #eff5ef;
25
+ --ink-soft: #c0ccc3;
26
+ --muted: #94a49a;
27
+ --line: #2e3b32;
28
+ --accent: #c5f36b;
29
+ --accent-dark: #1d392c;
30
+ --green: #b6d59c;
31
+ --green-soft: #223226;
32
+ --shadow: 0 14px 42px rgba(0, 0, 0, 0.2);
33
+ }
34
+
35
+ @theme inline {
36
+ --font-sans: var(--font-dm-sans);
37
+ --font-mono: var(--font-jetbrains-mono);
38
+ }
39
+
40
+ * { box-sizing: border-box; }
41
+ html { scroll-behavior: smooth; }
42
+ body {
43
+ margin: 0;
44
+ background: var(--page);
45
+ color: var(--ink);
46
+ font-family: var(--font-dm-sans), sans-serif;
47
+ transition: background-color 180ms ease, color 180ms ease;
48
+ }
49
+ button, input, textarea, select { font: inherit; }
50
+ button, a, select { -webkit-tap-highlight-color: transparent; }
51
+ button { cursor: pointer; }
52
+ a { color: inherit; text-decoration: none; }
53
+ ::selection { color: #17231e; background: #d3f7a4; }
54
+
55
+ .site-header {
56
+ height: 74px;
57
+ position: relative;
58
+ z-index: 20;
59
+ border-bottom: 1px solid var(--line);
60
+ background: var(--surface);
61
+ }
62
+ .header-inner {
63
+ width: min(1200px, calc(100% - 64px));
64
+ height: 100%;
65
+ margin: 0 auto;
66
+ display: flex;
67
+ align-items: center;
68
+ justify-content: space-between;
69
+ }
70
+ .brand { display: inline-flex; align-items: center; gap: 9px; font-size: 19px; font-weight: 750; letter-spacing: -0.8px; }
71
+ .brand-icon {
72
+ width: 31px; height: 31px; display: grid; place-items: center;
73
+ color: var(--accent-dark); background: var(--accent); border-radius: 9px;
74
+ }
75
+ .brand-period, .heading-dot { color: #8dbd48; }
76
+ .header-nav { display: flex; align-items: center; gap: 34px; margin-left: 35px; }
77
+ .nav-link { color: var(--muted); font-size: 13px; font-weight: 550; transition: color 160ms ease; }
78
+ .nav-link:hover, .nav-link.is-active { color: var(--ink); }
79
+ .header-actions { display: flex; align-items: center; gap: 10px; }
80
+ .theme-toggle {
81
+ width: 38px; height: 38px; display: grid; place-items: center;
82
+ border: 1px solid var(--line); border-radius: 50%; background: var(--surface);
83
+ color: var(--ink-soft); transition: background-color 160ms ease, border-color 160ms ease;
84
+ }
85
+ .theme-toggle:hover { background: var(--surface-soft); border-color: var(--muted); }
86
+ .sun-icon, .theme-dark .theme-toggle .moon-icon { display: none; }
87
+ .theme-dark .theme-toggle .sun-icon { display: block; }
88
+
89
+ .button {
90
+ min-height: 42px;
91
+ display: inline-flex;
92
+ align-items: center;
93
+ justify-content: center;
94
+ gap: 9px;
95
+ padding: 0 17px;
96
+ border: 1px solid transparent;
97
+ border-radius: 8px;
98
+ font-size: 13px;
99
+ font-weight: 650;
100
+ transition: transform 160ms ease, background-color 160ms ease, border-color 160ms ease, color 160ms ease, box-shadow 160ms ease;
101
+ }
102
+ .button:hover:not(:disabled) { transform: translateY(-1px); }
103
+ .button:disabled { cursor: wait; opacity: 0.65; }
104
+ .button-primary { background: var(--accent); color: #1b2b1d; box-shadow: 0 4px 12px rgba(151, 194, 80, 0.13); }
105
+ .button-primary:hover:not(:disabled) { background: #d0f783; box-shadow: 0 6px 16px rgba(151, 194, 80, 0.2); }
106
+ .button-secondary { border-color: var(--line); background: var(--surface); color: var(--ink); }
107
+ .button-secondary:hover:not(:disabled), .button-outline:hover:not(:disabled) { border-color: #bac8b8; background: var(--surface-soft); }
108
+ .button-quiet { color: var(--ink-soft); background: transparent; }
109
+ .button-quiet:hover:not(:disabled) { background: var(--surface); }
110
+ .button-outline { border-color: var(--line); background: var(--surface); color: var(--ink-soft); }
111
+ .button-small { min-height: 37px; padding: 0 13px; font-size: 12px; }
112
+
113
+ .hero {
114
+ min-height: 505px;
115
+ position: relative;
116
+ overflow: hidden;
117
+ background:
118
+ radial-gradient(ellipse at 75% 48%, rgba(196, 243, 107, 0.12), transparent 35%),
119
+ var(--surface);
120
+ border-bottom: 1px solid var(--line);
121
+ }
122
+ .hero-content { width: min(1200px, calc(100% - 64px)); position: relative; z-index: 2; margin: 0 auto; padding: 78px 0 82px; }
123
+ .eyebrow, .section-kicker {
124
+ display: flex; align-items: center; gap: 9px;
125
+ color: #637a60; font-family: var(--font-jetbrains-mono), monospace;
126
+ font-size: 10px; font-weight: 550; letter-spacing: 1.15px;
127
+ }
128
+ .eyebrow-dot { width: 7px; height: 7px; border-radius: 50%; background: #94c454; box-shadow: 0 0 0 4px rgba(148, 196, 84, 0.14); }
129
+ .hero h1 {
130
+ max-width: 650px;
131
+ margin: 24px 0 17px;
132
+ font-size: clamp(42px, 5.2vw, 67px);
133
+ line-height: 1.045;
134
+ letter-spacing: -3.2px;
135
+ font-weight: 650;
136
+ }
137
+ .hero h1 span { color: #6b9650; }
138
+ .hero-copy { max-width: 470px; margin: 0; color: var(--ink-soft); font-size: 15px; line-height: 1.75; }
139
+ .hero-actions { display: flex; align-items: center; gap: 14px; margin-top: 26px; }
140
+ .hero-cta { min-height: 46px; }
141
+ .hero-proof { display: flex; align-items: center; gap: 12px; margin-top: 42px; color: var(--muted); font-size: 11px; }
142
+ .avatar-stack { display: flex; align-items: center; padding-left: 1px; }
143
+ .avatar-stack span {
144
+ width: 25px; height: 25px; display: grid; place-items: center; margin-left: -4px;
145
+ border: 2px solid var(--surface); border-radius: 50%; background: #c9dfa9;
146
+ color: #30402d; font-size: 7px; font-weight: 800;
147
+ }
148
+ .avatar-stack span:nth-child(2) { background: #e3c9a9; }
149
+ .avatar-stack span:nth-child(3) { background: #c6d8d1; }
150
+ .avatar-stack span:nth-child(4) { background: #e6e9e0; font-size: 9px; }
151
+ .hero-art {
152
+ width: 425px; height: 425px; position: absolute; z-index: 1;
153
+ top: 31px; right: max(calc((100vw - 1200px) / 2 + 5px), 20px);
154
+ border-radius: 50%; opacity: 0.95;
155
+ }
156
+ .hero-art::before, .hero-art::after {
157
+ content: ""; position: absolute; inset: 44px; border: 1px solid var(--line); border-radius: 50%;
158
+ }
159
+ .hero-art::after { inset: 92px; border-style: dashed; opacity: 0.85; }
160
+ .art-orbit { position: absolute; inset: 75px; border: 1px solid rgba(135, 165, 111, 0.26); border-radius: 50%; transform: rotate(-35deg) scaleX(0.82); }
161
+ .orbit-inner { inset: 120px; transform: rotate(38deg) scaleX(1.45); border-style: dashed; }
162
+ .art-core {
163
+ width: 79px; height: 79px; position: absolute; top: 173px; left: 173px; z-index: 2;
164
+ display: grid; place-items: center; border: 1px solid #d5e6c6; border-radius: 25px;
165
+ background: linear-gradient(145deg, #f8fff0, #e8f3dc); color: #5a8250;
166
+ box-shadow: 0 0 0 12px rgba(206, 232, 183, 0.2), 0 12px 35px rgba(45, 79, 45, 0.1);
167
+ }
168
+ .art-node {
169
+ min-width: 144px; height: 43px; position: absolute; z-index: 3;
170
+ display: flex; align-items: center; gap: 8px; padding: 0 10px;
171
+ border: 1px solid var(--line); border-radius: 10px; background: var(--surface);
172
+ box-shadow: var(--shadow); color: var(--ink-soft); font-family: var(--font-jetbrains-mono), monospace;
173
+ font-size: 8px; letter-spacing: 0.35px;
174
+ }
175
+ .node-mark { width: 25px; height: 25px; display: grid; place-items: center; border-radius: 7px; background: #f0f5e9; color: #68894b; }
176
+ .node-top { left: 140px; top: 19px; }
177
+ .node-right { top: 187px; right: -14px; }
178
+ .node-bottom { left: 134px; bottom: 17px; }
179
+ .node-left { top: 188px; left: -9px; }
180
+ .hero-bottom-line {
181
+ width: min(1200px, calc(100% - 64px)); position: absolute; bottom: 21px; left: 50%; z-index: 3;
182
+ display: flex; align-items: center; gap: 12px; transform: translateX(-50%);
183
+ color: #8a968e; font-family: var(--font-jetbrains-mono), monospace; font-size: 9px; letter-spacing: 0.65px;
184
+ }
185
+ .hero-bottom-line .line { width: 42px; height: 1px; background: #c3d0c2; }
186
+
187
+ .directory-section { width: min(1200px, calc(100% - 64px)); margin: 0 auto; padding: 78px 0 76px; }
188
+ .section-heading { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 29px; }
189
+ .section-kicker { color: #87958a; font-size: 9px; }
190
+ .section-heading h2, .community-copy h2 {
191
+ margin: 10px 0 6px; font-size: 32px; font-weight: 650; letter-spacing: -1.3px; line-height: 1.2;
192
+ }
193
+ .section-heading p, .community-copy p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.6; }
194
+ .directory-count { display: flex; align-items: center; gap: 12px; color: var(--muted); font-size: 10px; line-height: 1.45; }
195
+ .count-number { color: var(--ink); font-family: var(--font-jetbrains-mono), monospace; font-size: 30px; letter-spacing: -2px; }
196
+ .directory-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
197
+ .search-field {
198
+ width: min(485px, 100%); height: 46px; display: flex; align-items: center; gap: 11px;
199
+ padding: 0 13px; border: 1px solid var(--line); border-radius: 8px;
200
+ background: var(--surface); color: #8a958e; transition: border-color 160ms ease, box-shadow 160ms ease;
201
+ }
202
+ .search-field:focus-within { border-color: #9ab67e; box-shadow: 0 0 0 3px rgba(143, 180, 111, 0.11); }
203
+ .search-field input { width: 100%; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--ink); font-size: 12px; }
204
+ .search-field input::placeholder { color: #9aa39d; }
205
+ .search-field kbd { padding: 2px 6px; border: 1px solid var(--line); border-radius: 4px; background: var(--surface-soft); color: #929b94; font-family: var(--font-jetbrains-mono), monospace; font-size: 10px; }
206
+ .sort-control {
207
+ height: 42px; position: relative; z-index: 30; display: flex; align-items: center; gap: 8px;
208
+ padding: 0 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); color: var(--muted);
209
+ }
210
+ .sort-label { font-size: 11px; }
211
+ .sort-control select { min-width: 91px; appearance: none; border: 0; outline: 0; background: transparent; color: var(--ink); font-size: 11px; font-weight: 600; cursor: pointer; }
212
+ .select-chevron { pointer-events: none; }
213
+ .category-list { display: flex; gap: 8px; overflow-x: auto; margin: 21px 0 20px; padding: 2px 1px 5px; scrollbar-width: none; }
214
+ .category-list::-webkit-scrollbar { display: none; }
215
+ .category-tab {
216
+ min-height: 34px; flex: 0 0 auto; display: inline-flex; align-items: center; gap: 8px; padding: 0 12px;
217
+ border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--muted);
218
+ font-size: 11px; font-weight: 550; transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease;
219
+ }
220
+ .category-tab:hover { background: var(--surface); color: var(--ink); }
221
+ .category-tab.selected { border-color: var(--line); background: var(--surface); color: var(--ink); box-shadow: 0 2px 5px rgba(19, 38, 24, 0.035); }
222
+ .tab-count { min-width: 18px; padding: 2px 4px; border-radius: 4px; background: var(--surface-soft); color: var(--muted); font-family: var(--font-jetbrains-mono), monospace; font-size: 9px; text-align: center; }
223
+ .tool-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 15px; }
224
+ .tool-card {
225
+ min-width: 0; display: flex; flex-direction: column; min-height: 240px; padding: 19px 19px 15px;
226
+ border: 1px solid var(--line); border-radius: 11px; background: var(--surface);
227
+ transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease;
228
+ }
229
+ .tool-card:hover { transform: translateY(-3px); border-color: #d1dccd; box-shadow: var(--shadow); }
230
+ .card-top { display: flex; align-items: center; gap: 9px; margin-bottom: 14px; }
231
+ .tool-mark { width: 34px; height: 34px; display: grid; place-items: center; margin-right: 1px; border-radius: 9px; background: #edf5e6; color: #668d4d; }
232
+ .mark-1 { background: #f2efe5; color: #987b4e; }
233
+ .mark-2 { background: #e9f1ef; color: #648b7a; }
234
+ .mark-3 { background: #f2eaf0; color: #946e88; }
235
+ .mark-4 { background: #e9edf5; color: #6d7f9c; }
236
+ .category-chip { display: inline-flex; align-items: center; padding: 5px 8px; border-radius: 5px; background: var(--green-soft); color: var(--green); font-size: 9px; font-weight: 650; }
237
+ .external-icon { width: 28px; height: 28px; display: grid; place-items: center; margin-left: auto; border-radius: 6px; color: #99a39b; transition: background 150ms ease, color 150ms ease; }
238
+ .external-icon:hover { background: var(--surface-soft); color: var(--ink); }
239
+ .card-title-link { width: fit-content; }
240
+ .tool-card h3 { margin: 0; color: var(--ink); font-size: 16px; font-weight: 650; letter-spacing: -0.35px; }
241
+ .card-title-link:hover h3 { color: #618044; }
242
+ .card-summary { display: -webkit-box; overflow: hidden; min-height: 40px; margin: 8px 0 14px; color: var(--ink-soft); font-size: 11px; line-height: 1.65; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
243
+ .repo-line { display: flex; align-items: center; gap: 7px; overflow: hidden; color: #839087; font-family: var(--font-jetbrains-mono), monospace; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
244
+ .github-mark { width: 15px; height: 15px; display: inline-grid; flex: 0 0 auto; place-items: center; border: 1px solid #d7ded7; border-radius: 50%; color: #55645d; font-size: 8px; }
245
+ .card-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: auto; padding-top: 14px; border-top: 1px solid var(--line); }
246
+ .rating-control { display: inline-flex; align-items: center; gap: 6px; min-width: 0; }
247
+ .rating-stars { display: inline-flex; align-items: center; gap: 1px; }
248
+ .star-button { display: inline-grid; place-items: center; padding: 1px; border: 0; background: transparent; color: #d7ddd4; transition: transform 120ms ease, color 120ms ease; }
249
+ .star-button:hover:not(:disabled) { transform: scale(1.12); color: #d6a53f; }
250
+ .star-button.is-filled { color: #d5a747; }
251
+ .star-button:disabled { cursor: wait; opacity: 0.65; }
252
+ .rating-count { color: var(--muted); font-family: var(--font-jetbrains-mono), monospace; font-size: 9px; white-space: nowrap; }
253
+ .card-view-link { display: inline-flex; align-items: center; gap: 5px; color: #718b60; font-size: 10px; font-weight: 650; transition: color 140ms ease; }
254
+ .card-view-link:hover { color: #3b6337; }
255
+ .empty-state { min-height: 240px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; border: 1px dashed var(--line); border-radius: 10px; color: var(--muted); text-align: center; }
256
+ .empty-state h3 { margin: 4px 0 0; color: var(--ink); font-size: 16px; }
257
+ .empty-state p { margin: 0 0 8px; font-size: 12px; }
258
+ .directory-foot { display: flex; align-items: center; justify-content: space-between; margin-top: 21px; color: var(--muted); font-size: 10px; }
259
+ .directory-foot strong { color: var(--ink-soft); font-family: var(--font-jetbrains-mono), monospace; font-weight: 550; }
260
+ .directory-foot .button { min-height: 36px; font-size: 11px; }
261
+
262
+ .community-section {
263
+ width: min(1200px, calc(100% - 64px)); min-height: 176px; display: flex; align-items: center; gap: 20px;
264
+ margin: 0 auto 75px; padding: 29px 34px; border: 1px solid #dce5d5; border-radius: 13px;
265
+ background: linear-gradient(110deg, #f3f8ed 0%, #f9fbf6 55%, #eef5e7 100%);
266
+ }
267
+ .theme-dark .community-section { background: linear-gradient(110deg, #1d2b20 0%, #19241d 55%, #223023 100%); }
268
+ .community-icon { width: 43px; height: 43px; display: grid; flex: 0 0 auto; place-items: center; border: 1px solid #dce9cf; border-radius: 12px; background: var(--surface); color: #72964e; }
269
+ .community-copy { flex: 1; }
270
+ .community-copy h2 { margin: 7px 0 4px; font-size: 22px; }
271
+ .community-copy p { max-width: 520px; font-size: 11px; }
272
+ .community-cta { flex: 0 0 auto; }
273
+ .site-footer {
274
+ min-height: 69px; display: flex; align-items: center; justify-content: space-between; gap: 20px;
275
+ padding: 0 max(calc((100vw - 1200px) / 2), 32px); border-top: 1px solid var(--line);
276
+ background: var(--surface); color: var(--muted); font-size: 10px;
277
+ }
278
+ .footer-brand { color: var(--ink); font-size: 15px; }
279
+ .footer-brand .brand-icon { width: 26px; height: 26px; border-radius: 7px; }
280
+ .site-footer > a:last-child { display: inline-flex; align-items: center; gap: 5px; color: var(--ink-soft); }
281
+
282
+ .modal-backdrop { position: fixed; inset: 0; z-index: 100; display: grid; overflow-y: auto; place-items: center; padding: 24px; background: rgba(15, 25, 18, 0.48); backdrop-filter: blur(4px); }
283
+ .submit-modal { width: min(100%, 510px); position: relative; padding: 31px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); box-shadow: 0 30px 90px rgba(9, 20, 12, 0.25); }
284
+ .modal-close { width: 34px; height: 34px; position: absolute; top: 16px; right: 16px; display: grid; place-items: center; border: 0; border-radius: 7px; background: transparent; color: var(--muted); transition: background 150ms ease; }
285
+ .modal-close:hover { background: var(--surface-soft); color: var(--ink); }
286
+ .submit-modal h2 { margin: 8px 0 5px; font-size: 27px; letter-spacing: -1px; }
287
+ .modal-intro { margin: 0 0 21px; color: var(--muted); font-size: 12px; line-height: 1.6; }
288
+ .submit-form { display: flex; flex-direction: column; gap: 13px; }
289
+ .submit-form label { display: flex; flex-direction: column; gap: 6px; color: var(--ink-soft); font-size: 10px; font-weight: 650; }
290
+ .submit-form input, .submit-form textarea, .submit-form select {
291
+ width: 100%; min-height: 40px; padding: 9px 11px; border: 1px solid var(--line); border-radius: 7px;
292
+ outline: 0; background: var(--surface); color: var(--ink); font-size: 11px; font-weight: 450;
293
+ transition: border-color 150ms ease, box-shadow 150ms ease;
294
+ }
295
+ .submit-form textarea { resize: vertical; line-height: 1.5; }
296
+ .submit-form input:focus, .submit-form textarea:focus, .submit-form select:focus { border-color: #9ab67e; box-shadow: 0 0 0 3px rgba(143, 180, 111, 0.11); }
297
+ .submit-form input::placeholder, .submit-form textarea::placeholder { color: #9aa39d; }
298
+ .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
299
+ .optional-label { color: #99a39b; font-weight: 450; }
300
+ .form-error { margin: -1px 0 0; color: #bd4d43; font-size: 11px; }
301
+ .form-submit { width: 100%; min-height: 44px; margin-top: 1px; }
302
+ .form-disclaimer { margin: -5px 0 0; color: #929c94; font-size: 9px; line-height: 1.5; text-align: center; }
303
+ .success-panel { display: flex; flex-direction: column; align-items: flex-start; padding: 18px 0 3px; }
304
+ .success-icon { width: 45px; height: 45px; display: grid; place-items: center; margin-bottom: 21px; border-radius: 50%; background: #edf6e4; color: #5c8845; }
305
+ .success-panel h2 { margin: 9px 0 6px; }
306
+ .success-panel p { margin: 0 0 21px; color: var(--muted); font-size: 12px; }
307
+
308
+ .detail-page { min-height: calc(100vh - 74px); padding: 55px 24px 80px; background: var(--page); }
309
+ .detail-wrap { width: min(900px, 100%); margin: 0 auto; }
310
+ .back-link { display: inline-flex; align-items: center; gap: 7px; margin-bottom: 19px; color: var(--muted); font-size: 12px; transition: color 140ms ease; }
311
+ .back-link:hover { color: var(--ink); }
312
+ .detail-card { padding: clamp(24px, 5vw, 48px); border: 1px solid var(--line); border-radius: 13px; background: var(--surface); box-shadow: var(--shadow); }
313
+ .detail-topline { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
314
+ .detail-date { color: var(--muted); font-family: var(--font-jetbrains-mono), monospace; font-size: 9px; }
315
+ .detail-card h1 { margin: 24px 0 12px; font-size: clamp(34px, 6vw, 52px); letter-spacing: -2.2px; line-height: 1.1; }
316
+ .detail-summary { max-width: 670px; margin: 0; color: var(--ink-soft); font-size: 15px; line-height: 1.7; }
317
+ .detail-rating { display: flex; align-items: center; gap: 7px; margin-top: 19px; color: #d0a03e; }
318
+ .detail-rating strong { color: var(--ink); font-size: 13px; }
319
+ .detail-rating span { color: var(--muted); font-size: 11px; }
320
+ .detail-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 24px; }
321
+ .detail-rating-panel { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin-top: 36px; padding: 18px 19px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-soft); }
322
+ .detail-rating-panel h2 { margin: 0 0 3px; font-size: 13px; }
323
+ .detail-rating-panel p { margin: 0; color: var(--muted); font-size: 10px; }
324
+ .rating-large { flex-direction: column; align-items: flex-end; gap: 4px; }
325
+ .rating-large .rating-message { min-height: 13px; color: var(--muted); font-size: 9px; }
326
+ .detail-meta { display: grid; grid-template-columns: 1fr 2fr; gap: 14px; margin: 25px 0 0; padding-top: 20px; border-top: 1px solid var(--line); }
327
+ .detail-meta div { min-width: 0; }
328
+ .detail-meta dt { margin-bottom: 7px; color: var(--muted); font-size: 9px; text-transform: uppercase; letter-spacing: 0.8px; }
329
+ .detail-meta dd { overflow: hidden; margin: 0; color: var(--ink-soft); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
330
+ .detail-meta a { color: #6a8c50; }
331
+ .detail-note { display: flex; align-items: center; justify-content: space-between; padding: 18px 2px; color: var(--muted); font-size: 10px; }
332
+ .text-link { display: inline-flex; align-items: center; gap: 5px; color: var(--green); font-weight: 600; }
333
+
334
+ @media (max-width: 900px) {
335
+ .hero-art { right: -85px; opacity: 0.48; }
336
+ .hero-content { z-index: 2; }
337
+ .tool-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
338
+ }
339
+ @media (max-width: 620px) {
340
+ .site-header { height: 64px; }
341
+ .header-inner, .hero-content, .hero-bottom-line, .directory-section, .community-section { width: calc(100% - 36px); }
342
+ .header-nav { display: none; }
343
+ .header-inner { justify-content: space-between; }
344
+ .brand { font-size: 17px; }
345
+ .header-actions { gap: 6px; }
346
+ .theme-toggle { width: 34px; height: 34px; }
347
+ .header-submit { min-height: 34px; padding: 0 10px; font-size: 10px; }
348
+ .hero { min-height: 489px; }
349
+ .hero-content { padding: 63px 0 68px; }
350
+ .eyebrow { font-size: 8px; letter-spacing: 0.65px; }
351
+ .hero h1 { margin-top: 22px; font-size: clamp(39px, 11vw, 56px); letter-spacing: -2.4px; }
352
+ .hero-copy { max-width: 380px; font-size: 13px; }
353
+ .hero-actions { flex-wrap: wrap; gap: 6px; margin-top: 21px; }
354
+ .hero-actions .button { min-height: 40px; padding: 0 12px; font-size: 11px; }
355
+ .hero-proof { margin-top: 29px; font-size: 9px; }
356
+ .hero-art { width: 330px; height: 330px; top: 155px; right: -150px; opacity: 0.14; }
357
+ .art-core { top: 136px; left: 136px; }
358
+ .art-node { display: none; }
359
+ .hero-bottom-line { bottom: 17px; font-size: 8px; }
360
+ .directory-section { padding: 57px 0 54px; }
361
+ .section-heading { align-items: flex-end; gap: 10px; }
362
+ .section-heading h2 { font-size: 25px; letter-spacing: -0.9px; }
363
+ .section-heading p { max-width: 260px; font-size: 11px; }
364
+ .directory-count { gap: 6px; font-size: 8px; }
365
+ .count-number { font-size: 24px; }
366
+ .directory-toolbar { align-items: stretch; flex-direction: column; gap: 9px; }
367
+ .search-field { width: 100%; height: 43px; }
368
+ .sort-control { width: fit-content; height: 36px; align-self: flex-end; }
369
+ .category-list { margin-top: 14px; }
370
+ .tool-grid { grid-template-columns: minmax(0, 1fr); gap: 10px; }
371
+ .tool-card { min-height: 224px; padding: 16px; }
372
+ .directory-foot { align-items: flex-start; gap: 10px; }
373
+ .directory-foot .button { min-height: 33px; padding: 0 9px; font-size: 9px; }
374
+ .community-section { align-items: flex-start; flex-wrap: wrap; gap: 13px; margin-bottom: 45px; padding: 22px 18px; }
375
+ .community-icon { width: 36px; height: 36px; }
376
+ .community-copy { flex: 1 1 calc(100% - 55px); }
377
+ .community-copy h2 { font-size: 18px; }
378
+ .community-copy p { font-size: 10px; }
379
+ .community-cta { margin-left: 49px; }
380
+ .site-footer { min-height: 63px; flex-wrap: wrap; gap: 4px 12px; padding: 12px 18px; font-size: 8px; }
381
+ .site-footer > span { display: none; }
382
+ .submit-modal { padding: 27px 19px 21px; }
383
+ .form-row { grid-template-columns: 1fr; }
384
+ .detail-page { min-height: calc(100vh - 64px); padding: 31px 18px 50px; }
385
+ .detail-card { padding: 23px 18px; }
386
+ .detail-card h1 { margin-top: 20px; }
387
+ .detail-summary { font-size: 13px; }
388
+ .detail-actions .button { width: 100%; }
389
+ .detail-rating-panel { align-items: flex-start; flex-direction: column; }
390
+ .rating-large { align-items: flex-start; }
391
+ .detail-meta { grid-template-columns: 1fr; }
392
+ }
393
+
394
+ @media (prefers-reduced-motion: reduce) {
395
+ *, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
396
+ }
src/app/icon.svg ADDED
src/app/layout.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import { DM_Sans, JetBrains_Mono } from "next/font/google";
3
+ import "./globals.css";
4
+
5
+ const dmSans = DM_Sans({
6
+ variable: "--font-dm-sans",
7
+ subsets: ["latin"],
8
+ });
9
+
10
+ const jetBrainsMono = JetBrains_Mono({
11
+ variable: "--font-jetbrains-mono",
12
+ subsets: ["latin"],
13
+ });
14
+
15
+ export const metadata: Metadata = {
16
+ metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://chainkit.community"),
17
+ title: {
18
+ default: "Chainkit — Supply chain AI, in one place",
19
+ template: "%s | Chainkit",
20
+ },
21
+ description:
22
+ "Discover, compare, and rate open-source AI tools for planning, procurement, logistics, warehousing, and supply chain visibility.",
23
+ applicationName: "Chainkit",
24
+ robots: { index: true, follow: true },
25
+ };
26
+
27
+ export default function RootLayout({ children }: LayoutProps<"/">) {
28
+ return (
29
+ <html lang="en" className={`${dmSans.variable} ${jetBrainsMono.variable} h-full antialiased`}>
30
+ <body className="min-h-full flex flex-col">{children}</body>
31
+ </html>
32
+ );
33
+ }
src/app/page.tsx ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import { Directory } from "@/app/directory";
3
+ import { getTools } from "@/lib/tools";
4
+
5
+ export const dynamic = "force-dynamic";
6
+
7
+ export const metadata: Metadata = {
8
+ title: "Open-source AI tools for supply chains",
9
+ description:
10
+ "Discover, compare, and rate open-source AI tools for supply chain planning, procurement, logistics, warehousing, and visibility.",
11
+ alternates: { canonical: "/" },
12
+ openGraph: {
13
+ title: "Chainkit — Supply chain AI, in one place",
14
+ description:
15
+ "A community-built directory of open-source AI projects for the supply chain.",
16
+ type: "website",
17
+ },
18
+ };
19
+
20
+ export default async function Home() {
21
+ const tools = await getTools();
22
+ return <Directory initialTools={tools} />;
23
+ }
src/app/robots.ts ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { MetadataRoute } from "next";
2
+
3
+ export const dynamic = "force-dynamic";
4
+
5
+ export default function robots(): MetadataRoute.Robots {
6
+ const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://chainkit.community";
7
+ return {
8
+ rules: { userAgent: "*", allow: "/" },
9
+ sitemap: `${siteUrl}/sitemap.xml`,
10
+ };
11
+ }
src/app/sitemap.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { MetadataRoute } from "next";
2
+ import { getTools } from "@/lib/tools";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
7
+ const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://chainkit.community";
8
+ const tools = await getTools();
9
+ return [
10
+ { url: siteUrl, changeFrequency: "daily", priority: 1 },
11
+ ...tools.map((tool) => ({
12
+ url: `${siteUrl}/tools/${tool.slug}`,
13
+ lastModified: new Date(tool.createdAt),
14
+ changeFrequency: "weekly" as const,
15
+ priority: 0.7,
16
+ })),
17
+ ];
18
+ }
src/app/tool-rating.tsx ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Star } from "lucide-react";
5
+
6
+ type Props = {
7
+ toolId: string;
8
+ initialRating: number;
9
+ initialCount: number;
10
+ size?: "small" | "large";
11
+ };
12
+
13
+ function getVoterId() {
14
+ const storageKey = "chainkit-voter-id";
15
+ let voterId = window.localStorage.getItem(storageKey);
16
+ if (!voterId) {
17
+ voterId = window.crypto.randomUUID();
18
+ window.localStorage.setItem(storageKey, voterId);
19
+ }
20
+ return voterId;
21
+ }
22
+
23
+ export function ToolRating({
24
+ toolId,
25
+ initialRating,
26
+ initialCount,
27
+ size = "small",
28
+ }: Props) {
29
+ const [rating, setRating] = useState(initialRating);
30
+ const [count, setCount] = useState(initialCount);
31
+ const [selected, setSelected] = useState(0);
32
+ const [busy, setBusy] = useState(false);
33
+ const [message, setMessage] = useState("");
34
+
35
+ async function submitRating(value: number) {
36
+ setBusy(true);
37
+ setMessage("");
38
+ try {
39
+ const response = await fetch("/api/ratings", {
40
+ method: "POST",
41
+ headers: { "Content-Type": "application/json" },
42
+ body: JSON.stringify({ toolId, rating: value, voterId: getVoterId() }),
43
+ });
44
+ const data = (await response.json()) as {
45
+ averageRating?: number;
46
+ ratingCount?: number;
47
+ error?: string;
48
+ };
49
+ if (!response.ok) throw new Error(data.error ?? "Could not save your rating.");
50
+ setRating(data.averageRating ?? rating);
51
+ setCount(data.ratingCount ?? count);
52
+ setSelected(value);
53
+ setMessage("Thanks for rating!");
54
+ } catch (error) {
55
+ setMessage(error instanceof Error ? error.message : "Could not save your rating.");
56
+ } finally {
57
+ setBusy(false);
58
+ }
59
+ }
60
+
61
+ return (
62
+ <div className={`rating-control rating-${size}`}>
63
+ <div className="rating-stars" role="group" aria-label="Rate this tool from 1 to 5 stars">
64
+ {[1, 2, 3, 4, 5].map((value) => (
65
+ <button
66
+ key={value}
67
+ type="button"
68
+ aria-label={`Rate ${value} out of 5`}
69
+ aria-pressed={selected === value}
70
+ disabled={busy}
71
+ onClick={() => void submitRating(value)}
72
+ className={(selected || rating) >= value ? "star-button is-filled" : "star-button"}
73
+ >
74
+ <Star size={size === "large" ? 22 : 15} fill="currentColor" aria-hidden="true" />
75
+ </button>
76
+ ))}
77
+ </div>
78
+ {size === "small" ? (
79
+ <span className="rating-count" aria-live="polite">
80
+ {rating ? rating.toFixed(1) : "New"} · {count}
81
+ </span>
82
+ ) : (
83
+ <span className="rating-message" aria-live="polite">
84
+ {message || (selected ? "Your rating is saved" : "Choose 1–5 stars")}
85
+ </span>
86
+ )}
87
+ </div>
88
+ );
89
+ }
src/app/tools/[slug]/page.tsx ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import Link from "next/link";
3
+ import { ArrowLeft, ArrowUpRight, ExternalLink, Star } from "lucide-react";
4
+ import { notFound } from "next/navigation";
5
+ import { getToolBySlug } from "@/lib/tools";
6
+ import { ToolRating } from "@/app/tool-rating";
7
+
8
+ type Props = { params: Promise<{ slug: string }> };
9
+
10
+ export const dynamic = "force-dynamic";
11
+
12
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
13
+ const { slug } = await params;
14
+ const tool = await getToolBySlug(slug);
15
+ if (!tool) return { title: "Tool not found" };
16
+ return {
17
+ title: tool.name,
18
+ description: tool.summary,
19
+ alternates: { canonical: `/tools/${tool.slug}` },
20
+ openGraph: {
21
+ title: `${tool.name} | Chainkit`,
22
+ description: tool.summary,
23
+ type: "article",
24
+ },
25
+ };
26
+ }
27
+
28
+ export default async function ToolPage({ params }: Props) {
29
+ const { slug } = await params;
30
+ const tool = await getToolBySlug(slug);
31
+ if (!tool) notFound();
32
+
33
+ const date = new Intl.DateTimeFormat("en", {
34
+ dateStyle: "medium",
35
+ timeZone: "UTC",
36
+ }).format(new Date(tool.createdAt));
37
+
38
+ return (
39
+ <main className="detail-page">
40
+ <div className="detail-wrap">
41
+ <Link href="/" className="back-link">
42
+ <ArrowLeft size={16} aria-hidden="true" />
43
+ Back to directory
44
+ </Link>
45
+ <article className="detail-card">
46
+ <div className="detail-topline">
47
+ <span className="category-chip">{tool.category}</span>
48
+ <span className="detail-date">Added {date}</span>
49
+ </div>
50
+ <h1>{tool.name}</h1>
51
+ <p className="detail-summary">{tool.summary}</p>
52
+ <div className="detail-rating">
53
+ <Star size={17} fill="currentColor" aria-hidden="true" />
54
+ <strong>{tool.averageRating ? tool.averageRating.toFixed(1) : "New"}</strong>
55
+ <span>{tool.ratingCount} community ratings</span>
56
+ </div>
57
+ <div className="detail-actions">
58
+ <a className="button button-primary" href={tool.repoUrl} target="_blank" rel="noopener noreferrer">
59
+ View GitHub repository <ArrowUpRight size={16} aria-hidden="true" />
60
+ </a>
61
+ {tool.websiteUrl ? (
62
+ <a className="button button-secondary" href={tool.websiteUrl} target="_blank" rel="noopener noreferrer">
63
+ Project website <ExternalLink size={15} aria-hidden="true" />
64
+ </a>
65
+ ) : null}
66
+ </div>
67
+ <section className="detail-rating-panel" aria-labelledby="rate-heading">
68
+ <div>
69
+ <h2 id="rate-heading">Tried this tool?</h2>
70
+ <p>Share a quick rating with the community.</p>
71
+ </div>
72
+ <ToolRating
73
+ toolId={tool.id}
74
+ initialRating={tool.averageRating}
75
+ initialCount={tool.ratingCount}
76
+ size="large"
77
+ />
78
+ </section>
79
+ <dl className="detail-meta">
80
+ <div>
81
+ <dt>Submitted by</dt>
82
+ <dd>{tool.submittedBy}</dd>
83
+ </div>
84
+ <div>
85
+ <dt>Repository</dt>
86
+ <dd>
87
+ <a href={tool.repoUrl} target="_blank" rel="noopener noreferrer">
88
+ {tool.repoUrl.replace("https://github.com/", "")}
89
+ </a>
90
+ </dd>
91
+ </div>
92
+ </dl>
93
+ </article>
94
+ <div className="detail-note">
95
+ <span>Independent projects, shared knowledge.</span>
96
+ <Link href="/" className="text-link">Explore more tools <ArrowUpRight size={14} /></Link>
97
+ </div>
98
+ </div>
99
+ </main>
100
+ );
101
+ }
src/lib/tool-types.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const categories = [
2
+ "Planning",
3
+ "Procurement",
4
+ "Logistics",
5
+ "Warehousing",
6
+ "Visibility",
7
+ ] as const;
8
+
9
+ export type ToolCategory = (typeof categories)[number];
10
+
11
+ export type SupplyChainTool = {
12
+ id: string;
13
+ slug: string;
14
+ name: string;
15
+ summary: string;
16
+ category: ToolCategory;
17
+ repoUrl: string;
18
+ websiteUrl: string;
19
+ submittedBy: string;
20
+ createdAt: string;
21
+ averageRating: number;
22
+ ratingCount: number;
23
+ };
src/lib/tools.ts ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { randomUUID } from "node:crypto";
2
+ import { BigQuery } from "@google-cloud/bigquery";
3
+ import type { SupplyChainTool, ToolCategory } from "@/lib/tool-types";
4
+ import { categories } from "@/lib/tool-types";
5
+
6
+ type NewTool = Omit<
7
+ SupplyChainTool,
8
+ "id" | "slug" | "createdAt" | "averageRating" | "ratingCount"
9
+ >;
10
+
11
+ type DemoState = {
12
+ tools: SupplyChainTool[];
13
+ ratings: Map<string, number>;
14
+ };
15
+
16
+ declare global {
17
+ var __chainkitDemoState: DemoState | undefined;
18
+ }
19
+
20
+ const demoTools: SupplyChainTool[] = [
21
+ {
22
+ id: "intelligent-supply-chain",
23
+ slug: "intelligent-supply-chain-management",
24
+ name: "Intelligent Supply Chain Management",
25
+ summary:
26
+ "Azure ML accelerator using deep-learning forecasts and distributed computing to simulate inventory optimization.",
27
+ category: "Planning",
28
+ repoUrl: "https://github.com/MSUSAzureAccelerators/Intelligent-Supply-Chain-Management",
29
+ websiteUrl: "",
30
+ submittedBy: "Chainkit demo",
31
+ createdAt: "2026-11-06T10:00:00.000Z",
32
+ averageRating: 0,
33
+ ratingCount: 0,
34
+ },
35
+ {
36
+ id: "scimai-gym",
37
+ slug: "scimai-gym",
38
+ name: "SCIMAI Gym",
39
+ summary:
40
+ "Python environment for supply-chain inventory management experiments with deep reinforcement learning.",
41
+ category: "Planning",
42
+ repoUrl: "https://github.com/frenkowski/SCIMAI-Gym",
43
+ websiteUrl: "",
44
+ submittedBy: "Chainkit demo",
45
+ createdAt: "2026-11-06T10:00:00.000Z",
46
+ averageRating: 0,
47
+ ratingCount: 0,
48
+ },
49
+ {
50
+ id: "product-demand-forecasting",
51
+ slug: "product-demand-forecasting-ml",
52
+ name: "Product Demand Forecasting with ML",
53
+ summary:
54
+ "Machine-learning project exploring product demand forecasting for supply-chain planning.",
55
+ category: "Planning",
56
+ repoUrl: "https://github.com/trydoff/Product-Demand-Forecasting-Using-ML",
57
+ websiteUrl: "",
58
+ submittedBy: "Chainkit demo",
59
+ createdAt: "2026-11-06T10:00:00.000Z",
60
+ averageRating: 0,
61
+ ratingCount: 0,
62
+ },
63
+ {
64
+ id: "timefold-solver",
65
+ slug: "timefold-solver",
66
+ name: "Timefold Solver",
67
+ summary:
68
+ "Open-source AI solver for vehicle routing, task assignment, and other operational planning problems.",
69
+ category: "Logistics",
70
+ repoUrl: "https://github.com/TimefoldAI/timefold-solver",
71
+ websiteUrl: "",
72
+ submittedBy: "Chainkit demo",
73
+ createdAt: "2026-11-06T10:00:00.000Z",
74
+ averageRating: 0,
75
+ ratingCount: 0,
76
+ },
77
+ {
78
+ id: "late-delivery-prediction",
79
+ slug: "supply-chain-late-delivery-prediction",
80
+ name: "Late Delivery Prediction",
81
+ summary:
82
+ "Industrial ML pipeline for shipment-delay prediction with model comparisons and a Streamlit batch app.",
83
+ category: "Visibility",
84
+ repoUrl: "https://github.com/workkrishnpatel/supply-chain-late-delivery-prediction",
85
+ websiteUrl: "",
86
+ submittedBy: "Chainkit demo",
87
+ createdAt: "2026-11-06T10:00:00.000Z",
88
+ averageRating: 0,
89
+ ratingCount: 0,
90
+ },
91
+ {
92
+ id: "ai-warehouse-slotting",
93
+ slug: "ai-warehouse-slotting",
94
+ name: "AI Warehouse Slotting",
95
+ summary:
96
+ "Warehouse-management prototype integrating ML demand forecasting and dynamic slotting optimization.",
97
+ category: "Warehousing",
98
+ repoUrl: "https://github.com/ArwaElsayed5/Graduation-Project",
99
+ websiteUrl: "",
100
+ submittedBy: "Chainkit demo",
101
+ createdAt: "2026-11-06T10:00:00.000Z",
102
+ averageRating: 0,
103
+ ratingCount: 0,
104
+ },
105
+ {
106
+ id: "fraudlens-ai",
107
+ slug: "fraudlens-ai",
108
+ name: "FraudLens AI",
109
+ summary:
110
+ "Procurement audit prototype that applies anomaly detection and NLP to flag suspicious bid and vendor patterns.",
111
+ category: "Procurement",
112
+ repoUrl: "https://github.com/thenishantraj/fraudlens-ai",
113
+ websiteUrl: "",
114
+ submittedBy: "Chainkit demo",
115
+ createdAt: "2026-11-06T10:00:00.000Z",
116
+ averageRating: 0,
117
+ ratingCount: 0,
118
+ },
119
+ ];
120
+
121
+ function getDemoState(): DemoState {
122
+ globalThis.__chainkitDemoState ??= {
123
+ tools: demoTools.map((tool) => ({ ...tool })),
124
+ ratings: new Map(),
125
+ };
126
+ return globalThis.__chainkitDemoState;
127
+ }
128
+
129
+ function getBigQueryConfig() {
130
+ const projectId = process.env.BIGQUERY_PROJECT_ID;
131
+ const dataset = process.env.BIGQUERY_DATASET;
132
+ if (!projectId && !dataset) return undefined;
133
+ if (!projectId || !dataset) {
134
+ throw new Error(
135
+ "Set both BIGQUERY_PROJECT_ID and BIGQUERY_DATASET to enable BigQuery.",
136
+ );
137
+ }
138
+ if (!/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(projectId)) {
139
+ throw new Error("BIGQUERY_PROJECT_ID is not a valid Google Cloud project ID.");
140
+ }
141
+ if (!/^[A-Za-z0-9_]{1,1024}$/.test(dataset)) {
142
+ throw new Error("BIGQUERY_DATASET contains unsupported characters.");
143
+ }
144
+ return { projectId, dataset };
145
+ }
146
+
147
+ let bigQueryClient: BigQuery | undefined;
148
+
149
+ function getBigQuery() {
150
+ const config = getBigQueryConfig();
151
+ if (!config) return undefined;
152
+ bigQueryClient ??= new BigQuery({ projectId: config.projectId });
153
+ return { client: bigQueryClient, ...config };
154
+ }
155
+
156
+ function toolTable(projectId: string, dataset: string) {
157
+ return `\`${projectId}.${dataset}.tools\``;
158
+ }
159
+
160
+ function ratingTable(projectId: string, dataset: string) {
161
+ return `\`${projectId}.${dataset}.tool_ratings\``;
162
+ }
163
+
164
+ function isToolCategory(value: string): value is ToolCategory {
165
+ return categories.some((item) => item === value);
166
+ }
167
+
168
+ function mapBigQueryRow(row: Record<string, unknown>): SupplyChainTool {
169
+ const category = String(row.category);
170
+ if (!isToolCategory(category)) {
171
+ throw new Error(`BigQuery returned an unsupported category: ${category}`);
172
+ }
173
+ return {
174
+ id: String(row.id),
175
+ slug: String(row.slug),
176
+ name: String(row.name),
177
+ summary: String(row.summary),
178
+ category,
179
+ repoUrl: String(row.repoUrl),
180
+ websiteUrl: String(row.websiteUrl ?? ""),
181
+ submittedBy: String(row.submittedBy),
182
+ createdAt: new Date(String(row.createdAt)).toISOString(),
183
+ averageRating: Number(row.averageRating ?? 0),
184
+ ratingCount: Number(row.ratingCount ?? 0),
185
+ };
186
+ }
187
+
188
+ function buildToolQuery(
189
+ projectId: string,
190
+ dataset: string,
191
+ where = "",
192
+ order = "",
193
+ ) {
194
+ return `
195
+ SELECT
196
+ t.tool_id AS id,
197
+ t.slug,
198
+ t.name,
199
+ t.summary,
200
+ t.category,
201
+ t.repo_url AS repoUrl,
202
+ t.website_url AS websiteUrl,
203
+ t.submitted_by AS submittedBy,
204
+ t.created_at AS createdAt,
205
+ COALESCE(AVG(r.rating), 0) AS averageRating,
206
+ COUNT(r.rating) AS ratingCount
207
+ FROM ${toolTable(projectId, dataset)} AS t
208
+ LEFT JOIN ${ratingTable(projectId, dataset)} AS r
209
+ ON t.tool_id = r.tool_id
210
+ ${where}
211
+ GROUP BY
212
+ t.tool_id, t.slug, t.name, t.summary, t.category, t.repo_url,
213
+ t.website_url, t.submitted_by, t.created_at
214
+ ${order}
215
+ `;
216
+ }
217
+
218
+ export async function getTools(): Promise<SupplyChainTool[]> {
219
+ const bigQuery = getBigQuery();
220
+ if (!bigQuery) return getDemoState().tools;
221
+
222
+ const [rows] = await bigQuery.client.query({
223
+ query: buildToolQuery(
224
+ bigQuery.projectId,
225
+ bigQuery.dataset,
226
+ "",
227
+ "ORDER BY t.created_at DESC",
228
+ ),
229
+ });
230
+ return rows.map(mapBigQueryRow);
231
+ }
232
+
233
+ export async function getToolBySlug(slug: string) {
234
+ const bigQuery = getBigQuery();
235
+ if (!bigQuery) {
236
+ return getDemoState().tools.find((tool) => tool.slug === slug);
237
+ }
238
+ const [rows] = await bigQuery.client.query({
239
+ query: `${buildToolQuery(
240
+ bigQuery.projectId,
241
+ bigQuery.dataset,
242
+ "WHERE t.slug = @slug",
243
+ )} LIMIT 1`,
244
+ params: { slug },
245
+ });
246
+ return rows[0] ? mapBigQueryRow(rows[0]) : undefined;
247
+ }
248
+
249
+ async function getToolById(toolId: string) {
250
+ const bigQuery = getBigQuery();
251
+ if (!bigQuery) {
252
+ return getDemoState().tools.find((tool) => tool.id === toolId);
253
+ }
254
+ const [rows] = await bigQuery.client.query({
255
+ query: `${buildToolQuery(
256
+ bigQuery.projectId,
257
+ bigQuery.dataset,
258
+ "WHERE t.tool_id = @toolId",
259
+ )} LIMIT 1`,
260
+ params: { toolId },
261
+ });
262
+ return rows[0] ? mapBigQueryRow(rows[0]) : undefined;
263
+ }
264
+
265
+ export async function submitTool(input: NewTool) {
266
+ const createdAt = new Date().toISOString();
267
+ const id = randomUUID();
268
+ const slugBase =
269
+ input.name
270
+ .toLowerCase()
271
+ .normalize("NFKD")
272
+ .replace(/[\u0300-\u036f]/g, "")
273
+ .replace(/[^a-z0-9]+/g, "-")
274
+ .replace(/^-|-$/g, "")
275
+ .slice(0, 48) || "supply-chain-tool";
276
+ const tool: SupplyChainTool = {
277
+ ...input,
278
+ id,
279
+ slug: `${slugBase}-${id.slice(0, 8)}`,
280
+ createdAt,
281
+ averageRating: 0,
282
+ ratingCount: 0,
283
+ };
284
+ const bigQuery = getBigQuery();
285
+
286
+ if (!bigQuery) {
287
+ getDemoState().tools.unshift(tool);
288
+ return tool;
289
+ }
290
+
291
+ await bigQuery.client.query({
292
+ query: `
293
+ INSERT INTO ${toolTable(bigQuery.projectId, bigQuery.dataset)}
294
+ (tool_id, slug, name, summary, category, repo_url, website_url, submitted_by, created_at)
295
+ VALUES
296
+ (@id, @slug, @name, @summary, @category, @repoUrl, @websiteUrl, @submittedBy, TIMESTAMP(@createdAt))
297
+ `,
298
+ params: {
299
+ id: tool.id,
300
+ slug: tool.slug,
301
+ name: tool.name,
302
+ summary: tool.summary,
303
+ category: tool.category,
304
+ repoUrl: tool.repoUrl,
305
+ websiteUrl: tool.websiteUrl,
306
+ submittedBy: tool.submittedBy,
307
+ createdAt: tool.createdAt,
308
+ },
309
+ });
310
+ return tool;
311
+ }
312
+
313
+ export async function rateTool(toolId: string, voterId: string, rating: number) {
314
+ const bigQuery = getBigQuery();
315
+ if (!bigQuery) {
316
+ const state = getDemoState();
317
+ const tool = state.tools.find((candidate) => candidate.id === toolId);
318
+ if (!tool) return undefined;
319
+
320
+ const key = `${toolId}:${voterId}`;
321
+ const previousRating = state.ratings.get(key);
322
+ const total = tool.averageRating * tool.ratingCount;
323
+ const nextCount = tool.ratingCount + (previousRating === undefined ? 1 : 0);
324
+ const nextTotal = total - (previousRating ?? 0) + rating;
325
+ state.ratings.set(key, rating);
326
+ tool.averageRating = Number((nextTotal / nextCount).toFixed(1));
327
+ tool.ratingCount = nextCount;
328
+ return tool;
329
+ }
330
+
331
+ const tool = await getToolById(toolId);
332
+ if (!tool) return undefined;
333
+
334
+ await bigQuery.client.query({
335
+ query: `
336
+ MERGE ${ratingTable(bigQuery.projectId, bigQuery.dataset)} AS target
337
+ USING (SELECT @toolId AS tool_id, @voterId AS voter_id, @rating AS rating) AS source
338
+ ON target.tool_id = source.tool_id AND target.voter_id = source.voter_id
339
+ WHEN MATCHED THEN
340
+ UPDATE SET rating = source.rating, updated_at = CURRENT_TIMESTAMP()
341
+ WHEN NOT MATCHED THEN
342
+ INSERT (tool_id, voter_id, rating, created_at, updated_at)
343
+ VALUES (source.tool_id, source.voter_id, source.rating, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP())
344
+ `,
345
+ params: { toolId, voterId, rating },
346
+ });
347
+ return getToolById(toolId);
348
+ }
tsconfig.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "react-jsx",
15
+ "incremental": true,
16
+ "plugins": [
17
+ {
18
+ "name": "next"
19
+ }
20
+ ],
21
+ "paths": {
22
+ "@/*": ["./src/*"]
23
+ }
24
+ },
25
+ "include": [
26
+ "next-env.d.ts",
27
+ "**/*.ts",
28
+ "**/*.tsx",
29
+ ".next/types/**/*.ts",
30
+ ".next/dev/types/**/*.ts",
31
+ "**/*.mts"
32
+ ],
33
+ "exclude": ["node_modules"]
34
+ }