Compare commits
83
Commits
a9b67385c3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dadbf7946 | ||
|
|
ea6532c703 | ||
|
|
84d8139cca | ||
|
|
6c5cf6d3e6 | ||
|
|
4330f47c65 | ||
|
|
1025454d87 | ||
|
|
66af4193f1 | ||
|
|
a1e550da41 | ||
|
|
c77fa5e15c | ||
|
|
9ce8ad190f | ||
|
|
229cb15632 | ||
|
|
f6ab0c38a0 | ||
|
|
a0da49e2f8 | ||
|
|
7186741258 | ||
|
|
05056cf31d | ||
|
|
1014014145 | ||
|
|
dd98201d79 | ||
|
|
2d26877b71 | ||
|
|
12b99c76a6 | ||
|
|
b4bbf6892c | ||
|
|
464164d8a7 | ||
|
|
ba320380a0 | ||
|
|
48566081f0 | ||
|
|
10a15a133c | ||
|
|
5b1345cf9e | ||
|
|
4ca5f0af39 | ||
|
|
283d66b5e0 | ||
|
|
b311c363c5 | ||
|
|
006bf79655 | ||
|
|
1a45ba9fdb | ||
|
|
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 | ||
|
|
d8da598f1d | ||
|
|
628068e33d | ||
|
|
074fa9f316 | ||
|
|
596d3cf616 | ||
|
|
77792c624b |
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"
|
||||||
+296
-19
@@ -6,14 +6,20 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
# Cancel in-progress runs for the same ref so a fresh push supersedes them.
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.ref }}
|
group: ci-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
VPS_HOST: ${{ secrets.SSH_DEPLOY_HOST }}
|
||||||
|
VPS_USER: ${{ secrets.SSH_DEPLOY_USER }}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
test:
|
||||||
name: typecheck + build (turbo)
|
name: typecheck + tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -22,28 +28,299 @@ jobs:
|
|||||||
- name: Set up Bun
|
- name: Set up Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
bun-version: "1.3.14"
|
bun-version: "1.4.0"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
# Real build: typecheck + turbo run build across all workspace packages
|
|
||||||
# (incl. the Next.js web app). A green run means the whole monorepo
|
|
||||||
# compiles and the web app prerenders.
|
|
||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
run: bun run typecheck
|
run: bun run typecheck
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: 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: bun --cwd apps/mcp 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
|
- name: Test
|
||||||
run: bun run test
|
run: bun run test
|
||||||
|
|
||||||
|
# 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-next
|
||||||
|
path: web-next.tar.gz
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
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: Set up Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: "1.4.0"
|
||||||
|
|
||||||
|
- 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-next
|
||||||
|
path: /tmp/web-next
|
||||||
|
|
||||||
|
- name: Unpack .next
|
||||||
|
run: tar -xzf /tmp/web-next/web-next.tar.gz -C apps/web/
|
||||||
|
|
||||||
|
- name: Install deps (for bunx in web.nix)
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- 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"
|
||||||
|
|
||||||
|
- 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/"
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
name: Deploy
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows: ["CI"]
|
|
||||||
types: [completed]
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: deploy-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
name: build + deploy
|
|
||||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
|
||||||
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 app
|
|
||||||
run: bun run build --filter=@mcpedia/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
|
|
||||||
git pull origin main
|
|
||||||
/home/code/.bun/bin/bun install --frozen-lockfile
|
|
||||||
cd apps/web && /home/code/.bun/bin/bun run build && cd /home/code/mcpedia
|
|
||||||
/home/code/.bun/bin/bun run scripts/indexer.ts
|
|
||||||
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
|
|
||||||
@@ -43,3 +43,9 @@ next-env.d.ts
|
|||||||
# monorepo / turbo
|
# monorepo / turbo
|
||||||
.turbo/
|
.turbo/
|
||||||
dist/
|
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
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
# MCPedia Frontend Redesign — Spec
|
||||||
|
|
||||||
|
Status: DRAFT — minta approval sebelum coding
|
||||||
|
Date: 2026-09-08 WIB
|
||||||
|
Author: Hermes
|
||||||
|
|
||||||
|
## 1. Tujuan
|
||||||
|
|
||||||
|
Merombak `apps/web` agar:
|
||||||
|
- Satu sumber kebenaran: semua data berasal dari `@mcpedia/core` types & `@mcpedia/config` section presets; tidak ada shape duplikat di FE.
|
||||||
|
- Selaras dengan gateway/BE data flow: tRPC `apps/api` (`/trpc/*`) + REST `/api/*` + MCP `:4021` semua mengembalikan shape yang sama (DocumentMeta, SectionInfo, SearchHit, ExportData).
|
||||||
|
- Ganti pola fetch ad-hoc (`fetch("/api/...")` tersebar) dengan **SWR** terpusat — cache, revalidate, mutate, optimistic UI, error boundary konsisten.
|
||||||
|
- Hapus sisa TanStack (saat ini tidak ada deps TanStack — audit `apps/web/package.json` clean; tapi pola akan dibakukan supaya tidak masuk lagi).
|
||||||
|
- UI: Archival Precision tetap (monochrome, hairline, mono telemetry), tapi lebih live dan terisi data real (bukan placeholder).
|
||||||
|
|
||||||
|
## 2. Audit Status Quo
|
||||||
|
|
||||||
|
### FE sekarang
|
||||||
|
- Next.js 16.3.4, App Router, `force-dynamic` di semua page yang baca DB.
|
||||||
|
- RSC langsung import `@mcpedia/core` (`listDocuments`, `getDocument`, `listSections`, `getRelated`, `listRevisions`, `getExportDocuments`). SEO bagus, tapi client components (Sidebar, Header, Search, CommandMenu) fetch ulang via REST tanpa cache sharing.
|
||||||
|
- REST: `/api/search?q&mode&limit`, `/api/docs` (list), `/api/sections`, `/api/export?path&sort`, `/api/revisions/restore`, `/api/auth/login`.
|
||||||
|
- tRPC `apps/api` (Hono) expose: search/semanticSearch/hybridSearch, getDocument, listDocuments, sections, related, revisions, CRUD (gated `x-webhook-secret`), jobStatus, reindex hooks, `/health`, `/metrics`.
|
||||||
|
- MCP `:4021` Streamable HTTP `/mcp` expose 13 tools (tidak dipakai FE, tapi shape-nya sama).
|
||||||
|
- Tidak ada `swr` / `@tanstack/react-query` di `apps/web/package.json` — jadi migrasi = greenfield SWR layer, bukan replace.
|
||||||
|
- Design tokens sudah rapi di `globals.css` (CSS vars light/dark + `--brand`, `--bg-*`, `--text-*`, `--code-*`). Header/Sidebar/Markdown/TOC/DocActions sudah pakai tokens.
|
||||||
|
|
||||||
|
### Masalah yang diselesaikan redesign
|
||||||
|
1. Sidebar & Header fetch `/api/docs` + `/api/sections` tanpa deduplikasi — tiap navigasi fetch ulang, tidak ada stale-while-revalidate.
|
||||||
|
2. Search page client-side `fetch` manual tanpa debounce terpusat, tanpa cache antar-mode (hybrid/keyword/semantic saling overwrite).
|
||||||
|
3. Doc page RSC tidak live: setelah create/update/delete atau reindex, harus hard refresh. Tidak ada `mutate` cross-component.
|
||||||
|
4. Shape drift: Search `snippet` vs `SearchHit` vs `ChunkHit` tidak konsisten antara `/api/search` dan `@mcpedia/search` — perlu typed fetcher.
|
||||||
|
5. Tidak ada surface untuk queue/embeddings status (BE punya `jobStatus`, `queueStatus`, `INDEX_QUEUE` BullMQ) — FE buta terhadap indexing.
|
||||||
|
|
||||||
|
## 3. Arsitektur Target
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/web/app/
|
||||||
|
lib/
|
||||||
|
fetcher.ts — typed fetcher (fetch JSON + error envelope)
|
||||||
|
swr.ts — SWRConfig global (dedupingInterval, focusThrottle, errorRetry)
|
||||||
|
api.ts — typed client untuk /api/* (listDocuments, search, sections, export, revisions)
|
||||||
|
hooks/
|
||||||
|
useSections.ts — SWR '/api/sections' -> SectionInfo[]
|
||||||
|
useDocuments.ts — SWR '/api/docs?section&status' -> DocumentMeta[]
|
||||||
|
useDocument.ts — SWR '/api/docs/:slug' (baru, GET single) -> Document
|
||||||
|
useSearch.ts — SWR key [q, mode, limit] -> SearchHit[] + debounced
|
||||||
|
useRelated.ts — SWR '/api/related?slug'
|
||||||
|
useRevisions.ts — SWR '/api/revisions?slug'
|
||||||
|
useQueueStatus.ts — SWR '/api/queue/status' poll 5s (opsional, admin only)
|
||||||
|
components/
|
||||||
|
(existing: Header, Sidebar, CommandMenu, Markdown, TOC, DocActions, ...)
|
||||||
|
SearchBar.tsx — reusable, dipakai Header + Search page + CommandMenu
|
||||||
|
SectionCard.tsx — extract dari page.tsx
|
||||||
|
DocumentCard.tsx — reusable card (recent, related, folder list)
|
||||||
|
TagCloud.tsx — baru: agregasi tags dari docs
|
||||||
|
QueueBadge.tsx — baru: show indexing status (waiting/active/failed)
|
||||||
|
EmptyState.tsx / ErrorState.tsx
|
||||||
|
app/
|
||||||
|
layout.tsx — tambah <SWRProvider> wrapper (client boundary)
|
||||||
|
page.tsx — refactor: RSC shell + client islands via SWR
|
||||||
|
[section]/page.tsx — sama
|
||||||
|
[section]/[...slug]/page.tsx — RSC hero + Markdown, client islands (related/revisions via SWR)
|
||||||
|
search/page.tsx — fully SWR, hapus useState fetch manual
|
||||||
|
create/page.tsx — mutate after create
|
||||||
|
dashboard/page.tsx — BARU (optional phase 2): queue, sections, recent, tag stats
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prinsip SWR
|
||||||
|
- Satu `fetcher` generic: `fetcher<T>(url: string): Promise<T>` — throw on !ok dengan `info` + `status` untuk error boundary.
|
||||||
|
- `SWRConfig` di `layout.tsx` client wrapper: `dedupingInterval: 4000`, `focusThrottleInterval: 10000`, `shouldRetryOnError: false` untuk search, `refreshInterval` hanya untuk queue.
|
||||||
|
- Semua hooks pakai key array/string yang deterministic supaya `mutate` cross-page bisa: `mutate('/api/docs')`, `mutate('/api/sections')`.
|
||||||
|
- Debounce search di hook, bukan di component: `useDebouncedValue(q, 180ms)` internal ke `useSearch`.
|
||||||
|
- Optimistic mutate untuk create/update/delete: `mutate('/api/docs', optimisticList, false)` lalu revalidate.
|
||||||
|
|
||||||
|
## 4. Kontrak Data (FE ↔ BE)
|
||||||
|
|
||||||
|
Semua types import dari `@mcpedia/types` — tidak ada re-declare shape di FE.
|
||||||
|
|
||||||
|
| FE Hook | BE Source | Endpoint | Shape |
|
||||||
|
|---------|-----------|----------|-------|
|
||||||
|
| useSections | `listSections()` | `GET /api/sections` | `SectionInfo[]` |
|
||||||
|
| useDocuments | `listDocuments({section,status})` | `GET /api/docs?section=&status=` | `DocumentMeta[]` |
|
||||||
|
| useDocument | `getDocument(slug)` | `GET /api/docs/:slug` (baru) | `Document` |
|
||||||
|
| useSearch | `keywordSearch/hybridSearch/semanticSearch` | `GET /api/search?q&mode&limit` | `{results: {slug,title,section,score,snippet}[]}` |
|
||||||
|
| useRelated | `getRelated(slug)` | `GET /api/related?slug=` (baru, atau reuse via core) | `DocumentMeta[]` |
|
||||||
|
| useRevisions | `listRevisions(slug)` | `GET /api/revisions?slug=` (baru) | `Revision[]` |
|
||||||
|
| useQueueStatus | BullMQ `getQueue()` | `GET /api/queue/status` (baru, proxy ke apps/api /metrics atau queue) | `{waiting,active,completed,failed,delayed}` |
|
||||||
|
|
||||||
|
**Tambahan endpoint yang perlu dibuat (tipis, proxy ke core):**
|
||||||
|
- `GET /api/docs/[slug]` — single doc (hindari RSC-only fetch, enable SWR).
|
||||||
|
- `GET /api/related?slug=&limit=` — wrapper `getRelated`.
|
||||||
|
- `GET /api/revisions?slug=&limit=` — wrapper `listRevisions`.
|
||||||
|
- `GET /api/queue/status` — wrapper `getQueue().get*Count()` (admin-only atau public read-only).
|
||||||
|
- (Opsional) `GET /api/tags` — agregasi tags dari `listDocuments` untuk TagCloud.
|
||||||
|
|
||||||
|
Semua endpoint return envelope sama: `{data}` atau `{results}` + `{error}` on 500, dengan `Content-Type: application/json`. Auth untuk mutasi tetap `x-webhook-secret` atau cookie `mcpedia_admin`.
|
||||||
|
|
||||||
|
## 5. Perubahan Per-File (Rincian)
|
||||||
|
|
||||||
|
### Phase 0 — Foundation (wajib)
|
||||||
|
- `apps/web/package.json`: tambah `swr: ^2.3.x` (latest 2.x). Tidak tambah TanStack. `bun install` + cek `typecheck`.
|
||||||
|
- `apps/web/app/lib/fetcher.ts` (baru): `export const fetcher = <T>(url: string): Promise<T> => fetch(url).then(r => { if(!r.ok) throw ...; return r.json() })`
|
||||||
|
- `apps/web/app/lib/swr.tsx` (baru): `SWRProvider` client component wrapping `SWRConfig`.
|
||||||
|
- `apps/web/app/lib/api.ts` (baru): typed helpers `searchDocs(q,mode,limit)`, `listDocs()`, `listSections()` — dipakai hooks.
|
||||||
|
- `apps/web/app/layout.tsx`: bungkus children dengan `<SWRProvider>` (client boundary minimal).
|
||||||
|
|
||||||
|
### Phase 1 — Hooks + Refactor Existing Pages
|
||||||
|
- `hooks/useSections.ts`, `useDocuments.ts`, `useSearch.ts`: ganti `useEffect+fetch` di `Header.tsx`, `Sidebar.tsx`, `search/page.tsx`, `CommandMenu.tsx`, `page.tsx` (home).
|
||||||
|
- `Header.tsx`: `useSections()` alih-alih `useState+useEffect fetch /api/sections`.
|
||||||
|
- `Sidebar.tsx`: `useDocuments()` + `useSections()` + filter memo — hilangkan `useEffect fetch /api/docs`.
|
||||||
|
- `search/page.tsx`: `useSearch(q, mode)` — hapus `handleSearch` manual, pakai `isLoading`, `isValidating`, `error` dari SWR. Mode & section filter tetap local state, tapi key SWR mencakup keduanya.
|
||||||
|
- `CommandMenu.tsx`: `useSearch(query)` debounced — hapus timer manual.
|
||||||
|
- `app/page.tsx`: tetap RSC untuk SEO (listDocuments/listSections server), tapi `SectionTree` dan `Recently Updated` bisa jadi client islands yang pakai SWR untuk live update (opsional: keep RSC + `mutate` on focus).
|
||||||
|
- `[section]/page.tsx` & `[section]/[...slug]/page.tsx`: RSC hero + Markdown tetap, `related` & `revisions` pindah ke client islands `RelatedGrid` & `RevisionList` pakai `useRelated`/`useRevisions`.
|
||||||
|
|
||||||
|
### Phase 2 — Fitur Baru (detail)
|
||||||
|
1. **Dashboard `/dashboard` (baru, admin-aware):**
|
||||||
|
- Cards: total docs, sections, queue (waiting/active/failed), embedding provider status.
|
||||||
|
- Section breakdown bar (docCount per section).
|
||||||
|
- Tag cloud (top 20 tags dengan count).
|
||||||
|
- Recent activity (last 10 updated docs).
|
||||||
|
- Queue status poll 5s via `useQueueStatus` (hanya jika admin cookie ada, else hide).
|
||||||
|
2. **Tag Explorer `/tags` atau sidebar widget:**
|
||||||
|
- `GET /api/tags` agregasi, klik tag → `/search?tag=...` atau filter docs.
|
||||||
|
3. **Doc page enhancements:**
|
||||||
|
- `QueueBadge` di hero jika doc sedang di-index (query queue by slug).
|
||||||
|
- Optimistic revision restore: `mutate('/api/revisions?slug=...')` setelah POST restore.
|
||||||
|
- Related docs pakai SWR + `keepPreviousData`.
|
||||||
|
4. **Search enhancements:**
|
||||||
|
- Persist `mode` di URL (`?q=&mode=hybrid`) — sudah ada, pertahankan + SWR key sync.
|
||||||
|
- Highlight snippet `mark` sudah ada — pertahankan.
|
||||||
|
- Empty & error states pakai `EmptyState`/`ErrorState` components.
|
||||||
|
5. **Create/Edit:**
|
||||||
|
- Setelah `POST /api/docs`, `mutate('/api/docs')` + `mutate('/api/sections')` + `router.push('/${slug}')`.
|
||||||
|
- Custom fields (extraFields) tetap flat → `splitPayload` di BE tidak berubah.
|
||||||
|
6. **Export:**
|
||||||
|
- `PdfExportView` tetap, tapi tambah SWR preload `getExportDocuments` untuk preview count sebelum export.
|
||||||
|
|
||||||
|
### Phase 3 — Polish & Cleanup
|
||||||
|
- Hapus semua `fetch` ad-hoc yang tersisa — semua lewat hooks.
|
||||||
|
- Pastikan `globals.css` tokens dipakai konsisten (tidak ada hard-coded hex baru).
|
||||||
|
- `CommandMenu` jadi satu-satunya omnisearch entry — `Header` search icon di mobile buka CommandMenu, bukan link `/search`.
|
||||||
|
- A11y: `aria-live` untuk search results count, `role="status"` untuk queue badge.
|
||||||
|
- Print/PDF styles sudah ada — tidak diubah.
|
||||||
|
|
||||||
|
## 6. File yang Disentuh (Checklist)
|
||||||
|
|
||||||
|
Baru:
|
||||||
|
- `apps/web/app/lib/fetcher.ts`
|
||||||
|
- `apps/web/app/lib/swr.tsx`
|
||||||
|
- `apps/web/app/lib/api.ts`
|
||||||
|
- `apps/web/app/hooks/useSections.ts`
|
||||||
|
- `apps/web/app/hooks/useDocuments.ts`
|
||||||
|
- `apps/web/app/hooks/useSearch.ts`
|
||||||
|
- `apps/web/app/hooks/useRelated.ts`
|
||||||
|
- `apps/web/app/hooks/useRevisions.ts`
|
||||||
|
- `apps/web/app/hooks/useQueueStatus.ts`
|
||||||
|
- `apps/web/app/hooks/useTags.ts` (opsional)
|
||||||
|
- `apps/web/app/components/SearchBar.tsx`
|
||||||
|
- `apps/web/app/components/SectionCard.tsx`
|
||||||
|
- `apps/web/app/components/DocumentCard.tsx`
|
||||||
|
- `apps/web/app/components/TagCloud.tsx`
|
||||||
|
- `apps/web/app/components/QueueBadge.tsx`
|
||||||
|
- `apps/web/app/components/EmptyState.tsx`
|
||||||
|
- `apps/web/app/dashboard/page.tsx` (baru)
|
||||||
|
- `apps/web/app/api/docs/[slug]/route.ts` (baru)
|
||||||
|
- `apps/web/app/api/related/route.ts` (baru)
|
||||||
|
- `apps/web/app/api/revisions/route.ts` (baru GET)
|
||||||
|
- `apps/web/app/api/queue/status/route.ts` (baru)
|
||||||
|
- `apps/web/app/api/tags/route.ts` (baru, opsional)
|
||||||
|
|
||||||
|
Edit:
|
||||||
|
- `apps/web/package.json` (+ swr)
|
||||||
|
- `apps/web/app/layout.tsx` (+ SWRProvider)
|
||||||
|
- `apps/web/app/page.tsx` (extract SectionCard, optional SWR islands)
|
||||||
|
- `apps/web/app/[section]/page.tsx` (DocumentCard)
|
||||||
|
- `apps/web/app/[section]/[...slug]/page.tsx` (related/revisions islands)
|
||||||
|
- `apps/web/app/search/page.tsx` (full SWR)
|
||||||
|
- `apps/web/app/components/Header.tsx` (useSections)
|
||||||
|
- `apps/web/app/components/Sidebar.tsx` (useDocuments/useSections)
|
||||||
|
- `apps/web/app/components/CommandMenu.tsx` (useSearch)
|
||||||
|
- `apps/web/app/create/page.tsx` (mutate)
|
||||||
|
- `apps/web/app/components/DocForm.tsx` (mutate setelah submit)
|
||||||
|
|
||||||
|
Tidak disentuh:
|
||||||
|
- `packages/core`, `packages/types`, `packages/config`, `packages/db` (hanya dibaca)
|
||||||
|
- `apps/api`, `apps/mcp`, `apps/worker` (hanya diproxy)
|
||||||
|
- `globals.css`, `next.config.ts`, `tailwind` config (kecuali perlu token baru)
|
||||||
|
|
||||||
|
## 7. Urutan Implementasi
|
||||||
|
|
||||||
|
1. Phase 0 foundation → `bun install && bun run typecheck` green.
|
||||||
|
2. Phase 1 hooks + Header/Sidebar/Search/CommandMenu → manual test + `next build` smoke.
|
||||||
|
3. Phase 2 doc page islands + create mutate → test CRUD flow dengan `x-webhook-secret` / admin cookie.
|
||||||
|
4. Phase 2 dashboard + tags → behind `canEdit` guard jika perlu.
|
||||||
|
5. Phase 3 cleanup, hapus fetch manual, final `bun run lint && typecheck && build`.
|
||||||
|
|
||||||
|
## 8. Verifikasi
|
||||||
|
|
||||||
|
- `bun run typecheck` — tidak ada error (SWR types + @mcpedia/types).
|
||||||
|
- `bun run lint` — rules Next 16 ok.
|
||||||
|
- `bun --cwd apps/web run build` — semua route `force-dynamic` OK, tidak ada import-time DB throw.
|
||||||
|
- Manual:
|
||||||
|
- Sidebar filter + section count live setelah create doc.
|
||||||
|
- Search mode switch (hybrid/keyword/semantic) cache terpisah, tidak flicker.
|
||||||
|
- CommandMenu ⌘K debounce 180ms, Enter navigasi benar.
|
||||||
|
- Doc related & revisions muncul via SWR tanpa hard refresh.
|
||||||
|
- Queue badge (jika ada) poll 5s, tidak spam (dedupingInterval).
|
||||||
|
- Dark/light toggle tetap work (SWRProvider tidak break `ThemeToggle`).
|
||||||
|
- Staging: `curl /api/search?q=test&mode=hybrid`, `/api/sections`, `/api/docs` return shape konsisten dengan tRPC.
|
||||||
|
|
||||||
|
## 9. Risiko & Mitigasi
|
||||||
|
|
||||||
|
- RSC + SWR double fetch: mitigasi dengan `fallbackData` dari RSC props ke SWR hook di islands (opsi, tidak wajib v1).
|
||||||
|
- Peningkatan request ke DB: SWR deduping 4s + `revalidateOnFocus: false` untuk search, `true` untuk sections/docs.
|
||||||
|
- Auth untuk `/api/queue/status`: jadikan public read-only (hanya counts, bukan detail job) — aman.
|
||||||
|
|
||||||
|
## 10. Keputusan yang Perlu Kamu Konfirmasi
|
||||||
|
|
||||||
|
1. **Dashboard `/dashboard` perlu tidak?** Atau cukup queue badge di existing pages? Rekomendasi: bikin, tapi Phase 2 — bisa ditunda.
|
||||||
|
2. **Endpoint baru (`/api/docs/[slug]`, `/api/related`, `/api/revisions` GET, `/api/queue/status`, `/api/tags`) setuju?** Alternatif: FE langsung pakai tRPC client (`@trpc/client`) — tapi spec ini pilih REST `/api/*` supaya Next cache & SWR fetcher simpel.
|
||||||
|
3. **SWR vs tRPC+SWR?** Spec ini pakai SWR+REST (paling simpel, tidak tambah `@trpc/client` di FE). Jika mau tRPC, tambah `createTRPCClient` + `httpBatchLink` — tradeoff: lebih typed tapi lebih berat. Default: SWR+REST.
|
||||||
|
4. **Keep RSC server fetch untuk SEO atau full client SWR?** Rekomendasi: keep RSC untuk initial HTML (SEO), SWR untuk live revalidation di client islands — hybrid, bukan full SWR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Jawab: `Lanjut Phase 0+1` untuk eksekusi, atau beri koreksi poin 10.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# MCPedia — Nix-based CI/CD Migration Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Migrate from tarball+SSH deploy to Nix-native build-and-deploy (like GMW).
|
||||||
|
CI builds with Nix in GitHub Actions → `nix copy` closure to VPS →
|
||||||
|
`nix-env --set` + `systemctl restart` on VPS. VPS never builds.
|
||||||
|
|
||||||
|
## Services to migrate
|
||||||
|
| Service | Build | Runtime | systemd unit |
|
||||||
|
|---------|-------|---------|-------------|
|
||||||
|
| web (Next.js) | `bun run build` → `.next` | `next start` | `mcpedia-web` |
|
||||||
|
| api (Hono) | `bun build src/index.ts` → `dist/index.js` | `bun run src/index.ts` | `mcpedia-api` |
|
||||||
|
| mcp (MCP server) | `bun build src/http.ts` → `dist/http.js` | `bun run src/http.ts` | `mcpedia-mcp` |
|
||||||
|
| worker (BullMQ) | `bun build src/index.ts` → `dist/index.js` | `bun run src/index.ts` | `mcpedia-worker` |
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### 1. flake.nix (new)
|
||||||
|
- `nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"` (Bun >=1.3)
|
||||||
|
- 4 packages: `web`, `api`, `mcp`, `worker`
|
||||||
|
- Each: `mkDerivation` with `bun` as nativeBuildInput, `sandbox=false`
|
||||||
|
- Filter source to exclude `.next`, `node_modules`, `.git`
|
||||||
|
|
||||||
|
### 2. CI workflow (.github/workflows/ci.yml — rewrite)
|
||||||
|
- `test` job: typecheck + test (Bun, as before)
|
||||||
|
- `build-and-deploy` job: matrix [web, api, mcp, worker]
|
||||||
|
- `DeterminateSystems/nix-installer-action@v16` with `determinate: false`, `sandbox=false`
|
||||||
|
- `DeterminateSystems/magic-nix-cache-action@v14` with `use-flakehub: false`
|
||||||
|
- `nix build .#<service>` → store path
|
||||||
|
- SSH setup (key sanitization)
|
||||||
|
- `nix copy --to ssh://...`
|
||||||
|
- `nix-env --profile /nix/var/nix/profiles/mcpedia-<service> --set <path>`
|
||||||
|
- `systemctl restart mcpedia-<service>`
|
||||||
|
|
||||||
|
### 3. systemd units (update on VPS)
|
||||||
|
- Change `ExecStart` from `bun run` to Nix profile binary path
|
||||||
|
- Profile path: `/nix/var/nix/profiles/mcpedia-web/bin/mcpedia-web`
|
||||||
|
- Keep `EnvironmentFile` pointing to `.env`
|
||||||
|
- Keep `Restart=always`
|
||||||
|
|
||||||
|
### 4. Deploy secrets
|
||||||
|
- `SSH_PRIVATE_KEY` (already exists as `SSH_DEPLOY_KEY`)
|
||||||
|
- `VPS_HOST`, `VPS_USER`, `VPS_SSH_PORT`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- `nix build .#web --impure` builds
|
||||||
|
- `gh run list` shows all 4 services `completed success`
|
||||||
|
- `curl https://wiki.asepharyana.my.id/` returns 200
|
||||||
|
- `journalctl -u mcpedia-web` shows Next.js started
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Phase 15b — CI/CD: Build in CI, Deploy only on VPS
|
||||||
|
|
||||||
|
> User: "alur ci nya juga benarkan lah masa build di vps,harusnya kan di vps hanya deploy, dan seharusnya pakai nix"
|
||||||
|
|
||||||
|
## Final Architecture
|
||||||
|
|
||||||
|
### CI Workflow (`.github/workflows/ci.yml`)
|
||||||
|
Single workflow with two jobs:
|
||||||
|
|
||||||
|
**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`)
|
||||||
|
|
||||||
|
**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`
|
||||||
|
|
||||||
|
### Key Fixes During This Phase
|
||||||
|
|
||||||
|
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 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
|
## 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`.
|
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`.
|
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).
|
4. **Mutations & Security**: State-changing operations (document creation/updates/deletions, reindexing, revision restoration) require authentication (`x-webhook-secret` header or session cookie).
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"@mcpedia/queue": "workspace:*",
|
"@mcpedia/queue": "workspace:*",
|
||||||
"@trpc/server": "^11.0.0",
|
"@trpc/server": "^11.0.0",
|
||||||
"hono": "^4.6.0",
|
"hono": "^4.6.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^4.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"start": "bun run src/index.ts",
|
"start": "bun run src/index.ts",
|
||||||
"chat": "bun run src/index.ts",
|
"chat": "bun run src/index.ts",
|
||||||
"ask": "bun run src/ask.ts",
|
"ask": "bun run src/ask.ts",
|
||||||
"lint": "bun run lint",
|
"lint": "tsc --noEmit",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"build": "bun build src/index.ts --outdir=dist --format=esm",
|
"build": "bun build src/index.ts --outdir=dist --format=esm",
|
||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^26",
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^26",
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,16 +15,20 @@ async function main() {
|
|||||||
const names = tools.tools.map((t) => t.name).sort();
|
const names = tools.tools.map((t) => t.name).sort();
|
||||||
console.log("tools:", names.join(", "));
|
console.log("tools:", names.join(", "));
|
||||||
const expected = [
|
const expected = [
|
||||||
|
"create_document",
|
||||||
|
"delete_document",
|
||||||
"get_document",
|
"get_document",
|
||||||
"get_related_documents",
|
"get_related_documents",
|
||||||
"hybrid_search",
|
"hybrid_search",
|
||||||
"index_document",
|
"index_document",
|
||||||
"list_documents",
|
"list_documents",
|
||||||
|
"list_sections",
|
||||||
"queue_status",
|
"queue_status",
|
||||||
"reindex_all",
|
"reindex_all",
|
||||||
"restore_revision",
|
"restore_revision",
|
||||||
"search_documents",
|
"search_documents",
|
||||||
"semantic_search",
|
"semantic_search",
|
||||||
|
"update_document",
|
||||||
].sort();
|
].sort();
|
||||||
if (JSON.stringify(names) !== JSON.stringify(expected)) {
|
if (JSON.stringify(names) !== JSON.stringify(expected)) {
|
||||||
throw new Error(`tool set mismatch: ${names.join(",")}`);
|
throw new Error(`tool set mismatch: ${names.join(",")}`);
|
||||||
|
|||||||
@@ -1,21 +1,37 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { getDocument, getRelated, listRevisions, listDocuments } from "@mcpedia/core";
|
import { getDocument, listDocuments, getExportDocuments } from "@mcpedia/core";
|
||||||
import { WEBHOOK_SECRET } from "@mcpedia/config";
|
import { WEBHOOK_SECRET } from "@mcpedia/config";
|
||||||
import Markdown from "@/components/Markdown";
|
import Markdown from "@/components/Markdown";
|
||||||
import DocForm from "@/components/DocForm";
|
import DocForm from "@/components/DocForm";
|
||||||
import TOC from "@/components/TOC";
|
import TOC from "@/components/TOC";
|
||||||
import DocActions from "@/components/DocActions";
|
import DocActions from "@/components/DocActions";
|
||||||
|
import PdfExportView from "@/components/PdfExportView";
|
||||||
|
import RelatedGrid from "@/components/RelatedGrid";
|
||||||
|
import RevisionList from "@/components/RevisionList";
|
||||||
import { classifyPath, extractFoldersForSection } from "@mcpedia/core";
|
import { classifyPath, extractFoldersForSection } from "@mcpedia/core";
|
||||||
import { getSectionMeta } from "@mcpedia/config";
|
import { getSectionMeta } from "@mcpedia/config";
|
||||||
import type { DocumentMeta } from "@mcpedia/core";
|
import type { DocumentMeta } from "@mcpedia/core";
|
||||||
|
import {
|
||||||
|
Folder,
|
||||||
|
FileText,
|
||||||
|
Clock,
|
||||||
|
FileDown,
|
||||||
|
ArrowLeft,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Target,
|
||||||
|
Trophy,
|
||||||
|
Zap,
|
||||||
|
Tag,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
interface DocPageProps {
|
interface DocPageProps {
|
||||||
params: Promise<{ section: string; slug: string[] }>;
|
params: Promise<{ section: string; slug: string[] }>;
|
||||||
searchParams: Promise<{ edit?: string }>;
|
searchParams: Promise<{ edit?: string; path?: string; sort?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSectionInfo(section: string) {
|
function getSectionInfo(section: string) {
|
||||||
@@ -48,44 +64,41 @@ function CustomFieldBadges({
|
|||||||
{customEntries.map(([key, value]) => {
|
{customEntries.map(([key, value]) => {
|
||||||
const label = key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " ");
|
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 colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-secondary)]";
|
||||||
let icon = "🏷️";
|
|
||||||
let displayValue: string;
|
let displayValue: string;
|
||||||
|
let IconComponent = Tag;
|
||||||
|
|
||||||
if (typeof value === "number") {
|
if (typeof value === "number") {
|
||||||
colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#5e6ad2] dark:text-[#7170ff]";
|
colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-primary)] font-semibold";
|
||||||
icon = "🎯";
|
IconComponent = Target;
|
||||||
displayValue = `${value} pts`;
|
displayValue = `${value} pts`;
|
||||||
} else if (typeof value === "boolean") {
|
} else if (typeof value === "boolean") {
|
||||||
colorClass = value
|
colorClass = value
|
||||||
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400"
|
? "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";
|
: "bg-rose-500/10 border-rose-500/30 text-rose-600 dark:text-rose-400 font-semibold";
|
||||||
icon = value ? "✓" : "✕";
|
IconComponent = value ? CheckCircle2 : XCircle;
|
||||||
displayValue = value ? "Solved" : "Pending";
|
displayValue = value ? "Solved" : "Pending";
|
||||||
} else if (Array.isArray(value)) {
|
} else if (Array.isArray(value)) {
|
||||||
colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#5e6ad2] dark:text-[#7170ff]";
|
colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-secondary)]";
|
||||||
icon = "🗂️";
|
IconComponent = Folder;
|
||||||
displayValue = value.join(", ");
|
displayValue = value.join(", ");
|
||||||
} else {
|
} else {
|
||||||
const v = String(value).toLowerCase();
|
const v = String(value).toLowerCase();
|
||||||
if (/^(easy|simple|beginner)/i.test(v)) {
|
if (/^(easy|simple|beginner)/i.test(v)) {
|
||||||
colorClass = "bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400";
|
colorClass = "bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400 font-semibold";
|
||||||
icon = "🟢";
|
|
||||||
displayValue = "Easy";
|
displayValue = "Easy";
|
||||||
} else if (/^(medium|intermediate)/i.test(v)) {
|
} else if (/^(medium|intermediate)/i.test(v)) {
|
||||||
colorClass = "bg-amber-500/10 border-amber-500/30 text-amber-600 dark:text-amber-400";
|
colorClass = "bg-amber-500/10 border-amber-500/30 text-amber-600 dark:text-amber-400 font-semibold";
|
||||||
icon = "🟡";
|
|
||||||
displayValue = "Medium";
|
displayValue = "Medium";
|
||||||
} else if (/^(hard|expert|advanced)/i.test(v)) {
|
} else if (/^(hard|expert|advanced)/i.test(v)) {
|
||||||
colorClass = "bg-rose-500/10 border-rose-500/30 text-rose-600 dark:text-rose-400";
|
colorClass = "bg-rose-500/10 border-rose-500/30 text-rose-600 dark:text-rose-400 font-semibold";
|
||||||
icon = "🔴";
|
|
||||||
displayValue = "Hard";
|
displayValue = "Hard";
|
||||||
} else if (/^(pwn|web|crypto|misc|forensic|reverse|binary)/i.test(v)) {
|
} 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";
|
colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-primary)] font-bold";
|
||||||
icon = "⚡";
|
IconComponent = Zap;
|
||||||
displayValue = v.toUpperCase();
|
displayValue = v.toUpperCase();
|
||||||
} else if (/ctf|def.?con|event/i.test(v)) {
|
} else if (/ctf|def.?con|event/i.test(v)) {
|
||||||
colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#5e6ad2] dark:text-[#7170ff]";
|
colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-primary)]";
|
||||||
icon = "🏆";
|
IconComponent = Trophy;
|
||||||
displayValue = String(value);
|
displayValue = String(value);
|
||||||
} else {
|
} else {
|
||||||
displayValue = String(value);
|
displayValue = String(value);
|
||||||
@@ -97,9 +110,9 @@ function CustomFieldBadges({
|
|||||||
key={key}
|
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}`}
|
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="text-[var(--text-muted)] font-sans">{label}:</span>
|
||||||
<span className="font-medium">{displayValue}</span>
|
<span>{displayValue}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -145,9 +158,9 @@ function FolderIndexPage({
|
|||||||
const folderName = slug.split("/").pop()!.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
const folderName = slug.split("/").pop()!.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-8">
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb Navigation */}
|
||||||
<nav className="flex items-center gap-2 text-xs text-[var(--text-dim)] mb-6 flex-wrap">
|
<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">
|
<Link href="/" className="hover:text-[var(--text-primary)] transition-colors">
|
||||||
MCPedia
|
MCPedia
|
||||||
</Link>
|
</Link>
|
||||||
@@ -170,48 +183,59 @@ function FolderIndexPage({
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Header card */}
|
{/* Header 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 shadow-xs">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<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-xl">
|
<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>
|
||||||
<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}
|
{folderName}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-[var(--text-muted)] mt-0.5">
|
<p className="text-xs text-[var(--text-muted)] mt-0.5 font-mono">
|
||||||
Directory path: <code className="text-[#5e6ad2] dark:text-[#7170ff] font-mono">{section}/{slug}</code>
|
Directory: <code>{section}/{slug}</code>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
|
||||||
|
<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" : ""}
|
{immediateDocs.length + folders.length} item{folders.length + immediateDocs.length !== 1 ? "s" : ""}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subfolders */}
|
{/* Subfolders Grid */}
|
||||||
{folders.length > 0 && (
|
{folders.length > 0 && (
|
||||||
<div className="mb-8">
|
<div>
|
||||||
<h2 className="text-xs font-semibold text-[var(--text-muted)] uppercase mb-3 tracking-wider flex items-center gap-2">
|
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase mb-3 tracking-wider font-mono flex items-center gap-2">
|
||||||
<span>Subfolders</span>
|
<Folder className="w-3.5 h-3.5 text-[var(--text-muted)]" />
|
||||||
<span className="text-[10px] text-[var(--text-dim)]">({folders.length})</span>
|
<span>Subfolders ({folders.length})</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
{folders.map(([folder, info]) => (
|
{folders.map(([folder, info]) => (
|
||||||
<Link
|
<Link
|
||||||
key={folder}
|
key={folder}
|
||||||
href={`/${section}/${slug}/${folder}`.replace(/\/+/g, "/")}
|
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="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(" ")}
|
{folder.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")}
|
||||||
</div>
|
</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" : ""}
|
{info.docCount} document{info.docCount !== 1 ? "s" : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -221,32 +245,32 @@ function FolderIndexPage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Documents */}
|
{/* Documents List */}
|
||||||
{immediateDocs.length > 0 && (
|
{immediateDocs.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xs font-semibold text-[var(--text-muted)] uppercase mb-3 tracking-wider flex items-center gap-2">
|
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase mb-3 tracking-wider font-mono flex items-center gap-2">
|
||||||
<span>Documents</span>
|
<FileText className="w-3.5 h-3.5 text-[var(--text-muted)]" />
|
||||||
<span className="text-[10px] text-[var(--text-dim)]">({immediateDocs.length})</span>
|
<span>Documents ({immediateDocs.length})</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{immediateDocs.map((doc) => (
|
{immediateDocs.map((doc) => (
|
||||||
<Link
|
<Link
|
||||||
key={doc.slug}
|
key={doc.slug}
|
||||||
href={`/${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">
|
<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="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}
|
{doc.title}
|
||||||
</div>
|
</div>
|
||||||
{doc.tags.length > 0 && (
|
{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) => (
|
{doc.tags.slice(0, 4).map((t) => (
|
||||||
<span
|
<span
|
||||||
key={t}
|
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}
|
#{t}
|
||||||
</span>
|
</span>
|
||||||
@@ -255,11 +279,15 @@ function FolderIndexPage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<time className="text-xs text-[var(--text-dim)] shrink-0 font-mono">
|
|
||||||
|
<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, {
|
{new Date(doc.updatedAt).toLocaleDateString(undefined, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
})}
|
})}
|
||||||
|
</span>
|
||||||
</time>
|
</time>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
@@ -268,8 +296,8 @@ function FolderIndexPage({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{folders.length === 0 && immediateDocs.length === 0 && (
|
{folders.length === 0 && immediateDocs.length === 0 && (
|
||||||
<div className="text-center py-12 border border-[var(--border-color)] rounded-xl bg-[var(--bg-surface)]">
|
<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 found in this directory.</p>
|
<p className="text-sm text-[var(--text-muted)]">No documents in this directory.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -278,7 +306,23 @@ function FolderIndexPage({
|
|||||||
|
|
||||||
export default async function DocPage({ params, searchParams }: DocPageProps) {
|
export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||||
const { section, slug } = await params;
|
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 fullSlug = `${section}/${slug.join("/")}`;
|
||||||
|
|
||||||
const allDocs = await listDocuments();
|
const allDocs = await listDocuments();
|
||||||
@@ -304,14 +348,15 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
|||||||
if (edit === "1" && canEdit) {
|
if (edit === "1" && canEdit) {
|
||||||
const existingFolders = extractFoldersForSection(docSlugs, doc.section);
|
const existingFolders = extractFoldersForSection(docSlugs, doc.section);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-6">
|
||||||
<Link
|
<Link
|
||||||
href={`/${doc.slug}`}
|
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>
|
</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}
|
Edit: {doc.title}
|
||||||
</h1>
|
</h1>
|
||||||
<DocForm
|
<DocForm
|
||||||
@@ -337,8 +382,6 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const related = await getRelated(fullSlug, 4);
|
|
||||||
const revisions = await listRevisions(fullSlug, 10);
|
|
||||||
const sectionInfo = getSectionInfo(doc.section);
|
const sectionInfo = getSectionInfo(doc.section);
|
||||||
const readingTime = calculateReadingTime(doc.body);
|
const readingTime = calculateReadingTime(doc.body);
|
||||||
|
|
||||||
@@ -346,8 +389,8 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
|||||||
<div className="flex items-start gap-8">
|
<div className="flex items-start gap-8">
|
||||||
{/* Main Content Column */}
|
{/* Main Content Column */}
|
||||||
<article className="flex-1 min-w-0">
|
<article className="flex-1 min-w-0">
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumbs Navigation */}
|
||||||
<nav className="flex items-center gap-2 text-xs text-[var(--text-dim)] mb-4 flex-wrap">
|
<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">
|
<Link href="/" className="hover:text-[var(--text-primary)] transition-colors">
|
||||||
MCPedia
|
MCPedia
|
||||||
</Link>
|
</Link>
|
||||||
@@ -362,7 +405,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
|||||||
<span>/</span>
|
<span>/</span>
|
||||||
<Link
|
<Link
|
||||||
href={`/${crumbPath}`}
|
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}
|
{label}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -372,24 +415,26 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Hero Metadata Card */}
|
{/* 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 flex-col sm:flex-row sm:items-start justify-between gap-4 mb-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
<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-medium bg-[#5e6ad2]/15 text-[#5e6ad2] dark:text-[#7170ff] border border-[#5e6ad2]/30">
|
<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>{sectionInfo.icon}</span>
|
||||||
<span className="uppercase">{sectionInfo.label}</span>
|
<span>{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>
|
</span>
|
||||||
<span className="text-xs text-[var(--text-dim)]">·</span>
|
<span className="text-xs text-[var(--text-dim)]">·</span>
|
||||||
<span className="text-xs text-[var(--text-muted)] font-mono">
|
<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>
|
</span>
|
||||||
</div>
|
</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}
|
{doc.title}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -398,24 +443,27 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
|||||||
<DocActions slug={doc.slug} body={doc.body} canEdit={canEdit} />
|
<DocActions slug={doc.slug} body={doc.body} canEdit={canEdit} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Author + Date + Tags */}
|
{/* 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)]">
|
<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 && (
|
{doc.author && (
|
||||||
<div className="flex items-center gap-1.5">
|
<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()}
|
{doc.author.charAt(0).toUpperCase()}
|
||||||
</div>
|
</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>
|
<span className="text-[var(--text-dim)]">·</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<span className="flex items-center gap-1 font-mono text-[11px]">
|
||||||
<span>
|
<span>
|
||||||
Updated {new Date(doc.updatedAt).toLocaleDateString(undefined, {
|
Updated {new Date(doc.updatedAt).toLocaleDateString(undefined, {
|
||||||
month: "long",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
{doc.tags.length > 0 && (
|
{doc.tags.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<span className="text-[var(--text-dim)]">·</span>
|
<span className="text-[var(--text-dim)]">·</span>
|
||||||
@@ -423,7 +471,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
|||||||
{doc.tags.map((t) => (
|
{doc.tags.map((t) => (
|
||||||
<span
|
<span
|
||||||
key={t}
|
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}
|
#{t}
|
||||||
</span>
|
</span>
|
||||||
@@ -437,93 +485,18 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
|||||||
<CustomFieldBadges doc={{ ...doc }} standardKeys={STANDARD_KEYS} />
|
<CustomFieldBadges doc={{ ...doc }} standardKeys={STANDARD_KEYS} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile TOC (rendered inline on smaller screens) */}
|
{/* Mobile TOC */}
|
||||||
<div className="xl:hidden mb-6 p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-lg shadow-sm">
|
<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} />
|
<TOC source={doc.body} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Markdown content */}
|
{/* Markdown Content */}
|
||||||
<div className="mb-14">
|
<div className="mb-14">
|
||||||
<Markdown source={doc.body} />
|
<Markdown source={doc.body} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Related Documents */}
|
<RelatedGrid slug={fullSlug} />
|
||||||
{related.length > 0 && (
|
<RevisionList slug={fullSlug} canEdit={canEdit} />
|
||||||
<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="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
||||||
{related.map((r) => {
|
|
||||||
const rInfo = getSectionInfo(r.section);
|
|
||||||
return (
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2 mb-2">
|
|
||||||
<span className="text-xs text-[#5e6ad2] dark:text-[#7170ff] font-mono uppercase">
|
|
||||||
{rInfo.icon} {r.section}
|
|
||||||
</span>
|
|
||||||
<time className="text-[11px] text-[var(--text-dim)] font-mono">
|
|
||||||
{new Date(r.updatedAt).toLocaleDateString(undefined, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
})}
|
|
||||||
</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">
|
|
||||||
{r.title}
|
|
||||||
</h3>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<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">
|
|
||||||
v{rev.revisionNo}
|
|
||||||
</span>
|
|
||||||
<span className="text-[var(--text-primary)] truncate">
|
|
||||||
{rev.reason || "Updated document"}
|
|
||||||
</span>
|
|
||||||
<span className="text-[var(--text-dim)] hidden sm:inline">
|
|
||||||
· {new Date(rev.createdAt).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{canEdit && (
|
|
||||||
<form action="/api/revisions/restore" method="post">
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
Restore
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
)}
|
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
{/* Desktop Sticky Table of Contents Column */}
|
{/* Desktop Sticky Table of Contents Column */}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { listDocuments } from "@mcpedia/core";
|
import { listDocuments } from "@mcpedia/core";
|
||||||
import { getSectionMeta } from "@mcpedia/config";
|
import { getSectionMeta } from "@mcpedia/config";
|
||||||
|
import {
|
||||||
|
Folder,
|
||||||
|
FileText,
|
||||||
|
FileDown,
|
||||||
|
Plus,
|
||||||
|
Clock,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -81,26 +88,28 @@ function renderTree(nodes: TreeNode[], pathname: string) {
|
|||||||
<li key={node.slug}>
|
<li key={node.slug}>
|
||||||
<Link
|
<Link
|
||||||
href={`/${node.slug}`}
|
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
|
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
|
: hasActiveChild
|
||||||
? "text-[var(--text-primary)] bg-[var(--bg-elevated)]"
|
? "text-[var(--text-primary)] bg-[var(--bg-elevated)]"
|
||||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover: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 className="flex items-center gap-2 truncate">
|
||||||
<span>{isFolder ? "📁" : "📄"}</span>
|
<span className="text-[var(--text-dim)]">
|
||||||
<span className="truncate">{node.doc?.title || node.name}</span>
|
{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>
|
</span>
|
||||||
{isFolder && (
|
{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
|
{node.docCount} docs
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
{node.children.length > 0 && (
|
{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)}
|
{renderTree(node.children, pathname)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -124,57 +133,73 @@ export default async function SectionIndexPage({
|
|||||||
const tree = buildFolderTree(allDocs, section);
|
const tree = buildFolderTree(allDocs, section);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-8">
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb Navigation */}
|
||||||
<nav className="flex items-center gap-2 text-xs text-[var(--text-dim)]">
|
<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">
|
<Link href="/" className="hover:text-[var(--text-primary)] transition-colors">
|
||||||
MCPedia
|
MCPedia
|
||||||
</Link>
|
</Link>
|
||||||
<span>/</span>
|
<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>
|
</nav>
|
||||||
|
|
||||||
{/* Hero Card */}
|
{/* 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 flex-col sm:flex-row sm:items-start justify-between gap-4 mb-3">
|
||||||
<div className="flex items-center gap-3">
|
<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 shadow-inner">
|
<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}
|
{sectionInfo.icon}
|
||||||
</div>
|
</div>
|
||||||
<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}
|
{sectionInfo.label}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-[var(--text-muted)] mt-0.5 font-mono uppercase">
|
<p className="text-xs text-[var(--text-muted)] font-mono uppercase tracking-wider mt-0.5">
|
||||||
Section: {section}
|
Section: <code>{section}</code>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap sm:flex-nowrap">
|
||||||
<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">
|
<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" : ""}
|
{sectionDocs.length} document{sectionDocs.length !== 1 ? "s" : ""}
|
||||||
</span>
|
</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
|
<Link
|
||||||
href="/create"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</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}
|
{sectionInfo.desc}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hierarchical Folder Directory */}
|
{/* Hierarchical Folder Directory */}
|
||||||
{tree.length > 0 && (
|
{tree.length > 0 && (
|
||||||
<section className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 shadow-sm">
|
<section className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-5 shadow-xs">
|
||||||
<h2 className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider mb-4 flex items-center gap-2">
|
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-[var(--border-color)]">
|
||||||
<span>Folder Structure</span>
|
<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>
|
</h2>
|
||||||
|
</div>
|
||||||
{renderTree(tree, section)}
|
{renderTree(tree, section)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
@@ -182,9 +207,13 @@ export default async function SectionIndexPage({
|
|||||||
{/* Document List */}
|
{/* Document List */}
|
||||||
{sectionDocs.length > 0 ? (
|
{sectionDocs.length > 0 ? (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider mb-4">
|
<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}
|
All Documents in {sectionInfo.label}
|
||||||
</h2>
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{[...sectionDocs]
|
{[...sectionDocs]
|
||||||
.sort(
|
.sort(
|
||||||
@@ -195,12 +224,12 @@ export default async function SectionIndexPage({
|
|||||||
<Link
|
<Link
|
||||||
key={d.slug}
|
key={d.slug}
|
||||||
href={`/${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">
|
<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">
|
<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}
|
{d.title}
|
||||||
</h3>
|
</h3>
|
||||||
{d.tags.length > 0 && (
|
{d.tags.length > 0 && (
|
||||||
@@ -208,7 +237,7 @@ export default async function SectionIndexPage({
|
|||||||
{d.tags.slice(0, 4).map((t) => (
|
{d.tags.slice(0, 4).map((t) => (
|
||||||
<span
|
<span
|
||||||
key={t}
|
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}
|
#{t}
|
||||||
</span>
|
</span>
|
||||||
@@ -217,12 +246,16 @@ export default async function SectionIndexPage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<time className="text-xs text-[var(--text-dim)] font-mono shrink-0">
|
|
||||||
|
<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, {
|
{new Date(d.updatedAt).toLocaleDateString(undefined, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
})}
|
})}
|
||||||
|
</span>
|
||||||
</time>
|
</time>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -13,15 +13,23 @@ function signCookie(value: string): string {
|
|||||||
return `${value}.${sig}`;
|
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 {
|
function verifyCookie(cookieValue: string | undefined): boolean {
|
||||||
if (!cookieValue) return false;
|
if (!cookieValue) return false;
|
||||||
const [value, sig] = cookieValue.split(".");
|
const [value, sig] = cookieValue.split(".");
|
||||||
if (!value || !sig) return false;
|
if (!value || !sig) return false;
|
||||||
const expected = signCookie(value);
|
const expected = signCookie(value);
|
||||||
return timingSafeEqual(
|
return safeTimingEqual(cookieValue, expected);
|
||||||
Buffer.from(cookieValue),
|
|
||||||
Buffer.from(expected),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
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 });
|
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const ok = timingSafeEqual(
|
const ok = safeTimingEqual(password, ADMIN_PASSWORD);
|
||||||
Buffer.from(password),
|
|
||||||
Buffer.from(ADMIN_PASSWORD),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ import { timingSafeEqual } from "node:crypto";
|
|||||||
function isAuthorized(req: NextRequest): boolean {
|
function isAuthorized(req: NextRequest): boolean {
|
||||||
if (!WEBHOOK_SECRET) return false;
|
if (!WEBHOOK_SECRET) return false;
|
||||||
const headerSecret = req.headers.get("x-webhook-secret") ?? "";
|
const headerSecret = req.headers.get("x-webhook-secret") ?? "";
|
||||||
if (headerSecret && timingSafeEqual(Buffer.from(headerSecret), Buffer.from(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;
|
return true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const cookie = req.cookies.get("mcpedia_admin")?.value ?? "";
|
const cookie = req.cookies.get("mcpedia_admin")?.value ?? "";
|
||||||
return cookie.startsWith("admin.");
|
return cookie.startsWith("admin.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,13 @@ import { timingSafeEqual } from "node:crypto";
|
|||||||
function isAuthorized(req: NextRequest): boolean {
|
function isAuthorized(req: NextRequest): boolean {
|
||||||
if (!WEBHOOK_SECRET) return false;
|
if (!WEBHOOK_SECRET) return false;
|
||||||
const headerSecret = req.headers.get("x-webhook-secret") ?? "";
|
const headerSecret = req.headers.get("x-webhook-secret") ?? "";
|
||||||
if (headerSecret && timingSafeEqual(Buffer.from(headerSecret), Buffer.from(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;
|
return true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const cookie = req.cookies.get("mcpedia_admin")?.value ?? "";
|
const cookie = req.cookies.get("mcpedia_admin")?.value ?? "";
|
||||||
return cookie.startsWith("admin.");
|
return cookie.startsWith("admin.");
|
||||||
}
|
}
|
||||||
@@ -74,8 +78,15 @@ function splitPayload(body: Record<string, unknown>): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/docs — list all documents (for sidebar navigation).
|
// GET /api/docs — list all documents (for sidebar navigation).
|
||||||
export async function GET() {
|
// Supports ?section=&status= filters.
|
||||||
const docs = await listDocuments();
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const section = searchParams.get("section") ?? undefined;
|
||||||
|
const status = searchParams.get("status") ?? undefined;
|
||||||
|
const docs = await listDocuments({
|
||||||
|
...(section ? { section } : {}),
|
||||||
|
...(status ? { status } : {}),
|
||||||
|
});
|
||||||
return NextResponse.json(docs);
|
return NextResponse.json(docs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// GET /api/queue/status — BullMQ queue counts (read-only, public)
|
||||||
|
// Falls back gracefully when Redis is unavailable so the UI never breaks.
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const { getQueue } = await import("@mcpedia/queue");
|
||||||
|
const q = getQueue();
|
||||||
|
const [waiting, active, completed, failed, delayed] = await Promise.all([
|
||||||
|
q.getWaitingCount(),
|
||||||
|
q.getActiveCount(),
|
||||||
|
q.getCompletedCount(),
|
||||||
|
q.getFailedCount(),
|
||||||
|
q.getDelayedCount(),
|
||||||
|
]);
|
||||||
|
return NextResponse.json({ waiting, active, completed, failed, delayed });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
// Return zeroed status so the FE can render gracefully
|
||||||
|
return NextResponse.json(
|
||||||
|
{ waiting: 0, active: 0, completed: 0, failed: 0, delayed: 0, error: msg },
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getRelated } from "@mcpedia/core";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// GET /api/related?slug=...&limit=5
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const slug = new URL(req.url).searchParams.get("slug") ?? "";
|
||||||
|
const limit = Math.min(Math.max(Number(new URL(req.url).searchParams.get("limit") ?? 5), 1), 20);
|
||||||
|
if (!slug.trim()) {
|
||||||
|
return NextResponse.json({ results: [] });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const results = await getRelated(slug, limit);
|
||||||
|
return NextResponse.json({ results });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
return NextResponse.json({ results: [], error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,32 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { restoreRevision, getRevision } from "@mcpedia/core";
|
import { restoreRevision, getRevision } from "@mcpedia/core";
|
||||||
|
import { WEBHOOK_SECRET } from "@mcpedia/config";
|
||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
import { revalidatePath } from "next/cache";
|
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.
|
// POST /api/revisions/restore — restore a document to a past revision.
|
||||||
// Body (form-urlencoded): id=<revision uuid>
|
// Body (form-urlencoded): id=<revision uuid>
|
||||||
// After restoring, we rebuild semantic chunks (handled inside restoreRevision)
|
// After restoring, we rebuild semantic chunks (handled inside restoreRevision)
|
||||||
// and revalidate the doc page so the Web UI reflects the restored body.
|
// and revalidate the doc page so the Web UI reflects the restored body.
|
||||||
export async function POST(req: NextRequest) {
|
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 form = await req.formData().catch(() => null);
|
||||||
const id = form?.get("id");
|
const id = form?.get("id");
|
||||||
if (typeof id !== "string" || id.length === 0) {
|
if (typeof id !== "string" || id.length === 0) {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { listRevisions } from "@mcpedia/core";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// GET /api/revisions?slug=...&limit=10
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const slug = searchParams.get("slug") ?? "";
|
||||||
|
const limit = Math.min(Math.max(Number(searchParams.get("limit") ?? 10), 1), 50);
|
||||||
|
if (!slug.trim()) {
|
||||||
|
return NextResponse.json({ revisions: [] });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const revisions = await listRevisions(slug, limit);
|
||||||
|
return NextResponse.json({ revisions });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
return NextResponse.json({ revisions: [], error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { listDocuments } from "@mcpedia/core";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// GET /api/tags?limit=30 — aggregated tag counts
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const limit = Math.min(Math.max(Number(new URL(req.url).searchParams.get("limit") ?? 30), 1), 100);
|
||||||
|
try {
|
||||||
|
const docs = await listDocuments();
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const d of docs) {
|
||||||
|
for (const t of d.tags ?? []) {
|
||||||
|
const tag = String(t).trim();
|
||||||
|
if (!tag) continue;
|
||||||
|
counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tags = [...counts.entries()]
|
||||||
|
.map(([tag, count]) => ({ tag, count }))
|
||||||
|
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag))
|
||||||
|
.slice(0, limit);
|
||||||
|
return NextResponse.json({ tags });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
return NextResponse.json({ tags: [], error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,28 +3,46 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useSearch } from "@/hooks/useSearch";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
ArrowRight,
|
||||||
|
BookOpen,
|
||||||
|
Flag,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
interface Hit {
|
interface QuickAction {
|
||||||
slug: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
section: string;
|
subtitle: string;
|
||||||
snippet?: string;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
score: number;
|
href?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CommandMenu() {
|
export default function CommandMenu() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [results, setResults] = useState<Hit[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Global shortcut ⌘K or /
|
const { hits, isSearching, isValidating } = useSearch(query, "hybrid", 10);
|
||||||
|
const results = query.trim() ? hits : [];
|
||||||
|
const loading = isSearching || isValidating;
|
||||||
|
|
||||||
|
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" },
|
||||||
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setOpen((prev) => !prev);
|
setOpen((prev) => !prev);
|
||||||
} else if (e.key === "/" && !["INPUT", "TEXTAREA"].includes((e.target as HTMLElement)?.tagName)) {
|
} else if (e.key === "/" && !["INPUT", "TEXTAREA"].includes((e.target as HTMLElement)?.tagName)) {
|
||||||
@@ -42,48 +60,35 @@ export default function CommandMenu() {
|
|||||||
if (open) {
|
if (open) {
|
||||||
setTimeout(() => inputRef.current?.focus(), 50);
|
setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setResults([]);
|
|
||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
// Debounced search
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!query.trim()) {
|
|
||||||
setResults([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const timer = setTimeout(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=8`);
|
|
||||||
const data = await res.json();
|
|
||||||
setResults(data.results || []);
|
|
||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
} catch {
|
|
||||||
setResults([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, 150);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [query]);
|
}, [query]);
|
||||||
|
|
||||||
function handleNavigate(slug: string) {
|
function handleNavigate(href: string) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
router.push(`/${slug}`);
|
router.push(href);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totalItems = query.trim() ? results.length : quickActions.length;
|
||||||
|
|
||||||
function handleKeyNavigation(e: React.KeyboardEvent) {
|
function handleKeyNavigation(e: React.KeyboardEvent) {
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
e.preventDefault();
|
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") {
|
} else if (e.key === "ArrowUp") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSelectedIndex((prev) => (results.length > 0 ? (prev - 1 + results.length) % results.length : 0));
|
setSelectedIndex((prev) => (totalItems > 0 ? (prev - 1 + totalItems) % totalItems : 0));
|
||||||
} else if (e.key === "Enter" && results[selectedIndex]) {
|
} else if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
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,102 +96,92 @@ export default function CommandMenu() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(true)}
|
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">
|
<Search className="w-3.5 h-3.5 text-[var(--text-dim)] group-hover:text-[var(--text-primary)] transition-colors" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
<span className="font-sans">Search...</span>
|
||||||
</svg>
|
<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>
|
||||||
<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)]">
|
|
||||||
⌘K
|
|
||||||
</kbd>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 backdrop-blur-xs transition-opacity" onClick={() => setOpen(false)} />
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-black/60 dark:bg-black/80 backdrop-blur-sm 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">
|
<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 sm:px-5 py-3.5 border-b border-[var(--border-color)] bg-[var(--bg-elevated)]">
|
||||||
<div className="flex items-center gap-3 px-4 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" />
|
||||||
<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>
|
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
onKeyDown={handleKeyNavigation}
|
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"
|
className="w-full bg-transparent text-[var(--text-primary)] placeholder-[var(--text-dim)] text-sm focus:outline-none"
|
||||||
/>
|
/>
|
||||||
{loading && (
|
{loading && <div className="w-4 h-4 border-2 border-[var(--text-muted)] border-t-transparent rounded-full animate-spin shrink-0" />}
|
||||||
<div className="w-4 h-4 border-2 border-[#5e6ad2] border-t-transparent rounded-full animate-spin shrink-0" />
|
<button onClick={() => setOpen(false)} 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>
|
||||||
)}
|
|
||||||
<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)]"
|
|
||||||
>
|
|
||||||
ESC
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results / Suggestions */}
|
<div className="max-h-[22rem] overflow-y-auto p-2">
|
||||||
<div className="max-h-96 overflow-y-auto p-2">
|
|
||||||
{query.trim() === "" ? (
|
{query.trim() === "" ? (
|
||||||
<div className="px-3 py-6 text-center">
|
<div className="p-1 space-y-0.5">
|
||||||
<p className="text-xs text-[var(--text-muted)]">Search full-text, semantic embeddings, and CTF writeups.</p>
|
<div className="px-2.5 py-1 text-[10px] font-mono uppercase tracking-wider text-[var(--text-muted)]">Quick Shortcuts</div>
|
||||||
<div className="flex justify-center gap-2 mt-3 text-[11px] text-[var(--text-dim)]">
|
{quickActions.map((action, idx) => {
|
||||||
<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>
|
const Icon = action.icon;
|
||||||
<span>Select: <kbd className="bg-[var(--bg-elevated)] px-1 py-0.5 rounded border border-[var(--border-color)]">↵</kbd></span>
|
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>
|
||||||
|
<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>
|
</div>
|
||||||
) : results.length === 0 && !loading ? (
|
) : results.length === 0 && !loading ? (
|
||||||
<div className="px-4 py-8 text-center text-xs text-[var(--text-muted)]">
|
<div className="px-4 py-10 text-center text-xs text-[var(--text-muted)] space-y-1">
|
||||||
No documents matching "{query}"
|
<p>No knowledge base items matched "{query}"</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1 p-1">
|
||||||
{results.map((hit, index) => {
|
{results.map((hit, index) => {
|
||||||
const isSelected = index === selectedIndex;
|
const isSelected = index === selectedIndex;
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={hit.slug}
|
key={hit.slug}
|
||||||
onClick={() => handleNavigate(hit.slug)}
|
onClick={() => handleNavigate(`/${hit.slug}`)}
|
||||||
onMouseEnter={() => setSelectedIndex(index)}
|
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-[var(--bg-elevated)] text-[var(--text-primary)] border border-[var(--border-color)]" : "hover:bg-[var(--bg-elevated)] text-[var(--text-secondary)] border border-transparent"}`}
|
||||||
isSelected
|
|
||||||
? "bg-[#5e6ad2]/15 border border-[#5e6ad2]/40 text-[var(--text-primary)]"
|
|
||||||
: "hover:bg-[var(--bg-elevated)] text-[var(--text-secondary)] border border-transparent"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
<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">
|
<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>
|
||||||
{hit.section}
|
<span className="text-xs sm:text-sm font-semibold text-[var(--text-primary)] truncate">{hit.title}</span>
|
||||||
</span>
|
|
||||||
<span className={`text-sm font-medium ${isSelected ? "text-[#5e6ad2] dark:text-[#7170ff]" : "text-[var(--text-primary)]"} truncate`}>
|
|
||||||
{hit.title}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{hit.snippet && (
|
{hit.snippet && (
|
||||||
<p
|
<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"
|
||||||
|
// snippet is server-sanitized ts_headline output
|
||||||
dangerouslySetInnerHTML={{ __html: hit.snippet }}
|
dangerouslySetInnerHTML={{ __html: hit.snippet }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] text-[var(--text-dim)] shrink-0 self-center">
|
<span className="text-[10px] font-mono text-[var(--text-dim)] shrink-0 self-center">/{hit.slug}</span>
|
||||||
{hit.slug}
|
|
||||||
</span>
|
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -194,16 +189,9 @@ export default function CommandMenu() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
<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 justify-between px-4 py-2.5 border-t border-[var(--border-color)] bg-[var(--bg-elevated)] text-[11px] text-[var(--text-dim)]">
|
<div className="flex items-center gap-2"><span>[ ↑↓ NAVIGATE ]</span><span>[ ↵ SELECT ]</span></div>
|
||||||
<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>
|
<Link href={`/search?q=${encodeURIComponent(query)}`} onClick={() => setOpen(false)} className="hover:text-[var(--text-primary)] font-medium flex items-center gap-1"><span>Full Search Console</span><ArrowRight className="w-3 h-3" /></Link>
|
||||||
<Link
|
|
||||||
href={`/search?q=${encodeURIComponent(query)}`}
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
className="hover:text-[#5e6ad2] dark:hover:text-[#7170ff] transition-colors"
|
|
||||||
>
|
|
||||||
Open full search page →
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Share2,
|
||||||
|
FileDown,
|
||||||
|
FileText,
|
||||||
|
Edit3,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
interface DocActionsProps {
|
interface DocActionsProps {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -27,62 +35,72 @@ export default function DocActions({ slug, body, canEdit }: DocActionsProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5 shrink-0 flex-wrap">
|
<div className="flex items-center gap-1.5 shrink-0 flex-wrap">
|
||||||
{/* Copy Markdown */}
|
{/* Copy Raw Markdown */}
|
||||||
<button
|
<button
|
||||||
onClick={handleCopyMarkdown}
|
onClick={handleCopyMarkdown}
|
||||||
type="button"
|
type="button"
|
||||||
title="Copy raw markdown content"
|
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 ? (
|
{copiedMd ? (
|
||||||
<>
|
<>
|
||||||
<svg className="w-3.5 h-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<Check className="w-3.5 h-3.5 text-emerald-500" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
<span className="text-emerald-500 font-medium">Copied</span>
|
||||||
</svg>
|
|
||||||
<span className="text-emerald-500 font-medium">Copied MD</span>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<Copy className="w-3.5 h-3.5" />
|
||||||
<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>
|
|
||||||
<span>Raw MD</span>
|
<span>Raw MD</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Copy Link */}
|
{/* Copy Share Link */}
|
||||||
<button
|
<button
|
||||||
onClick={handleCopyLink}
|
onClick={handleCopyLink}
|
||||||
type="button"
|
type="button"
|
||||||
title="Copy link to document"
|
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 ? (
|
{copiedLink ? (
|
||||||
<>
|
<>
|
||||||
<svg className="w-3.5 h-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<Check className="w-3.5 h-3.5 text-emerald-500" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
<span className="text-emerald-500 font-medium">Copied</span>
|
||||||
</svg>
|
|
||||||
<span className="text-emerald-500 font-medium">Copied Link</span>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<Share2 className="w-3.5 h-3.5" />
|
||||||
<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>
|
|
||||||
<span>Share</span>
|
<span>Share</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</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 */}
|
{/* Edit Link */}
|
||||||
<Link
|
<Link
|
||||||
href={canEdit ? `/${slug}?edit=1` : `/login?redirect=/${slug}`}
|
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">
|
<Edit3 className="w-3.5 h-3.5" />
|
||||||
<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>
|
|
||||||
<span>Edit</span>
|
<span>Edit</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,21 @@
|
|||||||
|
|
||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { mutate } from "swr";
|
||||||
import Markdown from "./Markdown";
|
import Markdown from "./Markdown";
|
||||||
|
import {
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
Heading2,
|
||||||
|
Heading3,
|
||||||
|
Code,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Eye,
|
||||||
|
Edit3,
|
||||||
|
Sliders,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
interface DocFormProps {
|
interface DocFormProps {
|
||||||
mode: "create" | "edit";
|
mode: "create" | "edit";
|
||||||
@@ -22,8 +36,26 @@ interface DocFormProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SECTION_LIST = ["docs", "writeups", "research", "notes", "guides", "tutorials", "ctf", "api", "projects"];
|
const DEFAULT_SECTION_LIST = [
|
||||||
const DEFAULT_TYPE_LIST = ["documentation", "writeup", "research", "note", "guide", "tutorial", "spec"];
|
"docs",
|
||||||
|
"writeups",
|
||||||
|
"research",
|
||||||
|
"notes",
|
||||||
|
"guides",
|
||||||
|
"tutorials",
|
||||||
|
"ctf",
|
||||||
|
"api",
|
||||||
|
"projects",
|
||||||
|
];
|
||||||
|
const DEFAULT_TYPE_LIST = [
|
||||||
|
"documentation",
|
||||||
|
"writeup",
|
||||||
|
"research",
|
||||||
|
"note",
|
||||||
|
"guide",
|
||||||
|
"tutorial",
|
||||||
|
"spec",
|
||||||
|
];
|
||||||
|
|
||||||
const PRESET_FIELDS = [
|
const PRESET_FIELDS = [
|
||||||
{ key: "difficulty", value: "medium" },
|
{ key: "difficulty", value: "medium" },
|
||||||
@@ -88,7 +120,7 @@ export default function DocForm({
|
|||||||
const availableFolders = existingFolders?.[section] ?? [];
|
const availableFolders = existingFolders?.[section] ?? [];
|
||||||
|
|
||||||
const baseInputCls =
|
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 = "") {
|
function addCustomField(key = "", value = "") {
|
||||||
setCustomFields([...customFields, { key, value }]);
|
setCustomFields([...customFields, { key, value }]);
|
||||||
@@ -128,7 +160,7 @@ export default function DocForm({
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
if (!effectiveSlug || !title || !body) {
|
if (!effectiveSlug || !title || !body) {
|
||||||
setError("Slug, title, and body are required.");
|
setError("Slug, title, and markdown body are required.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -175,11 +207,16 @@ export default function DocForm({
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
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();
|
const result = await res.json();
|
||||||
|
// Invalidate SWR caches so Sidebar/Header/search reflect the change immediately
|
||||||
|
mutate("/api/docs");
|
||||||
|
mutate("/api/sections");
|
||||||
|
mutate((key) => typeof key === "string" && key.startsWith("/api/tags"));
|
||||||
router.push(result.doc?.slug ? `/${result.doc.slug}` : "/");
|
router.push(result.doc?.slug ? `/${result.doc.slug}` : "/");
|
||||||
|
router.refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -190,39 +227,38 @@ export default function DocForm({
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-6 max-w-4xl">
|
<form onSubmit={handleSubmit} className="space-y-6 max-w-4xl">
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Title */}
|
{/* Document Title */}
|
||||||
<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">
|
||||||
Document Title *
|
Document Title *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="e.g. Database Architecture Overview"
|
placeholder="e.g. Exploiting Stack Alignment Issues in x86_64"
|
||||||
className={`${baseInputCls} text-base`}
|
className={`${baseInputCls} text-base font-semibold`}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section + Type + Status */}
|
{/* Section, Type, Status */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
<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">
|
||||||
Section
|
Section
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
list="section-list"
|
list="section-list"
|
||||||
value={section}
|
value={section}
|
||||||
onChange={(e) => setSection(e.target.value)}
|
onChange={(e) => setSection(e.target.value)}
|
||||||
placeholder="e.g. docs, writeups, ctf, guides"
|
placeholder="e.g. docs, writeups, research"
|
||||||
className={baseInputCls}
|
className={baseInputCls}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -234,18 +270,17 @@ export default function DocForm({
|
|||||||
))}
|
))}
|
||||||
</datalist>
|
</datalist>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<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">
|
||||||
Type
|
Document Type
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
list="type-list"
|
list="type-list"
|
||||||
value={type}
|
value={type}
|
||||||
onChange={(e) => setType(e.target.value)}
|
onChange={(e) => setType(e.target.value)}
|
||||||
placeholder="e.g. documentation, writeup, note"
|
placeholder="e.g. writeup, documentation, note"
|
||||||
className={baseInputCls}
|
className={baseInputCls}
|
||||||
/>
|
/>
|
||||||
<datalist id="type-list">
|
<datalist id="type-list">
|
||||||
@@ -256,9 +291,9 @@ export default function DocForm({
|
|||||||
))}
|
))}
|
||||||
</datalist>
|
</datalist>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<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
|
Status
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@@ -267,7 +302,7 @@ export default function DocForm({
|
|||||||
className={baseInputCls}
|
className={baseInputCls}
|
||||||
>
|
>
|
||||||
<option value="published">Published</option>
|
<option value="published">Published</option>
|
||||||
<option value="draft">Draft</option>
|
<option value="draft">Draft (Unindexed)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,8 +310,8 @@ export default function DocForm({
|
|||||||
{/* Folder + Slug */}
|
{/* Folder + Slug */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<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">
|
||||||
Parent Folder
|
Parent Folder / Chapter
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={parentFolder}
|
value={parentFolder}
|
||||||
@@ -292,9 +327,10 @@ export default function DocForm({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
||||||
{isEdit ? "Full Slug" : "Slug (File name) *"}
|
{isEdit ? "Document Path" : "Slug (File name) *"}
|
||||||
</label>
|
</label>
|
||||||
{isEdit ? (
|
{isEdit ? (
|
||||||
<input
|
<input
|
||||||
@@ -309,12 +345,12 @@ export default function DocForm({
|
|||||||
value={slugInput}
|
value={slugInput}
|
||||||
onChange={(e) => setSlugInput(e.target.value)}
|
onChange={(e) => setSlugInput(e.target.value)}
|
||||||
className={`${baseInputCls} font-mono`}
|
className={`${baseInputCls} font-mono`}
|
||||||
placeholder="my-new-post"
|
placeholder="ret2win-guide"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<p className="text-[11px] text-[var(--text-dim)] mt-1 font-mono">
|
<p className="text-[11px] text-[var(--text-dim)] mt-1.5 font-mono">
|
||||||
Path: <span className="text-[#5e6ad2] dark:text-[#7170ff]">{effectiveSlug || section}</span>
|
Full Slug: <span className="text-[var(--text-primary)] font-semibold">{effectiveSlug || section}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -322,7 +358,7 @@ export default function DocForm({
|
|||||||
{/* Tags + Author */}
|
{/* Tags + Author */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<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">
|
||||||
Tags (comma separated)
|
Tags (comma separated)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -330,11 +366,11 @@ export default function DocForm({
|
|||||||
value={tags}
|
value={tags}
|
||||||
onChange={(e) => setTags(e.target.value)}
|
onChange={(e) => setTags(e.target.value)}
|
||||||
className={baseInputCls}
|
className={baseInputCls}
|
||||||
placeholder="ctf, pwn, reverse, tutorial"
|
placeholder="pwn, x86_64, rop, ctf"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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
|
Author
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -342,31 +378,32 @@ export default function DocForm({
|
|||||||
value={author}
|
value={author}
|
||||||
onChange={(e) => setAuthor(e.target.value)}
|
onChange={(e) => setAuthor(e.target.value)}
|
||||||
className={baseInputCls}
|
className={baseInputCls}
|
||||||
placeholder="asep"
|
placeholder="e.g. asepharyana"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Custom Metadata Fields */}
|
{/* 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="p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl space-y-3.5 shadow-xs">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] font-mono flex items-center gap-1.5">
|
||||||
Dynamic Custom Metadata
|
<Sliders className="w-3.5 h-3.5 text-[var(--text-muted)]" />
|
||||||
|
<span>Technical Metadata</span>
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[11px] text-[var(--text-dim)] mt-0.5">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
<span className="text-[11px] text-[var(--text-dim)]">Presets:</span>
|
<span className="text-[10px] font-mono text-[var(--text-dim)] uppercase">Presets:</span>
|
||||||
{PRESET_FIELDS.map((preset) => (
|
{PRESET_FIELDS.map((preset) => (
|
||||||
<button
|
<button
|
||||||
key={preset.key}
|
key={preset.key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => addCustomField(preset.key, preset.value)}
|
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}
|
+{preset.key}
|
||||||
</button>
|
</button>
|
||||||
@@ -393,10 +430,10 @@ export default function DocForm({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeCustomField(i)}
|
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"
|
title="Remove field"
|
||||||
>
|
>
|
||||||
✕
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -404,14 +441,15 @@ export default function DocForm({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => addCustomField()}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body with Write / Preview Tabs */}
|
{/* Markdown Body Editor */}
|
||||||
<div className="border border-[var(--border-color)] rounded-xl overflow-hidden bg-[var(--bg-surface)] shadow-sm">
|
<div className="border border-[var(--border-color)] rounded-xl overflow-hidden bg-[var(--bg-surface)] shadow-xs">
|
||||||
{/* Editor Toolbar */}
|
{/* 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">
|
<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 */}
|
{/* Tabs */}
|
||||||
@@ -419,90 +457,92 @@ export default function DocForm({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveTab("write")}
|
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"
|
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)]"
|
: "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Write
|
<Edit3 className="w-3.5 h-3.5" />
|
||||||
|
<span>Write</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveTab("preview")}
|
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"
|
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)]"
|
: "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Live Preview
|
<Eye className="w-3.5 h-3.5" />
|
||||||
|
<span>Preview</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Markdown Action Shortcuts */}
|
{/* Quick Markdown Formatter Shortcuts */}
|
||||||
{activeTab === "write" && (
|
{activeTab === "write" && (
|
||||||
<div className="flex items-center gap-1 text-xs text-[var(--text-muted)]">
|
<div className="flex items-center gap-1 text-xs text-[var(--text-muted)]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => insertMarkdown("**", "**")}
|
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"
|
title="Bold"
|
||||||
>
|
>
|
||||||
<b>B</b>
|
<Bold className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => insertMarkdown("*", "*")}
|
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"
|
title="Italic"
|
||||||
>
|
>
|
||||||
<i>I</i>
|
<Italic className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => insertMarkdown("## ")}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => insertMarkdown("```\n", "\n```")}
|
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"
|
title="Code Block"
|
||||||
>
|
>
|
||||||
{"</>"}
|
<Code className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => insertMarkdown("[", "](https://)")}
|
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"
|
title="Link"
|
||||||
>
|
>
|
||||||
Link
|
<LinkIcon className="w-3.5 h-3.5" />
|
||||||
</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
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab content */}
|
{/* Tab Content */}
|
||||||
{activeTab === "write" ? (
|
{activeTab === "write" ? (
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => setBody(e.target.value)}
|
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"
|
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
|
required
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -510,28 +550,29 @@ export default function DocForm({
|
|||||||
{body.trim() ? (
|
{body.trim() ? (
|
||||||
<Markdown source={body} />
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Submit Action Buttons */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
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 && (
|
{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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.back()}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Clock, FileText } from "lucide-react";
|
||||||
|
import type { DocumentMeta } from "@mcpedia/types";
|
||||||
|
import { getSectionMeta } from "@mcpedia/config/sections";
|
||||||
|
|
||||||
|
export function DocumentCard({
|
||||||
|
doc,
|
||||||
|
showSection = false,
|
||||||
|
score,
|
||||||
|
}: {
|
||||||
|
doc: DocumentMeta;
|
||||||
|
showSection?: boolean;
|
||||||
|
score?: number;
|
||||||
|
}) {
|
||||||
|
const sInfo = getSectionMeta(doc.section);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
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(--text-muted)] rounded-xl transition-all shadow-xs"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<FileText className="w-4 h-4 text-[var(--text-muted)] shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-xs sm:text-sm font-semibold text-[var(--text-primary)] group-hover:underline truncate">
|
||||||
|
{doc.title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 mt-1.5">
|
||||||
|
{showSection && (
|
||||||
|
<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">
|
||||||
|
{sInfo.icon} {doc.section}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{doc.tags.slice(0, 4).map((t: string) => (
|
||||||
|
<span
|
||||||
|
key={t}
|
||||||
|
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>
|
||||||
|
))}
|
||||||
|
{typeof score === "number" && (
|
||||||
|
<span className="text-[10px] font-mono text-[var(--text-dim)] bg-[var(--bg-elevated)] px-1.5 py-0.2 rounded">
|
||||||
|
{score.toFixed(3)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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(doc.updatedAt).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
|
||||||
|
</span>
|
||||||
|
</time>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompactDocCard({ doc, snippet, score }: { doc: DocumentMeta; snippet?: string; score?: number }) {
|
||||||
|
const sInfo = getSectionMeta(doc.section);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/${doc.slug}`}
|
||||||
|
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-[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">
|
||||||
|
{sInfo.icon} {doc.section}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--text-dim)] font-mono truncate">/{doc.slug}</span>
|
||||||
|
</div>
|
||||||
|
{typeof score === "number" && (
|
||||||
|
<span className="text-xs font-mono text-[var(--text-dim)] bg-[var(--bg-elevated)] px-2 py-0.5 rounded">
|
||||||
|
{score.toFixed(3)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-sm font-semibold text-[var(--text-primary)] group-hover:underline mb-1.5">{doc.title}</h2>
|
||||||
|
{snippet && (
|
||||||
|
<p
|
||||||
|
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: snippet }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { FileQuestion, SearchX, AlertCircle, Inbox } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
title = "No results",
|
||||||
|
description,
|
||||||
|
icon: Icon = Inbox,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: typeof Inbox;
|
||||||
|
action?: { label: string; href: string };
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-xs space-y-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-[var(--bg-elevated)] border border-[var(--border-color)] flex items-center justify-center mx-auto text-[var(--text-muted)]">
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-bold text-[var(--text-primary)]">{title}</h3>
|
||||||
|
{description && <p className="text-xs text-[var(--text-muted)] max-w-sm mx-auto leading-relaxed">{description}</p>}
|
||||||
|
{action && (
|
||||||
|
<Link
|
||||||
|
href={action.href}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs bg-[var(--brand)] text-[var(--brand-fg)] px-3 py-1.5 rounded-md font-semibold mt-2"
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorState({ message, onRetry }: { message?: string; onRetry?: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 rounded-xl border border-amber-500/30 bg-amber-500/10 text-amber-900 dark:text-amber-200 text-xs flex items-start gap-2.5">
|
||||||
|
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-semibold">Something went wrong</div>
|
||||||
|
{message && <div className="text-[11px] opacity-80 mt-1 break-words">{message}</div>}
|
||||||
|
</div>
|
||||||
|
{onRetry && (
|
||||||
|
<button
|
||||||
|
onClick={onRetry}
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 px-2.5 py-1 rounded bg-[var(--bg-surface)] border border-[var(--border-color)] text-[var(--text-primary)] font-semibold hover:bg-[var(--bg-elevated)]"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,47 +1,47 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { DEFAULT_SECTIONS, type SectionConfig } from "@mcpedia/config/sections";
|
import { DEFAULT_SECTIONS } from "@mcpedia/config/sections";
|
||||||
|
import { useSections } from "@/hooks/useSections";
|
||||||
import ThemeToggle from "@/components/ThemeToggle";
|
import ThemeToggle from "@/components/ThemeToggle";
|
||||||
import CommandMenu from "@/components/CommandMenu";
|
import CommandMenu from "@/components/CommandMenu";
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
Lock,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [sections, setSections] = useState<SectionConfig[]>(DEFAULT_SECTIONS);
|
const { data: liveSections } = useSections();
|
||||||
|
const sections = liveSections && liveSections.length > 0 ? liveSections : DEFAULT_SECTIONS;
|
||||||
useEffect(() => {
|
|
||||||
fetch("/api/sections")
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => {
|
|
||||||
if (Array.isArray(data) && data.length > 0) {
|
|
||||||
setSections(data);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-30 border-b border-[var(--border-color)] glass-nav">
|
<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-4">
|
<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 */}
|
{/* Brand */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link href="/" className="flex items-center gap-2 group">
|
<Link href="/" className="flex items-center gap-2.5 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">
|
<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
|
M
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold text-[var(--text-primary)] text-base tracking-tight transition-colors">
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-bold text-[var(--text-primary)] text-sm tracking-tight leading-none">
|
||||||
MCPedia
|
MCPedia
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
<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">
|
||||||
<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)]">
|
CORE::DB
|
||||||
v0.1.0
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Desktop Nav */}
|
{/* Center: Desktop Sections Nav */}
|
||||||
<nav className="hidden md:flex items-center gap-1">
|
<nav className="hidden md:flex items-center gap-1">
|
||||||
{sections.map((s) => {
|
{sections.map((s) => {
|
||||||
const isActive = pathname === `/${s.id}` || pathname.startsWith(`/${s.id}/`);
|
const isActive = pathname === `/${s.id}` || pathname.startsWith(`/${s.id}/`);
|
||||||
@@ -49,101 +49,110 @@ export default function Header() {
|
|||||||
<Link
|
<Link
|
||||||
key={s.id}
|
key={s.id}
|
||||||
href={`/${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
|
isActive
|
||||||
? "text-[var(--text-primary)] bg-[var(--bg-elevated)] border border-[var(--border-color)]"
|
? "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-surface-hover)]"
|
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="mr-1.5 opacity-80">{s.icon}</span>
|
<span>{s.icon}</span>
|
||||||
{s.label}
|
<span>{s.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Right tools */}
|
{/* Right Tools & Navigation Deck */}
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2">
|
||||||
<CommandMenu />
|
<CommandMenu />
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/search"
|
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"
|
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"
|
title="Search knowledge base"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<Search className="w-4 h-4" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/create"
|
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>
|
<span>New Doc</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
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"
|
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)]"
|
: "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>
|
</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 />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile menu toggle */}
|
{/* Mobile menu toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
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"
|
aria-label="Toggle navigation"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
{mobileMenuOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||||
{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>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Drawer */}
|
{/* Mobile Drawer */}
|
||||||
{mobileMenuOpen && (
|
{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">
|
<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">
|
||||||
{sections.map((s) => (
|
<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
|
<Link
|
||||||
key={s.id}
|
key={s.id}
|
||||||
href={`/${s.id}`}
|
href={`/${s.id}`}
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
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)]"
|
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>{s.icon}</span>
|
||||||
<span>{s.label}</span>
|
<span className="truncate">{s.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
);
|
||||||
<div className="pt-2 border-t border-[var(--border-color)] flex gap-2">
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-3 border-t border-[var(--border-color)] flex gap-2">
|
||||||
<Link
|
<Link
|
||||||
href="/search"
|
href="/search"
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
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>
|
||||||
<Link
|
<Link
|
||||||
href="/create"
|
href="/create"
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</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 ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import rehypeSlug from "rehype-slug";
|
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 [copied, setCopied] = useState(false);
|
||||||
const textContent = String(children).replace(/\n$/, "");
|
const textContent = extractText(children).replace(/\n$/, "");
|
||||||
const match = /language-(\w+)/.exec(className || "");
|
|
||||||
const language = match ? match[1] : "";
|
|
||||||
|
|
||||||
// If inline code
|
let language = "text";
|
||||||
if (!className && typeof children === "string" && !children.includes("\n")) {
|
if (React.isValidElement(children)) {
|
||||||
return (
|
const className = (children.props as { className?: string })?.className || "";
|
||||||
<code className={className} {...props}>
|
const match = /language-(\w+)/.exec(className);
|
||||||
{children}
|
if (match) {
|
||||||
</code>
|
language = match[1];
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCopy() {
|
function handleCopy() {
|
||||||
@@ -27,37 +51,42 @@ function CodeBlock({ children, className, ...props }: React.HTMLAttributes<HTMLE
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* 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">
|
<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">
|
||||||
<span>{language || "text"}</span>
|
<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
|
<button
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
type="button"
|
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 ? (
|
{copied ? (
|
||||||
<>
|
<>
|
||||||
<svg className="w-3 h-3 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<Check className="w-3.5 h-3.5 text-emerald-500" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
<span className="text-emerald-500 font-medium">Copied!</span>
|
||||||
</svg>
|
|
||||||
<span className="text-emerald-400 font-sans">Copied!</span>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<Copy className="w-3.5 h-3.5" />
|
||||||
<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" />
|
<span>Copy</span>
|
||||||
</svg>
|
|
||||||
<span className="font-sans">Copy</span>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<pre className="!m-0 !bg-transparent p-4 overflow-x-auto text-sm text-[#e2e4e7] font-mono leading-relaxed">
|
<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}>
|
||||||
<code className={className} {...props}>
|
|
||||||
{children}
|
{children}
|
||||||
</code>
|
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -66,32 +95,88 @@ function CodeBlock({ children, className, ...props }: React.HTMLAttributes<HTMLE
|
|||||||
export default function Markdown({ source }: { source: string }) {
|
export default function Markdown({ source }: { source: string }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="prose prose-invert max-w-none
|
className="prose max-w-none
|
||||||
prose-headings:text-[#f7f8f8] prose-headings:font-medium prose-headings:tracking-tight
|
prose-headings:text-[var(--text-primary)] prose-headings:font-bold 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-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 prose-h2:mb-4 prose-h2:mt-8
|
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-base prose-h3:mb-3 prose-h3:mt-6
|
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-[#d0d6e0] prose-p:leading-relaxed prose-p:mb-4
|
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-[#7170ff] prose-a:no-underline hover:prose-a:underline hover:prose-a:text-[#828fff]
|
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-[#d0d6e0] prose-ul:my-3 prose-li:my-1
|
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-[#d0d6e0] prose-ol:my-3
|
prose-ol:text-[var(--text-secondary)] prose-ol:my-4 print:prose-ol:my-1
|
||||||
prose-strong:text-[#f7f8f8] prose-strong:font-semibold
|
prose-strong:text-[var(--text-primary)] prose-strong:font-semibold
|
||||||
prose-hr:border-[#1f2022] prose-hr:my-8"
|
prose-hr:border-[var(--border-color)] prose-hr:my-8 print:prose-hr:my-2"
|
||||||
>
|
>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
rehypePlugins={[rehypeSlug]}
|
rehypePlugins={[rehypeSlug, rehypeHighlight]}
|
||||||
components={{
|
components={{
|
||||||
code: CodeBlock as any,
|
pre: PreBlock as any,
|
||||||
pre: ({ children }) => <>{children}</>,
|
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 }) => (
|
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} />
|
<table className="w-full border-collapse" {...props} />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
blockquote: ({ children }) => {
|
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 (
|
return (
|
||||||
<blockquote className="border-l-4 border-[#5e6ad2] bg-[#141517]/60 pl-4 py-2 my-4 rounded-r text-[#a0a4a8] not-italic">
|
<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-[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}
|
{children}
|
||||||
</blockquote>
|
</blockquote>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import Markdown from "@/components/Markdown";
|
||||||
|
import {
|
||||||
|
Sparkles,
|
||||||
|
Terminal,
|
||||||
|
Server,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Laptop,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
const CONFIGS = [
|
const CONFIGS = [
|
||||||
{
|
{
|
||||||
id: "claude",
|
id: "claude",
|
||||||
label: "Claude Desktop",
|
label: "Claude Desktop",
|
||||||
|
icon: Sparkles,
|
||||||
|
lang: "json",
|
||||||
filename: "~/Library/Application Support/Claude/claude_desktop_config.json",
|
filename: "~/Library/Application Support/Claude/claude_desktop_config.json",
|
||||||
content: `{
|
content: `{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
@@ -13,17 +24,37 @@ const CONFIGS = [
|
|||||||
"command": "bun",
|
"command": "bun",
|
||||||
"args": ["run", "/path/to/mcpedia/apps/mcp/src/index.ts"],
|
"args": ["run", "/path/to/mcpedia/apps/mcp/src/index.ts"],
|
||||||
"env": {
|
"env": {
|
||||||
"DATABASE_URL": "postgresql://...",
|
"DATABASE_URL": "postgresql://postgres:password@localhost:5432/mcpedia",
|
||||||
"OPENROUTER_API_KEY": "sk-or-v1-..."
|
"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",
|
id: "http",
|
||||||
label: "Remote HTTP (SSE)",
|
label: "Streamable HTTP (SSE)",
|
||||||
filename: "Streamable HTTP Endpoint",
|
icon: Server,
|
||||||
|
lang: "json",
|
||||||
|
filename: "Streamable HTTP Client Config",
|
||||||
content: `{
|
content: `{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"mcpedia-remote": {
|
"mcpedia-remote": {
|
||||||
@@ -37,12 +68,14 @@ const CONFIGS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "cli",
|
id: "cli",
|
||||||
label: "CLI Client",
|
label: "CLI & Scripts",
|
||||||
filename: "Terminal One-Shot / REPL",
|
icon: Terminal,
|
||||||
content: `# One-shot question
|
lang: "bash",
|
||||||
bun run apps/mcp-client/src/ask.ts "How does ret2win stack alignment work?"
|
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`,
|
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];
|
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);
|
navigator.clipboard.writeText(current.content);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* 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 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">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
{CONFIGS.map((c) => (
|
{CONFIGS.map((c) => {
|
||||||
|
const Icon = c.icon;
|
||||||
|
const isSelected = activeTab === c.id;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={c.id}
|
key={c.id}
|
||||||
onClick={() => setActiveTab(c.id)}
|
onClick={() => setActiveTab(c.id)}
|
||||||
className={`px-3 py-1 text-xs rounded-md font-medium transition-all ${
|
type="button"
|
||||||
activeTab === c.id
|
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md font-medium transition-all cursor-pointer ${
|
||||||
? "bg-[#5e6ad2] text-white shadow-sm"
|
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)]"
|
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-surface)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{c.label}
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
<span>{c.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleCopy}
|
onClick={handleCopyAll}
|
||||||
type="button"
|
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 ? (
|
{copied ? (
|
||||||
<>
|
<>
|
||||||
<svg className="w-3.5 h-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<Check className="w-3.5 h-3.5 text-emerald-500" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
<span className="text-emerald-500">Copied</span>
|
||||||
</svg>
|
|
||||||
<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">
|
<Copy className="w-3.5 h-3.5" />
|
||||||
<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>Copy Config</span>
|
<span>Copy Config</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -103,12 +142,26 @@ export default function McpConfigSnippet() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Code preview */}
|
{/* Code preview */}
|
||||||
<pre className="p-4 text-xs font-mono text-[var(--pre-text)] bg-[var(--code-block-bg)] overflow-x-auto leading-relaxed">
|
<div className="p-4 sm:p-5 [&>.prose>.group]:my-0 [&>.prose]:max-w-none">
|
||||||
<code>{current.content}</code>
|
<Markdown source={markdownSnippet} />
|
||||||
</pre>
|
</div>
|
||||||
|
|
||||||
<div className="px-4 py-2 bg-[var(--bg-elevated)] border-t border-[var(--border-color)] text-[11px] text-[var(--text-dim)]">
|
{/* Footer Info */}
|
||||||
Config location: <span className="font-mono text-[var(--text-muted)]">{current.filename}</span>
|
<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>
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { fetcher } from "@/lib/fetcher";
|
||||||
|
import { Clock, CheckCircle2, AlertTriangle, Hourglass, Layers } from "lucide-react";
|
||||||
|
|
||||||
|
export default function QueueBadge() {
|
||||||
|
const { data, error, isLoading } = useSWR<{ waiting: number; active: number; completed: number; failed: number; delayed: number }>(
|
||||||
|
"/api/queue/status",
|
||||||
|
fetcher,
|
||||||
|
{ refreshInterval: 8_000, dedupingInterval: 4_000, revalidateOnFocus: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[11px] font-mono text-[var(--text-dim)] bg-[var(--bg-elevated)] border border-[var(--border-color)] px-2.5 py-1 rounded">
|
||||||
|
<Hourglass className="w-3 h-3 animate-spin" /> queue…
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) return null;
|
||||||
|
|
||||||
|
const total = data.waiting + data.active + data.delayed;
|
||||||
|
const hasWork = total > 0 || data.failed > 0;
|
||||||
|
|
||||||
|
if (!hasWork) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[11px] font-mono text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2.5 py-1 rounded">
|
||||||
|
<CheckCircle2 className="w-3 h-3" /> index idle
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 text-[11px] font-mono px-2.5 py-1 rounded border ${
|
||||||
|
data.failed > 0
|
||||||
|
? "text-amber-700 dark:text-amber-400 bg-amber-500/10 border-amber-500/30"
|
||||||
|
: "text-[var(--text-secondary)] bg-[var(--bg-elevated)] border-[var(--border-color)]"
|
||||||
|
}`}
|
||||||
|
title={`waiting:${data.waiting} active:${data.active} delayed:${data.delayed} failed:${data.failed}`}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{data.failed > 0 ? <AlertTriangle className="w-3 h-3" /> : <Clock className="w-3 h-3" />}
|
||||||
|
<span>
|
||||||
|
q:{data.waiting}w · {data.active}a
|
||||||
|
{data.delayed ? ` · ${data.delayed}d` : ""}
|
||||||
|
{data.failed ? ` · ${data.failed} ✕` : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QueueStrip() {
|
||||||
|
const { data } = useSWR<{ waiting: number; active: number; completed: number; failed: number; delayed: number }>(
|
||||||
|
"/api/queue/status",
|
||||||
|
fetcher,
|
||||||
|
{ refreshInterval: 6_000, revalidateOnFocus: false },
|
||||||
|
);
|
||||||
|
if (!data) return null;
|
||||||
|
const items: { label: string; value: number; tone: string }[] = [
|
||||||
|
{ label: "waiting", value: data.waiting, tone: "text-[var(--text-muted)]" },
|
||||||
|
{ label: "active", value: data.active, tone: "text-sky-600 dark:text-sky-400" },
|
||||||
|
{ label: "delayed", value: data.delayed, tone: "text-[var(--text-muted)]" },
|
||||||
|
{ label: "failed", value: data.failed, tone: data.failed ? "text-amber-600 dark:text-amber-400" : "text-[var(--text-dim)]" },
|
||||||
|
{ label: "completed", value: data.completed, tone: "text-emerald-600 dark:text-emerald-400" },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2 text-[11px] font-mono">
|
||||||
|
{items.map((it) => (
|
||||||
|
<span key={it.label} className={`px-2 py-1 rounded bg-[var(--bg-surface)] border border-[var(--border-color)] ${it.tone}`}>
|
||||||
|
{it.label}: <strong>{it.value}</strong>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRelated } from "@/hooks/useRelated";
|
||||||
|
import { getSectionMeta } from "@mcpedia/config/sections";
|
||||||
|
import { Sparkles } from "lucide-react";
|
||||||
|
|
||||||
|
export default function RelatedGrid({ slug }: { slug: string }) {
|
||||||
|
const { related, isLoading, error } = useRelated(slug, 4);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 animate-pulse">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-24 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || related.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="mt-12 pt-8 border-t border-[var(--border-color)]">
|
||||||
|
<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 = getSectionMeta(r.section);
|
||||||
|
return (
|
||||||
|
<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(--text-muted)] rounded-lg p-4 transition-colors shadow-xs"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
{new Date(r.updatedAt).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xs sm:text-sm font-semibold text-[var(--text-primary)] group-hover:underline line-clamp-1">{r.title}</h3>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { mutate } from "swr";
|
||||||
|
import { useRevisions } from "@/hooks/useRevisions";
|
||||||
|
import { History } from "lucide-react";
|
||||||
|
|
||||||
|
export default function RevisionList({ slug, canEdit }: { slug: string; canEdit: boolean }) {
|
||||||
|
const { revisions, isLoading, error, mutate: revalidate } = useRevisions(slug, 10);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 animate-pulse">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-12 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <p className="text-xs text-amber-600 dark:text-amber-400">Failed to load revisions: {(error as Error).message}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (revisions.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="mt-10 pt-8 border-t border-[var(--border-color)]">
|
||||||
|
<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-xs">
|
||||||
|
<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-secondary)] font-sans truncate text-xs">{rev.reason || "Updated document in PostgreSQL"}</span>
|
||||||
|
<span className="text-[var(--text-dim)] hidden sm:inline text-[11px]">· {new Date(rev.createdAt).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<form
|
||||||
|
action="/api/revisions/restore"
|
||||||
|
method="post"
|
||||||
|
onSubmit={() => {
|
||||||
|
// Optimistically revalidate after form post navigates back
|
||||||
|
setTimeout(() => {
|
||||||
|
revalidate();
|
||||||
|
mutate((k) => typeof k === "string" && k.startsWith("/api/docs"));
|
||||||
|
}, 800);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={rev.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
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>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Search, X } from "lucide-react";
|
||||||
|
|
||||||
|
interface SearchBarProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
onClear?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchBar({ value, onChange, placeholder, autoFocus, onClear }: SearchBarProps) {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder ?? "Search knowledge base..."}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<Search className="w-4 h-4 text-[var(--text-muted)] absolute left-3.5 top-3.5 pointer-events-none" />
|
||||||
|
{value && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onChange("");
|
||||||
|
onClear?.();
|
||||||
|
}}
|
||||||
|
className="absolute right-3 top-3 p-0.5 text-[var(--text-dim)] hover:text-[var(--text-primary)] rounded"
|
||||||
|
aria-label="Clear query"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InlineSearchBar({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={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"
|
||||||
|
/>
|
||||||
|
<Search className="w-3.5 h-3.5 text-[var(--text-dim)] absolute left-2.5 top-2.5 pointer-events-none" />
|
||||||
|
{value && (
|
||||||
|
<button
|
||||||
|
onClick={() => onChange("")}
|
||||||
|
className="absolute right-2 top-2 p-0.5 text-[var(--text-dim)] hover:text-[var(--text-primary)] rounded"
|
||||||
|
aria-label="Clear filter"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export function SectionCard({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
desc,
|
||||||
|
docCount,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
desc: string;
|
||||||
|
docCount: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div 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">{icon}</span>
|
||||||
|
<Link
|
||||||
|
href={`/${id}`}
|
||||||
|
className="text-xs font-mono text-[var(--text-muted)] hover:text-[var(--text-primary)] font-semibold"
|
||||||
|
>
|
||||||
|
{docCount} docs →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-sm text-[var(--text-primary)] mb-1">{label}</h3>
|
||||||
|
<p className="text-xs text-[var(--text-muted)] mb-4 line-clamp-2 leading-relaxed">{desc}</p>
|
||||||
|
</div>
|
||||||
|
<div className="pt-3 border-t border-[var(--border-color)] text-xs text-[var(--text-dim)] font-mono">
|
||||||
|
/{id} · {docCount} document{docCount !== 1 ? "s" : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { getSectionMeta } from "@mcpedia/config/sections";
|
import { getSectionMeta } from "@mcpedia/config/sections";
|
||||||
|
import { useDocuments } from "@/hooks/useDocuments";
|
||||||
|
import {
|
||||||
|
Folder,
|
||||||
|
FolderOpen,
|
||||||
|
FileText,
|
||||||
|
ChevronRight,
|
||||||
|
Search,
|
||||||
|
X,
|
||||||
|
Plus,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import type { DocumentMeta } from "@mcpedia/types";
|
||||||
|
|
||||||
interface Doc {
|
interface Doc {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -102,13 +114,13 @@ function TreeNodeItem({
|
|||||||
const isCollapsed = collapsedMap[node.slug] ?? false;
|
const isCollapsed = collapsedMap[node.slug] ?? false;
|
||||||
|
|
||||||
return (
|
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
|
<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
|
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
|
: 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)]"
|
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -117,15 +129,23 @@ function TreeNodeItem({
|
|||||||
className="flex items-center gap-2 min-w-0 flex-1 py-0.5"
|
className="flex items-center gap-2 min-w-0 flex-1 py-0.5"
|
||||||
title={node.title || node.name}
|
title={node.title || node.name}
|
||||||
>
|
>
|
||||||
<span className="text-xs shrink-0 opacity-80 group-hover:opacity-100">
|
<span className="shrink-0 text-[var(--text-dim)] group-hover:text-[var(--text-primary)] transition-colors">
|
||||||
{isFolder ? "📁" : "📄"}
|
{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>
|
||||||
<span className="truncate">{node.title || node.name}</span>
|
<span className="truncate">{node.title || node.name}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{isFolder && (
|
{isFolder && (
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<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}
|
{node.docCount}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -135,24 +155,19 @@ function TreeNodeItem({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
toggleCollapse(node.slug);
|
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"}
|
aria-label={isCollapsed ? "Expand folder" : "Collapse folder"}
|
||||||
>
|
>
|
||||||
<svg
|
<ChevronRight
|
||||||
className={`w-3 h-3 transition-transform duration-200 ${isCollapsed ? "-rotate-90" : "rotate-0"}`}
|
className={`w-3 h-3 transition-transform duration-200 ${isCollapsed ? "rotate-0" : "rotate-90"}`}
|
||||||
fill="none"
|
/>
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isFolder && !isCollapsed && (
|
{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) => (
|
{node.children.map((child) => (
|
||||||
<TreeNodeItem
|
<TreeNodeItem
|
||||||
key={child.slug}
|
key={child.slug}
|
||||||
@@ -170,21 +185,11 @@ function TreeNodeItem({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const [docs, setDocs] = useState<Doc[]>([]);
|
const { docs } = useDocuments();
|
||||||
const [filterText, setFilterText] = useState("");
|
const [filterText, setFilterText] = useState("");
|
||||||
const [collapsedMap, setCollapsedMap] = useState<Record<string, boolean>>({});
|
const [collapsedMap, setCollapsedMap] = useState<Record<string, boolean>>({});
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch("/api/docs")
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => {
|
|
||||||
if (Array.isArray(data)) setDocs(data);
|
|
||||||
else if (data?.docs) setDocs(data.docs);
|
|
||||||
})
|
|
||||||
.catch(() => setDocs([]));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function toggleCollapse(slug: string) {
|
function toggleCollapse(slug: string) {
|
||||||
setCollapsedMap((prev) => ({ ...prev, [slug]: !prev[slug] }));
|
setCollapsedMap((prev) => ({ ...prev, [slug]: !prev[slug] }));
|
||||||
}
|
}
|
||||||
@@ -196,7 +201,7 @@ export default function Sidebar() {
|
|||||||
(d) =>
|
(d) =>
|
||||||
d.title.toLowerCase().includes(lower) ||
|
d.title.toLowerCase().includes(lower) ||
|
||||||
d.slug.toLowerCase().includes(lower) ||
|
d.slug.toLowerCase().includes(lower) ||
|
||||||
d.tags.some((t) => t.toLowerCase().includes(lower)),
|
d.tags.some((t: string) => t.toLowerCase().includes(lower)),
|
||||||
);
|
);
|
||||||
}, [docs, filterText]);
|
}, [docs, filterText]);
|
||||||
|
|
||||||
@@ -212,7 +217,7 @@ export default function Sidebar() {
|
|||||||
}, [filteredDocs]);
|
}, [filteredDocs]);
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Sidebar search filter */}
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -220,31 +225,25 @@ export default function Sidebar() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={filterText}
|
value={filterText}
|
||||||
onChange={(e) => setFilterText(e.target.value)}
|
onChange={(e) => setFilterText(e.target.value)}
|
||||||
placeholder="Filter docs..."
|
placeholder="Filter index..."
|
||||||
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"
|
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
|
<Search className="w-3.5 h-3.5 text-[var(--text-dim)] absolute left-2.5 top-2.5 pointer-events-none" />
|
||||||
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>
|
|
||||||
{filterText && (
|
{filterText && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setFilterText("")}
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary count */}
|
{/* Summary count */}
|
||||||
<div className="flex items-center justify-between px-1 mb-3 text-[11px] text-[var(--text-dim)]">
|
<div className="flex items-center justify-between px-1 mb-2 text-[10px] text-[var(--text-muted)] font-mono uppercase tracking-wider">
|
||||||
<span className="uppercase tracking-wider font-mono">Knowledge Base</span>
|
<span>Knowledge Index</span>
|
||||||
<span>{filteredDocs.length} items</span>
|
<span>{filteredDocs.length} items</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -261,17 +260,17 @@ export default function Sidebar() {
|
|||||||
<div key={id} className="border-b border-[var(--border-color)] pb-3 last:border-0">
|
<div key={id} className="border-b border-[var(--border-color)] pb-3 last:border-0">
|
||||||
<Link
|
<Link
|
||||||
href={`/${id}`}
|
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
|
isSectionActive
|
||||||
? "text-[#5e6ad2] dark:text-[#7170ff]"
|
? "text-[var(--text-primary)] bg-[var(--bg-elevated)]"
|
||||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-2">
|
||||||
<span>{icon}</span>
|
<span className="text-xs">{icon}</span>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
</div>
|
</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}
|
{sectionDocs.length}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -291,15 +290,22 @@ export default function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{filteredDocs.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-xs text-[var(--text-dim)] font-mono">
|
||||||
|
No matching documents
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom quick links */}
|
{/* Bottom quick links */}
|
||||||
<div className="pt-3 border-t border-[var(--border-color)] mt-auto">
|
<div className="pt-3 border-t border-[var(--border-color)] mt-auto">
|
||||||
<Link
|
<Link
|
||||||
href="/create"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import GithubSlugger from "github-slugger";
|
import GithubSlugger from "github-slugger";
|
||||||
|
import { AlignLeft, ArrowUp, Hash } from "lucide-react";
|
||||||
|
|
||||||
interface TOCEntry {
|
interface TOCEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -58,18 +59,29 @@ export default function TOC({ source }: { source: string }) {
|
|||||||
|
|
||||||
if (toc.length === 0) return null;
|
if (toc.length === 0) return null;
|
||||||
|
|
||||||
|
function scrollToTop() {
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="text-xs">
|
<nav className="text-xs space-y-4">
|
||||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-[var(--border-color)]">
|
<div className="flex items-center justify-between 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">
|
<div className="flex items-center gap-1.5 font-semibold uppercase tracking-wider text-[var(--text-muted)] text-[11px] font-mono">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h7" />
|
<AlignLeft className="w-3.5 h-3.5 text-[var(--brand)] dark:text-[var(--accent)]" />
|
||||||
</svg>
|
<span>On This Page</span>
|
||||||
<span className="font-semibold uppercase tracking-wider text-[var(--text-muted)] text-[11px]">
|
</div>
|
||||||
On this page
|
<button
|
||||||
</span>
|
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>
|
</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) => {
|
{toc.map((entry) => {
|
||||||
const isActive = activeId === entry.id;
|
const isActive = activeId === entry.id;
|
||||||
return (
|
return (
|
||||||
@@ -90,9 +102,9 @@ export default function TOC({ source }: { source: string }) {
|
|||||||
history.pushState(null, "", `#${entry.id}`);
|
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
|
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)]"
|
: "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||||
}`}
|
}`}
|
||||||
title={entry.text}
|
title={entry.text}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { fetcher } from "@/lib/fetcher";
|
||||||
|
import { Tag, TrendingUp } from "lucide-react";
|
||||||
|
|
||||||
|
export default function TagCloud({ limit = 24 }: { limit?: number }) {
|
||||||
|
const { data, isLoading } = useSWR<{ tags: { tag: string; count: number }[] }>(
|
||||||
|
`/api/tags?limit=${limit}`,
|
||||||
|
fetcher,
|
||||||
|
{ revalidateOnFocus: false },
|
||||||
|
);
|
||||||
|
const tags = data?.tags ?? [];
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1.5 animate-pulse">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-6 w-16 bg-[var(--bg-elevated)] rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tags.length === 0) return null;
|
||||||
|
|
||||||
|
const max = Math.max(...tags.map((t) => t.count), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Tag className="w-3.5 h-3.5 text-[var(--text-muted)]" />
|
||||||
|
<h3 className="text-xs font-bold text-[var(--text-muted)] uppercase tracking-wider font-mono">Popular Tags</h3>
|
||||||
|
<span className="text-[11px] text-[var(--text-dim)] font-mono">({tags.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{tags.map(({ tag, count }) => {
|
||||||
|
const intensity = count / max;
|
||||||
|
const opacity = 0.45 + intensity * 0.55;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tag}
|
||||||
|
href={`/search?q=${encodeURIComponent(tag)}`}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-mono transition-all hover:scale-[1.02] bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border-[var(--border-color)] hover:border-[var(--text-muted)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||||
|
style={{ opacity }}
|
||||||
|
title={`${count} document${count !== 1 ? "s" : ""}`}
|
||||||
|
>
|
||||||
|
<span>#{tag}</span>
|
||||||
|
<span className="text-[10px] bg-[var(--bg-elevated)] px-1 py-0.2 rounded font-semibold">{count}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { Sun, Moon } from "lucide-react";
|
||||||
|
|
||||||
const STORAGE_KEY = "mcpedia-theme";
|
const STORAGE_KEY = "mcpedia-theme";
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ export default function ThemeToggle() {
|
|||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return (
|
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"
|
type="button"
|
||||||
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
title={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 ? (
|
{isDark ? (
|
||||||
<svg className="w-4 h-4 text-amber-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<Sun className="w-4 h-4 text-amber-400 hover:rotate-45 transition-transform" />
|
||||||
<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>
|
|
||||||
) : (
|
) : (
|
||||||
<svg className="w-4 h-4 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<Moon className="w-4 h-4 text-indigo-600 hover:-rotate-12 transition-transform" />
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { WEBHOOK_SECRET } from "@mcpedia/config";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import DocForm from "@/components/DocForm";
|
import DocForm from "@/components/DocForm";
|
||||||
|
import { Edit3, ArrowLeft, Plus } from "lucide-react";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -33,14 +34,16 @@ export default async function CreatePage() {
|
|||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between pb-4 border-b border-[var(--border-color)]">
|
<div className="flex items-center justify-between pb-4 border-b border-[var(--border-color)]">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3.5">
|
||||||
<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="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>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-[var(--text-primary)] tracking-tight">Create Document</h1>
|
<h1 className="text-2xl sm:text-3xl font-extrabold text-[var(--text-primary)] tracking-tight">
|
||||||
<p className="text-xs text-[var(--text-muted)] mt-0.5">
|
Create Knowledge Document
|
||||||
Writes directly to PostgreSQL, records revision history, and computes vector embeddings.
|
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { fetcher } from "@/lib/fetcher";
|
||||||
|
|
||||||
|
export default function DashboardQueueStrip() {
|
||||||
|
const { data } = useSWR<{ waiting: number; active: number; completed: number; failed: number; delayed: number }>("/api/queue/status", fetcher, {
|
||||||
|
refreshInterval: 6000,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
});
|
||||||
|
if (!data) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2 text-[11px] font-mono" role="status" aria-live="polite">
|
||||||
|
<span className="px-2.5 py-1 rounded bg-[var(--bg-surface)] border border-[var(--border-color)]">waiting: <strong>{data.waiting}</strong></span>
|
||||||
|
<span className="px-2.5 py-1 rounded bg-[var(--bg-surface)] border border-[var(--border-color)] text-sky-600 dark:text-sky-400">active: <strong>{data.active}</strong></span>
|
||||||
|
<span className={`px-2.5 py-1 rounded bg-[var(--bg-surface)] border ${data.failed ? "border-amber-500/40 text-amber-600 dark:text-amber-400" : "border-[var(--border-color)] text-[var(--text-dim)]"}`}>failed: <strong>{data.failed}</strong></span>
|
||||||
|
<span className="px-2.5 py-1 rounded bg-[var(--bg-surface)] border border-[var(--border-color)] text-emerald-600 dark:text-emerald-400">completed: <strong>{data.completed}</strong></span>
|
||||||
|
{data.delayed ? <span className="px-2.5 py-1 rounded bg-[var(--bg-surface)] border border-[var(--border-color)]">delayed: <strong>{data.delayed}</strong></span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import TagCloud from "@/components/TagCloud";
|
||||||
|
|
||||||
|
export default function TagCloudClient() {
|
||||||
|
return <TagCloud limit={30} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { listDocuments, listSections } from "@mcpedia/core";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function DashboardPage() {
|
||||||
|
const [all, sections] = await Promise.all([listDocuments(), listSections()]);
|
||||||
|
|
||||||
|
const totalTags = new Set(all.flatMap((d) => d.tags)).size;
|
||||||
|
const recentDocs = [...all].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()).slice(0, 8);
|
||||||
|
|
||||||
|
// Tag aggregation server-side for SSR snapshot (client TagCloud will hydrate live via SWR)
|
||||||
|
const tagCounts = new Map<string, number>();
|
||||||
|
for (const d of all) for (const t of d.tags) tagCounts.set(t, (tagCounts.get(t) ?? 0) + 1);
|
||||||
|
const topTags = [...tagCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 max-w-5xl">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-extrabold text-[var(--text-primary)] tracking-tight">Dashboard</h1>
|
||||||
|
<p className="text-xs sm:text-sm text-[var(--text-muted)] mt-1">Live overview of the MCPedia knowledge base. Data via PostgreSQL Core + BullMQ queue.</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/create" className="hidden sm:inline-flex items-center gap-1.5 px-3 py-1.5 bg-[var(--brand)] text-[var(--brand-fg)] rounded-md text-xs font-semibold shadow-xs">
|
||||||
|
+ New Doc
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metric strip — SSR snapshot */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
<div className="p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-xs">
|
||||||
|
<div className="text-[10px] font-mono uppercase tracking-wider text-[var(--text-muted)]">DB Documents</div>
|
||||||
|
<div className="text-2xl font-bold font-mono text-[var(--text-primary)] mt-1">{all.length}</div>
|
||||||
|
<div className="text-[11px] text-[var(--text-dim)] font-mono">{totalTags} unique tags</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-xs">
|
||||||
|
<div className="text-[10px] font-mono uppercase tracking-wider text-[var(--text-muted)]">Active Sections</div>
|
||||||
|
<div className="text-2xl font-bold font-mono text-[var(--text-primary)] mt-1">{sections.length}</div>
|
||||||
|
<div className="text-[11px] text-[var(--text-dim)] font-mono truncate">{sections.map((s) => s.id).join(" · ")}</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-xs">
|
||||||
|
<div className="text-[10px] font-mono uppercase tracking-wider text-[var(--text-muted)]">Search</div>
|
||||||
|
<div className="text-2xl font-bold font-mono text-[var(--text-primary)] mt-1">RRF</div>
|
||||||
|
<div className="text-[11px] text-[var(--text-dim)] font-mono">FTS + Cosine Vectors</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-xs">
|
||||||
|
<div className="text-[10px] font-mono uppercase tracking-wider text-[var(--text-muted)]">MCP Tools</div>
|
||||||
|
<div className="text-2xl font-bold font-mono text-[var(--text-primary)] mt-1">13</div>
|
||||||
|
<div className="text-[11px] text-[var(--text-dim)] font-mono">HTTP :4021</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Queue strip — client component polls live */}
|
||||||
|
<DashboardQueueStrip />
|
||||||
|
|
||||||
|
{/* Section breakdown */}
|
||||||
|
<section className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-5 shadow-xs">
|
||||||
|
<h2 className="text-xs font-bold uppercase tracking-wider text-[var(--text-muted)] font-mono mb-3">Section Breakdown</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sections.map((s) => {
|
||||||
|
const pct = all.length ? Math.round((s.docCount / all.length) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<Link key={s.id} href={`/${s.id}`} className="flex items-center gap-3 group">
|
||||||
|
<span className="text-sm w-6 text-center">{s.icon}</span>
|
||||||
|
<span className="text-xs font-semibold text-[var(--text-primary)] group-hover:underline w-28 truncate">{s.label}</span>
|
||||||
|
<div className="flex-1 h-2 bg-[var(--bg-elevated)] rounded-full overflow-hidden border border-[var(--border-color)]">
|
||||||
|
<div className="h-full bg-[var(--brand)] rounded-full transition-all" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] font-mono text-[var(--text-muted)] w-14 text-right">{s.docCount} docs · {pct}%</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Top tags */}
|
||||||
|
{topTags.length > 0 && (
|
||||||
|
<section className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-5 shadow-xs">
|
||||||
|
<h2 className="text-xs font-bold uppercase tracking-wider text-[var(--text-muted)] font-mono mb-3">Top Tags</h2>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{topTags.map(([tag, count]) => (
|
||||||
|
<Link key={tag} href={`/search?q=${encodeURIComponent(tag)}`} className="px-2.5 py-1 rounded-full bg-[var(--bg-elevated)] border border-[var(--border-color)] text-xs font-mono text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-muted)]">
|
||||||
|
#{tag} <span className="text-[10px] bg-[var(--bg-surface)] px-1 py-0.2 rounded ml-1">{count}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<TagCloudClient />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent */}
|
||||||
|
{recentDocs.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xs font-bold uppercase tracking-wider text-[var(--text-muted)] font-mono mb-3">Recently Updated</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{recentDocs.map((d) => (
|
||||||
|
<Link key={d.slug} href={`/${d.slug}`} className="p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-xl shadow-xs group">
|
||||||
|
<div className="text-[10px] font-mono uppercase text-[var(--text-muted)]">{d.section} · {new Date(d.updatedAt).toLocaleDateString()}</div>
|
||||||
|
<div className="text-sm font-semibold text-[var(--text-primary)] group-hover:underline line-clamp-1 mt-1">{d.title}</div>
|
||||||
|
{d.tags.length > 0 && <div className="flex gap-1 mt-2 flex-wrap">{d.tags.slice(0, 3).map((t) => <span key={t} className="text-[10px] px-1.5 py-0.2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded font-mono text-[var(--text-muted)]">#{t}</span>)}</div>}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client islands — isolated so the page stays RSC
|
||||||
|
import DashboardQueueStrip from "./QueueStrip";
|
||||||
|
import TagCloudClient from "./TagCloudClient";
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
Loaded 50 of 96 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user