Suchinthana Wijesundara commited on
Commit ·
f64a3bf
1
Parent(s): c66c9dd
Init commit with project codes
Browse files- .dockerignore +8 -0
- .env.example +6 -0
- .gitignore +133 -0
- AGENTS.md +9 -0
- Dockerfile +25 -0
- README.md +80 -0
- eslint.config.mjs +18 -0
- infra/bigquery_schema.sql +23 -0
- next-env.d.ts +7 -0
- next.config.ts +15 -0
- package-lock.json +0 -0
- package.json +31 -0
- public/file.svg +1 -0
- public/globe.svg +1 -0
- public/next.svg +1 -0
- public/vercel.svg +1 -0
- public/window.svg +1 -0
- scripts/prepare-standalone.mjs +7 -0
- src/app/api/ratings/route.ts +44 -0
- src/app/api/tools/route.ts +69 -0
- src/app/directory.tsx +409 -0
- src/app/globals.css +396 -0
- src/app/icon.svg +7 -0
- src/app/layout.tsx +33 -0
- src/app/page.tsx +23 -0
- src/app/robots.ts +11 -0
- src/app/sitemap.ts +18 -0
- src/app/tool-rating.tsx +89 -0
- src/app/tools/[slug]/page.tsx +101 -0
- src/lib/tool-types.ts +23 -0
- src/lib/tools.ts +348 -0
- tsconfig.json +34 -0
.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 |
+
}
|