Initial commit: Astro scaffold, review template, voorstelformulier, screenshot/AI-automatisering
This commit is contained in:
4
.env.example
Normal file
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
# Kopieer naar .env en vul in. .env zelf staat in .gitignore, wordt nooit gecommit.
|
||||
|
||||
# Nodig voor `npm run new-review` / `make review`
|
||||
ANTHROPIC_API_KEY=
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
10
Makefile
Normal file
10
Makefile
Normal file
@@ -0,0 +1,10 @@
|
||||
.PHONY: review
|
||||
|
||||
# Screenshot + AI-conceptreview maken en meteen in vim openen om verder te schrijven.
|
||||
# Gebruik: make review URL=https://voorbeeld.nl [SLUG=voorbeeld]
|
||||
review:
|
||||
@if [ -z "$(URL)" ]; then \
|
||||
echo "Gebruik: make review URL=https://voorbeeld.nl [SLUG=voorbeeld]"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@FILE=$$(node --env-file-if-exists=.env scripts/new-review.mjs "$(URL)" $(SLUG)) && vim "$$FILE"
|
||||
6
astro.config.mjs
Normal file
6
astro.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from "astro/config";
|
||||
|
||||
export default defineConfig({
|
||||
site: "https://aitestbank.nl",
|
||||
output: "static",
|
||||
});
|
||||
4754
package-lock.json
generated
Normal file
4754
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
package.json
Normal file
21
package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "aitestbank",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"screenshot": "node scripts/screenshot.mjs",
|
||||
"new-review": "node --env-file-if-exists=.env scripts/new-review.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"playwright": "^1.48.0",
|
||||
"@anthropic-ai/sdk": "^0.32.0"
|
||||
}
|
||||
}
|
||||
0
public/logos/.gitkeep
Normal file
0
public/logos/.gitkeep
Normal file
212
scripts/new-review.mjs
Normal file
212
scripts/new-review.mjs
Normal file
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Zet een nieuw review-conceptbestand + screenshot klaar op basis van alleen een URL.
|
||||
*
|
||||
* Gebruik:
|
||||
* ANTHROPIC_API_KEY=... npm run new-review -- <url> [slug]
|
||||
* (of: ANTHROPIC_API_KEY in .env zetten — zie .env.example — en dan gewoon
|
||||
* `npm run new-review -- <url> [slug]`, of `make review URL=<url> [SLUG=slug]`
|
||||
* om na afloop meteen vim te openen op het net aangemaakte bestand)
|
||||
*
|
||||
* Wat het doet:
|
||||
* 1. Screenshot van de site (Playwright) -> src/assets/reviews/<slug>/screenshot.png
|
||||
* 2. Titel/meta/zichtbare tekst van de site scrapen
|
||||
* 3. Claude (Haiku) laten samenvatten tot titel/bedrijfsnaam/categorie/doelgroep/prijsindicatie
|
||||
* — met expliciete instructie om niets te verzinnen, onbekend = leeg veld
|
||||
* 4. Nieuw src/content/reviews/<slug>.md wegschrijven vanuit _template.md, met die velden ingevuld
|
||||
*
|
||||
* Wat het BEWUST NIET automatisch invult:
|
||||
* score, verdict, hands-on indruk — dat is jouw eigen testervaring na het zelf proberen,
|
||||
* niet iets wat een script (of AI die de website leest) kan beoordelen.
|
||||
* Alle AI-ingevulde velden krijgen een "# AI-concept, controleren!"-comment: check ze na.
|
||||
*/
|
||||
import { chromium } from "playwright";
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { mkdir, readFile, writeFile, access } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
|
||||
function slugify(hostname) {
|
||||
return hostname
|
||||
.replace(/^www\./, "")
|
||||
.replace(/\.(com|nl|ai|io|app|co)$/i, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
}
|
||||
|
||||
function fillField(template, field, value) {
|
||||
// Laat de regel (met het voorbeeld in de comment) ongewijzigd als er niets gevonden is,
|
||||
// zodat de gebruiker nog steeds ziet wat er verwacht wordt en het zelf kan invullen.
|
||||
if (!value) return template;
|
||||
const re = new RegExp(`^${field}: ""(\\s*#.*)?$`, "m");
|
||||
return template.replace(re, `${field}: ${JSON.stringify(value)} # AI-concept, controleren!`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [, , urlArg, slugArg] = process.argv;
|
||||
if (!urlArg) {
|
||||
console.error("Gebruik: npm run new-review -- <url> [slug]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(urlArg).toString();
|
||||
} catch {
|
||||
console.error(`Ongeldige URL: ${urlArg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const slug = slugArg || slugify(new URL(url).hostname);
|
||||
const contentPath = path.join(projectRoot, "src", "content", "reviews", `${slug}.md`);
|
||||
|
||||
try {
|
||||
await access(contentPath);
|
||||
console.error(
|
||||
`Bestaat al: ${path.relative(projectRoot, contentPath)} — kies een andere slug of verwijder 'm eerst.`,
|
||||
);
|
||||
process.exit(1);
|
||||
} catch {
|
||||
// bestaat nog niet, prima
|
||||
}
|
||||
|
||||
if (!process.env.ANTHROPIC_API_KEY) {
|
||||
console.error("ANTHROPIC_API_KEY niet gezet. Voorbeeld:");
|
||||
console.error(` ANTHROPIC_API_KEY=sk-ant-... npm run new-review -- ${urlArg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.error(`Site laden: ${url}`);
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
try {
|
||||
await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 });
|
||||
} catch {
|
||||
await page.goto(url, { waitUntil: "load", timeout: 30_000 });
|
||||
}
|
||||
|
||||
const outDir = path.join(projectRoot, "src", "assets", "reviews", slug);
|
||||
await mkdir(outDir, { recursive: true });
|
||||
const screenshotPath = path.join(outDir, "screenshot.png");
|
||||
await page.screenshot({ path: screenshotPath });
|
||||
console.error(`Screenshot: ${path.relative(projectRoot, screenshotPath)}`);
|
||||
|
||||
const pageTitle = await page.title();
|
||||
const metaDescription = await page
|
||||
.locator('meta[name="description"]')
|
||||
.first()
|
||||
.getAttribute("content")
|
||||
.catch(() => null);
|
||||
const bodyText = (await page.locator("body").innerText()).slice(0, 8000);
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.error("Claude laten meekijken voor de basisinfo...");
|
||||
const anthropic = new Anthropic();
|
||||
|
||||
const extraction = await anthropic.messages.create({
|
||||
model: "claude-haiku-4-5-20251001",
|
||||
max_tokens: 1024,
|
||||
tools: [
|
||||
{
|
||||
name: "record_tool_info",
|
||||
description: "Leg de basisinformatie van deze tool vast voor een reviewtemplate.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: {
|
||||
type: "string",
|
||||
description: "Nederlandse review-titel in de stijl 'Bedrijfsnaam: wat het doet in één zin'",
|
||||
},
|
||||
company: { type: "string", description: "Naam van het bedrijf/product" },
|
||||
location: {
|
||||
type: "string",
|
||||
description: "Vestigingsplaats, ALLEEN als die letterlijk ergens op de site staat, anders lege string",
|
||||
},
|
||||
category: {
|
||||
type: "string",
|
||||
description: "Korte Nederlandse categorie, bv. 'Klantenservice', 'Boekhouding', 'Marketing'",
|
||||
},
|
||||
for_who: { type: "string", description: "Korte typering van de doelgroep, gebaseerd op de site-tekst" },
|
||||
price_from: {
|
||||
type: "string",
|
||||
description:
|
||||
"Laagste genoemde prijs incl. periode, bv. '€39 /maand', ALLEEN als een bedrag op de site staat, anders lege string",
|
||||
},
|
||||
hosting: {
|
||||
type: "string",
|
||||
description:
|
||||
"Korte typering van hosting/privacy ALLEEN als expliciet vermeld (bv. 'EU, GDPR-verklaring'), anders lege string",
|
||||
},
|
||||
},
|
||||
required: ["title", "company", "location", "category", "for_who", "price_from", "hosting"],
|
||||
},
|
||||
},
|
||||
],
|
||||
tool_choice: { type: "tool", name: "record_tool_info" },
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
"Hier is de inhoud van een bedrijfswebsite. Haal er alleen op wat er ECHT letterlijk staat, " +
|
||||
"verzin nooit iets. Onbekende/niet-vermelde velden: lege string, niet gokken.\n\n" +
|
||||
`Paginatitel: ${pageTitle}\n` +
|
||||
`Meta-omschrijving: ${metaDescription ?? ""}\n\n` +
|
||||
`Zichtbare tekst van de pagina:\n${bodyText}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const toolUse = extraction.content.find((block) => block.type === "tool_use");
|
||||
if (!toolUse) {
|
||||
console.error("Kon geen structured info uit het Claude-antwoord halen.");
|
||||
process.exit(1);
|
||||
}
|
||||
const info = toolUse.input;
|
||||
|
||||
const templatePath = path.join(projectRoot, "src", "content", "reviews", "_template.md");
|
||||
let content = await readFile(templatePath, "utf-8");
|
||||
|
||||
// instructie-comment bovenaan het template hoort niet in een echt reviewbestand
|
||||
content = content.replace(/# Kopieer dit bestand[\s\S]*?koppeltekens\.\n\n/, "");
|
||||
|
||||
// date/score horen pas een echte waarde te krijgen bij het publiceren. Het
|
||||
// template zet ze op respectievelijk "" en 0 als leesbaar voorbeeld voor de
|
||||
// handmatige workflow, maar allebei zijn dat *ongeldige* waarden voor het
|
||||
// Zod-schema (date: "" faalt de date-coercion, score: 0 faalt min(1)) — dus
|
||||
// hier de regels helemaal weghalen, zodat het veld echt afwezig is en de
|
||||
// .optional() in het schema z'n werk kan doen.
|
||||
content = content.replace(/^date: ""\s*#.*\n/m, "");
|
||||
content = content.replace(/^score: 0\s*#.*\n/m, "");
|
||||
|
||||
content = content.replace(/^website: ""(\s*#.*)?$/m, `website: ${JSON.stringify(url)}`);
|
||||
content = fillField(content, "title", info.title);
|
||||
content = fillField(content, "company", info.company);
|
||||
content = fillField(content, "location", info.location);
|
||||
content = fillField(content, "category", info.category);
|
||||
content = fillField(content, "for_who", info.for_who);
|
||||
content = fillField(content, "price_from", info.price_from);
|
||||
content = fillField(content, "hosting", info.hosting);
|
||||
|
||||
const companyForAlt = info.company || slug;
|
||||
content = content.replace(
|
||||
"## Wat is het\n\n<!--",
|
||||
`## Wat is het\n\n\n\n<!--`,
|
||||
);
|
||||
|
||||
await writeFile(contentPath, content, "utf-8");
|
||||
console.error(`Concept aangemaakt: ${path.relative(projectRoot, contentPath)}`);
|
||||
console.error(
|
||||
"Nog met de hand te doen: elk 'AI-concept, controleren!'-veld naast lezen, en zelf score/verdict/hands-on indruk schrijven na het testen.",
|
||||
);
|
||||
|
||||
// enige stdout-regel, bedoeld om door scripts (bv. de Makefile) opgepakt te worden
|
||||
console.log(contentPath);
|
||||
}
|
||||
|
||||
main();
|
||||
50
scripts/screenshot.mjs
Normal file
50
scripts/screenshot.mjs
Normal file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Maakt een screenshot van een tool-website voor gebruik in een review.
|
||||
*
|
||||
* Gebruik:
|
||||
* npm run screenshot -- <url> <slug> [--full-page]
|
||||
*
|
||||
* Voorbeeld:
|
||||
* npm run screenshot -- https://watermelon.ai watermelon
|
||||
*
|
||||
* Zet het resultaat in src/assets/reviews/<slug>/screenshot.png
|
||||
*/
|
||||
import { chromium } from "playwright";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const fullPage = args.includes("--full-page");
|
||||
const positional = args.filter((a) => !a.startsWith("--"));
|
||||
const [url, slug] = positional;
|
||||
|
||||
if (!url || !slug) {
|
||||
console.error("Gebruik: npm run screenshot -- <url> <slug> [--full-page]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const outDir = path.join(projectRoot, "src", "assets", "reviews", slug);
|
||||
const outFile = path.join(outDir, "screenshot.png");
|
||||
|
||||
await mkdir(outDir, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
try {
|
||||
await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 });
|
||||
} catch {
|
||||
// sommige sites (chat-widgets, analytics) worden nooit helemaal "idle" —
|
||||
// val dan terug op gewoon "load" zodat de screenshot toch doorgaat
|
||||
await page.goto(url, { waitUntil: "load", timeout: 30_000 });
|
||||
}
|
||||
|
||||
await page.screenshot({ path: outFile, fullPage });
|
||||
await browser.close();
|
||||
|
||||
console.log(`Screenshot opgeslagen: ${path.relative(projectRoot, outFile)}`);
|
||||
87
server/cgi-bin/submit-tool.py
Executable file
87
server/cgi-bin/submit-tool.py
Executable file
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CGI-handler voor het tool-voorstelformulier op aitestbank.nl.
|
||||
|
||||
Ontvangt een gewone (niet-JS) form-POST, mailt de inzending via de lokale
|
||||
Postfix (localhost:25) en redirect naar /bedankt. Stdlib-only, bewust geen
|
||||
`cgi`-module (die is deprecated/verwijderd vanaf Python 3.13).
|
||||
"""
|
||||
import html
|
||||
import os
|
||||
import smtplib
|
||||
import sys
|
||||
from email.message import EmailMessage
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
# TODO: zet hier het gewenste ontvangstadres
|
||||
TO_ADDR = "mjblom@gmail.com"
|
||||
FROM_ADDR = "aitestbank@b10m.net"
|
||||
MAX_BODY_BYTES = 20_000
|
||||
|
||||
|
||||
def redirect(location: str) -> None:
|
||||
sys.stdout.write(f"Status: 303 See Other\r\nLocation: {location}\r\n\r\n")
|
||||
|
||||
|
||||
def bad_request(message: str) -> None:
|
||||
sys.stdout.write("Status: 400 Bad Request\r\nContent-Type: text/html; charset=utf-8\r\n\r\n")
|
||||
sys.stdout.write(
|
||||
f'<!doctype html><html lang="nl"><body>'
|
||||
f"<p>{html.escape(message)}</p>"
|
||||
f'<p><a href="/voorstellen">Terug naar het formulier</a></p>'
|
||||
f"</body></html>"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
length = int(os.environ.get("CONTENT_LENGTH", 0) or 0)
|
||||
if length <= 0 or length > MAX_BODY_BYTES:
|
||||
bad_request("Ongeldige of te grote inzending.")
|
||||
return
|
||||
|
||||
raw = sys.stdin.buffer.read(length).decode("utf-8", errors="replace")
|
||||
fields = parse_qs(raw)
|
||||
|
||||
def field(name: str) -> str:
|
||||
return fields.get(name, [""])[0].strip()
|
||||
|
||||
# Honeypot: onzichtbaar veld voor mensen, bots vullen het vaak wel in.
|
||||
if field("website"):
|
||||
redirect("/bedankt")
|
||||
return
|
||||
|
||||
tool_naam = field("tool_naam")
|
||||
tool_url = field("tool_url")
|
||||
naam = field("naam")
|
||||
email = field("email")
|
||||
toelichting = field("toelichting")
|
||||
|
||||
if not tool_naam or not tool_url:
|
||||
bad_request("Tool-naam en tool-URL zijn verplicht.")
|
||||
return
|
||||
|
||||
body = (
|
||||
"Nieuwe tool-suggestie via aitestbank.nl/voorstellen\n\n"
|
||||
f"Tool: {tool_naam}\n"
|
||||
f"URL: {tool_url}\n"
|
||||
f"Naam indiener: {naam or '(niet opgegeven)'}\n"
|
||||
f"E-mail indiener: {email or '(niet opgegeven)'}\n\n"
|
||||
"Toelichting:\n"
|
||||
f"{toelichting or '(geen)'}\n"
|
||||
)
|
||||
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = f"Tool-suggestie: {tool_naam}"
|
||||
msg["From"] = FROM_ADDR
|
||||
msg["To"] = TO_ADDR
|
||||
if email:
|
||||
msg["Reply-To"] = email
|
||||
msg.set_content(body)
|
||||
|
||||
with smtplib.SMTP("localhost", 25) as smtp:
|
||||
smtp.send_message(msg)
|
||||
|
||||
redirect("/bedankt")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
src/assets/reviews/.gitkeep
Normal file
0
src/assets/reviews/.gitkeep
Normal file
64
src/components/Cover.astro
Normal file
64
src/components/Cover.astro
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
interface Props {
|
||||
hue: "teal" | "amber" | "rust" | "slate" | "olive";
|
||||
logo?: string;
|
||||
company: string;
|
||||
size?: "thumb" | "hero";
|
||||
}
|
||||
const { hue, logo, company, size = "thumb" } = Astro.props;
|
||||
---
|
||||
|
||||
<div class={`cover ${size} hue-${hue}`}>
|
||||
{
|
||||
logo ? (
|
||||
<img src={logo} alt={`${company} logo`} class="logo" />
|
||||
) : (
|
||||
<svg class="mark" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<use href="#robot-icon" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cover {
|
||||
--ink-c: var(--hue-teal-ink);
|
||||
--wash-c: var(--hue-teal-wash);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius);
|
||||
background:
|
||||
radial-gradient(var(--ink-c) 1px, transparent 1.6px) 0 0 / 12px 12px,
|
||||
var(--wash-c);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cover.hue-amber { --ink-c: var(--hue-amber-ink); --wash-c: var(--hue-amber-wash); }
|
||||
.cover.hue-rust { --ink-c: var(--hue-rust-ink); --wash-c: var(--hue-rust-wash); }
|
||||
.cover.hue-slate { --ink-c: var(--hue-slate-ink); --wash-c: var(--hue-slate-wash); }
|
||||
.cover.hue-olive { --ink-c: var(--hue-olive-ink); --wash-c: var(--hue-olive-wash); }
|
||||
|
||||
.cover .mark {
|
||||
position: absolute;
|
||||
fill: var(--ink-c);
|
||||
opacity: 0.5;
|
||||
}
|
||||
.cover .logo {
|
||||
position: relative;
|
||||
max-width: 60%;
|
||||
max-height: 60%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.cover.thumb { width: 88px; height: 88px; }
|
||||
.cover.thumb .mark { width: 52px; height: 52px; right: -8px; bottom: -8px; }
|
||||
.cover.hero { width: 100%; height: 168px; margin-bottom: 26px; }
|
||||
.cover.hero .mark { width: 110px; height: 110px; right: 18px; bottom: -20px; }
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.cover.thumb { width: 64px; height: 64px; }
|
||||
.cover.thumb .mark { width: 38px; height: 38px; }
|
||||
}
|
||||
</style>
|
||||
41
src/components/RobotScore.astro
Normal file
41
src/components/RobotScore.astro
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
interface Props {
|
||||
score?: number;
|
||||
size?: "md" | "lg";
|
||||
showNumber?: boolean;
|
||||
}
|
||||
// score is optioneel voor een nog-niet-gepubliceerd concept (draft: true) —
|
||||
// dan tonen we gewoon 0/5 in plaats van te crashen.
|
||||
const { score = 0, size = "md", showNumber = true } = Astro.props;
|
||||
const cells = Array.from({ length: 5 }, (_, i) => i < score);
|
||||
---
|
||||
|
||||
<span class={`robots ${size === "lg" ? "lg" : ""}`} aria-label={`${score} van de 5 robots`}>
|
||||
{
|
||||
cells.map((filled) => (
|
||||
<svg class={filled ? "robot-fill" : "robot-off"} aria-hidden="true">
|
||||
<use href="#robot-icon" />
|
||||
</svg>
|
||||
))
|
||||
}
|
||||
{showNumber && <span class="score-num tabnum">{score}/5</span>}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.robots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.robots svg { width: 17px; height: 17px; display: block; }
|
||||
.robots.lg svg { width: 24px; height: 24px; }
|
||||
.robot-fill { fill: var(--accent); }
|
||||
.robot-off { fill: var(--robot-off); }
|
||||
.score-num {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-left: 4px;
|
||||
}
|
||||
</style>
|
||||
54
src/content.config.ts
Normal file
54
src/content.config.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { defineCollection, z } from "astro:content";
|
||||
import { glob } from "astro/loaders";
|
||||
|
||||
const reviews = defineCollection({
|
||||
loader: glob({
|
||||
pattern: ["*.md", "!_template.md"],
|
||||
base: "./src/content/reviews",
|
||||
}),
|
||||
schema: z
|
||||
.object({
|
||||
title: z.string(),
|
||||
company: z.string(),
|
||||
website: z.string().url(),
|
||||
location: z.string(),
|
||||
category: z.string(),
|
||||
date: z.coerce.date().optional(),
|
||||
draft: z.boolean().default(true),
|
||||
|
||||
for_who: z.string(),
|
||||
price_from: z.string(),
|
||||
hosting: z.string(),
|
||||
|
||||
// Optioneel zolang draft: true — een concept mag nog geen score/oordeel
|
||||
// hebben. Zodra draft: false wordt gezet, dwingt de refine hieronder af
|
||||
// dat beide echt ingevuld zijn (voorkomt een leeg/0-sterren oordeel dat
|
||||
// per ongeluk gepubliceerd wordt).
|
||||
score: z.number().int().min(1).max(5).optional(),
|
||||
verdict: z.string().optional().default(""),
|
||||
|
||||
logo: z.string().optional().default(""),
|
||||
coverHue: z
|
||||
.enum(["teal", "amber", "rust", "slate", "olive"])
|
||||
.default("teal"),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.draft) return;
|
||||
if (data.score === undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["score"],
|
||||
message: "score (1-5) is verplicht zodra draft: false staat",
|
||||
});
|
||||
}
|
||||
if (!data.verdict) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["verdict"],
|
||||
message: "verdict mag niet leeg zijn zodra draft: false staat",
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { reviews };
|
||||
45
src/content/reviews/_template.md
Normal file
45
src/content/reviews/_template.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
# Kopieer dit bestand naar <slug>.md (bv. watermelon.md) en vul in.
|
||||
# Bestandsnaam = URL-slug, dus alleen kleine letters/cijfers/koppeltekens.
|
||||
|
||||
title: "" # bv. "Watermelon: AI-klantenservice die je zelf binnen een uur opzet"
|
||||
company: "" # bv. "Watermelon"
|
||||
website: "" # bv. "https://watermelon.ai"
|
||||
location: "" # bv. "Utrecht"
|
||||
category: "" # bv. "Klantenservice" — 1 hoofdcategorie, wordt ook gebruikt voor de coverkleur
|
||||
date: "" # publicatiedatum, bv. 2026-07-23 — leeg laten zolang draft: true
|
||||
draft: true # false zetten bij publiceren (bepaalt "gepubliceerd"/"binnenkort" op de homepage)
|
||||
|
||||
# --- Specs-balkje bovenaan de review ---
|
||||
for_who: "" # korte typering, bv. "MKB met veel herhalende vragen"
|
||||
price_from: "" # bv. "€39 /maand" — "Gratis" of "Op aanvraag" mag ook
|
||||
hosting: "" # bv. "EU, GDPR-verklaring"
|
||||
|
||||
# --- Eindoordeel ---
|
||||
score: 0 # geheel getal 1-5 (aantal robots)
|
||||
verdict: "" # één zin, komt in het verdict-kader bovenaan
|
||||
|
||||
# --- Cover ---
|
||||
logo: "" # bv. "/logos/watermelon.svg" — bestand in public/logos/ zetten, anders leeg laten
|
||||
coverHue: "teal" # teal | amber | rust | slate | olive — alleen gebruikt zolang er geen logo is
|
||||
---
|
||||
|
||||
## Wat is het
|
||||
|
||||
<!-- Wat doet de tool, in gewone-mensen-taal. Eén tot twee alinea's. -->
|
||||
|
||||
## Voor wie
|
||||
|
||||
<!-- Voor welk type gebruiker/bedrijf is dit relevant, en voor wie juist niet. -->
|
||||
|
||||
## Wie zit erachter
|
||||
|
||||
<!-- Team, locatie, funding/omzet indien bekend. Onbekend? Dat gewoon zo benoemen. -->
|
||||
|
||||
## Prijs
|
||||
|
||||
<!-- Prijsmodel iets uitgebreider dan het specs-balkje: tiers, gratis laag, addertjes onder het gras. -->
|
||||
|
||||
## Hands-on indruk
|
||||
|
||||
<!-- De eigen ervaring: wat werkte goed, wat viel tegen. Dit is de kern van de review. -->
|
||||
39
src/content/reviews/watermelon.md
Normal file
39
src/content/reviews/watermelon.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: "Watermelon: AI-klantenservice die je zelf binnen een uur opzet"
|
||||
company: "Watermelon"
|
||||
website: "https://watermelon.ai"
|
||||
location: "Utrecht"
|
||||
category: "Klantenservice"
|
||||
date: 2026-07-23
|
||||
draft: false
|
||||
|
||||
for_who: "MKB met veel herhalende vragen"
|
||||
price_from: "€39 /maand"
|
||||
hosting: "EU, GDPR-verklaring"
|
||||
|
||||
score: 4
|
||||
verdict: "Sterk, zelfstandig te testen product met een geloofwaardig GDPR-verhaal — mist alleen wat diepgang in de rapportages."
|
||||
|
||||
logo: ""
|
||||
coverHue: "teal"
|
||||
---
|
||||
|
||||
## Wat is het
|
||||
|
||||
Watermelon is een AI-klantenservice-agent die vragen via chat, WhatsApp, e-mail en telefoon beantwoordt, getraind op de kennisbank van het eigen bedrijf. Geen los gesprek met sales nodig: je maakt een account, wijst een paar pagina's of documenten aan, en de bot beantwoordt daarna zelfstandig binnenkomende vragen.
|
||||
|
||||
## Voor wie
|
||||
|
||||
Vooral relevant voor MKB-bedrijven met een voorspelbare stroom herhalende vragen (verzendstatus, openingstijden, retourbeleid) die nu telefoon of mailbox belasten.
|
||||
|
||||
## Wie zit erachter
|
||||
|
||||
Opgericht in Utrecht. GDPR/EU-hosted, ISO-gecertificeerd — verdere teamdetails nog na te trekken voor publicatie.
|
||||
|
||||
## Prijs
|
||||
|
||||
Instapplan vanaf €39 per maand, oplopend met volume en kanalen. Geen gratis laag, wel een proefperiode zonder creditcard.
|
||||
|
||||
## Hands-on indruk
|
||||
|
||||
<!-- TODO: eigen testervaring invullen zodra getest -->
|
||||
188
src/layouts/Base.astro
Normal file
188
src/layouts/Base.astro
Normal file
@@ -0,0 +1,188 @@
|
||||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
const { title, description } = Astro.props;
|
||||
const path = Astro.url.pathname;
|
||||
const isHome = path === "/";
|
||||
const isVoorstellen = path.startsWith("/voorstellen");
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{title} · AITestBank</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="icon" href="data:," />
|
||||
</head>
|
||||
<body>
|
||||
<svg width="0" height="0" style="position:absolute">
|
||||
<symbol id="robot-icon" viewBox="0 0 24 24">
|
||||
<rect x="4" y="7" width="16" height="14" rx="3"></rect>
|
||||
<rect x="10" y="2" width="4" height="4" rx="1"></rect>
|
||||
<circle class="robot-eye" cx="9" cy="14" r="1.6"></circle>
|
||||
<circle class="robot-eye" cx="15" cy="14" r="1.6"></circle>
|
||||
<rect class="robot-eye" x="9" y="17.5" width="6" height="1.6" rx="0.8"></rect>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
||||
<div class="shell">
|
||||
<header class="site">
|
||||
<a class="wordmark" href="/">
|
||||
<span class="name">AITestBank</span>
|
||||
<span class="tag">aitestbank.nl</span>
|
||||
</a>
|
||||
<nav class="site">
|
||||
<a href="/" aria-current={isHome ? "page" : undefined}>Reviews</a>
|
||||
<a href="/voorstellen" aria-current={isVoorstellen ? "page" : undefined}>Tool voorstellen</a>
|
||||
</nav>
|
||||
</header>
|
||||
<slot />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style is:global>
|
||||
:root {
|
||||
--paper: #faf9f6;
|
||||
--paper-raised: #f2f0ea;
|
||||
--ink: #1c1b19;
|
||||
--muted: #6b6a64;
|
||||
--line: #dedcd5;
|
||||
--accent: #3c6e63;
|
||||
--accent-soft: #e4ece9;
|
||||
--robot-off: #d6d4cc;
|
||||
--radius: 4px;
|
||||
--sans: ui-sans-serif, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
--mono: ui-monospace, "SF Mono", "Cascadia Code", "Roboto Mono", Consolas, monospace;
|
||||
|
||||
--hue-teal-ink: #3c6e63;
|
||||
--hue-teal-wash: #e4ece9;
|
||||
--hue-amber-ink: #9c6b23;
|
||||
--hue-amber-wash: #f3e8d3;
|
||||
--hue-rust-ink: #a85039;
|
||||
--hue-rust-wash: #f5e0da;
|
||||
--hue-slate-ink: #3f5a7d;
|
||||
--hue-slate-wash: #dfe6ef;
|
||||
--hue-olive-ink: #6b7a3a;
|
||||
--hue-olive-wash: #e9edd9;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--paper: #17181a;
|
||||
--paper-raised: #1e2022;
|
||||
--ink: #eae8e2;
|
||||
--muted: #94928a;
|
||||
--line: #2b2d2d;
|
||||
--accent: #74ab9d;
|
||||
--accent-soft: #1c2926;
|
||||
--robot-off: #35373a;
|
||||
|
||||
--hue-teal-ink: #74ab9d;
|
||||
--hue-teal-wash: #1c2926;
|
||||
--hue-amber-ink: #d9a75c;
|
||||
--hue-amber-wash: #2e2617;
|
||||
--hue-rust-ink: #d98267;
|
||||
--hue-rust-wash: #33211c;
|
||||
--hue-slate-ink: #7fa0c4;
|
||||
--hue-slate-wash: #1c2530;
|
||||
--hue-olive-ink: #a3b56a;
|
||||
--hue-olive-wash: #262c18;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--paper: #17181a;
|
||||
--paper-raised: #1e2022;
|
||||
--ink: #eae8e2;
|
||||
--muted: #94928a;
|
||||
--line: #2b2d2d;
|
||||
--accent: #74ab9d;
|
||||
--accent-soft: #1c2926;
|
||||
--robot-off: #35373a;
|
||||
--hue-teal-ink: #74ab9d;
|
||||
--hue-teal-wash: #1c2926;
|
||||
--hue-amber-ink: #d9a75c;
|
||||
--hue-amber-wash: #2e2617;
|
||||
--hue-rust-ink: #d98267;
|
||||
--hue-rust-wash: #33211c;
|
||||
--hue-slate-ink: #7fa0c4;
|
||||
--hue-slate-wash: #1c2530;
|
||||
--hue-olive-ink: #a3b56a;
|
||||
--hue-olive-wash: #262c18;
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
--paper: #faf9f6;
|
||||
--paper-raised: #f2f0ea;
|
||||
--ink: #1c1b19;
|
||||
--muted: #6b6a64;
|
||||
--line: #dedcd5;
|
||||
--accent: #3c6e63;
|
||||
--accent-soft: #e4ece9;
|
||||
--robot-off: #d6d4cc;
|
||||
--hue-teal-ink: #3c6e63;
|
||||
--hue-teal-wash: #e4ece9;
|
||||
--hue-amber-ink: #9c6b23;
|
||||
--hue-amber-wash: #f3e8d3;
|
||||
--hue-rust-ink: #a85039;
|
||||
--hue-rust-wash: #f5e0da;
|
||||
--hue-slate-ink: #3f5a7d;
|
||||
--hue-slate-wash: #dfe6ef;
|
||||
--hue-olive-ink: #6b7a3a;
|
||||
--hue-olive-wash: #e9edd9;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: var(--sans);
|
||||
font-size: 18px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
a:focus-visible,
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.tabnum { font-variant-numeric: tabular-nums; }
|
||||
|
||||
.shell { max-width: 820px; margin: 0 auto; padding: 0 24px 100px; }
|
||||
|
||||
header.site {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 30px 0 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wordmark {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
}
|
||||
.wordmark .name { font-size: 21px; font-weight: 700; letter-spacing: -0.01em; }
|
||||
.wordmark .tag { font-family: var(--mono); font-size: 13px; color: var(--muted); }
|
||||
nav.site { display: flex; gap: 22px; }
|
||||
nav.site a { color: var(--muted); text-decoration: none; font-size: 15px; }
|
||||
nav.site a:hover,
|
||||
nav.site a[aria-current="page"] { color: var(--ink); }
|
||||
|
||||
/* shared by RobotScore and Cover: the eyes of the #robot-icon symbol
|
||||
always need to punch through to the page background */
|
||||
.robot-eye { fill: var(--paper); }
|
||||
</style>
|
||||
17
src/pages/bedankt.astro
Normal file
17
src/pages/bedankt.astro
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
import Base from "../layouts/Base.astro";
|
||||
---
|
||||
|
||||
<Base title="Bedankt" description="Je tool-suggestie is ontvangen.">
|
||||
<section class="intro">
|
||||
<h1>Bedankt!</h1>
|
||||
<p>Je suggestie is binnen. Ik lees alles, maar kan niet beloven dat elke tool een review krijgt.</p>
|
||||
<p><a href="/">← Terug naar de reviews</a></p>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
section.intro { padding: 44px 0 10px; margin-bottom: 8px; }
|
||||
section.intro h1 { font-size: 34px; margin: 0 0 12px; text-wrap: balance; letter-spacing: -0.01em; }
|
||||
section.intro p { margin: 0 0 16px; color: var(--muted); max-width: 58ch; font-size: 18px; }
|
||||
</style>
|
||||
138
src/pages/index.astro
Normal file
138
src/pages/index.astro
Normal file
@@ -0,0 +1,138 @@
|
||||
---
|
||||
import { getCollection } from "astro:content";
|
||||
import Base from "../layouts/Base.astro";
|
||||
import Cover from "../components/Cover.astro";
|
||||
import RobotScore from "../components/RobotScore.astro";
|
||||
|
||||
const all = await getCollection("reviews");
|
||||
const published = all
|
||||
.filter((r) => !r.data.draft)
|
||||
.sort((a, b) => (b.data.date?.valueOf() ?? 0) - (a.data.date?.valueOf() ?? 0));
|
||||
const upcoming = all.filter((r) => r.data.draft);
|
||||
const posts = [...published, ...upcoming];
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("nl-NL", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
---
|
||||
|
||||
<Base
|
||||
title="Reviews"
|
||||
description="Wekelijkse, eerlijke tests van Nederlandse AI-tools. Score van 1 tot 5 robots."
|
||||
>
|
||||
<section class="intro">
|
||||
<h1>Nederlandse AI-tools, eerlijk getest.</h1>
|
||||
<p>
|
||||
Elke week (of vaker) een AI-tool onder de loep: hoe het werkt, voor wie
|
||||
het is, wat het kost. Score van 1 tot 5 robots, geen sponsored fluff.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<ul class="postlist">
|
||||
{
|
||||
posts.map((post) => (
|
||||
<li class="post-row">
|
||||
<Cover
|
||||
hue={post.data.coverHue}
|
||||
logo={post.data.logo}
|
||||
company={post.data.company}
|
||||
size="thumb"
|
||||
/>
|
||||
<div class="meta">
|
||||
<span class="tabnum">
|
||||
{post.data.draft ? "—" : dateFormatter.format(post.data.date)}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{post.data.category}</span>
|
||||
<span class={`status ${post.data.draft ? "pending" : ""}`}>
|
||||
{post.data.draft ? "binnenkort" : "gepubliceerd"}
|
||||
</span>
|
||||
</div>
|
||||
<h2>
|
||||
<a href={post.data.draft ? undefined : `/reviews/${post.id}`}>
|
||||
{post.data.title}
|
||||
</a>
|
||||
</h2>
|
||||
<p class="dek">
|
||||
{post.data.location} · {post.data.verdict}
|
||||
</p>
|
||||
<div class="score">
|
||||
{!post.data.draft && <RobotScore score={post.data.score} />}
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
section.intro {
|
||||
padding: 44px 0 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
section.intro h1 {
|
||||
font-size: 34px;
|
||||
margin: 0 0 12px;
|
||||
text-wrap: balance;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
section.intro p {
|
||||
margin: 0 0 30px;
|
||||
color: var(--muted);
|
||||
max-width: 58ch;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
ul.postlist { list-style: none; margin: 0; padding: 0; }
|
||||
.post-row {
|
||||
display: grid;
|
||||
grid-template-columns: 88px 1fr auto;
|
||||
gap: 6px 20px;
|
||||
padding: 28px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
align-items: start;
|
||||
}
|
||||
.post-row :global(.cover) { grid-row: 1 / 3; }
|
||||
.post-row .meta {
|
||||
grid-column: 2 / 3;
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.post-row .meta .status {
|
||||
padding: 1px 7px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.post-row .meta .status.pending {
|
||||
background: transparent;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
}
|
||||
.post-row h2 { grid-column: 2 / 3; margin: 0; font-size: 20px; line-height: 1.35; }
|
||||
.post-row h2 a { color: var(--ink); text-decoration: none; }
|
||||
.post-row h2 a:hover { color: var(--accent); }
|
||||
.post-row p.dek {
|
||||
grid-column: 2 / 3;
|
||||
margin: 4px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 15.5px;
|
||||
max-width: 54ch;
|
||||
}
|
||||
.post-row .score { grid-column: 3 / 4; grid-row: 1 / 2; justify-self: end; white-space: nowrap; }
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.post-row { grid-template-columns: 64px 1fr auto; }
|
||||
}
|
||||
</style>
|
||||
157
src/pages/reviews/[slug].astro
Normal file
157
src/pages/reviews/[slug].astro
Normal file
@@ -0,0 +1,157 @@
|
||||
---
|
||||
import { getCollection, render } from "astro:content";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import Cover from "../../components/Cover.astro";
|
||||
import RobotScore from "../../components/RobotScore.astro";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const all = await getCollection("reviews");
|
||||
return all.map((entry) => ({
|
||||
params: { slug: entry.id },
|
||||
props: { entry },
|
||||
}));
|
||||
}
|
||||
|
||||
const { entry } = Astro.props;
|
||||
const { Content } = await render(entry);
|
||||
const d = entry.data;
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("nl-NL", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
---
|
||||
|
||||
<Base title={d.title} description={d.verdict}>
|
||||
<article class="review">
|
||||
<p class="breadcrumb"><a href="/">← Alle reviews</a></p>
|
||||
|
||||
<Cover hue={d.coverHue} logo={d.logo} company={d.company} size="hero" />
|
||||
|
||||
<h1>{d.title}</h1>
|
||||
<div class="review-meta">
|
||||
{d.date && <span class="tabnum">{dateFormatter.format(d.date)}</span>}
|
||||
{d.date && <span>·</span>}
|
||||
<span>{d.category}</span>
|
||||
<span>·</span>
|
||||
<span>{d.location}</span>
|
||||
<span>·</span>
|
||||
<a href={d.website} target="_blank" rel="noopener">{d.company} →</a>
|
||||
</div>
|
||||
|
||||
<div class="verdict">
|
||||
<div>
|
||||
<div class="label">Eindoordeel</div>
|
||||
<p class="oneliner">{d.verdict}</p>
|
||||
</div>
|
||||
<div class="score-block">
|
||||
<RobotScore score={d.score} size="lg" showNumber={false} />
|
||||
<span class="score-num tabnum">{d.score} / 5</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="specs">
|
||||
<div>
|
||||
<dt>Voor wie</dt>
|
||||
<dd>{d.for_who}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Vanaf</dt>
|
||||
<dd class="tabnum">{d.price_from}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Hosting</dt>
|
||||
<dd>{d.hosting}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="body">
|
||||
<Content />
|
||||
</div>
|
||||
</article>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.breadcrumb {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin: 30px 0 20px;
|
||||
}
|
||||
.breadcrumb a { color: var(--muted); }
|
||||
|
||||
article.review h1 {
|
||||
font-size: 40px;
|
||||
margin: 0 0 10px;
|
||||
text-wrap: balance;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.review-meta {
|
||||
font-family: var(--mono);
|
||||
font-size: 13.5px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 22px;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.verdict {
|
||||
background: var(--paper-raised);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 22px 24px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 12px 24px;
|
||||
align-items: center;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
.verdict .label {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.verdict .oneliner { margin: 0; font-size: 17px; max-width: 46ch; }
|
||||
.verdict .score-block { text-align: right; }
|
||||
.verdict .score-block .score-num {
|
||||
font-family: var(--mono);
|
||||
font-size: 16px;
|
||||
color: var(--muted);
|
||||
display: block;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.specs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 18px;
|
||||
margin: 0 0 36px;
|
||||
padding: 18px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.specs dt {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.specs dd { margin: 0; font-size: 16px; }
|
||||
|
||||
.body { max-width: 64ch; }
|
||||
.body :global(h2) { font-size: 22px; margin: 42px 0 12px; letter-spacing: -0.005em; }
|
||||
.body :global(p) { margin: 0 0 17px; }
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.specs { grid-template-columns: 1fr 1fr; }
|
||||
.verdict { grid-template-columns: 1fr; }
|
||||
.verdict .score-block { text-align: left; }
|
||||
}
|
||||
</style>
|
||||
130
src/pages/voorstellen.astro
Normal file
130
src/pages/voorstellen.astro
Normal file
@@ -0,0 +1,130 @@
|
||||
---
|
||||
import Base from "../layouts/Base.astro";
|
||||
---
|
||||
|
||||
<Base
|
||||
title="Tool voorstellen"
|
||||
description="Stel een Nederlandse AI-tool voor om te reviewen op AITestBank."
|
||||
>
|
||||
<section class="intro">
|
||||
<h1>Stel een tool voor</h1>
|
||||
<p>
|
||||
Ken je een (bij voorkeur Nederlandse) AI-tool die een review verdient?
|
||||
Laat het weten, ik lees alles.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<form method="post" action="/submit-tool" class="suggest-form">
|
||||
<div class="field">
|
||||
<label for="tool_naam">Naam van de tool <span class="req">*</span></label>
|
||||
<input type="text" id="tool_naam" name="tool_naam" required maxlength="120" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="tool_url">Website van de tool <span class="req">*</span></label>
|
||||
<input type="url" id="tool_url" name="tool_url" required placeholder="https://" maxlength="300" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="toelichting">Waarom deze tool?</label>
|
||||
<textarea id="toelichting" name="toelichting" rows="4" maxlength="2000"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="naam">Je naam <span class="opt">(optioneel)</span></label>
|
||||
<input type="text" id="naam" name="naam" maxlength="120" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="email">Je e-mail <span class="opt">(optioneel, voor een reactie)</span></label>
|
||||
<input type="email" id="email" name="email" maxlength="200" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- honeypot: verborgen voor mensen via CSS, bots vullen dit vaak automatisch in -->
|
||||
<div class="hp" aria-hidden="true">
|
||||
<label for="website">Website</label>
|
||||
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<button type="submit">Versturen</button>
|
||||
</form>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
section.intro {
|
||||
padding: 44px 0 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
section.intro h1 {
|
||||
font-size: 34px;
|
||||
margin: 0 0 12px;
|
||||
text-wrap: balance;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
section.intro p {
|
||||
margin: 0 0 30px;
|
||||
color: var(--muted);
|
||||
max-width: 58ch;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.suggest-form {
|
||||
max-width: 56ch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
margin-bottom: 80px;
|
||||
}
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 22px;
|
||||
}
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
label {
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.req { color: var(--accent); }
|
||||
.opt { text-transform: none; letter-spacing: 0; }
|
||||
|
||||
input, textarea {
|
||||
font: inherit;
|
||||
font-size: 16px;
|
||||
color: var(--ink);
|
||||
background: var(--paper-raised);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
textarea { resize: vertical; }
|
||||
input:focus-visible, textarea:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.hp { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
|
||||
|
||||
button {
|
||||
align-self: start;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--paper);
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { opacity: 0.9; }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.field-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
3
tsconfig.json
Normal file
3
tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict"
|
||||
}
|
||||
Reference in New Issue
Block a user