diff --git a/docs/superpowers/specs/2026-05-29-gallery-projects-design.md b/docs/superpowers/specs/2026-05-29-gallery-projects-design.md new file mode 100644 index 0000000..744f25a --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-gallery-projects-design.md @@ -0,0 +1,83 @@ +# Student Projects Gallery — Design Spec + +**Date:** 2026-05-29 +**App:** MakerLab Tools v5 (`v5/`), Next.js 16 App Router, Notion-backed, deployed on Vercel. +**Scope:** One PR. Read side (gallery + bidirectional tool links) is independently reviewable from the submit side. + +## Goal + +A gallery of student project write-ups (blog-style markdown), backed by a new Notion **Projects** database, with **bidirectional linking** between projects and the tools used to build them. Students submit via a **form** (no AI). Submissions land in Notion unpublished; a staff/admin flips `published` in Notion to make them public. + +## Non-goals (explicitly out of scope) + +- No AI authoring / assistant involvement. The MakerBot chat is untouched. +- No in-app auth or accounts. +- No automatic translation of project content (separate parked feature). Project body stays in whatever language the student wrote. +- Editing/deleting existing projects from the app (admins manage in Notion). + +## Notion schema — new `Projects` database + +Operator creates the DB in Notion, shares it with the integration, and sets `NOTION_DB_PROJECTS`. + +| Field | Type | Notes | +|-------|------|-------| +| `title` | title | Project name | +| `author` | rich_text | Student name/handle (free text; no account) | +| `body` | rich_text | Markdown write-up | +| `photos` | files | Uploaded images (first = cover) | +| `tools_used` | relation → Tools DB | **Bidirectional link.** Auto-creates a back-relation on Tools. | +| `link` | url | Optional — repo / video / external | +| `materials` | multi_select | Optional | +| `published` | checkbox | Default false. Staff gate. | +| `date` | created_time | Submission timestamp | + +Notion relations are inherently two-way, so `tools_used` yields a "Projects" back-relation on each Tool with no extra schema work. + +## Data layer (`v5/src/lib/`) + +- **`notion.ts`**: add `projects` to the DB env map (`NOTION_DB_PROJECTS`), a `ProjectRecord` type + `pageToProject()` parser, `fetchAllProjects()` (published-only filter option), `fetchProject(id)`, and `createProject(fields)` (writes page with `published=false`, photo `file_upload` attachments reusing the pattern from the maintenance photo flow, and `tools_used` relation IDs). Tolerate lower/Title-cased property names like the existing parsers. +- **`catalog.ts`** (or a new `projects.ts`): `getPublishedProjects()` and `getProject(id)` behind `"use cache"` with a new `cacheTag("projects")` + `cacheLife("minutes")`. Add a helper to map a tool id → its linked published projects for the "Built with this" section (derive from the back-relation, or by scanning projects' `tools_used`). +- **`types.ts` / `catalog-types.ts`**: `MakerLabProject` view type (id, title, author, body, photos[], tools[{id,name,slug}], link, materials[], date). + +## Routes & UI + +- **`/projects`** — replace the current "coming soon" stub with a gallery grid of published projects: cover photo, title, author, tool chips. Reuse card/grid styling and (optionally) the search pattern from the tool gallery (#17) if cheap; otherwise a simple grid. +- **`/projects/[id]`** — detail: cover + photo gallery, `react-markdown` + `remark-gfm` rendered `body` (already used in ChatFab), author, date, `materials`, optional `link`, and **tool chips** linking to `/tools/[id]`. +- **`/projects/new`** — submission form (client component): + - Fields: title, author, markdown `body` (textarea + small live preview via react-markdown), photo upload (reuse `/api/upload-notion` from PR #12 → collect `file_upload` ids), **tool multiselect** populated from `getCatalogTools()`, optional `link`, optional `materials`. + - Submits to `POST /api/projects`. On success: "Thanks — your project is pending review" confirmation; on error: inline message. +- **`/tools/[id]`** (DetailShell): add a **"Built with this"** section listing published projects that reference the tool → link to `/projects/[id]`. Hidden when none. + +## API + +- **`POST /api/projects`** — validates payload (title, author, body required; photos/tools/link/materials optional), rate-limited via the existing `rate-limit.ts` (`projects:${ip}`, e.g. 10/min), calls `createProject()` with `published=false`. Returns the new page id. Node default runtime (no `runtime` export — cacheComponents). +- Extend **`POST /api/admin/revalidate`** to also bust the `projects` cache tag (so a freshly-published project appears without waiting for `cacheLife`). + +## Cross-cutting + +- **Moderation:** `published=false` by default; admin flips in Notion. Gallery + detail + "Built with this" only ever show published projects. `/projects/new` is always reachable. +- **i18n:** all new UI strings (gallery, detail labels, form labels, confirmation/errors, nav) into all 12 `v5/messages/*.json` files; English authoritative, others machine-translated (flag for native QC). Project *content* is not translated. +- **Rate limiting:** the submit endpoint uses the existing limiter. +- **Caching:** `projects` tag; revalidate endpoint busts it. +- **Nav:** the existing `/projects` nav entry stays; add a visible "Submit a project" affordance on `/projects`. + +## Build structure (one PR, two logical halves) + +1. **Read side:** schema wiring + data layer + `/projects` + `/projects/[id]` + "Built with this" on tool detail. Reviewable against a Notion DB that has a couple of manually-published rows. +2. **Submit side:** `/projects/new` form + `POST /api/projects` + photo/tool wiring + revalidate-tag extension. + +## Operator follow-up (after merge) + +1. Create the `Projects` Notion DB with the schema above; add the `tools_used` relation to the Tools DB. +2. Share the DB with the integration; set `NOTION_DB_PROJECTS` in Vercel (all environments). +3. Redeploy / purge the `projects` (and `catalog`) cache tag. +4. Submit a test project; flip `published` in Notion; confirm it appears in `/projects` and under "Built with this" on each linked tool. + +## Test plan + +- [ ] `cd v5 && npm run typecheck && npm run lint && npm run build` pass. +- [ ] With no `NOTION_DB_PROJECTS` set, `/projects` renders an empty state (no crash) and `/projects/new` still loads. +- [ ] Submitting the form creates a Notion page with `published=false`, photos attached, tool relations set. +- [ ] An unpublished project does NOT appear in `/projects`, `/projects/[id]` (404/!published), or "Built with this". +- [ ] After flipping `published=true` + cache bust, the project appears in the gallery, its detail renders markdown + photos, tool chips link to tools, and the tool pages show it under "Built with this". +- [ ] Submit endpoint returns 429 past the rate limit. diff --git a/v5/messages/ar.json b/v5/messages/ar.json index 16c21e5..6f73473 100644 --- a/v5/messages/ar.json +++ b/v5/messages/ar.json @@ -72,13 +72,56 @@ "serial": "الرقم التسلسلي", "acquired": "تاريخ الاقتناء", "notesTips": "ملاحظات ونصائح", - "backToTools": "‹ العودة إلى جميع الأدوات" + "backToTools": "‹ العودة إلى جميع الأدوات", + "builtWithThis": "صُنع باستخدام هذه الأداة", + "builtWithThisBy": "بواسطة {author}" }, "projects": { - "eyebrow": "المشاريع", - "title": "قريبًا", - "body": "يجري إعداد معرض لمشاريع MakerLab — أعمال الطلاب ونتائج المقررات ومعروضات منتقاة من الفريق. في الوقت الحالي، تصفّح كتالوج الأدوات لمعرفة ما هو متاح في المختبر.", - "browseTools": "تصفّح الأدوات" + "title": "مشاريع الطلاب", + "lede": "إبداعات وتجارب ونتائج مقررات من مجتمع MakerLab.", + "galleryLabel": "معرض مشاريع الطلاب", + "submit": "إرسال مشروع", + "by": "بواسطة {author}", + "empty": "لم يتم نشر أي مشاريع بعد. كن أول من يشارك عمله." + }, + "projectDetail": { + "breadcrumbProjects": "المشاريع", + "by": "بواسطة {author}", + "viewLink": "عرض رابط المشروع", + "photosLabel": "صور المشروع", + "toolsUsed": "الأدوات المستخدمة", + "materials": "المواد", + "back": "‹ العودة إلى جميع المشاريع" + }, + "projectForm": { + "eyebrow": "إرسال مشروع", + "title": "شارك مشروعك", + "lede": "أخبر مجتمع MakerLab بما صنعته. تتم مراجعة المشاركات قبل ظهورها في المعرض.", + "breadcrumbProjects": "المشاريع", + "breadcrumbNew": "إرسال", + "titleLabel": "عنوان المشروع", + "authorLabel": "اسمك", + "bodyLabel": "الوصف (يدعم Markdown)", + "bodyPlaceholder": "صِف مشروعك وكيف صنعته وما تعلمته…", + "previewLabel": "معاينة", + "photosLabel": "الصور", + "uploading": "جارٍ رفع الصور…", + "removePhoto": "إزالة", + "toolsLabel": "الأدوات المستخدمة", + "toolsSearch": "ابحث عن الأدوات…", + "materialsLabel": "المواد (اختياري)", + "materialsPlaceholder": "مفصولة بفواصل، مثل الخشب الرقائقي، PLA، الأكريليك", + "linkLabel": "رابط (اختياري)", + "submit": "إرسال المشروع", + "submitting": "جارٍ الإرسال…", + "cancel": "إلغاء", + "onlyImages": "يتم دعم ملفات الصور فقط.", + "uploadFailed": "فشل رفع الصورة.", + "requiredError": "العنوان واسمك والوصف حقول مطلوبة.", + "submitError": "حدث خطأ ما. يرجى المحاولة مرة أخرى.", + "thanksTitle": "شكرًا — مشروعك قيد المراجعة", + "thanksBody": "سيراجع أحد الموظفين مشاركتك وينشرها في المعرض قريبًا.", + "backToGallery": "العودة إلى المعرض" }, "about": { "eyebrow": "حول", diff --git a/v5/messages/en.json b/v5/messages/en.json index 5f359df..bb0039d 100644 --- a/v5/messages/en.json +++ b/v5/messages/en.json @@ -72,13 +72,56 @@ "serial": "Serial", "acquired": "Acquired", "notesTips": "Notes & Tips", - "backToTools": "‹ Back to all tools" + "backToTools": "‹ Back to all tools", + "builtWithThis": "Built with this", + "builtWithThisBy": "by {author}" }, "projects": { - "eyebrow": "Projects", - "title": "Coming soon", - "body": "A gallery of MakerLab projects is in the works — student builds, course outcomes, and staff-curated showcases. For now, browse the tool catalog to see what's available in the lab.", - "browseTools": "Browse tools" + "title": "STUDENT PROJECTS", + "lede": "Builds, experiments, and course outcomes from the MakerLab community.", + "galleryLabel": "Student projects gallery", + "submit": "Submit a project", + "by": "by {author}", + "empty": "No projects published yet. Be the first to share your build." + }, + "projectDetail": { + "breadcrumbProjects": "Projects", + "by": "by {author}", + "viewLink": "View project link", + "photosLabel": "Project photos", + "toolsUsed": "Tools used", + "materials": "Materials", + "back": "‹ Back to all projects" + }, + "projectForm": { + "eyebrow": "Submit a project", + "title": "Share your project", + "lede": "Tell the MakerLab community what you built. Submissions are reviewed before they appear in the gallery.", + "breadcrumbProjects": "Projects", + "breadcrumbNew": "Submit", + "titleLabel": "Project title", + "authorLabel": "Your name", + "bodyLabel": "Write-up (Markdown supported)", + "bodyPlaceholder": "Describe your project, how you made it, and what you learned…", + "previewLabel": "Preview", + "photosLabel": "Photos", + "uploading": "Uploading photos…", + "removePhoto": "Remove", + "toolsLabel": "Tools used", + "toolsSearch": "Search tools…", + "materialsLabel": "Materials (optional)", + "materialsPlaceholder": "Comma-separated, e.g. plywood, PLA, acrylic", + "linkLabel": "Link (optional)", + "submit": "Submit project", + "submitting": "Submitting…", + "cancel": "Cancel", + "onlyImages": "Only image files are supported.", + "uploadFailed": "Photo upload failed.", + "requiredError": "Title, your name, and a write-up are required.", + "submitError": "Something went wrong. Please try again.", + "thanksTitle": "Thanks — your project is pending review", + "thanksBody": "A staff member will review your submission and publish it to the gallery soon.", + "backToGallery": "Back to gallery" }, "about": { "eyebrow": "About", diff --git a/v5/messages/es.json b/v5/messages/es.json index f9a7d58..e1c7bf1 100644 --- a/v5/messages/es.json +++ b/v5/messages/es.json @@ -72,13 +72,56 @@ "serial": "Número de serie", "acquired": "Adquirido", "notesTips": "Notas y consejos", - "backToTools": "‹ Volver a todas las herramientas" + "backToTools": "‹ Volver a todas las herramientas", + "builtWithThis": "Hecho con esto", + "builtWithThisBy": "por {author}" }, "projects": { - "eyebrow": "Proyectos", - "title": "Próximamente", - "body": "Una galería de proyectos del MakerLab está en marcha: creaciones de estudiantes, resultados de cursos y muestras seleccionadas por el personal. Por ahora, explora el catálogo de herramientas para ver lo que hay disponible en el laboratorio.", - "browseTools": "Explorar herramientas" + "title": "PROYECTOS DE ESTUDIANTES", + "lede": "Creaciones, experimentos y resultados de cursos de la comunidad del MakerLab.", + "galleryLabel": "Galería de proyectos de estudiantes", + "submit": "Enviar un proyecto", + "by": "por {author}", + "empty": "Aún no hay proyectos publicados. Sé el primero en compartir tu creación." + }, + "projectDetail": { + "breadcrumbProjects": "Proyectos", + "by": "por {author}", + "viewLink": "Ver enlace del proyecto", + "photosLabel": "Fotos del proyecto", + "toolsUsed": "Herramientas utilizadas", + "materials": "Materiales", + "back": "‹ Volver a todos los proyectos" + }, + "projectForm": { + "eyebrow": "Enviar un proyecto", + "title": "Comparte tu proyecto", + "lede": "Cuéntale a la comunidad del MakerLab qué construiste. Las propuestas se revisan antes de aparecer en la galería.", + "breadcrumbProjects": "Proyectos", + "breadcrumbNew": "Enviar", + "titleLabel": "Título del proyecto", + "authorLabel": "Tu nombre", + "bodyLabel": "Descripción (admite Markdown)", + "bodyPlaceholder": "Describe tu proyecto, cómo lo hiciste y qué aprendiste…", + "previewLabel": "Vista previa", + "photosLabel": "Fotos", + "uploading": "Subiendo fotos…", + "removePhoto": "Quitar", + "toolsLabel": "Herramientas utilizadas", + "toolsSearch": "Buscar herramientas…", + "materialsLabel": "Materiales (opcional)", + "materialsPlaceholder": "Separados por comas, p. ej. contrachapado, PLA, acrílico", + "linkLabel": "Enlace (opcional)", + "submit": "Enviar proyecto", + "submitting": "Enviando…", + "cancel": "Cancelar", + "onlyImages": "Solo se admiten archivos de imagen.", + "uploadFailed": "Error al subir la foto.", + "requiredError": "El título, tu nombre y una descripción son obligatorios.", + "submitError": "Algo salió mal. Inténtalo de nuevo.", + "thanksTitle": "Gracias: tu proyecto está pendiente de revisión", + "thanksBody": "Un miembro del personal revisará tu propuesta y la publicará pronto en la galería.", + "backToGallery": "Volver a la galería" }, "about": { "eyebrow": "Acerca de", diff --git a/v5/messages/fr.json b/v5/messages/fr.json index 2568804..e687b0a 100644 --- a/v5/messages/fr.json +++ b/v5/messages/fr.json @@ -72,13 +72,56 @@ "serial": "Numéro de série", "acquired": "Acquis le", "notesTips": "Notes et conseils", - "backToTools": "‹ Retour à tous les outils" + "backToTools": "‹ Retour à tous les outils", + "builtWithThis": "Réalisé avec cet outil", + "builtWithThisBy": "par {author}" }, "projects": { - "eyebrow": "Projets", - "title": "Bientôt disponible", - "body": "Une galerie de projets du MakerLab est en préparation — réalisations d'étudiants, résultats de cours et vitrines sélectionnées par l'équipe. Pour l'instant, parcourez le catalogue d'outils pour voir ce qui est disponible au labo.", - "browseTools": "Parcourir les outils" + "title": "PROJETS ÉTUDIANTS", + "lede": "Réalisations, expériences et résultats de cours de la communauté du MakerLab.", + "galleryLabel": "Galerie de projets étudiants", + "submit": "Soumettre un projet", + "by": "par {author}", + "empty": "Aucun projet publié pour l'instant. Soyez le premier à partager votre réalisation." + }, + "projectDetail": { + "breadcrumbProjects": "Projets", + "by": "par {author}", + "viewLink": "Voir le lien du projet", + "photosLabel": "Photos du projet", + "toolsUsed": "Outils utilisés", + "materials": "Matériaux", + "back": "‹ Retour à tous les projets" + }, + "projectForm": { + "eyebrow": "Soumettre un projet", + "title": "Partagez votre projet", + "lede": "Racontez à la communauté du MakerLab ce que vous avez créé. Les soumissions sont examinées avant d'apparaître dans la galerie.", + "breadcrumbProjects": "Projets", + "breadcrumbNew": "Soumettre", + "titleLabel": "Titre du projet", + "authorLabel": "Votre nom", + "bodyLabel": "Description (Markdown pris en charge)", + "bodyPlaceholder": "Décrivez votre projet, comment vous l'avez réalisé et ce que vous avez appris…", + "previewLabel": "Aperçu", + "photosLabel": "Photos", + "uploading": "Téléversement des photos…", + "removePhoto": "Retirer", + "toolsLabel": "Outils utilisés", + "toolsSearch": "Rechercher des outils…", + "materialsLabel": "Matériaux (facultatif)", + "materialsPlaceholder": "Séparés par des virgules, p. ex. contreplaqué, PLA, acrylique", + "linkLabel": "Lien (facultatif)", + "submit": "Soumettre le projet", + "submitting": "Envoi…", + "cancel": "Annuler", + "onlyImages": "Seuls les fichiers image sont pris en charge.", + "uploadFailed": "Échec du téléversement de la photo.", + "requiredError": "Le titre, votre nom et une description sont obligatoires.", + "submitError": "Une erreur est survenue. Veuillez réessayer.", + "thanksTitle": "Merci — votre projet est en attente de validation", + "thanksBody": "Un membre de l'équipe examinera votre soumission et la publiera bientôt dans la galerie.", + "backToGallery": "Retour à la galerie" }, "about": { "eyebrow": "À propos", diff --git a/v5/messages/he.json b/v5/messages/he.json index 0befa99..ad72b29 100644 --- a/v5/messages/he.json +++ b/v5/messages/he.json @@ -72,13 +72,56 @@ "serial": "מספר סידורי", "acquired": "נרכש בתאריך", "notesTips": "הערות וטיפים", - "backToTools": "‹ חזרה לכל הכלים" + "backToTools": "‹ חזרה לכל הכלים", + "builtWithThis": "נבנה עם זה", + "builtWithThisBy": "מאת {author}" }, "projects": { - "eyebrow": "פרויקטים", - "title": "בקרוב", - "body": "גלריית פרויקטים של MakerLab נמצאת בהכנה — יצירות סטודנטים, תוצרי קורסים ותצוגות שנבחרו על ידי הצוות. בינתיים, עיינו בקטלוג הכלים כדי לראות מה זמין במעבדה.", - "browseTools": "עיון בכלים" + "title": "פרויקטים של סטודנטים", + "lede": "יצירות, ניסויים ותוצרי קורסים מקהילת MakerLab.", + "galleryLabel": "גלריית פרויקטים של סטודנטים", + "submit": "שליחת פרויקט", + "by": "מאת {author}", + "empty": "עדיין לא פורסמו פרויקטים. היו הראשונים לשתף את היצירה שלכם." + }, + "projectDetail": { + "breadcrumbProjects": "פרויקטים", + "by": "מאת {author}", + "viewLink": "צפייה בקישור הפרויקט", + "photosLabel": "תמונות הפרויקט", + "toolsUsed": "כלים שנעשה בהם שימוש", + "materials": "חומרים", + "back": "‹ חזרה לכל הפרויקטים" + }, + "projectForm": { + "eyebrow": "שליחת פרויקט", + "title": "שתפו את הפרויקט שלכם", + "lede": "ספרו לקהילת MakerLab מה בניתם. ההגשות נבדקות לפני שהן מופיעות בגלריה.", + "breadcrumbProjects": "פרויקטים", + "breadcrumbNew": "שליחה", + "titleLabel": "שם הפרויקט", + "authorLabel": "השם שלכם", + "bodyLabel": "תיאור (תומך ב-Markdown)", + "bodyPlaceholder": "תארו את הפרויקט, איך יצרתם אותו ומה למדתם…", + "previewLabel": "תצוגה מקדימה", + "photosLabel": "תמונות", + "uploading": "מעלה תמונות…", + "removePhoto": "הסרה", + "toolsLabel": "כלים שנעשה בהם שימוש", + "toolsSearch": "חיפוש כלים…", + "materialsLabel": "חומרים (אופציונלי)", + "materialsPlaceholder": "מופרדים בפסיקים, למשל דיקט, PLA, אקריליק", + "linkLabel": "קישור (אופציונלי)", + "submit": "שליחת פרויקט", + "submitting": "שולח…", + "cancel": "ביטול", + "onlyImages": "נתמכים קבצי תמונה בלבד.", + "uploadFailed": "העלאת התמונה נכשלה.", + "requiredError": "שם, שמכם ותיאור הם שדות חובה.", + "submitError": "משהו השתבש. נסו שוב.", + "thanksTitle": "תודה — הפרויקט שלכם ממתין לבדיקה", + "thanksBody": "חבר צוות יבדוק את ההגשה ויפרסם אותה בגלריה בקרוב.", + "backToGallery": "חזרה לגלריה" }, "about": { "eyebrow": "אודות", diff --git a/v5/messages/hi.json b/v5/messages/hi.json index bb6a007..c01f531 100644 --- a/v5/messages/hi.json +++ b/v5/messages/hi.json @@ -72,13 +72,56 @@ "serial": "सीरियल", "acquired": "अधिग्रहण तिथि", "notesTips": "नोट्स और सुझाव", - "backToTools": "‹ सभी उपकरणों पर वापस जाएँ" + "backToTools": "‹ सभी उपकरणों पर वापस जाएँ", + "builtWithThis": "इससे बनाया गया", + "builtWithThisBy": "{author} द्वारा" }, "projects": { - "eyebrow": "परियोजनाएँ", - "title": "जल्द आ रहा है", - "body": "MakerLab परियोजनाओं की एक गैलरी तैयार की जा रही है — छात्रों की रचनाएँ, पाठ्यक्रम परिणाम और स्टाफ़ द्वारा चुनी गई प्रस्तुतियाँ। फ़िलहाल, लैब में उपलब्ध चीज़ें देखने के लिए उपकरण सूची ब्राउज़ करें।", - "browseTools": "उपकरण ब्राउज़ करें" + "title": "छात्र परियोजनाएँ", + "lede": "MakerLab समुदाय की रचनाएँ, प्रयोग और कोर्स परिणाम।", + "galleryLabel": "छात्र परियोजना गैलरी", + "submit": "परियोजना सबमिट करें", + "by": "{author} द्वारा", + "empty": "अभी तक कोई परियोजना प्रकाशित नहीं हुई। अपनी रचना साझा करने वाले पहले व्यक्ति बनें।" + }, + "projectDetail": { + "breadcrumbProjects": "परियोजनाएँ", + "by": "{author} द्वारा", + "viewLink": "परियोजना लिंक देखें", + "photosLabel": "परियोजना तस्वीरें", + "toolsUsed": "उपयोग किए गए उपकरण", + "materials": "सामग्री", + "back": "‹ सभी परियोजनाओं पर वापस जाएँ" + }, + "projectForm": { + "eyebrow": "परियोजना सबमिट करें", + "title": "अपनी परियोजना साझा करें", + "lede": "MakerLab समुदाय को बताएं कि आपने क्या बनाया। गैलरी में दिखने से पहले सबमिशन की समीक्षा की जाती है।", + "breadcrumbProjects": "परियोजनाएँ", + "breadcrumbNew": "सबमिट करें", + "titleLabel": "परियोजना शीर्षक", + "authorLabel": "आपका नाम", + "bodyLabel": "विवरण (Markdown समर्थित)", + "bodyPlaceholder": "अपनी परियोजना, उसे बनाने का तरीका और आपने क्या सीखा, इसका वर्णन करें…", + "previewLabel": "पूर्वावलोकन", + "photosLabel": "तस्वीरें", + "uploading": "तस्वीरें अपलोड हो रही हैं…", + "removePhoto": "हटाएँ", + "toolsLabel": "उपयोग किए गए उपकरण", + "toolsSearch": "उपकरण खोजें…", + "materialsLabel": "सामग्री (वैकल्पिक)", + "materialsPlaceholder": "अल्पविराम से अलग, जैसे प्लाईवुड, PLA, ऐक्रेलिक", + "linkLabel": "लिंक (वैकल्पिक)", + "submit": "परियोजना सबमिट करें", + "submitting": "सबमिट हो रहा है…", + "cancel": "रद्द करें", + "onlyImages": "केवल छवि फ़ाइलें समर्थित हैं।", + "uploadFailed": "तस्वीर अपलोड विफल।", + "requiredError": "शीर्षक, आपका नाम और विवरण आवश्यक हैं।", + "submitError": "कुछ गलत हुआ। कृपया पुनः प्रयास करें।", + "thanksTitle": "धन्यवाद — आपकी परियोजना समीक्षा हेतु लंबित है", + "thanksBody": "एक स्टाफ सदस्य आपके सबमिशन की समीक्षा करेगा और इसे जल्द ही गैलरी में प्रकाशित करेगा।", + "backToGallery": "गैलरी पर वापस जाएँ" }, "about": { "eyebrow": "परिचय", diff --git a/v5/messages/ja.json b/v5/messages/ja.json index 38308e2..39c7230 100644 --- a/v5/messages/ja.json +++ b/v5/messages/ja.json @@ -72,13 +72,56 @@ "serial": "シリアル番号", "acquired": "取得日", "notesTips": "メモとヒント", - "backToTools": "‹ すべてのツールに戻る" + "backToTools": "‹ すべてのツールに戻る", + "builtWithThis": "これで作られた作品", + "builtWithThisBy": "作: {author}" }, "projects": { - "eyebrow": "プロジェクト", - "title": "近日公開", - "body": "MakerLab プロジェクトのギャラリーを準備中です。学生の制作物、授業の成果、スタッフが厳選した展示などを掲載予定です。今のところは、ツールカタログを閲覧してラボで利用できるものをご確認ください。", - "browseTools": "ツールを閲覧" + "title": "学生プロジェクト", + "lede": "MakerLab コミュニティによる制作物、実験、授業の成果。", + "galleryLabel": "学生プロジェクトギャラリー", + "submit": "プロジェクトを投稿", + "by": "作: {author}", + "empty": "まだ公開されたプロジェクトはありません。最初に作品を共有しましょう。" + }, + "projectDetail": { + "breadcrumbProjects": "プロジェクト", + "by": "作: {author}", + "viewLink": "プロジェクトリンクを見る", + "photosLabel": "プロジェクトの写真", + "toolsUsed": "使用したツール", + "materials": "材料", + "back": "‹ すべてのプロジェクトに戻る" + }, + "projectForm": { + "eyebrow": "プロジェクトを投稿", + "title": "プロジェクトを共有", + "lede": "あなたが作ったものを MakerLab コミュニティに伝えましょう。投稿はギャラリーに表示される前に審査されます。", + "breadcrumbProjects": "プロジェクト", + "breadcrumbNew": "投稿", + "titleLabel": "プロジェクト名", + "authorLabel": "お名前", + "bodyLabel": "説明(Markdown 対応)", + "bodyPlaceholder": "プロジェクトの内容、作り方、学んだことを説明してください…", + "previewLabel": "プレビュー", + "photosLabel": "写真", + "uploading": "写真をアップロード中…", + "removePhoto": "削除", + "toolsLabel": "使用したツール", + "toolsSearch": "ツールを検索…", + "materialsLabel": "材料(任意)", + "materialsPlaceholder": "カンマ区切り、例: 合板、PLA、アクリル", + "linkLabel": "リンク(任意)", + "submit": "プロジェクトを送信", + "submitting": "送信中…", + "cancel": "キャンセル", + "onlyImages": "画像ファイルのみ対応しています。", + "uploadFailed": "写真のアップロードに失敗しました。", + "requiredError": "タイトル、お名前、説明は必須です。", + "submitError": "問題が発生しました。もう一度お試しください。", + "thanksTitle": "ありがとうございます — プロジェクトは審査待ちです", + "thanksBody": "スタッフが投稿を確認し、まもなくギャラリーに公開します。", + "backToGallery": "ギャラリーに戻る" }, "about": { "eyebrow": "概要", diff --git a/v5/messages/ko.json b/v5/messages/ko.json index 1950d6a..40ee3cd 100644 --- a/v5/messages/ko.json +++ b/v5/messages/ko.json @@ -72,13 +72,56 @@ "serial": "일련번호", "acquired": "취득일", "notesTips": "메모 및 팁", - "backToTools": "‹ 모든 도구로 돌아가기" + "backToTools": "‹ 모든 도구로 돌아가기", + "builtWithThis": "이 도구로 제작", + "builtWithThisBy": "{author} 작성" }, "projects": { - "eyebrow": "프로젝트", - "title": "준비 중", - "body": "MakerLab 프로젝트 갤러리를 준비 중입니다 — 학생 작품, 강의 결과물, 직원이 선별한 전시물 등. 지금은 도구 카탈로그를 둘러보며 랩에서 사용할 수 있는 항목을 확인하세요.", - "browseTools": "도구 둘러보기" + "title": "학생 프로젝트", + "lede": "MakerLab 커뮤니티의 작품, 실험, 수업 결과물.", + "galleryLabel": "학생 프로젝트 갤러리", + "submit": "프로젝트 제출", + "by": "{author} 작성", + "empty": "아직 게시된 프로젝트가 없습니다. 첫 번째로 작품을 공유해 보세요." + }, + "projectDetail": { + "breadcrumbProjects": "프로젝트", + "by": "{author} 작성", + "viewLink": "프로젝트 링크 보기", + "photosLabel": "프로젝트 사진", + "toolsUsed": "사용한 도구", + "materials": "재료", + "back": "‹ 모든 프로젝트로 돌아가기" + }, + "projectForm": { + "eyebrow": "프로젝트 제출", + "title": "프로젝트를 공유하세요", + "lede": "무엇을 만들었는지 MakerLab 커뮤니티에 알려주세요. 제출물은 갤러리에 게시되기 전에 검토됩니다.", + "breadcrumbProjects": "프로젝트", + "breadcrumbNew": "제출", + "titleLabel": "프로젝트 제목", + "authorLabel": "이름", + "bodyLabel": "설명 (Markdown 지원)", + "bodyPlaceholder": "프로젝트, 제작 방법, 배운 점을 설명하세요…", + "previewLabel": "미리보기", + "photosLabel": "사진", + "uploading": "사진 업로드 중…", + "removePhoto": "제거", + "toolsLabel": "사용한 도구", + "toolsSearch": "도구 검색…", + "materialsLabel": "재료 (선택)", + "materialsPlaceholder": "쉼표로 구분, 예: 합판, PLA, 아크릴", + "linkLabel": "링크 (선택)", + "submit": "프로젝트 제출", + "submitting": "제출 중…", + "cancel": "취소", + "onlyImages": "이미지 파일만 지원됩니다.", + "uploadFailed": "사진 업로드에 실패했습니다.", + "requiredError": "제목, 이름, 설명은 필수입니다.", + "submitError": "문제가 발생했습니다. 다시 시도해 주세요.", + "thanksTitle": "감사합니다 — 프로젝트가 검토 대기 중입니다", + "thanksBody": "담당자가 제출 내용을 검토한 후 곧 갤러리에 게시합니다.", + "backToGallery": "갤러리로 돌아가기" }, "about": { "eyebrow": "소개", diff --git a/v5/messages/pt-BR.json b/v5/messages/pt-BR.json index 42be387..22bb79a 100644 --- a/v5/messages/pt-BR.json +++ b/v5/messages/pt-BR.json @@ -72,13 +72,56 @@ "serial": "Número de série", "acquired": "Adquirido em", "notesTips": "Notas e dicas", - "backToTools": "‹ Voltar a todas as ferramentas" + "backToTools": "‹ Voltar a todas as ferramentas", + "builtWithThis": "Feito com esta ferramenta", + "builtWithThisBy": "por {author}" }, "projects": { - "eyebrow": "Projetos", - "title": "Em breve", - "body": "Uma galeria de projetos do MakerLab está em desenvolvimento — criações de estudantes, resultados de cursos e mostras selecionadas pela equipe. Por enquanto, navegue pelo catálogo de ferramentas para ver o que está disponível no laboratório.", - "browseTools": "Explorar ferramentas" + "title": "PROJETOS DE ESTUDANTES", + "lede": "Criações, experimentos e resultados de cursos da comunidade do MakerLab.", + "galleryLabel": "Galeria de projetos de estudantes", + "submit": "Enviar um projeto", + "by": "por {author}", + "empty": "Nenhum projeto publicado ainda. Seja o primeiro a compartilhar sua criação." + }, + "projectDetail": { + "breadcrumbProjects": "Projetos", + "by": "por {author}", + "viewLink": "Ver link do projeto", + "photosLabel": "Fotos do projeto", + "toolsUsed": "Ferramentas usadas", + "materials": "Materiais", + "back": "‹ Voltar para todos os projetos" + }, + "projectForm": { + "eyebrow": "Enviar um projeto", + "title": "Compartilhe seu projeto", + "lede": "Conte à comunidade do MakerLab o que você construiu. As submissões são revisadas antes de aparecer na galeria.", + "breadcrumbProjects": "Projetos", + "breadcrumbNew": "Enviar", + "titleLabel": "Título do projeto", + "authorLabel": "Seu nome", + "bodyLabel": "Descrição (suporta Markdown)", + "bodyPlaceholder": "Descreva seu projeto, como você o fez e o que aprendeu…", + "previewLabel": "Pré-visualização", + "photosLabel": "Fotos", + "uploading": "Enviando fotos…", + "removePhoto": "Remover", + "toolsLabel": "Ferramentas usadas", + "toolsSearch": "Buscar ferramentas…", + "materialsLabel": "Materiais (opcional)", + "materialsPlaceholder": "Separados por vírgula, ex.: compensado, PLA, acrílico", + "linkLabel": "Link (opcional)", + "submit": "Enviar projeto", + "submitting": "Enviando…", + "cancel": "Cancelar", + "onlyImages": "Apenas arquivos de imagem são suportados.", + "uploadFailed": "Falha ao enviar a foto.", + "requiredError": "Título, seu nome e uma descrição são obrigatórios.", + "submitError": "Algo deu errado. Tente novamente.", + "thanksTitle": "Obrigado — seu projeto está aguardando revisão", + "thanksBody": "Um membro da equipe revisará sua submissão e a publicará na galeria em breve.", + "backToGallery": "Voltar para a galeria" }, "about": { "eyebrow": "Sobre", diff --git a/v5/messages/ru.json b/v5/messages/ru.json index 38e4721..87c2dcb 100644 --- a/v5/messages/ru.json +++ b/v5/messages/ru.json @@ -72,13 +72,56 @@ "serial": "Серийный номер", "acquired": "Приобретено", "notesTips": "Заметки и советы", - "backToTools": "‹ Назад ко всем инструментам" + "backToTools": "‹ Назад ко всем инструментам", + "builtWithThis": "Сделано с помощью этого инструмента", + "builtWithThisBy": "автор: {author}" }, "projects": { - "eyebrow": "Проекты", - "title": "Скоро", - "body": "Готовится галерея проектов MakerLab — студенческие работы, результаты курсов и подборки от сотрудников. А пока просмотрите каталог инструментов, чтобы узнать, что доступно в лаборатории.", - "browseTools": "Просмотреть инструменты" + "title": "СТУДЕНЧЕСКИЕ ПРОЕКТЫ", + "lede": "Работы, эксперименты и результаты курсов сообщества MakerLab.", + "galleryLabel": "Галерея студенческих проектов", + "submit": "Отправить проект", + "by": "автор: {author}", + "empty": "Пока нет опубликованных проектов. Станьте первым, кто поделится своей работой." + }, + "projectDetail": { + "breadcrumbProjects": "Проекты", + "by": "автор: {author}", + "viewLink": "Открыть ссылку проекта", + "photosLabel": "Фотографии проекта", + "toolsUsed": "Использованные инструменты", + "materials": "Материалы", + "back": "‹ Назад ко всем проектам" + }, + "projectForm": { + "eyebrow": "Отправить проект", + "title": "Поделитесь своим проектом", + "lede": "Расскажите сообществу MakerLab, что вы создали. Заявки проверяются перед публикацией в галерее.", + "breadcrumbProjects": "Проекты", + "breadcrumbNew": "Отправить", + "titleLabel": "Название проекта", + "authorLabel": "Ваше имя", + "bodyLabel": "Описание (поддерживается Markdown)", + "bodyPlaceholder": "Опишите свой проект, как вы его сделали и чему научились…", + "previewLabel": "Предпросмотр", + "photosLabel": "Фотографии", + "uploading": "Загрузка фотографий…", + "removePhoto": "Удалить", + "toolsLabel": "Использованные инструменты", + "toolsSearch": "Поиск инструментов…", + "materialsLabel": "Материалы (необязательно)", + "materialsPlaceholder": "Через запятую, напр. фанера, PLA, акрил", + "linkLabel": "Ссылка (необязательно)", + "submit": "Отправить проект", + "submitting": "Отправка…", + "cancel": "Отмена", + "onlyImages": "Поддерживаются только файлы изображений.", + "uploadFailed": "Не удалось загрузить фото.", + "requiredError": "Название, ваше имя и описание обязательны.", + "submitError": "Что-то пошло не так. Попробуйте снова.", + "thanksTitle": "Спасибо — ваш проект ожидает проверки", + "thanksBody": "Сотрудник проверит вашу заявку и вскоре опубликует её в галерее.", + "backToGallery": "Вернуться в галерею" }, "about": { "eyebrow": "О проекте", diff --git a/v5/messages/tr.json b/v5/messages/tr.json index 633b91e..1308b62 100644 --- a/v5/messages/tr.json +++ b/v5/messages/tr.json @@ -72,13 +72,56 @@ "serial": "Seri numarası", "acquired": "Edinilme tarihi", "notesTips": "Notlar ve ipuçları", - "backToTools": "‹ Tüm araçlara geri dön" + "backToTools": "‹ Tüm araçlara geri dön", + "builtWithThis": "Bununla yapıldı", + "builtWithThisBy": "{author} tarafından" }, "projects": { - "eyebrow": "Projeler", - "title": "Yakında", - "body": "Bir MakerLab proje galerisi hazırlanıyor — öğrenci yapımları, ders çıktıları ve ekip tarafından seçilmiş vitrinler. Şimdilik, laboratuvarda nelerin mevcut olduğunu görmek için araç kataloğuna göz atın.", - "browseTools": "Araçlara göz at" + "title": "ÖĞRENCİ PROJELERİ", + "lede": "MakerLab topluluğundan yapımlar, denemeler ve ders çıktıları.", + "galleryLabel": "Öğrenci projeleri galerisi", + "submit": "Proje gönder", + "by": "{author} tarafından", + "empty": "Henüz yayınlanmış proje yok. Yapımını paylaşan ilk kişi ol." + }, + "projectDetail": { + "breadcrumbProjects": "Projeler", + "by": "{author} tarafından", + "viewLink": "Proje bağlantısını görüntüle", + "photosLabel": "Proje fotoğrafları", + "toolsUsed": "Kullanılan araçlar", + "materials": "Malzemeler", + "back": "‹ Tüm projelere dön" + }, + "projectForm": { + "eyebrow": "Proje gönder", + "title": "Projeni paylaş", + "lede": "MakerLab topluluğuna ne yaptığını anlat. Gönderiler galeride görünmeden önce incelenir.", + "breadcrumbProjects": "Projeler", + "breadcrumbNew": "Gönder", + "titleLabel": "Proje başlığı", + "authorLabel": "Adın", + "bodyLabel": "Açıklama (Markdown destekli)", + "bodyPlaceholder": "Projeni, nasıl yaptığını ve neler öğrendiğini anlat…", + "previewLabel": "Önizleme", + "photosLabel": "Fotoğraflar", + "uploading": "Fotoğraflar yükleniyor…", + "removePhoto": "Kaldır", + "toolsLabel": "Kullanılan araçlar", + "toolsSearch": "Araç ara…", + "materialsLabel": "Malzemeler (isteğe bağlı)", + "materialsPlaceholder": "Virgülle ayrılmış, örn. kontrplak, PLA, akrilik", + "linkLabel": "Bağlantı (isteğe bağlı)", + "submit": "Projeyi gönder", + "submitting": "Gönderiliyor…", + "cancel": "İptal", + "onlyImages": "Yalnızca resim dosyaları desteklenir.", + "uploadFailed": "Fotoğraf yüklenemedi.", + "requiredError": "Başlık, adın ve bir açıklama gereklidir.", + "submitError": "Bir şeyler ters gitti. Lütfen tekrar deneyin.", + "thanksTitle": "Teşekkürler — projen inceleme bekliyor", + "thanksBody": "Bir ekip üyesi gönderini inceleyecek ve kısa süre içinde galeride yayınlayacak.", + "backToGallery": "Galeriye dön" }, "about": { "eyebrow": "Hakkında", diff --git a/v5/messages/zh-CN.json b/v5/messages/zh-CN.json index 0a8768b..878395d 100644 --- a/v5/messages/zh-CN.json +++ b/v5/messages/zh-CN.json @@ -72,13 +72,56 @@ "serial": "序列号", "acquired": "购置日期", "notesTips": "备注与提示", - "backToTools": "‹ 返回所有工具" + "backToTools": "‹ 返回所有工具", + "builtWithThis": "用它制作", + "builtWithThisBy": "作者:{author}" }, "projects": { - "eyebrow": "项目", - "title": "敬请期待", - "body": "MakerLab 项目图库正在筹备中——包括学生作品、课程成果以及工作人员精选展示。目前,请浏览工具目录,了解实验室提供的设备。", - "browseTools": "浏览工具" + "title": "学生项目", + "lede": "来自 MakerLab 社区的作品、实验和课程成果。", + "galleryLabel": "学生项目图库", + "submit": "提交项目", + "by": "作者:{author}", + "empty": "尚未发布任何项目。来成为第一个分享作品的人吧。" + }, + "projectDetail": { + "breadcrumbProjects": "项目", + "by": "作者:{author}", + "viewLink": "查看项目链接", + "photosLabel": "项目照片", + "toolsUsed": "使用的工具", + "materials": "材料", + "back": "‹ 返回所有项目" + }, + "projectForm": { + "eyebrow": "提交项目", + "title": "分享你的项目", + "lede": "告诉 MakerLab 社区你制作了什么。提交内容经过审核后才会出现在图库中。", + "breadcrumbProjects": "项目", + "breadcrumbNew": "提交", + "titleLabel": "项目标题", + "authorLabel": "你的姓名", + "bodyLabel": "说明(支持 Markdown)", + "bodyPlaceholder": "描述你的项目、制作过程以及收获…", + "previewLabel": "预览", + "photosLabel": "照片", + "uploading": "正在上传照片…", + "removePhoto": "移除", + "toolsLabel": "使用的工具", + "toolsSearch": "搜索工具…", + "materialsLabel": "材料(可选)", + "materialsPlaceholder": "用逗号分隔,例如 胶合板、PLA、亚克力", + "linkLabel": "链接(可选)", + "submit": "提交项目", + "submitting": "提交中…", + "cancel": "取消", + "onlyImages": "仅支持图片文件。", + "uploadFailed": "照片上传失败。", + "requiredError": "标题、你的姓名和说明为必填项。", + "submitError": "出现问题,请重试。", + "thanksTitle": "谢谢——你的项目正在等待审核", + "thanksBody": "工作人员将审核你的提交,并很快将其发布到图库。", + "backToGallery": "返回图库" }, "about": { "eyebrow": "关于", diff --git a/v5/src/app/api/admin/revalidate/route.ts b/v5/src/app/api/admin/revalidate/route.ts index 61c24e5..9c89e24 100644 --- a/v5/src/app/api/admin/revalidate/route.ts +++ b/v5/src/app/api/admin/revalidate/route.ts @@ -12,5 +12,6 @@ export async function POST(req: Request) { return Response.json({ ok: false, error: "forbidden" }, { status: 403 }); } revalidateTag("catalog", "minutes"); - return Response.json({ ok: true, tag: "catalog" }); + revalidateTag("projects", "minutes"); + return Response.json({ ok: true, tags: ["catalog", "projects"] }); } diff --git a/v5/src/app/api/projects/route.ts b/v5/src/app/api/projects/route.ts new file mode 100644 index 0000000..88f1b3c --- /dev/null +++ b/v5/src/app/api/projects/route.ts @@ -0,0 +1,137 @@ +import { NextRequest } from "next/server"; +import { createProject, hasProjectsEnv } from "../../../lib/notion"; +import { getClientIp, rateLimitAsync } from "../../../lib/rate-limit"; + +// `runtime` cannot be set when nextConfig.cacheComponents is enabled. +// Default Node.js runtime is used. +export const maxDuration = 30; + +const MAX_BODY_CHARS = 20_000; +const MAX_PHOTOS = 8; +const MAX_TOOLS = 20; +const MAX_MATERIALS = 20; + +interface ProjectPayload { + title?: unknown; + author?: unknown; + body?: unknown; + link?: unknown; + tools?: unknown; + materials?: unknown; + photos?: unknown; +} + +interface PhotoUpload { + id: string; + name: string; +} + +function asString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function asStringArray(value: unknown, max: number): string[] { + if (!Array.isArray(value)) return []; + return value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter(Boolean) + .slice(0, max); +} + +function asPhotoUploads(value: unknown): PhotoUpload[] { + if (!Array.isArray(value)) return []; + return value + .filter( + (item): item is { id: string; name?: string } => + typeof item === "object" && + item !== null && + typeof (item as { id?: unknown }).id === "string" + ) + .map((item) => ({ id: item.id, name: asString(item.name) || "upload" })) + .slice(0, MAX_PHOTOS); +} + +function isValidUrl(value: string): boolean { + try { + const parsed = new URL(value); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +export async function POST(req: NextRequest) { + // Rate limit before any expensive work (Notion page create). + const ip = getClientIp(req); + const { allowed } = await rateLimitAsync(`projects:${ip}`, { + limit: 10, + windowMs: 60_000, + }); + if (!allowed) { + return Response.json( + { error: "Too many requests. Please slow down." }, + { status: 429, headers: { "Retry-After": "60" } } + ); + } + + if (!hasProjectsEnv()) { + return Response.json( + { error: "Project submissions are not configured yet." }, + { status: 503 } + ); + } + + let payload: ProjectPayload; + try { + payload = (await req.json()) as ProjectPayload; + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const title = asString(payload.title); + const author = asString(payload.author); + const body = asString(payload.body); + const link = asString(payload.link); + const tools = asStringArray(payload.tools, MAX_TOOLS); + const materials = asStringArray(payload.materials, MAX_MATERIALS); + const photoUploads = asPhotoUploads(payload.photos); + + if (!title || !author || !body) { + return Response.json( + { error: "Title, author, and a write-up are required." }, + { status: 400 } + ); + } + if (body.length > MAX_BODY_CHARS) { + return Response.json( + { error: "Write-up is too long." }, + { status: 400 } + ); + } + if (link && !isValidUrl(link)) { + return Response.json( + { error: "Link must be a valid http(s) URL." }, + { status: 400 } + ); + } + + try { + const record = await createProject({ + title, + author, + body, + link: link || undefined, + tools_used: tools, + materials, + photo_uploads: photoUploads, + }); + return Response.json({ id: record.id }, { status: 201 }); + } catch (err) { + console.error("Project submission failed", err); + return Response.json( + { error: "Submission failed. Please try again." }, + { status: 502 } + ); + } +} diff --git a/v5/src/app/projects/[id]/page.tsx b/v5/src/app/projects/[id]/page.tsx new file mode 100644 index 0000000..d79520d --- /dev/null +++ b/v5/src/app/projects/[id]/page.tsx @@ -0,0 +1,24 @@ +import { notFound } from "next/navigation"; +import { ProjectDetail } from "../../../components/ProjectDetail"; +import { getProject } from "../../../lib/projects"; + +interface ProjectDetailPageProps { + params: Promise<{ id: string }>; +} + +// No `generateStaticParams`: the project set is empty without `NOTION_DB_PROJECTS`, +// which Cache Components rejects for prerender. Detail pages render on demand +// from the cached published set instead. + +export default async function ProjectDetailPage({ + params, +}: ProjectDetailPageProps) { + const { id } = await params; + const project = await getProject(id); + + if (!project) { + notFound(); + } + + return ; +} diff --git a/v5/src/app/projects/new/page.tsx b/v5/src/app/projects/new/page.tsx new file mode 100644 index 0000000..3670c78 --- /dev/null +++ b/v5/src/app/projects/new/page.tsx @@ -0,0 +1,16 @@ +import { siteConfig } from "../../../lib/site-config"; +import { getCatalogTools } from "../../../lib/catalog"; +import { ProjectSubmitForm } from "../../../components/ProjectSubmitForm"; + +export const metadata = { + title: `Submit a project — ${siteConfig.name}`, +}; + +export default async function NewProjectPage() { + const tools = await getCatalogTools(); + const toolOptions = tools + .map((tool) => ({ id: tool.id, name: tool.name })) + .sort((a, b) => a.name.localeCompare(b.name)); + + return ; +} diff --git a/v5/src/app/projects/page.tsx b/v5/src/app/projects/page.tsx index f6feac0..555388d 100644 --- a/v5/src/app/projects/page.tsx +++ b/v5/src/app/projects/page.tsx @@ -1,23 +1,80 @@ +import Image from "next/image"; import Link from "next/link"; -import { useTranslations } from "next-intl"; +import { getTranslations } from "next-intl/server"; import { siteConfig } from "../../lib/site-config"; +import { getPublishedProjects } from "../../lib/projects"; export const metadata = { title: `Projects — ${siteConfig.name}`, }; -export default function ProjectsPage() { - const t = useTranslations("projects"); +export default async function ProjectsPage() { + const t = await getTranslations("projects"); + const projects = await getPublishedProjects(); return ( -
-
-

{t("eyebrow")}

-

{t("title")}

-

{t("body")}

- - {t("browseTools")} - +
+
+
+ +

{t("title")}

+
+
+

{t("lede")}

+ + {t("submit")} + +
+
+ +
+ {projects.length > 0 ? ( + projects.map((project) => ( + + + + {project.title} + + {t("by", { author: project.author })} + + {project.tools.length > 0 ? ( + + {project.tools.slice(0, 4).map((tool) => ( + + {tool.name} + + ))} + + ) : null} + + + )) + ) : ( +
+

{t("empty")}

+ + {t("submit")} + +
+ )}
); diff --git a/v5/src/app/tools/[id]/page.tsx b/v5/src/app/tools/[id]/page.tsx index 62d6d0b..902fb6f 100644 --- a/v5/src/app/tools/[id]/page.tsx +++ b/v5/src/app/tools/[id]/page.tsx @@ -1,6 +1,7 @@ import { notFound } from "next/navigation"; import { DetailShell } from "../../../components/DetailShell"; import { getCatalogTool, getCatalogTools } from "../../../lib/catalog"; +import { getProjectsForTool } from "../../../lib/projects"; interface ToolDetailPageProps { params: Promise<{ @@ -24,5 +25,9 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) { notFound(); } - return ; + // "Built with this" — published projects referencing this tool (empty if no + // projects DB is configured). + const projects = await getProjectsForTool(tool.id); + + return ; } diff --git a/v5/src/components/DetailShell.tsx b/v5/src/components/DetailShell.tsx index 11911bd..013d5c9 100644 --- a/v5/src/components/DetailShell.tsx +++ b/v5/src/components/DetailShell.tsx @@ -1,10 +1,12 @@ import Image from "next/image"; import Link from "next/link"; import { useTranslations } from "next-intl"; -import type { MakerLabTool, ToolStatus } from "./catalog-types"; +import type { MakerLabProject, MakerLabTool, ToolStatus } from "./catalog-types"; interface DetailShellProps { tool: MakerLabTool; + /** Published projects that reference this tool ("Built with this"). */ + projects?: MakerLabProject[]; } const STATUS_CHIP: Record = { @@ -50,7 +52,7 @@ function findResource( return tool.links.find((link) => link.kind === kind); } -export function DetailShell({ tool }: DetailShellProps) { +export function DetailShell({ tool, projects = [] }: DetailShellProps) { const t = useTranslations("detail"); const status = STATUS_CHIP[tool.status]; const safetyLink = findResource(tool, "Safety"); @@ -310,6 +312,27 @@ export function DetailShell({ tool }: DetailShellProps) {
) : null} + {projects.length > 0 ? ( +
+
+

{t("builtWithThis")}

+
+
+ {projects.map((project) => ( + + + {project.title} +

{t("builtWithThisBy", { author: project.author })}

+
+ + + ))} +
+
+ ) : null} + {t("backToTools")} diff --git a/v5/src/components/ProjectDetail.tsx b/v5/src/components/ProjectDetail.tsx new file mode 100644 index 0000000..1e538e4 --- /dev/null +++ b/v5/src/components/ProjectDetail.tsx @@ -0,0 +1,127 @@ +import Image from "next/image"; +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import type { MakerLabProject } from "./catalog-types"; + +interface ProjectDetailProps { + project: MakerLabProject; +} + +function formatDate(date: string | null): string { + if (!date) return ""; + const parsed = new Date(date); + if (Number.isNaN(parsed.getTime())) return ""; + return parsed.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +export async function ProjectDetail({ project }: ProjectDetailProps) { + const t = await getTranslations("projectDetail"); + const formattedDate = formatDate(project.date); + const [cover, ...rest] = project.photos; + + return ( +
+
+
+ {t("breadcrumbProjects")} + + {project.title} +
+
+ +
+

{project.title}

+

+ {t("by", { author: project.author })} + {formattedDate ? ` · ${formattedDate}` : ""} +

+ {project.link ? ( + + {t("viewLink")} + + ) : null} +
+ + {cover ? ( +
+
+ +
+ {rest.length > 0 ? ( +
+ {rest.map((photo, index) => ( +
+ +
+ ))} +
+ ) : null} +
+ ) : null} + +
+
+ {project.body} +
+
+ + {project.tools.length > 0 ? ( +
+
+

{t("toolsUsed")}

+
+
+ {project.tools.map((tool) => ( + + {tool.name} + + ))} +
+
+ ) : null} + + {project.materials.length > 0 ? ( +
+
+

{t("materials")}

+
+
+ {project.materials.map((material) => ( + + {material} + + ))} +
+
+ ) : null} + + + {t("back")} + +
+ ); +} diff --git a/v5/src/components/ProjectSubmitForm.tsx b/v5/src/components/ProjectSubmitForm.tsx new file mode 100644 index 0000000..0ad05c5 --- /dev/null +++ b/v5/src/components/ProjectSubmitForm.tsx @@ -0,0 +1,310 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +interface ToolOption { + id: string; + name: string; +} + +interface ProjectSubmitFormProps { + tools: ToolOption[]; +} + +interface UploadedPhoto { + id: string; + name: string; +} + +export function ProjectSubmitForm({ tools }: ProjectSubmitFormProps) { + const t = useTranslations("projectForm"); + + const [title, setTitle] = useState(""); + const [author, setAuthor] = useState(""); + const [body, setBody] = useState(""); + const [link, setLink] = useState(""); + const [materials, setMaterials] = useState(""); + const [selectedTools, setSelectedTools] = useState([]); + const [photos, setPhotos] = useState([]); + + const [toolQuery, setToolQuery] = useState(""); + const [uploading, setUploading] = useState(0); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [submitted, setSubmitted] = useState(false); + + const filteredTools = toolQuery.trim() + ? tools.filter((tool) => + tool.name.toLowerCase().includes(toolQuery.trim().toLowerCase()) + ) + : tools; + + function toggleTool(id: string) { + setSelectedTools((prev) => + prev.includes(id) ? prev.filter((toolId) => toolId !== id) : [...prev, id] + ); + } + + async function handleFiles(fileList: FileList | null) { + if (!fileList || fileList.length === 0) return; + setError(null); + const files = Array.from(fileList).filter((f) => f.type.startsWith("image/")); + if (files.length === 0) { + setError(t("onlyImages")); + return; + } + + setUploading((n) => n + files.length); + await Promise.all( + files.map(async (file) => { + try { + const form = new FormData(); + form.append("file", file); + const res = await fetch("/api/upload-notion", { + method: "POST", + body: form, + }); + if (!res.ok) { + const data = (await res.json().catch(() => null)) as + | { error?: string } + | null; + throw new Error(data?.error || t("uploadFailed")); + } + const data = (await res.json()) as { + file_upload_id: string; + name: string; + }; + setPhotos((prev) => [...prev, { id: data.file_upload_id, name: data.name }]); + } catch (err) { + setError(err instanceof Error ? err.message : t("uploadFailed")); + } finally { + setUploading((n) => Math.max(0, n - 1)); + } + }) + ); + } + + function removePhoto(id: string) { + setPhotos((prev) => prev.filter((photo) => photo.id !== id)); + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + + if (!title.trim() || !author.trim() || !body.trim()) { + setError(t("requiredError")); + return; + } + + setSubmitting(true); + try { + const materialList = materials + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + + const res = await fetch("/api/projects", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: title.trim(), + author: author.trim(), + body: body.trim(), + link: link.trim() || undefined, + tools: selectedTools, + materials: materialList, + photos, + }), + }); + + if (!res.ok) { + const data = (await res.json().catch(() => null)) as + | { error?: string } + | null; + throw new Error(data?.error || t("submitError")); + } + + setSubmitted(true); + } catch (err) { + setError(err instanceof Error ? err.message : t("submitError")); + } finally { + setSubmitting(false); + } + } + + if (submitted) { + return ( +
+
+

{t("eyebrow")}

+

{t("thanksTitle")}

+

{t("thanksBody")}

+
+ + {t("backToGallery")} + +
+
+
+ ); + } + + return ( +
+
+
+ {t("breadcrumbProjects")} + + {t("breadcrumbNew")} +
+
+ +
+

{t("eyebrow")}

+

{t("title")}

+

{t("lede")}

+ + + + + +