project-portfolio 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # project-portfolio
2
+
3
+ Self-contained project portfolio component for Next.js App Router. Pass a `clientSlug` and `apiBase` — it fetches, caches, and renders everything.
4
+
5
+ ## Requirements
6
+
7
+ - Next.js 13+ (App Router)
8
+ - React 18+
9
+
10
+ No other dependencies.
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ npm install project-portfolio
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ```tsx
21
+ // app/page.tsx
22
+ import { ProjectPortfolio } from "project-portfolio"
23
+
24
+ export default function Page() {
25
+ return (
26
+ <ProjectPortfolio
27
+ clientSlug="your-client-slug"
28
+ apiBase="https://your-api.com"
29
+ />
30
+ )
31
+ }
32
+ ```
33
+
34
+ ## Props
35
+
36
+ | Prop | Type | Required | Default | Description |
37
+ |--------------|----------|----------|---------------|------------------------------------------------------|
38
+ | `clientSlug` | `string` | Yes | — | Identifies which client's projects to load |
39
+ | `apiBase` | `string` | Yes | — | Base URL of the projects API |
40
+ | `title` | `string` | No | Client name | Override the header title |
41
+ | `basePath` | `string` | No | `"/projects"` | Base path for project detail links |
42
+
43
+ ## Caching
44
+
45
+ Data is cached at two levels:
46
+
47
+ 1. **Per-render** — `React.cache()` deduplicates any duplicate calls within the same render pass
48
+ 2. **Cross-request** — `next: { revalidate: 60 }` on each fetch call caches responses in Next.js's built-in Data Cache for 60 seconds
49
+
50
+ No third-party caching libraries are used.
51
+
52
+ ## Publishing to npm
53
+
54
+ ```bash
55
+ # 1. Create an account at https://npmjs.com
56
+ # 2. Log in
57
+ npm login
58
+
59
+ # 3. Build and publish
60
+ cd package
61
+ npm run build
62
+ npm publish --access public
63
+ ```
64
+
65
+ ## Updating
66
+
67
+ Bump the `version` field in `package/package.json` then run `npm publish` again.
@@ -0,0 +1,13 @@
1
+ import type { Project, CustomFieldSchema } from "./types";
2
+ export type CardVariant = "card" | "compact";
3
+ interface ProjectCardProps {
4
+ project: Project;
5
+ schema: CustomFieldSchema[];
6
+ priority?: boolean;
7
+ variant?: CardVariant;
8
+ /** Base path for project detail links. Defaults to "/projects" */
9
+ basePath?: string;
10
+ }
11
+ export declare function ProjectCard({ project, schema, priority, variant, basePath, }: ProjectCardProps): import("react/jsx-runtime").JSX.Element;
12
+ export {};
13
+ //# sourceMappingURL=ProjectCard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ProjectCard.d.ts","sourceRoot":"","sources":["../src/ProjectCard.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,iBAAiB,EAAmC,MAAM,SAAS,CAAA;AAE1F,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,SAAS,CAAA;AAE5C,UAAU,gBAAgB;IACxB,OAAO,EAAE,OAAO,CAAA;IAChB,MAAM,EAAE,iBAAiB,EAAE,CAAA;IAC3B,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,OAAO,CAAC,EAAE,WAAW,CAAA;IACrB,kEAAkE;IAClE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAeD,wBAAgB,WAAW,CAAC,EAC1B,OAAO,EACP,MAAM,EACN,QAAQ,EACR,OAAgB,EAChB,QAAsB,GACvB,EAAE,gBAAgB,2CAkPlB"}
@@ -0,0 +1,128 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ function parseMultiValue(raw) {
3
+ if (Array.isArray(raw))
4
+ return raw.map(String).map((s) => s.replace(/`/g, "").trim()).filter(Boolean);
5
+ if (typeof raw === "string") {
6
+ return raw.replace(/`/g, "").split(",").map((s) => s.trim()).filter(Boolean);
7
+ }
8
+ return [];
9
+ }
10
+ function parseSingleValue(raw) {
11
+ if (typeof raw === "string")
12
+ return raw.replace(/`/g, "").trim();
13
+ return String(raw !== null && raw !== void 0 ? raw : "");
14
+ }
15
+ export function ProjectCard({ project, schema, priority, variant = "card", basePath = "/projects", }) {
16
+ var _a, _b, _c, _d;
17
+ const imageUrl = (_d = (_a = project.image_url) !== null && _a !== void 0 ? _a : (_c = (_b = project.media) === null || _b === void 0 ? void 0 : _b[0]) === null || _c === void 0 ? void 0 : _c.url) !== null && _d !== void 0 ? _d : null;
18
+ const badgeField = schema.find((f) => f.display_position === "badge_overlay");
19
+ const tagFields = schema
20
+ .filter((f) => f.display_position === "tags" && f.type !== "number")
21
+ .filter((f, i, arr) => arr.findIndex((x) => x.key === f.key) === i);
22
+ const numericField = schema.find((f) => f.type === "number" && f.display_position !== "hidden");
23
+ const locationField = schema.find((f) => f.type === "location");
24
+ const badgeValue = badgeField
25
+ ? parseSingleValue(project.custom_field_values[badgeField.key])
26
+ : null;
27
+ const locationValue = locationField
28
+ ? project.custom_field_values[locationField.key]
29
+ : null;
30
+ const locationString = locationValue
31
+ ? [locationValue.city, locationValue.state].filter(Boolean).join(", ")
32
+ : null;
33
+ const footerStat = (() => {
34
+ if (!numericField)
35
+ return null;
36
+ const val = project.custom_field_values[numericField.key];
37
+ if (val === undefined || val === null)
38
+ return null;
39
+ const numVal = typeof val === "number" ? val : parseFloat(String(val));
40
+ const formatted = isNaN(numVal) ? String(val) : `${numVal.toLocaleString()} SF`;
41
+ const label = numericField.name.replace(/\s*\([^)]+\)/, "");
42
+ return { label, formatted };
43
+ })();
44
+ const compactTags = tagFields.flatMap((field) => parseMultiValue(project.custom_field_values[field.key]));
45
+ const href = `${basePath}/${project.slug}`;
46
+ if (variant === "compact") {
47
+ return (_jsxs("a", { href: href, style: {
48
+ display: "flex",
49
+ alignItems: "center",
50
+ gap: "1rem",
51
+ padding: "1rem",
52
+ backgroundColor: "#fff",
53
+ border: "1px solid #e4e4e7",
54
+ borderRadius: "2px",
55
+ textDecoration: "none",
56
+ transition: "border-color 0.2s, box-shadow 0.2s",
57
+ }, children: [_jsx("div", { style: {
58
+ position: "relative",
59
+ width: "80px",
60
+ height: "80px",
61
+ flexShrink: 0,
62
+ borderRadius: "2px",
63
+ overflow: "hidden",
64
+ backgroundColor: "#f4f4f5",
65
+ }, children: imageUrl ? (_jsx("img", { src: imageUrl, alt: project.title, style: { width: "100%", height: "100%", objectFit: "cover" } })) : (_jsx("div", { style: { width: "100%", height: "100%", backgroundColor: "#e4e4e7" } })) }), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: "4px", minWidth: 0 }, children: [_jsx("p", { style: { fontWeight: 700, color: "#18181b", fontSize: "14px", margin: 0, lineHeight: 1.4, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }, children: project.title }), badgeValue && (_jsx("p", { style: { fontSize: "12px", color: "#71717a", margin: 0 }, children: badgeValue })), compactTags.length > 0 && (_jsx("p", { style: { fontSize: "12px", color: "#f18a00", margin: 0, lineHeight: 1.4 }, children: compactTags.join(" · ") }))] })] }));
66
+ }
67
+ return (_jsxs("article", { style: {
68
+ backgroundColor: "#fff",
69
+ borderRadius: "2px",
70
+ boxShadow: "0 2px 8px 0 rgba(0,0,0,0.10), 0 0 0 1px rgba(0,0,0,0.07)",
71
+ overflow: "hidden",
72
+ display: "flex",
73
+ flexDirection: "column",
74
+ transition: "box-shadow 0.3s",
75
+ }, children: [_jsxs("div", { style: { position: "relative", aspectRatio: "16/10", overflow: "hidden", flexShrink: 0 }, children: [imageUrl ? (_jsx("img", { src: imageUrl, alt: project.title, loading: priority ? "eager" : "lazy", style: { width: "100%", height: "100%", objectFit: "cover" } })) : (_jsx("div", { style: { position: "absolute", inset: 0, backgroundColor: "#f4f4f5" } })), _jsx("div", { style: {
76
+ position: "absolute",
77
+ inset: 0,
78
+ background: "linear-gradient(to top, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.25) 50%, transparent 100%)",
79
+ } }), project.is_featured && (_jsx("span", { style: {
80
+ position: "absolute",
81
+ top: "1rem",
82
+ left: "1rem",
83
+ backgroundColor: "#fbbf24",
84
+ color: "#451a03",
85
+ fontSize: "11px",
86
+ fontWeight: 700,
87
+ textTransform: "uppercase",
88
+ letterSpacing: "0.05em",
89
+ padding: "3px 10px",
90
+ zIndex: 10,
91
+ }, children: "Featured" })), _jsxs("div", { style: {
92
+ position: "absolute",
93
+ bottom: 0,
94
+ left: 0,
95
+ right: 0,
96
+ padding: "1.25rem",
97
+ display: "flex",
98
+ flexDirection: "column",
99
+ gap: "6px",
100
+ zIndex: 10,
101
+ }, children: [badgeValue && (_jsx("span", { style: {
102
+ alignSelf: "flex-start",
103
+ backgroundColor: "rgba(255,255,255,0.85)",
104
+ backdropFilter: "blur(4px)",
105
+ color: "#27272a",
106
+ fontSize: "11px",
107
+ fontWeight: 600,
108
+ padding: "3px 10px",
109
+ }, children: badgeValue })), _jsx("h3", { style: { color: "#fff", fontWeight: 700, fontSize: "20px", lineHeight: 1.3, margin: 0 }, children: project.title }), locationString && (_jsx("p", { style: { color: "rgba(255,255,255,0.8)", fontSize: "14px", margin: 0 }, children: locationString }))] })] }), _jsxs("div", { style: { display: "flex", flexDirection: "column", flex: 1, padding: "1.5rem" }, children: [(project.blurb || project.description) && (_jsx("p", { style: { fontSize: "14px", color: "#3f3f46", lineHeight: 1.6, margin: "0 0 16px 0" }, children: project.blurb || project.description })), tagFields.map((field) => {
110
+ const vals = parseMultiValue(project.custom_field_values[field.key]);
111
+ if (vals.length === 0)
112
+ return null;
113
+ return (_jsxs("div", { style: { marginBottom: "16px" }, children: [_jsx("p", { style: { fontSize: "10px", fontWeight: 600, letterSpacing: "0.1em", textTransform: "uppercase", color: "#a1a1aa", marginBottom: "8px" }, children: "Systems Used:" }), _jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: "8px" }, children: vals.map((val, i) => {
114
+ var _a;
115
+ const url = (_a = field.option_urls) === null || _a === void 0 ? void 0 : _a[val];
116
+ const tagStyle = {
117
+ border: "1px solid #d4d4d8",
118
+ padding: "4px 12px",
119
+ fontSize: "14px",
120
+ color: "#3f3f46",
121
+ textDecoration: "none",
122
+ backgroundColor: "transparent",
123
+ cursor: url ? "pointer" : "default",
124
+ };
125
+ return url ? (_jsx("a", { href: url, style: tagStyle, children: val }, `${project.id}-${field.key}-${i}`)) : (_jsx("span", { style: tagStyle, children: val }, `${project.id}-${field.key}-${i}`));
126
+ }) })] }, `${project.id}-${field.key}`));
127
+ }), _jsx("div", { style: { flex: 1 } }), _jsxs("div", { style: { marginTop: "16px", borderTop: "1px solid #e4e4e7", paddingTop: "12px", display: "flex", alignItems: "flex-end", justifyContent: "space-between", gap: "16px" }, children: [footerStat ? (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: "2px" }, children: [_jsx("span", { style: { fontSize: "12px", color: "#a1a1aa" }, children: footerStat.label }), _jsx("span", { style: { color: "#18181b", fontWeight: 700, fontSize: "16px" }, children: footerStat.formatted })] })) : _jsx("div", {}), _jsx("a", { href: href, style: { color: "#f18a00", fontWeight: 600, fontSize: "14px", textDecoration: "none", flexShrink: 0 }, children: "Details \u2192" })] })] })] }));
128
+ }
@@ -0,0 +1,11 @@
1
+ import type { CustomFieldSchema } from "./types";
2
+ interface ProjectFiltersProps {
3
+ fields: CustomFieldSchema[];
4
+ filters: Record<string, string>;
5
+ onFilterChange: (key: string, value: string) => void;
6
+ onClearFilters: () => void;
7
+ hasActiveFilters: boolean;
8
+ }
9
+ export declare function ProjectFilters({ fields, filters, onFilterChange, onClearFilters, hasActiveFilters, }: ProjectFiltersProps): import("react/jsx-runtime").JSX.Element;
10
+ export {};
11
+ //# sourceMappingURL=ProjectFilters.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ProjectFilters.d.ts","sourceRoot":"","sources":["../src/ProjectFilters.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAA;AAEhD,UAAU,mBAAmB;IAC3B,MAAM,EAAE,iBAAiB,EAAE,CAAA;IAC3B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC/B,cAAc,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IACpD,cAAc,EAAE,MAAM,IAAI,CAAA;IAC1B,gBAAgB,EAAE,OAAO,CAAA;CAC1B;AAED,wBAAgB,cAAc,CAAC,EAC7B,MAAM,EACN,OAAO,EACP,cAAc,EACd,cAAc,EACd,gBAAgB,GACjB,EAAE,mBAAmB,2CA8FrB"}
@@ -0,0 +1,49 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ export function ProjectFilters({ fields, filters, onFilterChange, onClearFilters, hasActiveFilters, }) {
3
+ const deduped = fields.filter((f, i, arr) => arr.findIndex((x) => x.key === f.key) === i);
4
+ return (_jsxs("div", { style: {
5
+ marginBottom: "2rem",
6
+ paddingBottom: "2rem",
7
+ borderBottom: "1px solid #e4e4e7",
8
+ display: "flex",
9
+ flexWrap: "wrap",
10
+ alignItems: "flex-end",
11
+ gap: "1rem",
12
+ }, children: [deduped.map((field, fieldIndex) => {
13
+ if (field.type === "select" || field.type === "multi-select") {
14
+ return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: "6px" }, children: [_jsx("label", { style: { fontSize: "13px", fontWeight: 500, color: "#71717a" }, children: field.name }), _jsxs("select", { value: filters[field.key] || "all", onChange: (e) => onFilterChange(field.key, e.target.value === "all" ? "" : e.target.value), style: {
15
+ width: "180px",
16
+ padding: "8px 12px",
17
+ border: "1px solid #e4e4e7",
18
+ borderRadius: "6px",
19
+ fontSize: "14px",
20
+ backgroundColor: "#fff",
21
+ color: "#18181b",
22
+ outline: "none",
23
+ cursor: "pointer",
24
+ }, children: [_jsxs("option", { value: "all", children: ["All ", field.name] }), [...new Set(field.options)].map((option, i) => (_jsx("option", { value: option, children: option }, `${field.key}-opt-${i}`)))] })] }, `filter-${fieldIndex}-${field.key}`));
25
+ }
26
+ if (field.type === "text" || field.type === "location") {
27
+ return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: "6px" }, children: [_jsx("label", { style: { fontSize: "13px", fontWeight: 500, color: "#71717a" }, children: field.name }), _jsx("input", { type: "text", placeholder: `Search ${field.name.toLowerCase()}...`, value: filters[field.key] || "", onChange: (e) => onFilterChange(field.key, e.target.value), style: {
28
+ width: "180px",
29
+ padding: "8px 12px",
30
+ border: "1px solid #e4e4e7",
31
+ borderRadius: "6px",
32
+ fontSize: "14px",
33
+ color: "#18181b",
34
+ outline: "none",
35
+ } })] }, `filter-${fieldIndex}-${field.key}`));
36
+ }
37
+ return null;
38
+ }), hasActiveFilters && (_jsx("button", { onClick: onClearFilters, style: {
39
+ display: "flex",
40
+ alignItems: "center",
41
+ gap: "4px",
42
+ padding: "8px 12px",
43
+ background: "none",
44
+ border: "none",
45
+ fontSize: "14px",
46
+ color: "#71717a",
47
+ cursor: "pointer",
48
+ }, children: "\u00D7 Clear filters" }))] }));
49
+ }
@@ -0,0 +1,10 @@
1
+ import type { Project, CustomFieldSchema } from "./types";
2
+ interface ProjectGridProps {
3
+ projects: Project[];
4
+ schema: CustomFieldSchema[];
5
+ /** Base path for project detail links. Defaults to "/projects" */
6
+ basePath?: string;
7
+ }
8
+ export declare function ProjectGrid({ projects, schema, basePath }: ProjectGridProps): import("react/jsx-runtime").JSX.Element;
9
+ export {};
10
+ //# sourceMappingURL=ProjectGrid.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ProjectGrid.d.ts","sourceRoot":"","sources":["../src/ProjectGrid.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,OAAO,EAAE,iBAAiB,EAAoB,MAAM,SAAS,CAAA;AAE3E,UAAU,gBAAgB;IACxB,QAAQ,EAAE,OAAO,EAAE,CAAA;IACnB,MAAM,EAAE,iBAAiB,EAAE,CAAA;IAC3B,kEAAkE;IAClE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,wBAAgB,WAAW,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAsB,EAAE,EAAE,gBAAgB,2CA6HzF"}
@@ -0,0 +1,64 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState, useMemo } from "react";
4
+ import { ProjectCard } from "./ProjectCard";
5
+ import { ProjectFilters } from "./ProjectFilters";
6
+ export function ProjectGrid({ projects, schema, basePath = "/projects" }) {
7
+ const [filters, setFilters] = useState({});
8
+ const [variant, setVariant] = useState("card");
9
+ const filterableFields = useMemo(() => schema.filter((f) => f.is_filterable).sort((a, b) => a.sort_order - b.sort_order), [schema]);
10
+ const filteredProjects = useMemo(() => {
11
+ return projects.filter((project) => Object.entries(filters).every(([key, filterValue]) => {
12
+ if (!filterValue)
13
+ return true;
14
+ const field = schema.find((f) => f.key === key);
15
+ if (!field)
16
+ return true;
17
+ const projectValue = project.custom_field_values[key];
18
+ const parseVal = (v) => {
19
+ if (Array.isArray(v))
20
+ return v.map(String).map((s) => s.replace(/`/g, "").trim());
21
+ if (typeof v === "string")
22
+ return v.replace(/`/g, "").split(",").map((s) => s.trim()).filter(Boolean);
23
+ return [String(v !== null && v !== void 0 ? v : "")];
24
+ };
25
+ if (field.type === "multi-select")
26
+ return parseVal(projectValue).includes(filterValue);
27
+ if (field.type === "select")
28
+ return parseVal(projectValue)[0] === filterValue;
29
+ if (field.type === "text")
30
+ return String(projectValue || "").toLowerCase().includes(filterValue.toLowerCase());
31
+ if (field.type === "location") {
32
+ const loc = projectValue;
33
+ if (!loc)
34
+ return false;
35
+ return `${loc.city || ""} ${loc.state || ""}`.toLowerCase().includes(filterValue.toLowerCase());
36
+ }
37
+ return true;
38
+ }));
39
+ }, [projects, filters, schema]);
40
+ const handleFilterChange = (key, value) => setFilters((prev) => (Object.assign(Object.assign({}, prev), { [key]: value })));
41
+ const clearFilters = () => setFilters({});
42
+ const hasActiveFilters = Object.values(filters).some((v) => v);
43
+ return (_jsxs("div", { children: [filterableFields.length > 0 && (_jsx(ProjectFilters, { fields: filterableFields, filters: filters, onFilterChange: handleFilterChange, onClearFilters: clearFilters, hasActiveFilters: hasActiveFilters })), _jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "1.5rem" }, children: [_jsxs("p", { style: { fontSize: "14px", color: "#71717a", margin: 0 }, children: [filteredProjects.length, " ", filteredProjects.length === 1 ? "project" : "projects"] }), _jsxs("div", { style: { display: "flex", alignItems: "center", gap: "4px", border: "1px solid #e4e4e7", borderRadius: "8px", padding: "4px", backgroundColor: "#fff" }, children: [_jsx("button", { onClick: () => setVariant("card"), "aria-label": "Card view", style: {
44
+ padding: "6px",
45
+ border: "none",
46
+ borderRadius: "6px",
47
+ cursor: "pointer",
48
+ backgroundColor: variant === "card" ? "#18181b" : "transparent",
49
+ color: variant === "card" ? "#fff" : "#a1a1aa",
50
+ display: "flex",
51
+ alignItems: "center",
52
+ transition: "background-color 0.2s",
53
+ }, children: _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [_jsx("rect", { x: "3", y: "3", width: "7", height: "7" }), _jsx("rect", { x: "14", y: "3", width: "7", height: "7" }), _jsx("rect", { x: "3", y: "14", width: "7", height: "7" }), _jsx("rect", { x: "14", y: "14", width: "7", height: "7" })] }) }), _jsx("button", { onClick: () => setVariant("compact"), "aria-label": "Compact view", style: {
54
+ padding: "6px",
55
+ border: "none",
56
+ borderRadius: "6px",
57
+ cursor: "pointer",
58
+ backgroundColor: variant === "compact" ? "#18181b" : "transparent",
59
+ color: variant === "compact" ? "#fff" : "#a1a1aa",
60
+ display: "flex",
61
+ alignItems: "center",
62
+ transition: "background-color 0.2s",
63
+ }, children: _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [_jsx("line", { x1: "3", y1: "6", x2: "21", y2: "6" }), _jsx("line", { x1: "3", y1: "12", x2: "21", y2: "12" }), _jsx("line", { x1: "3", y1: "18", x2: "21", y2: "18" })] }) })] })] }), filteredProjects.length === 0 ? (_jsxs("div", { style: { textAlign: "center", padding: "4rem 0" }, children: [_jsx("p", { style: { color: "#71717a", fontSize: "18px" }, children: "No projects found matching your filters." }), hasActiveFilters && (_jsx("button", { onClick: clearFilters, style: { marginTop: "1rem", background: "none", border: "none", textDecoration: "underline", cursor: "pointer", fontSize: "14px" }, children: "Clear all filters" }))] })) : variant === "compact" ? (_jsx("div", { style: { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))", gap: "12px" }, children: filteredProjects.map((project, index) => (_jsx(ProjectCard, { project: project, schema: schema, priority: index === 0, variant: "compact", basePath: basePath }, project.id))) })) : (_jsx("div", { style: { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))", gap: "2rem" }, children: filteredProjects.map((project, index) => (_jsx(ProjectCard, { project: project, schema: schema, priority: index === 0, variant: "card", basePath: basePath }, project.id))) }))] }));
64
+ }
@@ -0,0 +1,30 @@
1
+ export interface ProjectPortfolioProps {
2
+ /** Client slug identifying which client's projects to load */
3
+ clientSlug: string;
4
+ /** Base URL of the projects API */
5
+ apiBase: string;
6
+ /** Optional title override; falls back to the client name from the API */
7
+ title?: string;
8
+ /** Base path for project detail links. Defaults to "/projects" */
9
+ basePath?: string;
10
+ }
11
+ /**
12
+ * ProjectPortfolio — self-contained async server component.
13
+ *
14
+ * Drop it into any Next.js App Router page:
15
+ *
16
+ * import { ProjectPortfolio } from "project-portfolio"
17
+ *
18
+ * export default function Page() {
19
+ * return (
20
+ * <ProjectPortfolio
21
+ * clientSlug="my-client"
22
+ * apiBase="https://your-api.com"
23
+ * />
24
+ * )
25
+ * }
26
+ *
27
+ * Requirements: Next.js 13+ App Router, React 18+
28
+ */
29
+ export declare function ProjectPortfolio({ clientSlug, apiBase, title, basePath, }: ProjectPortfolioProps): Promise<import("react/jsx-runtime").JSX.Element>;
30
+ //# sourceMappingURL=ProjectPortfolio.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ProjectPortfolio.d.ts","sourceRoot":"","sources":["../src/ProjectPortfolio.tsx"],"names":[],"mappings":"AAGA,MAAM,WAAW,qBAAqB;IACpC,8DAA8D;IAC9D,UAAU,EAAE,MAAM,CAAA;IAClB,mCAAmC;IACnC,OAAO,EAAE,MAAM,CAAA;IACf,0EAA0E;IAC1E,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,kEAAkE;IAClE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAgED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,gBAAgB,CAAC,EACrC,UAAU,EACV,OAAO,EACP,KAAK,EACL,QAAsB,GACvB,EAAE,qBAAqB,oDAuCvB"}
@@ -0,0 +1,59 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ProjectGrid } from "./ProjectGrid";
3
+ const fetchPortfolioData = async (apiBase, clientSlug) => {
4
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
5
+ const revalidateInit = { next: { revalidate: 60 } };
6
+ const [clientRes, projectsRes] = await Promise.all([
7
+ fetch(`${apiBase}/api/v1/clients/${clientSlug}`, revalidateInit),
8
+ fetch(`${apiBase}/api/v1/clients/${clientSlug}/projects`, revalidateInit),
9
+ ]);
10
+ const clientJson = clientRes.ok
11
+ ? await clientRes.json()
12
+ : { data: { name: "Projects", description: null, custom_fields_schema: [] } };
13
+ const projectsJson = projectsRes.ok
14
+ ? await projectsRes.json()
15
+ : { client: { custom_fields_schema: [] }, data: [] };
16
+ // Deduplicate schema keys
17
+ const seen = new Set();
18
+ const schema = ((_d = (_b = (_a = projectsJson.client) === null || _a === void 0 ? void 0 : _a.custom_fields_schema) !== null && _b !== void 0 ? _b : (_c = clientJson.data) === null || _c === void 0 ? void 0 : _c.custom_fields_schema) !== null && _d !== void 0 ? _d : []).filter((f) => {
19
+ if (seen.has(f.key))
20
+ return false;
21
+ seen.add(f.key);
22
+ return true;
23
+ });
24
+ return {
25
+ clientName: (_f = (_e = clientJson.data) === null || _e === void 0 ? void 0 : _e.name) !== null && _f !== void 0 ? _f : "Projects",
26
+ clientDescription: (_h = (_g = clientJson.data) === null || _g === void 0 ? void 0 : _g.description) !== null && _h !== void 0 ? _h : null,
27
+ projects: (_j = projectsJson.data) !== null && _j !== void 0 ? _j : [],
28
+ schema,
29
+ };
30
+ };
31
+ /**
32
+ * ProjectPortfolio — self-contained async server component.
33
+ *
34
+ * Drop it into any Next.js App Router page:
35
+ *
36
+ * import { ProjectPortfolio } from "project-portfolio"
37
+ *
38
+ * export default function Page() {
39
+ * return (
40
+ * <ProjectPortfolio
41
+ * clientSlug="my-client"
42
+ * apiBase="https://your-api.com"
43
+ * />
44
+ * )
45
+ * }
46
+ *
47
+ * Requirements: Next.js 13+ App Router, React 18+
48
+ */
49
+ export async function ProjectPortfolio({ clientSlug, apiBase, title, basePath = "/projects", }) {
50
+ const { clientName, clientDescription, projects, schema } = await fetchPortfolioData(apiBase, clientSlug);
51
+ return (_jsxs("main", { style: { minHeight: "100vh", backgroundColor: "#fafafa" }, children: [_jsx("header", { style: {
52
+ borderBottom: "1px solid #e4e4e7",
53
+ backgroundColor: "rgba(255,255,255,0.95)",
54
+ backdropFilter: "blur(8px)",
55
+ position: "sticky",
56
+ top: 0,
57
+ zIndex: 50,
58
+ }, children: _jsxs("div", { style: { maxWidth: "1280px", margin: "0 auto", padding: "1.5rem 1rem" }, children: [_jsx("h1", { style: { fontSize: "24px", fontWeight: 700, margin: 0, color: "#18181b" }, children: title !== null && title !== void 0 ? title : clientName }), clientDescription && (_jsx("p", { style: { color: "#71717a", marginTop: "4px", marginBottom: 0 }, children: clientDescription }))] }) }), _jsx("div", { style: { maxWidth: "1280px", margin: "0 auto", padding: "2rem 1rem" }, children: projects.length > 0 ? (_jsx(ProjectGrid, { projects: projects, schema: schema, basePath: basePath })) : (_jsx("div", { style: { textAlign: "center", padding: "4rem 0" }, children: _jsx("p", { style: { color: "#71717a" }, children: "No projects found." }) })) })] }));
59
+ }
@@ -0,0 +1,7 @@
1
+ export { ProjectPortfolio } from "./ProjectPortfolio";
2
+ export type { ProjectPortfolioProps } from "./ProjectPortfolio";
3
+ export { ProjectGrid } from "./ProjectGrid";
4
+ export { ProjectCard } from "./ProjectCard";
5
+ export type { CardVariant } from "./ProjectCard";
6
+ export type { Project, CustomFieldSchema, CustomFieldValue, LocationValue, Media, } from "./types";
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AACrD,YAAY,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAA;AAC/D,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAC3C,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAC3C,YAAY,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAChD,YAAY,EACV,OAAO,EACP,iBAAiB,EACjB,gBAAgB,EAChB,aAAa,EACb,KAAK,GACN,MAAM,SAAS,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { ProjectPortfolio } from "./ProjectPortfolio";
2
+ export { ProjectGrid } from "./ProjectGrid";
3
+ export { ProjectCard } from "./ProjectCard";
@@ -0,0 +1,38 @@
1
+ export interface CustomFieldSchema {
2
+ key: string;
3
+ name: string;
4
+ type: "text" | "number" | "select" | "multi-select" | "location";
5
+ options: string[];
6
+ option_urls?: Record<string, string>;
7
+ is_filterable: boolean;
8
+ sort_order: number;
9
+ display_position?: "badge_overlay" | "tags" | "metadata" | "hidden";
10
+ }
11
+ export interface Media {
12
+ id: string;
13
+ project_id: string;
14
+ url: string;
15
+ alt: string;
16
+ is_primary: boolean;
17
+ sort_order: number;
18
+ }
19
+ export interface LocationValue {
20
+ city?: string;
21
+ state?: string;
22
+ }
23
+ export type CustomFieldValue = string | number | string[] | LocationValue | null;
24
+ export interface Project {
25
+ id: string;
26
+ title: string;
27
+ slug: string;
28
+ blurb: string;
29
+ description: string;
30
+ image_url: string | null;
31
+ is_featured: boolean;
32
+ is_published?: boolean;
33
+ custom_field_values: Record<string, CustomFieldValue>;
34
+ created_at: string;
35
+ updated_at: string;
36
+ media: Media[];
37
+ }
38
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,iBAAiB;IAChC,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,GAAG,QAAQ,GAAG,QAAQ,GAAG,cAAc,GAAG,UAAU,CAAA;IAChE,OAAO,EAAE,MAAM,EAAE,CAAA;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACpC,aAAa,EAAE,OAAO,CAAA;IACtB,UAAU,EAAE,MAAM,CAAA;IAClB,gBAAgB,CAAC,EAAE,eAAe,GAAG,MAAM,GAAG,UAAU,GAAG,QAAQ,CAAA;CACpE;AAED,MAAM,WAAW,KAAK;IACpB,EAAE,EAAE,MAAM,CAAA;IACV,UAAU,EAAE,MAAM,CAAA;IAClB,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,MAAM,CAAA;IACX,UAAU,EAAE,OAAO,CAAA;IACnB,UAAU,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,EAAE,GAAG,aAAa,GAAG,IAAI,CAAA;AAEhF,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;IACnB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,WAAW,EAAE,OAAO,CAAA;IACpB,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,mBAAmB,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAA;IACrD,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;IAClB,KAAK,EAAE,KAAK,EAAE,CAAA;CACf"}
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "project-portfolio",
3
+ "version": "1.0.0",
4
+ "description": "Self-contained project portfolio component for Next.js App Router. Drop in one component, pass a clientSlug and apiBase, done.",
5
+ "keywords": ["nextjs", "react", "portfolio", "projects"],
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "main": "./dist/index.js",
9
+ "module": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "import": "./dist/index.js",
14
+ "types": "./dist/index.d.ts"
15
+ }
16
+ },
17
+ "files": ["dist"],
18
+ "scripts": {
19
+ "build": "tsc",
20
+ "prepublishOnly": "npm run build"
21
+ },
22
+ "peerDependencies": {
23
+ "next": ">=13.0.0",
24
+ "react": ">=18.0.0",
25
+ "react-dom": ">=18.0.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/react": "^18.0.0",
29
+ "@types/react-dom": "^18.0.0",
30
+ "typescript": "^5.0.0"
31
+ }
32
+ }