diff --git a/.trivyignore b/.trivyignore index 6c986f98..7b3b79aa 100644 --- a/.trivyignore +++ b/.trivyignore @@ -9,3 +9,11 @@ # - Uniform bucket-level access (no ACLs) # - Write access restricted to authenticated backend service account only AVD-GCP-0001 + +# PostCSS: Information disclosure and denial of service via crafted CSS input +CVE-2026-45623 +# PostCSS: Path Traversal in Previous Source Map Auto-Loading +GHSA-r28c-9q8g-f849 + +# sharp inherited vulnerabilities in libvips +GHSA-f88m-g3jw-g9cj diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs index cd9286ae..39a8b29e 100644 --- a/apps/web/eslint.config.mjs +++ b/apps/web/eslint.config.mjs @@ -47,6 +47,13 @@ const eslintConfig = [ ...nextTypescript, // Disables conflicting ESLint rules eslintConfigPrettier, + { + settings: { + react: { + version: "19.0", + }, + }, + }, { plugins: { prettier, diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 2faae375..23633f40 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -81,7 +81,9 @@ const nextConfig: NextConfig = { headers: [ { key: "Cache-Control", - value: "public, max-age=31536000, immutable", + value: isDev + ? "no-store, max-age=0" + : "public, max-age=31536000, immutable", }, ], }, @@ -109,16 +111,16 @@ const nextConfig: NextConfig = { value: "max-age=31536000; includeSubDomains", }, - // Content Security Policy - allows Mapbox, Google Analytics, Supabase, fonts + // Content Security Policy - allows MapLibre/CARTO, Google Analytics, Supabase, fonts { key: "Content-Security-Policy", value: [ "default-src 'self'", - "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://api.mapbox.com https://www.googletagmanager.com https://www.google-analytics.com https://www.clarity.ms https://static.cloudflareinsights.com", - "style-src 'self' 'unsafe-inline' https://api.mapbox.com https://fonts.googleapis.com", - "img-src 'self' data: blob: https: https://*.mapbox.com https://*.supabase.co", + `script-src 'self' 'unsafe-inline' ${isDev ? "'unsafe-eval' " : ""}'wasm-unsafe-eval' https://www.googletagmanager.com https://www.google-analytics.com https://www.clarity.ms https://static.cloudflareinsights.com`, + "style-src 'self' 'unsafe-inline' https://basemaps.cartocdn.com https://fonts.googleapis.com", + "img-src 'self' data: blob: https: https://*.cartocdn.com https://basemaps.cartocdn.com https://*.supabase.co", "font-src 'self' https://fonts.gstatic.com", - `connect-src 'self' ${isDev ? "http://localhost:8080 " : ""}https://api.nosilha.com https://*.nosilha.com https://api.mapbox.com https://*.mapbox.com https://www.googletagmanager.com https://www.google-analytics.com https://analytics.google.com https://*.supabase.co wss://*.supabase.co https://www.clarity.ms https://*.clarity.ms https://*.r2.cloudflarestorage.com https://cloudflareinsights.com`, + `connect-src 'self' ${isDev ? "http://localhost:8080 " : ""}https://api.nosilha.com https://*.nosilha.com https://*.cartocdn.com https://basemaps.cartocdn.com https://fonts.openmaptiles.org https://s3.amazonaws.com https://www.googletagmanager.com https://www.google-analytics.com https://analytics.google.com https://*.supabase.co wss://*.supabase.co https://www.clarity.ms https://*.clarity.ms https://*.r2.cloudflarestorage.com https://cloudflareinsights.com`, "worker-src 'self' blob:", "frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com", "frame-ancestors 'none'", diff --git a/apps/web/package.json b/apps/web/package.json index ac75314f..94d8dbf3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -41,7 +41,7 @@ "dependencies": { "@headlessui/react": "^2.2.10", "@hookform/resolvers": "^5.4.0", - "@next/third-parties": "16.2.10", + "@next/third-parties": "16.2.12", "@octokit/rest": "^22.0.1", "@supabase/ssr": "^0.12.0", "@supabase/supabase-js": "^2.110.2", @@ -52,9 +52,8 @@ "exifr": "^7.1.3", "framer-motion": "^12.42.2", "lucide-react": "^1.24.0", - "mapbox-gl": "^3.26.0", - "next": "16.2.10", - "next-router-mock": "^1.0.5", + "maplibre-gl": "^5.1.0", + "next": "16.2.12", "react": "19.2.7", "react-dom": "19.2.7", "react-dropzone": "^17.0.0", @@ -63,10 +62,8 @@ "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", "recharts": "^3.9.2", - "rehype-pretty-code": "^0.14.4", "resend": "^6.17.2", "sharp": "^0.35.3", - "shiki": "^4.3.1", "supercluster": "^8.0.1", "use-supercluster": "^1.2.0", "velite": "^0.4.0", @@ -74,9 +71,6 @@ "zustand": "^5.0.14" }, "devDependencies": { - "@eslint/eslintrc": "^3", - "@microsoft/eslint-formatter-sarif": "^3.1.0", - "@next/bundle-analyzer": "16.2.10", "@playwright/test": "^1.61.1", "@tailwindcss/forms": "^0.5.10", "@tailwindcss/postcss": "^4", @@ -85,7 +79,6 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/geojson": "^7946.0.16", - "@types/mapbox-gl": "^3.5.0", "@types/mdx": "^2.0.14", "@types/node": "^26.1.1", "@types/react": "19.2.17", @@ -95,7 +88,7 @@ "babel-plugin-react-compiler": "^1.0.0", "dotenv": "^17.4.1", "eslint": "^10", - "eslint-config-next": "16.2.10", + "eslint-config-next": "16.2.12", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.6", "jsdom": "^29.0.2", diff --git a/apps/web/src/app/(admin)/admin/dev-tools/map/page.tsx b/apps/web/src/app/(admin)/admin/dev-tools/map/page.tsx index ab59c735..c7e8d5ee 100644 --- a/apps/web/src/app/(admin)/admin/dev-tools/map/page.tsx +++ b/apps/web/src/app/(admin)/admin/dev-tools/map/page.tsx @@ -13,18 +13,18 @@ interface MapStyleSwitcherProps { const STYLES: { id: MapStyle; label: string; url: string }[] = [ { id: "streets", - label: "Streets", - url: "mapbox://styles/mapbox/streets-v12", + label: "Voyager", + url: "https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json", }, { id: "outdoors", - label: "Outdoors", - url: "mapbox://styles/mapbox/outdoors-v12", + label: "Positron", + url: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json", }, { id: "satellite", - label: "Satellite", - url: "mapbox://styles/mapbox/satellite-streets-v12", + label: "Dark Matter", + url: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json", }, ]; @@ -105,7 +105,7 @@ export default function TestMapPage() {

Map Style Switcher Test

console.log("Style changed:", style)} />
diff --git a/apps/web/src/app/(admin)/admin/dev-tools/page.tsx b/apps/web/src/app/(admin)/admin/dev-tools/page.tsx index d9732143..b0dcb911 100644 --- a/apps/web/src/app/(admin)/admin/dev-tools/page.tsx +++ b/apps/web/src/app/(admin)/admin/dev-tools/page.tsx @@ -152,7 +152,7 @@ const sections: DevToolSection[] = [ { title: "Map Viewer", description: - "Mapbox style switcher component for streets, outdoors, and satellite views", + "Map style switcher component for streets, outdoors, and satellite views", href: "/admin/dev-tools/map", icon: Map, }, diff --git a/apps/web/src/app/(main)/design-system/_data/components.ts b/apps/web/src/app/(main)/design-system/_data/components.ts deleted file mode 100644 index b1caaacb..00000000 --- a/apps/web/src/app/(main)/design-system/_data/components.ts +++ /dev/null @@ -1,357 +0,0 @@ -/** - * Component metadata for the design system gallery. - * Used by specimen components to display import paths and descriptions. - */ - -export interface ComponentExample { - name: string; - description: string; - importPath: string; - source: "custom" | "catalyst"; -} - -export const buttonComponents: ComponentExample[] = [ - { - name: "AnimatedButton", - description: - "Primary action button with Framer Motion animations. Supports variants (primary, secondary, outline, ghost), sizes, loading states, and icons.", - importPath: "@/components/ui/animated-button", - source: "custom", - }, -]; - -export const inputComponents: ComponentExample[] = [ - { - name: "Input", - description: - "Text input field with validation states and theming. Based on HeadlessUI.", - importPath: "@/components/catalyst-ui/input", - source: "catalyst", - }, - { - name: "InputGroup", - description: - "Container for input with icons. Positions icons automatically.", - importPath: "@/components/catalyst-ui/input", - source: "catalyst", - }, -]; - -export const checkboxComponents: ComponentExample[] = [ - { - name: "Checkbox", - description: - "Checkbox with 22 color variants. Supports checked, indeterminate, and disabled states.", - importPath: "@/components/catalyst-ui/checkbox", - source: "catalyst", - }, - { - name: "CheckboxField", - description: "Layout wrapper for checkbox with label and description.", - importPath: "@/components/catalyst-ui/checkbox", - source: "catalyst", - }, - { - name: "CheckboxGroup", - description: "Container for multiple related checkboxes.", - importPath: "@/components/catalyst-ui/checkbox", - source: "catalyst", - }, -]; - -export const feedbackComponents: ComponentExample[] = [ - { - name: "Banner", - description: - "Promotional banner with gradient background. Supports default and high-contrast tones.", - importPath: "@/components/ui/banner", - source: "custom", - }, - { - name: "LoadingSpinner", - description: - "Animated loading indicator with three spinning dots. Available in sm, md, lg sizes.", - importPath: "@/components/ui/loading-spinner", - source: "custom", - }, - { - name: "LoadingDots", - description: "Inline loading dots animation for text contexts.", - importPath: "@/components/ui/loading-spinner", - source: "custom", - }, - { - name: "LoadingPulse", - description: "Subtle pulse animation wrapper for skeleton states.", - importPath: "@/components/ui/loading-spinner", - source: "custom", - }, - { - name: "ConfirmationDialog", - description: - "Modal dialog for destructive actions. Supports default, warning, and danger variants.", - importPath: "@/components/ui/confirmation-dialog", - source: "custom", - }, -]; - -export const cardComponents: ComponentExample[] = [ - { - name: "Card", - description: - "Base card component with Calm Premium tokens. Optional hoverable prop adds lift animation.", - importPath: "@/components/ui/card", - source: "custom", - }, - { - name: "DirectoryCard", - description: - "Project-specific card for directory entries. Includes image, category badge, bookmark button, and metadata.", - importPath: "@/components/directory/directory-card", - source: "custom", - }, -]; - -export const toastComponents: ComponentExample[] = [ - { - name: "Toast", - description: - "Toast notification with 4 semantic variants (success, error, info, warning). Supports action buttons, keyboard dismissal (Esc), and hover-pause for auto-dismiss.", - importPath: "@/components/ui/toast/toast", - source: "custom", - }, - { - name: "useToast", - description: - "Fluent builder hook for creating toasts. Chain methods: .success().action().duration().id().show()", - importPath: "@/hooks/use-toast", - source: "custom", - }, -]; - -export const badgeComponents: ComponentExample[] = [ - { - name: "Badge", - description: - "Inline status indicator with 5 color variants (blue, green, yellow, red, zinc). Uses design system brand colors.", - importPath: "@/components/catalyst-ui/badge", - source: "catalyst", - }, - { - name: "BadgeButton", - description: - "Interactive badge that can be a button or link. Includes focus ring and hover state.", - importPath: "@/components/catalyst-ui/badge", - source: "catalyst", - }, -]; - -export const dialogComponents: ComponentExample[] = [ - { - name: "Dialog", - description: - "Modal dialog with HeadlessUI. Supports 9 size variants from xs to 5xl. Mobile-responsive as bottom sheet.", - importPath: "@/components/catalyst-ui/dialog", - source: "catalyst", - }, - { - name: "DialogTitle", - description: "Dialog header with proper heading semantics.", - importPath: "@/components/catalyst-ui/dialog", - source: "catalyst", - }, - { - name: "DialogDescription", - description: "Descriptive text beneath the dialog title.", - importPath: "@/components/catalyst-ui/dialog", - source: "catalyst", - }, - { - name: "DialogBody", - description: "Container for dialog content with proper spacing.", - importPath: "@/components/catalyst-ui/dialog", - source: "catalyst", - }, - { - name: "DialogActions", - description: "Footer container for action buttons with responsive layout.", - importPath: "@/components/catalyst-ui/dialog", - source: "catalyst", - }, -]; - -export const skeletonComponents: ComponentExample[] = [ - { - name: "DirectoryCardSkeleton", - description: - "Skeleton loading state matching DirectoryCard layout. Uses semantic color tokens for dark mode.", - importPath: "@/components/ui/directory-card-skeleton", - source: "custom", - }, - { - name: "DirectoryGridSkeleton", - description: - "Grid of skeleton cards for directory pages. Configurable count.", - importPath: "@/components/ui/directory-grid-skeleton", - source: "custom", - }, -]; - -export const layoutComponents: ComponentExample[] = [ - { - name: "PageHeader", - description: - "Animated page header with title, optional subtitle, heading level (h1/h2), size variants, and bougainvillea accent bar.", - importPath: "@/components/ui/page-header", - source: "custom", - }, -]; - -export const navigationComponents: ComponentExample[] = [ - { - name: "MobileBottomNav", - description: - "Fixed bottom navigation for mobile. Thumb-zone optimized with iOS safe area support. Auto-hides on detail pages.", - importPath: "@/components/navigation/mobile-bottom-nav", - source: "custom", - }, -]; - -export const toolbarComponents: ComponentExample[] = [ - { - name: "ContentActionToolbar", - description: - "Responsive toolbar for content pages. Desktop: fixed left-rail. Mobile: FAB with expandable menu. Includes share, reactions, print, suggest.", - importPath: "@/components/content/content-action-toolbar", - source: "custom", - }, - { - name: "ContentActionDesktop", - description: - "Desktop variant of toolbar. Fixed left-rail with vertical action stack.", - importPath: - "@/components/content/content-action-toolbar/content-action-desktop", - source: "custom", - }, - { - name: "ContentActionFAB", - description: - "Mobile variant of toolbar. 56×56px FAB that expands upward with stagger animation.", - importPath: - "@/components/content/content-action-toolbar/content-action-fab", - source: "custom", - }, -]; - -export const avatarComponents: ComponentExample[] = [ - { - name: "Avatar", - description: - "User profile image with initials fallback, size variants (xs-xl), and optional status indicator.", - importPath: "@/components/ui/avatar", - source: "custom", - }, - { - name: "AvatarGroup", - description: - "Stacked avatar display with configurable max count and overflow indicator (+N).", - importPath: "@/components/ui/avatar", - source: "custom", - }, - { - name: "AvatarButton", - description: - "Clickable avatar with expanded touch target (44×44px) and focus ring.", - importPath: "@/components/ui/avatar", - source: "custom", - }, -]; - -export const overlayComponents: ComponentExample[] = [ - { - name: "Tooltip", - description: - "Simple text hint on hover/focus. Position variants: top, bottom, left, right.", - importPath: "@/components/ui/tooltip", - source: "custom", - }, - { - name: "Popover", - description: - "Rich content panel that opens on click. Auto-positioning with HeadlessUI anchor.", - importPath: "@/components/catalyst-ui/popover", - source: "catalyst", - }, - { - name: "Dropdown", - description: - "Action menu with items, dividers, icons, and keyboard shortcuts.", - importPath: "@/components/catalyst-ui/dropdown", - source: "catalyst", - }, - { - name: "DropdownItem", - description: - "Menu item supporting buttons, links, icons, disabled, and destructive states.", - importPath: "@/components/catalyst-ui/dropdown", - source: "catalyst", - }, -]; - -export const tabsComponents: ComponentExample[] = [ - { - name: "TabGroup", - description: - "HeadlessUI Tab component. Container for tabs with managed state.", - importPath: "@headlessui/react", - source: "catalyst", - }, - { - name: "TabList", - description: "Container for tab buttons with flex layout.", - importPath: "@headlessui/react", - source: "catalyst", - }, - { - name: "Tab", - description: - "Individual tab button. Supports data-selected for active styling.", - importPath: "@headlessui/react", - source: "catalyst", - }, - { - name: "TabPanels / TabPanel", - description: "Container and individual panels for tab content.", - importPath: "@headlessui/react", - source: "catalyst", - }, -]; - -export const paginationComponents: ComponentExample[] = [ - { - name: "Pagination", - description: - "Page navigation with prev/next buttons, page numbers, and ellipsis for large ranges. Size variants: sm, md, lg.", - importPath: "local pattern", - source: "custom", - }, -]; - -export const allComponents = [ - ...buttonComponents, - ...inputComponents, - ...checkboxComponents, - ...feedbackComponents, - ...cardComponents, - ...toastComponents, - ...badgeComponents, - ...dialogComponents, - ...skeletonComponents, - ...layoutComponents, - ...navigationComponents, - ...toolbarComponents, - ...avatarComponents, - ...overlayComponents, - ...tabsComponents, - ...paginationComponents, -]; diff --git a/apps/web/src/app/(main)/privacy/page.tsx b/apps/web/src/app/(main)/privacy/page.tsx index cf1e7ea1..69868185 100644 --- a/apps/web/src/app/(main)/privacy/page.tsx +++ b/apps/web/src/app/(main)/privacy/page.tsx @@ -116,7 +116,10 @@ export default async function PrivacyPage() { • Analytics data for platform improvement (anonymized usage patterns) -
  • • Map data from Mapbox for location services
  • +
  • + • Map tile data from CARTO and OpenStreetMap for location + services +
  • @@ -333,14 +336,14 @@ export default async function PrivacyPage() { Maps & Location

    - Mapbox: Provides interactive maps and - location services. + CARTO / OpenStreetMap: Provides interactive + map tiles and location rendering services via MapLibre GL.

    - View Mapbox Privacy Policy + View CARTO Privacy Policy diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 9e26e1ea..ff1003f0 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -481,11 +481,11 @@ * - Dark mode: lighter surface on hover (elevation = closer to light source) */ .hover-surface { - @apply hover:bg-mist-50 dark:hover:bg-basalt-800/50; + @apply dark:hover:bg-basalt-800/50 hover:bg-mist-50; } .hover-surface-strong { - @apply hover:bg-mist-200 dark:hover:bg-basalt-800; + @apply dark:hover:bg-basalt-800 hover:bg-mist-200; } /* Glassmorphism Utilities */ diff --git a/apps/web/src/components/admin/mdx-engine/index.ts b/apps/web/src/components/admin/mdx-engine/index.ts deleted file mode 100644 index c3f047df..00000000 --- a/apps/web/src/components/admin/mdx-engine/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { MdxPreviewModal } from "./mdx-preview-modal"; -export { MdxCommitButton } from "./mdx-commit-button"; diff --git a/apps/web/src/components/admin/mdx-engine/mdx-commit-button.tsx b/apps/web/src/components/admin/mdx-engine/mdx-commit-button.tsx deleted file mode 100644 index 2be419fd..00000000 --- a/apps/web/src/components/admin/mdx-engine/mdx-commit-button.tsx +++ /dev/null @@ -1,104 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { FileCode, Loader2 } from "lucide-react"; -import { MdxPreviewModal } from "./mdx-preview-modal"; -import { generateMdx, commitMdx } from "@/lib/api"; -import type { MdxContent } from "@/types/admin"; - -interface MdxCommitButtonProps { - storyId: string; - storyTitle: string; -} - -export function MdxCommitButton({ storyId, storyTitle }: MdxCommitButtonProps) { - const [isGenerating, setIsGenerating] = useState(false); - const [isCommitting, setIsCommitting] = useState(false); - const [mdxContent, setMdxContent] = useState(null); - const [isModalOpen, setIsModalOpen] = useState(false); - - const handleGenerate = async () => { - setIsGenerating(true); - - try { - const content = await generateMdx(storyId); - setMdxContent(content); - setIsModalOpen(true); - } catch (err) { - console.error("Failed to generate MDX:", err); - // Show error toast/notification - alert( - `Error: ${err instanceof Error ? err.message : "Failed to generate MDX content"}` - ); - } finally { - setIsGenerating(false); - } - }; - - const handleCommit = async (mdxSource: string) => { - setIsCommitting(true); - - try { - const result = await commitMdx( - storyId, - mdxSource, - `Archive story: ${storyTitle}` - ); - - // Success - close modal and show success message - setIsModalOpen(false); - setMdxContent(null); - - // Show success toast/notification - alert( - `MDX committed successfully!\nPath: ${result.mdxPath}\nCommitted by: ${result.committedBy}` - ); - } catch (err) { - console.error("Failed to commit MDX:", err); - // Show error toast/notification - alert( - `Error: ${err instanceof Error ? err.message : "Failed to commit MDX content"}` - ); - } finally { - setIsCommitting(false); - } - }; - - const handleCloseModal = () => { - if (!isCommitting) { - setIsModalOpen(false); - setMdxContent(null); - } - }; - - return ( - <> - - - - - ); -} diff --git a/apps/web/src/components/admin/mdx-engine/mdx-preview-modal.tsx b/apps/web/src/components/admin/mdx-engine/mdx-preview-modal.tsx deleted file mode 100644 index 23a5ead7..00000000 --- a/apps/web/src/components/admin/mdx-engine/mdx-preview-modal.tsx +++ /dev/null @@ -1,266 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { - Dialog, - DialogBackdrop, - DialogPanel, - DialogTitle, -} from "@headlessui/react"; -import { - X, - CheckCircle, - AlertTriangle, - FileText, - Calendar, - User, - MapPin, -} from "lucide-react"; -import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; -import { dark } from "react-syntax-highlighter/dist/esm/styles/prism"; -import type { MdxContent } from "@/types/admin"; -import { ConfirmationDialog } from "@/components/ui/confirmation-dialog"; - -interface MdxPreviewModalProps { - mdxContent: MdxContent | null; - isOpen: boolean; - onClose: () => void; - onCommit: (mdxSource: string) => void; - isCommitting?: boolean; -} - -export function MdxPreviewModal({ - mdxContent, - isOpen, - onClose, - onCommit, - isCommitting = false, -}: MdxPreviewModalProps) { - const [showValidationDetails, setShowValidationDetails] = useState(false); - const [showValidationConfirm, setShowValidationConfirm] = useState(false); - - if (!mdxContent) return null; - - const handleCommit = () => { - if (!mdxContent.schemaValid) { - setShowValidationConfirm(true); - return; - } - onCommit(mdxContent.mdxSource); - }; - - const handleValidationConfirm = () => { - setShowValidationConfirm(false); - onCommit(mdxContent.mdxSource); - }; - - return ( - - - -
    -
    - - {/* Header */} -
    -
    -
    - - - MDX Archival Engine - -
    -

    - Preview and commit generated MDX content -

    -
    - -
    - - {/* Content */} -
    - {/* Schema Validation Status */} -
    - {mdxContent.schemaValid ? ( -
    - -
    -

    - Schema-compliant MDX -

    -

    - All validation checks passed -

    -
    -
    - ) : ( -
    -
    - -
    -

    - Schema validation errors -

    - -
    -
    - {showValidationDetails && - mdxContent.validationErrors && - mdxContent.validationErrors.length > 0 && ( -
      - {mdxContent.validationErrors.map((error, index) => ( -
    • - • {error} -
    • - ))} -
    - )} -
    - )} -
    - - {/* Frontmatter Preview */} -
    -

    - - Frontmatter Preview -

    -
    -
    - - Author: - {mdxContent.frontmatter.author} -
    -
    - - Date: - {mdxContent.frontmatter.date} -
    - {mdxContent.frontmatter.location && ( -
    - - Location: - {mdxContent.frontmatter.location} -
    - )} -
    - - Type: - {mdxContent.frontmatter.storyType} -
    -
    - {mdxContent.frontmatter.tags && - mdxContent.frontmatter.tags.length > 0 && ( -
    - {mdxContent.frontmatter.tags.map((tag, index) => ( - - {tag} - - ))} -
    - )} -
    - - {/* MDX Source Code Preview */} -
    -
    -

    - MDX Source -

    -
    -
    - - {mdxContent.mdxSource} - -
    -
    - - {/* Metadata */} -
    -

    - Generated at:{" "} - {new Date(mdxContent.generatedAt).toLocaleString()} -

    -

    Slug: {mdxContent.slug}

    -
    -
    - - {/* Actions */} -
    - - -
    -
    -
    -
    - - {/* Validation Warning Confirmation */} - setShowValidationConfirm(false)} - onConfirm={handleValidationConfirm} - title="Commit with validation errors?" - description="Schema validation has errors. The MDX will be committed but not merged until validation passes." - confirmLabel="Commit Anyway" - variant="warning" - /> -
    - ); -} diff --git a/apps/web/src/components/catalyst-ui/fieldset.tsx b/apps/web/src/components/catalyst-ui/fieldset.tsx index a1f4120d..f77366c4 100644 --- a/apps/web/src/components/catalyst-ui/fieldset.tsx +++ b/apps/web/src/components/catalyst-ui/fieldset.tsx @@ -2,49 +2,7 @@ import * as Headless from "@headlessui/react"; import clsx from "clsx"; import type React from "react"; -export function Fieldset({ - className, - ...props -}: { className?: string } & Omit) { - return ( - *+[data-slot=control]]:mt-6" - )} - /> - ); -} - -export function Legend({ - className, - ...props -}: { className?: string } & Omit) { - return ( - - ); -} -export function FieldGroup({ - className, - ...props -}: React.ComponentPropsWithoutRef<"div">) { - return ( -
    - ); -} export function Field({ className, diff --git a/apps/web/src/components/catalyst-ui/popover.tsx b/apps/web/src/components/catalyst-ui/popover.tsx index 899cd4b1..97625fc7 100644 --- a/apps/web/src/components/catalyst-ui/popover.tsx +++ b/apps/web/src/components/catalyst-ui/popover.tsx @@ -97,33 +97,4 @@ export function PopoverGroup({ ); } -/** - * PopoverBackdrop adds a backdrop when popover is open. - */ -export function PopoverBackdrop({ className }: { className?: string }) { - return ( - - ); -} -/** - * CloseButton closes the popover when clicked. - */ -export function PopoverClose({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return ( - {children} - ); -} diff --git a/apps/web/src/components/contact/contact-form.tsx b/apps/web/src/components/contact/contact-form.tsx deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/web/src/components/content/search.tsx b/apps/web/src/components/content/search.tsx deleted file mode 100644 index 382b2fd1..00000000 --- a/apps/web/src/components/content/search.tsx +++ /dev/null @@ -1,194 +0,0 @@ -"use client"; - -import { useEffect, useState, useCallback } from "react"; -import Link from "next/link"; -import { Search, X } from "lucide-react"; - -interface SearchResult { - url: string; - excerpt: string; - meta: { - title?: string; - category?: string; - }; -} - -interface PagefindUI { - search: (query: string) => Promise<{ - results: Array<{ - id: string; - data: () => Promise; - }>; - }>; -} - -interface ContentSearchProps { - placeholder?: string; - className?: string; - /** Callback when user submits search (Enter key or explicit action) */ - onSearchSubmit?: (query: string) => void; -} - -export function ContentSearch({ - placeholder = "Search articles...", - className = "", - onSearchSubmit, -}: ContentSearchProps) { - const [query, setQuery] = useState(""); - const [results, setResults] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [isOpen, setIsOpen] = useState(false); - const [pagefind, setPagefind] = useState(null); - - // Load Pagefind on mount (only in browser, after build) - useEffect(() => { - async function loadPagefind() { - if (typeof window === "undefined") return; - - try { - // Use dynamic path to prevent static analysis by bundler - const pagefindPath = ["/pagefind", "pagefind.js"].join("/"); - const pf = await import(/* webpackIgnore: true */ pagefindPath); - await pf.init(); - setPagefind(pf); - } catch (_error) { - // Pagefind not available - likely in development mode before build - console.warn( - "Pagefind not loaded. Run 'pnpm run build' to generate search index." - ); - } - } - loadPagefind(); - }, []); - - // Search function - const performSearch = useCallback( - async (searchQuery: string) => { - if (!pagefind || !searchQuery.trim()) { - setResults([]); - return; - } - - setIsLoading(true); - try { - const search = await pagefind.search(searchQuery); - const searchResults = await Promise.all( - search.results.slice(0, 10).map((result) => result.data()) - ); - setResults(searchResults); - setIsOpen(true); - } catch (error) { - console.error("Search error:", error); - setResults([]); - } finally { - setIsLoading(false); - } - }, - [pagefind] - ); - - // Debounced search - useEffect(() => { - const timer = setTimeout(() => { - if (query) { - performSearch(query); - } else { - setResults([]); - setIsOpen(false); - } - }, 300); - - return () => clearTimeout(timer); - }, [query, performSearch]); - - // Close on escape - useEffect(() => { - function handleKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") { - setIsOpen(false); - setQuery(""); - } - } - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, []); - - return ( -
    - {/* Search input */} -
    - - setQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && query.trim() && onSearchSubmit) { - e.preventDefault(); - onSearchSubmit(query.trim()); - } - }} - placeholder={placeholder} - className="focus:border-ocean-blue focus:ring-ocean-blue border-hairline bg-surface text-body w-full rounded-lg border py-2 pr-10 pl-10 text-sm focus:ring-1 focus:outline-none" - aria-label="Search articles" - /> - {query && ( - - )} -
    - - {/* Search results dropdown */} - {isOpen && (query || results.length > 0) && ( -
    - {isLoading ? ( -
    - Searching... -
    - ) : results.length > 0 ? ( -
      - {results.map((result, index) => ( -
    • - { - setIsOpen(false); - setQuery(""); - }} - className="hover:bg-surface-alt block px-4 py-3" - > -
      - {result.meta.title || "Untitled"} -
      - {result.meta.category && ( - - {result.meta.category} - - )} -

      - -

    • - ))} -
    - ) : query ? ( -
    - No results found for "{query}" -
    - ) : null} -
    - )} -
    - ); -} diff --git a/apps/web/src/components/gallery/gallery-map-canvas.tsx b/apps/web/src/components/gallery/gallery-map-canvas.tsx index b03e1ef4..9826e06a 100644 --- a/apps/web/src/components/gallery/gallery-map-canvas.tsx +++ b/apps/web/src/components/gallery/gallery-map-canvas.tsx @@ -1,8 +1,12 @@ "use client"; import { useState, useCallback, useEffect, useMemo, useRef } from "react"; -import { Marker, Popup, type ViewStateChangeEvent } from "react-map-gl/mapbox"; -import type { MapRef } from "react-map-gl/mapbox"; +import { + Marker, + Popup, + type ViewStateChangeEvent, +} from "react-map-gl/maplibre"; +import type { MapRef } from "react-map-gl/maplibre"; import { Loader2, Camera, MapPin } from "lucide-react"; import { clsx } from "clsx"; import { BaseMap, useMapClustering } from "@/features/map/shared"; @@ -314,6 +318,7 @@ export function GalleryMapCanvas({ thumbnailUrl={geoProps.thumbnailUrl} title={geoProps.title} isSelected={isSelected} + mediaType={geoProps.mediaType} onClick={() => handleMarkerClick(geoProps.mediaId, latitude, longitude) } diff --git a/apps/web/src/components/gallery/gallery-map-marker.tsx b/apps/web/src/components/gallery/gallery-map-marker.tsx index 10cf5964..80509494 100644 --- a/apps/web/src/components/gallery/gallery-map-marker.tsx +++ b/apps/web/src/components/gallery/gallery-map-marker.tsx @@ -2,24 +2,26 @@ import Image from "next/image"; import { clsx } from "clsx"; -import { Camera } from "lucide-react"; +import { Camera, Play } from "lucide-react"; interface GalleryMapMarkerProps { thumbnailUrl: string | null; title: string; isSelected: boolean; onClick: () => void; + mediaType?: "IMAGE" | "VIDEO"; } /** - * Photo thumbnail marker for the gallery map. - * 48x48px rounded image with a subtle pin nub below. + * Photo/Video thumbnail marker for the gallery map. + * 48x48px rounded image with a subtle pin nub below and video indicator badge. */ export function GalleryMapMarker({ thumbnailUrl, title, isSelected, onClick, + mediaType, }: GalleryMapMarkerProps) { return (
    {/* Pin nub */}
    { - const sections = document.querySelectorAll("section"); - const heritageSection = sections[1]; - if (heritageSection) { - const rect = heritageSection.getBoundingClientRect(); - window.scrollTo({ - top: window.scrollY + rect.top - 60, - behavior: "smooth", - }); - } else { - window.scrollTo({ - top: window.innerHeight, - behavior: "smooth", - }); - } - }; - - // --- Animation Variants (entrance only, no continuous loops) --- - const containerVariants = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { - staggerChildren: 0.15, - delayChildren: 0.2, - }, - }, - } as const; - - const itemVariants = { - hidden: { y: 20, opacity: 0 }, - visible: { - y: 0, - opacity: 1, - transition: { duration: 0.4, ease: "easeOut" as const }, - }, - } as const; - - return ( -
    - {/* --- 1. Background Layer (Static) --- */} -
    -
    - Brava Island coastline at dawn -
    -
    - - {/* --- 2. Gradient Overlay --- */} -
    - - {/* --- 3. Main Content Container --- */} -
    - - {/* Context Badges - Trust & Location */} - - - - - - {/* Headlines - capped at text-6xl */} - -

    - Discover the
    - - Soul of Brava - -

    -

    - “Nos terra, nos gente, nos memoria.” -

    -
    - - {/* Description */} - -

    - A living archive connecting the people of Djabraba and the global - diaspora. We preserve Brava's oral histories and celebrate the - resilience of our island culture. -

    -
    - - {/* Dual CTAs - CSS-only hover effects */} - - {/* Primary CTA - Share Your Story */} - - Share Your Story - - - {/* Search Bar - Simplified focus state */} - -
    - - router.push(`/directory?q=${encodeURIComponent(query)}`) - } - /> -
    -
    -
    -
    - - {/* --- 4. Scroll Indicator --- */} - -
    - ); -} - -export default HeroSectionAtmospheric; diff --git a/apps/web/src/components/landing/map-teaser-section.tsx b/apps/web/src/components/landing/map-teaser-section.tsx index 39084ead..f329b8f2 100644 --- a/apps/web/src/components/landing/map-teaser-section.tsx +++ b/apps/web/src/components/landing/map-teaser-section.tsx @@ -56,7 +56,7 @@ function MapMarker({
    {/* Label (Floating below) */} -
    +
    {label}
    @@ -199,7 +199,7 @@ export function MapTeaserSection() { className="order-1 w-full lg:order-2 lg:w-1/2" > {/* Map Container */} -
    +
    -
    +
    -
    +
    3D
    diff --git a/apps/web/src/components/newsletter/homepage-newsletter-section.tsx b/apps/web/src/components/newsletter/homepage-newsletter-section.tsx deleted file mode 100644 index e679e484..00000000 --- a/apps/web/src/components/newsletter/homepage-newsletter-section.tsx +++ /dev/null @@ -1,251 +0,0 @@ -"use client"; - -import { useState, useRef } from "react"; -import clsx from "clsx"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { - newsletterSchema, - type NewsletterInput, -} from "@/lib/validation/newsletter-schema"; -import { subscribeToNewsletter } from "@/app/actions/newsletter"; -import { - hasSubmittedEmail, - recordSubmittedEmail, -} from "@/lib/newsletter-submission-cache"; - -/** - * Homepage Newsletter Subscription Section - * - * Features: - * - React Hook Form with Zod validation - * - Server Action integration - * - Inline success/error messages with ARIA live regions - * - Hidden honeypot field for spam prevention - * - Ocean Blue gradient background (Nos Ilha brand) - * - Mobile-first responsive design - * - WCAG 2.1 Level AA accessible - * - * @see frontend/src/app/actions/newsletter.ts - Server Action - * @see frontend/src/lib/validation/newsletter-schema.ts - Validation schema - */ -export default function HomepageNewsletterSection() { - const [responseMessage, setResponseMessage] = useState(null); - const [messageVariant, setMessageVariant] = useState< - "success" | "error" | "info" - >("success"); - const [isProcessing, setIsProcessing] = useState(false); - const emailInputRef = useRef(null); - - const { - register, - handleSubmit, - formState: { errors }, - reset, - } = useForm({ - resolver: zodResolver(newsletterSchema), - }); - - const onSubmit = async (data: NewsletterInput) => { - try { - // Clear any previous messages when form is submitted - setResponseMessage(null); - setIsProcessing(true); - - const normalizedEmail = data.email.trim().toLowerCase(); - - // Security: Show same success message to prevent email enumeration attacks - // (OWASP recommendation: consistent messages for existent/non-existent accounts) - if (hasSubmittedEmail(normalizedEmail)) { - setMessageVariant("success"); - setResponseMessage("Thank you for subscribing!"); - reset(); - emailInputRef.current?.focus(); - return; - } - - // Create FormData for Server Action - const formData = new FormData(); - formData.append("email", data.email); - if (data.website) { - formData.append("website", data.website); - } - - // Call Server Action - const response = await subscribeToNewsletter(formData); - - if (response.success) { - recordSubmittedEmail(normalizedEmail); - // Always show success message (server no longer returns duplicate flag) - setMessageVariant("success"); - setResponseMessage(response.message); - reset(); - emailInputRef.current?.focus(); - } else { - setMessageVariant("error"); - setResponseMessage(response.message); - } - } catch (_error) { - setMessageVariant("error"); - setResponseMessage( - "An unexpected error occurred. Please try again later." - ); - } finally { - setIsProcessing(false); - } - }; - - /** - * Handle validation errors by clearing any existing messages. - * This prevents message persistence when validation fails after showing - * an "already subscribed" message. - */ - const onInvalid = () => { - // Clear the response message when validation fails - setResponseMessage(null); - }; - - const messageClassName = clsx( - "mt-4 rounded-md px-4 py-3 text-center text-base font-semibold", - { - "bg-white/10 text-white": messageVariant === "success", - "bg-sobrado-ochre/20 text-sobrado-ochre": messageVariant === "info", - "bg-white text-bougainvillea-pink": messageVariant === "error", - } - ); - - return ( -
    -
    -
    - {/* Ocean-inspired radial gradient overlay */} - -
    -
    - ); -} diff --git a/apps/web/src/components/ui/image-lightbox.tsx b/apps/web/src/components/ui/image-lightbox.tsx index 94e52707..6c61fc6d 100644 --- a/apps/web/src/components/ui/image-lightbox.tsx +++ b/apps/web/src/components/ui/image-lightbox.tsx @@ -235,6 +235,19 @@ export function ImageLightbox({ lastTapRef.current = now; }, []); + // Preload adjacent images for instant, flash-free transitions + useEffect(() => { + if (!isOpen || photos.length <= 1) return; + const nextIndex = (currentIndex + 1) % photos.length; + const prevIndex = (currentIndex - 1 + photos.length) % photos.length; + [photos[nextIndex]?.src, photos[prevIndex]?.src].forEach((src) => { + if (src && typeof window !== "undefined") { + const img = new window.Image(); + img.src = src; + } + }); + }, [currentIndex, isOpen, photos]); + if (!photo) return null; return ( @@ -262,7 +275,7 @@ export function ImageLightbox({