diff --git a/package-lock.json b/package-lock.json index 9c1fc2c..455e529 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "aiatlas", "version": "0.1.0", + "hasInstallScript": true, "dependencies": { "@prisma/client": "^6.9.0", "@supabase/supabase-js": "^2.49.0", @@ -55,6 +56,29 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -740,7 +764,6 @@ "integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -801,7 +824,6 @@ "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", @@ -1365,7 +1387,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1794,7 +1815,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2551,7 +2571,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2721,7 +2740,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4454,7 +4472,6 @@ "integrity": "sha512-s98mCOMOWLGGpGOfgKSnleXLuegvvH415qtRZXpSp00HeEgdmrxmwL9cgKU+h4XrhB16zEI5d/7BnkS3ATInsA==", "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "14.2.29", "@swc/helpers": "0.5.5", @@ -5060,7 +5077,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -5224,7 +5240,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -5265,7 +5280,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/config": "6.19.3", "@prisma/engines": "6.19.3" @@ -5360,7 +5374,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5373,7 +5386,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6166,7 +6178,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -6371,7 +6382,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6539,7 +6549,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/app/models/page.tsx b/src/app/models/page.tsx index 6c36afc..f267bda 100644 --- a/src/app/models/page.tsx +++ b/src/app/models/page.tsx @@ -1,5 +1,6 @@ "use client"; - +import TrackModelView from '@/components/models/TrackModelView'; +import RecentlyViewed from '@/components/models/RecentlyViewed'; import { useState, useMemo, useEffect } from "react"; import { mockModels, getUniqueProviders, getUniqueModalities, getUniqueLicenses } from "@/lib/mock-data"; import { ModelFilters as ModelFiltersType, Model } from "@/types"; @@ -61,6 +62,8 @@ export default function ModelsPage() { }, [filters, models]); return ( + +
{/* Header */}
@@ -148,5 +151,6 @@ export default function ModelsPage() {
)}
+ ); } diff --git a/src/components/models/RecentlyViewed.tsx b/src/components/models/RecentlyViewed.tsx new file mode 100644 index 0000000..ff68630 --- /dev/null +++ b/src/components/models/RecentlyViewed.tsx @@ -0,0 +1,51 @@ +// src/components/models/RecentlyViewed.tsx +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import Image from 'next/image'; +import { RecentlyViewedModel } from '@/hooks/useRecentlyViewed'; + +const STORAGE_KEY = 'aiatlas_recently_viewed'; + +export default function RecentlyViewed() { + const [models, setModels] = useState([]); + const [mounted, setMounted] = useState(false); + + // Only run on client — prevents Next.js hydration mismatch + useEffect(() => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) setModels(JSON.parse(stored)); + } catch {} + setMounted(true); + }, []); + + if (!mounted || models.length === 0) return null; + + return ( +
+

Recently Viewed

+
+ {models.map(model => ( + + {model.avatar && ( + {model.name} + )} + {model.name} + + ))} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/models/TrackModelView.tsx b/src/components/models/TrackModelView.tsx new file mode 100644 index 0000000..d0e1dea --- /dev/null +++ b/src/components/models/TrackModelView.tsx @@ -0,0 +1,20 @@ +// src/components/models/TrackModelView.tsx +'use client'; + +import { useEffect } from 'react'; +import { useRecentlyViewed, RecentlyViewedModel } from '@/hooks/useRecentlyViewed'; + +interface Props { + model: RecentlyViewedModel; +} + +export default function TrackModelView({ model }: Props) { + const { addModel } = useRecentlyViewed(); + + useEffect(() => { + addModel(model); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [model.slug]); + + return null; // renders nothing, just tracks +} \ No newline at end of file diff --git a/src/hooks/useRecentlyviewed.ts b/src/hooks/useRecentlyviewed.ts new file mode 100644 index 0000000..c4d548f --- /dev/null +++ b/src/hooks/useRecentlyviewed.ts @@ -0,0 +1,38 @@ +// src/hooks/useRecentlyViewed.ts +import { useState, useEffect } from 'react'; + +export interface RecentlyViewedModel { + slug: string; + name: string; + avatar: string; // provider logo / avatar URL +} + +const STORAGE_KEY = 'aiatlas_recently_viewed'; +const MAX_ITEMS = 5; + +export function useRecentlyViewed() { + const [recentlyViewed, setRecentlyViewed] = useState([]); + + // Load from localStorage on mount (client-only) + useEffect(() => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) setRecentlyViewed(JSON.parse(stored)); + } catch {} + }, []); + + const addModel = (model: RecentlyViewedModel) => { + setRecentlyViewed(prev => { + // Remove duplicate if it already exists + const filtered = prev.filter(m => m.slug !== model.slug); + // Prepend new item, cap at MAX_ITEMS + const updated = [model, ...filtered].slice(0, MAX_ITEMS); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + } catch {} + return updated; + }); + }; + + return { recentlyViewed, addModel }; +} \ No newline at end of file