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 +67 -0
- package/dist/ProjectCard.d.ts +13 -0
- package/dist/ProjectCard.d.ts.map +1 -0
- package/dist/ProjectCard.js +128 -0
- package/dist/ProjectFilters.d.ts +11 -0
- package/dist/ProjectFilters.d.ts.map +1 -0
- package/dist/ProjectFilters.js +49 -0
- package/dist/ProjectGrid.d.ts +10 -0
- package/dist/ProjectGrid.d.ts.map +1 -0
- package/dist/ProjectGrid.js +64 -0
- package/dist/ProjectPortfolio.d.ts +30 -0
- package/dist/ProjectPortfolio.d.ts.map +1 -0
- package/dist/ProjectPortfolio.js +59 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/types.d.ts +38 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +32 -0
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
package/dist/types.d.ts
ADDED
|
@@ -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
|
+
}
|