@mevdragon/vidfarm-devcli 0.18.1 → 0.19.1
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/.agents/skills/editor-capabilities/SKILL.md +166 -0
- package/.agents/skills/editor-capabilities/references/re-theme-walkthrough.md +58 -0
- package/.agents/skills/vidfarm-media/SKILL.md +2 -0
- package/SKILL.director.md +174 -16
- package/SKILL.platform.md +7 -3
- package/demo/dist/app.css +1 -1
- package/demo/dist/app.js +79 -75
- package/dist/src/account-pages-legacy.js +1 -1
- package/dist/src/app.js +1694 -217
- package/dist/src/cli.js +1365 -105
- package/dist/src/config.js +8 -0
- package/dist/src/devcli/clips.js +3 -0
- package/dist/src/devcli/composition-edit.js +401 -10
- package/dist/src/devcli/local-backend.js +123 -0
- package/dist/src/devcli/migrate-local.js +140 -0
- package/dist/src/devcli/sync.js +311 -0
- package/dist/src/devcli/timeline-edit.js +208 -1
- package/dist/src/editor-chat.js +37 -18
- package/dist/src/frontend/file-directory.js +271 -20
- package/dist/src/frontend/homepage-view.js +3 -3
- package/dist/src/frontend/template-editor-chat.js +6 -3
- package/dist/src/page-shell.js +1 -1
- package/dist/src/primitive-context.js +31 -2
- package/dist/src/primitive-registry.js +409 -4
- package/dist/src/reskin/chat-page.js +103 -15
- package/dist/src/reskin/discover-page.js +247 -10
- package/dist/src/reskin/document.js +147 -10
- package/dist/src/reskin/inpaint-clipper-page.js +649 -0
- package/dist/src/reskin/inpaint-page.js +2459 -452
- package/dist/src/reskin/inpaint-video-page.js +1339 -0
- package/dist/src/reskin/library-page.js +324 -82
- package/dist/src/reskin/portfolio-page.js +687 -0
- package/dist/src/reskin/theme.js +36 -11
- package/dist/src/services/billing.js +4 -0
- package/dist/src/services/clip-curation/hunt.js +2 -0
- package/dist/src/services/clip-records.js +28 -0
- package/dist/src/services/file-directory.js +6 -3
- package/dist/src/services/hyperframes.js +535 -86
- package/dist/src/services/serverless-jobs.js +3 -1
- package/dist/src/services/serverless-records.js +43 -0
- package/dist/src/services/storage.js +24 -2
- package/dist/src/template-editor-pages.js +1 -1
- package/package.json +1 -1
- package/public/assets/file-directory-app.js +2 -2
- package/public/assets/homepage-client-app.js +1 -1
- package/public/assets/page-runtime-client-app.js +2 -2
- package/public/assets/placeholders/scene-placeholder.png +0 -0
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
// /portfolio/:slug — a public-facing showcase for a creator / video agency.
|
|
2
|
+
//
|
|
3
|
+
// A standalone farmville-styled page (see .claude/skills/farmville-saas-ux) that
|
|
4
|
+
// shows off a body of work: portfolio title + quick info, an author highlight
|
|
5
|
+
// that stays sticky with a "Contact" CTA (opens in a new tab), and the reel
|
|
6
|
+
// itself as rows of "video on the left, title + description on the right".
|
|
7
|
+
//
|
|
8
|
+
// All data is HARDCODED in the PORTFOLIOS map below (keyed by slug) — there is
|
|
9
|
+
// no backend. Swap the entries (or point videoUrl/avatarUrl at real assets) to
|
|
10
|
+
// publish a real portfolio. Every field is escaped on render.
|
|
11
|
+
//
|
|
12
|
+
// Interactions (self-contained inline script, no bundle):
|
|
13
|
+
// • Click any reel row → opens a fullscreen TikTok-style vertical scroll deck
|
|
14
|
+
// (wheel / swipe / ↑↓ keys / nav buttons between videos), autoplaying only
|
|
15
|
+
// the slide in view, sound toggle, ESC / scrim to close.
|
|
16
|
+
// • The modal slide keeps the same "video left, title+description right" split
|
|
17
|
+
// on desktop/tablet; on phones it collapses to just the video with a compact
|
|
18
|
+
// caption strip, exactly like the request asked.
|
|
19
|
+
import { reskinDocument, rkEscape } from "./document.js";
|
|
20
|
+
// Where the "Contact" and "Start Free Trial" CTAs point (ZoomGTM collab funnel).
|
|
21
|
+
const CTA_URL = "https://catalog.zoomgtm.com/tiktok-collab-quiz";
|
|
22
|
+
// Placeholder avatar for the example portfolios.
|
|
23
|
+
const PROFILE_PIC = "https://rimage.gnst.jp/livejapan.com/public/article/detail/a/00/03/a0003572/img/basic/a0003572_main.jpg";
|
|
24
|
+
// Real short-form videos reused from the live /discover/feed (public S3 media).
|
|
25
|
+
const FEED_BASE = "https://vidfarmprodstack-vidfarmbucket335ee12f-0vsvtd5earqy.s3.us-east-1.amazonaws.com/primitives/primitive%3Avideo_download/users/cus_2cada8a884204713b9eb614b7ca1f50d/jobs";
|
|
26
|
+
const feedVideo = (jobId) => `${FEED_BASE}/${jobId}/source-video.mp4`;
|
|
27
|
+
const SAMPLE = {
|
|
28
|
+
bunny: feedVideo("job_c3ef70d83ec3414093213e7f100051bb"), // Stop motion
|
|
29
|
+
fun: feedVideo("job_2b012397282b49df835cb4b503e52139"), // Memoirs
|
|
30
|
+
joyrides: feedVideo("job_90a4f42224e04f85b2751f94da31ea61"), // Audiogram
|
|
31
|
+
escapes: feedVideo("job_0dae0ea1abdc46899ff6e8d1ee0ed12f"), // Podcast clip
|
|
32
|
+
blazes: feedVideo("job_bdfe356b33be4065a1e10431fbe53bdc"), // Motion graphics
|
|
33
|
+
meltdowns: feedVideo("job_bc20ac00e44b48e98a533e86acd1f33c"), // 2D character animation
|
|
34
|
+
sintel: feedVideo("job_53b10c76e3fc46cdb8b9263c04d2716c"), // Diary Confession
|
|
35
|
+
elephants: feedVideo("job_6341497d068b472e93e5223d796af901") // Montage Video
|
|
36
|
+
};
|
|
37
|
+
// ── the hardcoded portfolios ─────────────────────────────────────────────────
|
|
38
|
+
export const PORTFOLIOS = {
|
|
39
|
+
"sunny-studio": {
|
|
40
|
+
slug: "sunny-studio",
|
|
41
|
+
title: "Sunny Studio — Short-form that sells",
|
|
42
|
+
tagline: "A boutique video studio turning brands into scroll-stopping short-form. 40+ ads shipped, 12M+ organic views.",
|
|
43
|
+
about: "We script, shoot, and edit high-retention vertical video for DTC and SaaS brands. Every cut below started as a rough brief and shipped in under a week. Hover to preview, tap any piece to watch it full-screen.",
|
|
44
|
+
author: {
|
|
45
|
+
name: "Mia Okonkwo",
|
|
46
|
+
role: "Founder & lead editor · Sunny Studio",
|
|
47
|
+
avatarUrl: PROFILE_PIC,
|
|
48
|
+
bio: "Ex-agency editor now running a two-person studio. I obsess over the first 3 seconds, punchy pacing, and captions that carry the story even on mute.",
|
|
49
|
+
location: "Lisbon · working worldwide",
|
|
50
|
+
availableForHire: true,
|
|
51
|
+
stats: [
|
|
52
|
+
{ label: "Videos shipped", value: "140+" },
|
|
53
|
+
{ label: "Starting from", value: "$75/hr" },
|
|
54
|
+
{ label: "Client rating", value: "4.9/5" }
|
|
55
|
+
],
|
|
56
|
+
certifications: [
|
|
57
|
+
{ title: "Scroll-Stopping Hooks: The First 3 Seconds", hours: 2, grade: 95 },
|
|
58
|
+
{ title: "Retention Editing Masterclass", hours: 44, grade: 91 },
|
|
59
|
+
{ title: "Short-Form Story Structure & Pacing", hours: 48, grade: 88 },
|
|
60
|
+
{ title: "Caption Craft: Kinetic Type on Mute", hours: 5, grade: 78 }
|
|
61
|
+
]
|
|
62
|
+
},
|
|
63
|
+
videos: [
|
|
64
|
+
{
|
|
65
|
+
id: "v1",
|
|
66
|
+
title: "Skincare launch — 3-second hook test",
|
|
67
|
+
description: "A pattern-interrupt open, tight jump cuts, and a bold price reveal. Cut three ways to A/B the opening line — the winner ran as the brand's evergreen ad.",
|
|
68
|
+
videoUrl: SAMPLE.fun,
|
|
69
|
+
meta: "0:24 · Paid ad",
|
|
70
|
+
tags: ["DTC", "Paid social", "Hook test"]
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "v2",
|
|
74
|
+
title: "SaaS explainer — feature in 20 seconds",
|
|
75
|
+
description: "Screen-recording b-roll dressed up with kinetic captions and a calm VO. Turns a dry feature into a story a founder can post on launch day.",
|
|
76
|
+
videoUrl: SAMPLE.joyrides,
|
|
77
|
+
meta: "0:21 · Explainer",
|
|
78
|
+
tags: ["SaaS", "Kinetic captions"]
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: "v3",
|
|
82
|
+
title: "Founder talking-head recut",
|
|
83
|
+
description: "One long ramble, recut into a tight 30-second story with on-screen callouts and lower-thirds. Same footage, 4× the watch time.",
|
|
84
|
+
videoUrl: SAMPLE.escapes,
|
|
85
|
+
meta: "0:31 · Recut",
|
|
86
|
+
tags: ["Talking head", "Retention edit"]
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: "v4",
|
|
90
|
+
title: "Product drop teaser",
|
|
91
|
+
description: "Moody, fast, and loop-friendly. Built to autoplay silently in-feed and still land the message in the last frame.",
|
|
92
|
+
videoUrl: SAMPLE.blazes,
|
|
93
|
+
meta: "0:15 · Teaser",
|
|
94
|
+
tags: ["Launch", "Loop"]
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: "v5",
|
|
98
|
+
title: "UGC-style testimonial polish",
|
|
99
|
+
description: "Raw phone footage color-matched, captioned, and paced into a believable, high-converting testimonial ad.",
|
|
100
|
+
videoUrl: SAMPLE.meltdowns,
|
|
101
|
+
meta: "0:28 · UGC",
|
|
102
|
+
tags: ["UGC", "Testimonial"]
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: "v6",
|
|
106
|
+
title: "Brand sizzle reel",
|
|
107
|
+
description: "A 60-second highlight montage cut to music for the top of a landing page — energy first, details second.",
|
|
108
|
+
videoUrl: SAMPLE.bunny,
|
|
109
|
+
meta: "1:00 · Reel",
|
|
110
|
+
tags: ["Sizzle", "Montage"]
|
|
111
|
+
}
|
|
112
|
+
]
|
|
113
|
+
},
|
|
114
|
+
"aria-films": {
|
|
115
|
+
slug: "aria-films",
|
|
116
|
+
title: "Aria Films — Cinematic stories, vertical-first",
|
|
117
|
+
tagline: "Narrative-driven brand films and docu-style shorts. Featured by three creator funds.",
|
|
118
|
+
about: "Aria makes short films that happen to sell. We lean into story, sound design, and restraint. Below is a selection of recent work — tap to watch full-screen.",
|
|
119
|
+
author: {
|
|
120
|
+
name: "Daniel Reyes",
|
|
121
|
+
role: "Director & editor · Aria Films",
|
|
122
|
+
avatarUrl: PROFILE_PIC,
|
|
123
|
+
bio: "Documentary background, now directing brand shorts. I care about tension, quiet moments, and endings that make people rewatch.",
|
|
124
|
+
location: "Mexico City",
|
|
125
|
+
availableForHire: true,
|
|
126
|
+
stats: [
|
|
127
|
+
{ label: "Films directed", value: "60+" },
|
|
128
|
+
{ label: "Starting from", value: "$120/hr" },
|
|
129
|
+
{ label: "Repeat clients", value: "80%" }
|
|
130
|
+
],
|
|
131
|
+
certifications: [
|
|
132
|
+
{ title: "Cinematic Vertical Storytelling", hours: 52, grade: 94 },
|
|
133
|
+
{ title: "Directing Talking-Head & Interview", hours: 8, grade: 89 },
|
|
134
|
+
{ title: "Color & Mood for Short Films", hours: 40, grade: 96 },
|
|
135
|
+
{ title: "Sound Design for Emotional Impact", hours: 3, grade: 82 }
|
|
136
|
+
]
|
|
137
|
+
},
|
|
138
|
+
videos: [
|
|
139
|
+
{
|
|
140
|
+
id: "a1",
|
|
141
|
+
title: "Coffee roaster origin short",
|
|
142
|
+
description: "A two-minute origin story shot on location, cut down to a punchy vertical for the brand's pinned post.",
|
|
143
|
+
videoUrl: SAMPLE.sintel,
|
|
144
|
+
meta: "0:52 · Brand film",
|
|
145
|
+
tags: ["Docu", "Origin story"]
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
id: "a2",
|
|
149
|
+
title: "Nonprofit awareness spot",
|
|
150
|
+
description: "Quiet, human, and unhurried. Built to make people stop scrolling and feel something before the ask.",
|
|
151
|
+
videoUrl: SAMPLE.elephants,
|
|
152
|
+
meta: "0:40 · Spot",
|
|
153
|
+
tags: ["Nonprofit", "Narrative"]
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
id: "a3",
|
|
157
|
+
title: "Fashion lookbook motion piece",
|
|
158
|
+
description: "Slow-motion b-roll and rhythmic cuts synced to a custom track for a seasonal drop.",
|
|
159
|
+
videoUrl: SAMPLE.escapes,
|
|
160
|
+
meta: "0:33 · Lookbook",
|
|
161
|
+
tags: ["Fashion", "Motion"]
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: "a4",
|
|
165
|
+
title: "Behind-the-scenes cutdown",
|
|
166
|
+
description: "The making-of, recut into a satisfying loop that doubled as social proof for the main campaign.",
|
|
167
|
+
videoUrl: SAMPLE.joyrides,
|
|
168
|
+
meta: "0:26 · BTS",
|
|
169
|
+
tags: ["BTS", "Loop"]
|
|
170
|
+
}
|
|
171
|
+
]
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
/** The slug used when someone hits bare /portfolio. */
|
|
175
|
+
export const DEFAULT_PORTFOLIO_SLUG = "sunny-studio";
|
|
176
|
+
// ── icons ────────────────────────────────────────────────────────────────────
|
|
177
|
+
const PLAY_ICON = `<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5.4v13.2L19 12z"/></svg>`;
|
|
178
|
+
const CROSS_ICON = `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l12 12M18 6L6 18"/></svg>`;
|
|
179
|
+
const CHEV_UP_ICON = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m5 14.5 7-7 7 7"/></svg>`;
|
|
180
|
+
const CHEV_DOWN_ICON = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m5 9.5 7 7 7-7"/></svg>`;
|
|
181
|
+
const SOUND_ON_ICON = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M18.5 6a9 9 0 0 1 0 12"/></svg>`;
|
|
182
|
+
const SOUND_OFF_ICON = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="m16 9.5 5 5M21 9.5l-5 5"/></svg>`;
|
|
183
|
+
const MAIL_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg>`;
|
|
184
|
+
// A filled "verified" seal (badge outline + inner check) — colored via CSS.
|
|
185
|
+
const VERIFIED_ICON = `<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 1.6l2.4 1.9 3 .1 1 2.9 2.4 1.8-.8 2.9.8 2.9-2.4 1.8-1 2.9-3 .1L12 22.4l-2.4-1.9-3-.1-1-2.9L3.2 15.7l.8-2.9-.8-2.9 2.4-1.8 1-2.9 3-.1z"/><path d="M10.6 14.6l-2.2-2.2-1.3 1.3 3.5 3.5 6-6-1.3-1.3z" fill="#fff"/></svg>`;
|
|
186
|
+
const CHECK_ICON = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4.5 12.5l5 5 10-11"/></svg>`;
|
|
187
|
+
// Award rosette for the Certified Expert credentials.
|
|
188
|
+
const CERT_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="8" r="6"/><path d="M8.2 13.3 7 22l5-3 5 3-1.2-8.7"/></svg>`;
|
|
189
|
+
const CAP_ICON = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 10 12 5 2 10l10 5 10-5Z"/><path d="M6 12v5c0 1 2.7 2.5 6 2.5s6-1.5 6-2.5v-5"/></svg>`;
|
|
190
|
+
/** Exam-grade qualifier from a 0–100 score. */
|
|
191
|
+
function gradeTier(g) {
|
|
192
|
+
if (g >= 90)
|
|
193
|
+
return { label: "Excellent", cls: "is-excellent" };
|
|
194
|
+
if (g >= 75)
|
|
195
|
+
return { label: "Good", cls: "is-good" };
|
|
196
|
+
return { label: "Passed", cls: "is-pass" };
|
|
197
|
+
}
|
|
198
|
+
/** monogram from a name (up to 2 initials). */
|
|
199
|
+
function monogram(name) {
|
|
200
|
+
const parts = String(name || "").trim().split(/\s+/).filter(Boolean);
|
|
201
|
+
const letters = parts.slice(0, 2).map((p) => p.charAt(0).toUpperCase()).join("");
|
|
202
|
+
return letters || "V";
|
|
203
|
+
}
|
|
204
|
+
/** Render one avatar (image if given, else a gold monogram tile). */
|
|
205
|
+
function avatarHtml(author, cls) {
|
|
206
|
+
if (author.avatarUrl) {
|
|
207
|
+
return `<span class="${cls}"><img src="${rkEscape(author.avatarUrl)}" alt="${rkEscape(author.name)}" loading="lazy" /></span>`;
|
|
208
|
+
}
|
|
209
|
+
return `<span class="${cls} pf-avatar-mono">${rkEscape(monogram(author.name))}</span>`;
|
|
210
|
+
}
|
|
211
|
+
/** Poster/video tile shared by reel rows + modal slides. */
|
|
212
|
+
function mediaTile(v, opts = {}) {
|
|
213
|
+
const poster = v.posterUrl ? ` poster="${rkEscape(v.posterUrl)}"` : "";
|
|
214
|
+
// muted + playsinline so grid rows can quietly autoplay-on-hover; the modal
|
|
215
|
+
// controls playback itself. preload metadata keeps the page light.
|
|
216
|
+
const auto = opts.autoplay ? " autoplay" : "";
|
|
217
|
+
return `<video class="pf-video" src="${rkEscape(v.videoUrl)}"${poster} muted loop playsinline preload="metadata"${auto}></video>`;
|
|
218
|
+
}
|
|
219
|
+
// A single reel row: video on the left, title + description on the right. The
|
|
220
|
+
// whole row is a button that opens the modal at this index.
|
|
221
|
+
function reelRow(v, index) {
|
|
222
|
+
const metaChip = v.meta ? `<span class="pf-media-chip">${rkEscape(v.meta)}</span>` : "";
|
|
223
|
+
const tags = (v.tags && v.tags.length)
|
|
224
|
+
? `<div class="pf-tags">${v.tags.map((t) => `<span class="pf-tag">${rkEscape(t)}</span>`).join("")}</div>`
|
|
225
|
+
: "";
|
|
226
|
+
return `<button type="button" class="pf-row rk-card-hover" data-pf-open="${index}" aria-label="Play ${rkEscape(v.title)}">
|
|
227
|
+
<span class="pf-row-media">
|
|
228
|
+
${mediaTile(v, { autoplay: true })}
|
|
229
|
+
${metaChip}
|
|
230
|
+
<span class="pf-row-play" aria-hidden="true">${PLAY_ICON}</span>
|
|
231
|
+
</span>
|
|
232
|
+
<span class="pf-row-body">
|
|
233
|
+
<span class="pf-row-index">${String(index + 1).padStart(2, "0")}</span>
|
|
234
|
+
<span class="pf-row-title">${rkEscape(v.title)}</span>
|
|
235
|
+
<span class="pf-row-desc">${rkEscape(v.description)}</span>
|
|
236
|
+
${tags}
|
|
237
|
+
</span>
|
|
238
|
+
</button>`;
|
|
239
|
+
}
|
|
240
|
+
// A modal slide: keeps the "video left / info right" split on desktop & tablet;
|
|
241
|
+
// phones collapse to the video with a compact caption strip (CSS-driven).
|
|
242
|
+
function modalSlide(v, index, total) {
|
|
243
|
+
const tags = (v.tags && v.tags.length)
|
|
244
|
+
? `<div class="pf-tags pf-tags-light">${v.tags.map((t) => `<span class="pf-tag">${rkEscape(t)}</span>`).join("")}</div>`
|
|
245
|
+
: "";
|
|
246
|
+
const meta = v.meta ? `<div class="pf-slide-meta">${rkEscape(v.meta)}</div>` : "";
|
|
247
|
+
return `<div class="pf-slide" data-pf-slide="${index}">
|
|
248
|
+
<div class="pf-slide-inner">
|
|
249
|
+
<div class="pf-slide-video">
|
|
250
|
+
${mediaTile(v)}
|
|
251
|
+
<div class="pf-slide-cap"><div class="pf-slide-cap-title">${rkEscape(v.title)}</div><div class="pf-slide-cap-desc">${rkEscape(v.description)}</div></div>
|
|
252
|
+
</div>
|
|
253
|
+
<div class="pf-slide-info">
|
|
254
|
+
<div class="pf-slide-count">${index + 1} / ${total}</div>
|
|
255
|
+
${meta}
|
|
256
|
+
<h3 class="pf-slide-title">${rkEscape(v.title)}</h3>
|
|
257
|
+
<p class="pf-slide-desc">${rkEscape(v.description)}</p>
|
|
258
|
+
${tags}
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
</div>`;
|
|
262
|
+
}
|
|
263
|
+
/** Renders the /portfolio/:slug page. Returns null when the slug is unknown so
|
|
264
|
+
* the route can 404. */
|
|
265
|
+
export function renderReskinPortfolio(slug, input = {}) {
|
|
266
|
+
const data = PORTFOLIOS[slug];
|
|
267
|
+
if (!data)
|
|
268
|
+
return null;
|
|
269
|
+
const { author, videos } = data;
|
|
270
|
+
const contactLabel = author.contactLabel || "Check Availability";
|
|
271
|
+
const contactHref = author.contactUrl || CTA_URL;
|
|
272
|
+
const statPills = (author.stats && author.stats.length)
|
|
273
|
+
? `<div class="pf-stats">${author.stats
|
|
274
|
+
.map((s) => `<div class="pf-stat"><span class="pf-stat-value">${rkEscape(s.value)}</span><span class="pf-stat-label">${rkEscape(s.label)}</span></div>`)
|
|
275
|
+
.join("")}</div>`
|
|
276
|
+
: "";
|
|
277
|
+
const locationLine = author.location
|
|
278
|
+
? `<div class="pf-author-loc"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg><span>${rkEscape(author.location)}</span></div>`
|
|
279
|
+
: "";
|
|
280
|
+
const availableLine = author.availableForHire
|
|
281
|
+
? `<div class="pf-badge pf-badge-gray">${CHECK_ICON}<span>Available for In-House Hire</span></div>`
|
|
282
|
+
: "";
|
|
283
|
+
// "Certified Expert" — the education/credential series. Each course row opens
|
|
284
|
+
// the same CTA funnel as "Start Free Trial" in a new tab.
|
|
285
|
+
const certs = author.certifications ?? [];
|
|
286
|
+
const certsBlock = certs.length
|
|
287
|
+
? `<div class="pf-certs">
|
|
288
|
+
<div class="pf-certs-head">${CAP_ICON}<span>Certified Expert</span></div>
|
|
289
|
+
<div class="pf-certs-list">
|
|
290
|
+
${certs
|
|
291
|
+
.map((cr) => {
|
|
292
|
+
const t = gradeTier(cr.grade);
|
|
293
|
+
const hrs = `${cr.hours} hour${cr.hours === 1 ? "" : "s"}`;
|
|
294
|
+
return `<a class="pf-cert" href="${CTA_URL}" target="_blank" rel="noopener noreferrer" title="View the VidFarm education series">
|
|
295
|
+
<span class="pf-cert-ic">${CERT_ICON}</span>
|
|
296
|
+
<span class="pf-cert-main">
|
|
297
|
+
<span class="pf-cert-title">${rkEscape(cr.title)}</span>
|
|
298
|
+
<span class="pf-cert-meta">${rkEscape(hrs)} + exam · <span class="pf-cert-grade ${t.cls}">${cr.grade}% ${rkEscape(t.label)}</span></span>
|
|
299
|
+
</span>
|
|
300
|
+
</a>`;
|
|
301
|
+
})
|
|
302
|
+
.join("")}
|
|
303
|
+
</div>
|
|
304
|
+
</div>`
|
|
305
|
+
: "";
|
|
306
|
+
// The real homepage top nav (floating frosted-glass pill), reproduced with
|
|
307
|
+
// reskin tokens so the public portfolio carries the same site chrome.
|
|
308
|
+
const topNav = `<header class="pf-topnav">
|
|
309
|
+
<nav class="pf-nav" aria-label="Primary">
|
|
310
|
+
<a class="pf-nav-brand" href="/"><span class="rk-brand-mark pf-nav-mark">V</span>VidFarm</a>
|
|
311
|
+
<ul class="pf-nav-links">
|
|
312
|
+
<li><a href="/discover">Templates</a></li>
|
|
313
|
+
<li><a href="/clips">Clips</a></li>
|
|
314
|
+
<li><a href="/chat">Brainstorm</a></li>
|
|
315
|
+
</ul>
|
|
316
|
+
<div class="pf-nav-cta">
|
|
317
|
+
<a class="pf-nav-quiet" href="/login">Log in</a>
|
|
318
|
+
<a class="pf-nav-btn" href="/discover">Get started <span class="rk-arrow">→</span></a>
|
|
319
|
+
</div>
|
|
320
|
+
</nav>
|
|
321
|
+
</header>`;
|
|
322
|
+
// The author highlight (sticky left rail on desktop, top of stack on mobile).
|
|
323
|
+
const authorCard = `<div class="pf-author-card rk-card">
|
|
324
|
+
<div class="pf-author-head">
|
|
325
|
+
${avatarHtml(author, "pf-avatar")}
|
|
326
|
+
<div class="pf-author-id">
|
|
327
|
+
<div class="pf-author-name">${rkEscape(author.name)}</div>
|
|
328
|
+
<div class="pf-author-role">${rkEscape(author.role)}</div>
|
|
329
|
+
</div>
|
|
330
|
+
</div>
|
|
331
|
+
${locationLine}
|
|
332
|
+
${availableLine}
|
|
333
|
+
<p class="pf-author-bio">${rkEscape(author.bio)}</p>
|
|
334
|
+
${certsBlock}
|
|
335
|
+
${statPills}
|
|
336
|
+
<a class="rk-btn rk-btn-gold pf-contact" href="${rkEscape(contactHref)}" target="_blank" rel="noopener noreferrer">
|
|
337
|
+
${MAIL_ICON}<span>${rkEscape(contactLabel)}</span>
|
|
338
|
+
</a>
|
|
339
|
+
<div class="pf-badge pf-badge-blue pf-author-foot">${VERIFIED_ICON}<span>Power User on VidFarm</span></div>
|
|
340
|
+
</div>`;
|
|
341
|
+
// Cardless VidFarm CTA below the author info (secondary button → collab funnel).
|
|
342
|
+
const ctaBlock = `<div class="pf-cta">
|
|
343
|
+
<p class="pf-cta-text">Looking to create videos for social media? Try VidFarm AI for high quality videos starting at <strong>$0.01</strong> each.</p>
|
|
344
|
+
<a class="rk-btn rk-btn-ghost pf-cta-btn" href="${CTA_URL}" target="_blank" rel="noopener noreferrer">Start Free Trial <span class="rk-arrow">→</span></a>
|
|
345
|
+
</div>`;
|
|
346
|
+
const railInner = `${authorCard}${ctaBlock}`;
|
|
347
|
+
const about = data.about ? `<p class="pf-about">${rkEscape(data.about)}</p>` : "";
|
|
348
|
+
const body = `
|
|
349
|
+
<main class="rk-container rk-page pf-page">
|
|
350
|
+
${topNav}
|
|
351
|
+
<div class="pf-layout">
|
|
352
|
+
<aside class="pf-rail" aria-label="About the creator">
|
|
353
|
+
<div class="pf-rail-inner">${railInner}</div>
|
|
354
|
+
</aside>
|
|
355
|
+
<div class="pf-main">
|
|
356
|
+
<header class="pf-head">
|
|
357
|
+
<span class="rk-eyebrow pf-eyebrow">Portfolio</span>
|
|
358
|
+
<h1 class="rk-h1 pf-title">${rkEscape(data.title)}</h1>
|
|
359
|
+
<p class="rk-sub pf-tagline">${rkEscape(data.tagline)}</p>
|
|
360
|
+
${about}
|
|
361
|
+
</header>
|
|
362
|
+
<section class="pf-reel" aria-label="Selected work">
|
|
363
|
+
${videos.map((v, i) => reelRow(v, i)).join("\n")}
|
|
364
|
+
</section>
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
|
|
368
|
+
<div class="pf-viewer" data-pf-viewer hidden role="dialog" aria-modal="true" aria-label="${rkEscape(data.title)} — video viewer">
|
|
369
|
+
<div class="pf-viewer-scrim" data-pf-close></div>
|
|
370
|
+
<div class="pf-viewer-track" data-pf-track>
|
|
371
|
+
${videos.map((v, i) => modalSlide(v, i, videos.length)).join("\n")}
|
|
372
|
+
</div>
|
|
373
|
+
<div class="pf-viewer-ui">
|
|
374
|
+
<button type="button" class="pf-viewer-btn" data-pf-mute aria-label="Toggle sound"><span class="pf-m-on">${SOUND_ON_ICON}</span><span class="pf-m-off">${SOUND_OFF_ICON}</span></button>
|
|
375
|
+
<button type="button" class="pf-viewer-btn" data-pf-close aria-label="Close viewer">${CROSS_ICON}</button>
|
|
376
|
+
</div>
|
|
377
|
+
<div class="pf-viewer-nav">
|
|
378
|
+
<button type="button" class="pf-viewer-btn" data-pf-prev aria-label="Previous video">${CHEV_UP_ICON}</button>
|
|
379
|
+
<button type="button" class="pf-viewer-btn" data-pf-next aria-label="Next video">${CHEV_DOWN_ICON}</button>
|
|
380
|
+
</div>
|
|
381
|
+
</div>
|
|
382
|
+
</main>`;
|
|
383
|
+
return reskinDocument({
|
|
384
|
+
// Signed-out, SEO-facing page: no left sidebar nav, no chat FAB/dock.
|
|
385
|
+
chrome: "bare",
|
|
386
|
+
title: `${data.title} — Portfolio`,
|
|
387
|
+
description: data.tagline,
|
|
388
|
+
activeSlug: null,
|
|
389
|
+
pageCss: PORTFOLIO_CSS,
|
|
390
|
+
script: PORTFOLIO_SCRIPT,
|
|
391
|
+
body
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
// ── page-scoped styles ────────────────────────────────────────────────────────
|
|
395
|
+
const PORTFOLIO_CSS = `
|
|
396
|
+
/* bare (no sidebar): the top nav owns the top air; container stays centered */
|
|
397
|
+
.pf-page{padding-top:18px;padding-bottom:72px}
|
|
398
|
+
|
|
399
|
+
/* homepage top nav — floating frosted-glass pill (reskin tokens), spaced below */
|
|
400
|
+
.pf-topnav{display:flex;justify-content:center;margin:0 0 40px}
|
|
401
|
+
.pf-nav{width:100%;display:flex;align-items:center;gap:24px;padding:10px 12px 10px 20px;border-radius:var(--rk-r-full);
|
|
402
|
+
background:rgba(255,255,255,.72);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);
|
|
403
|
+
border:1px solid var(--rk-border);box-shadow:0 4px 24px -8px #00000026,0 1px 2px #0000000d}
|
|
404
|
+
.pf-nav-brand{display:inline-flex;align-items:center;gap:9px;font-family:var(--rk-font-display);font-weight:800;font-size:21px;color:var(--rk-ink);letter-spacing:-.02em}
|
|
405
|
+
.pf-nav-mark{width:40px;height:40px;border-radius:11px;flex:none}
|
|
406
|
+
.pf-nav-links{display:flex;gap:2px;margin:0 auto;list-style:none;padding:0}
|
|
407
|
+
.pf-nav-links a{display:inline-flex;align-items:center;padding:8px 12px;border-radius:var(--rk-r-lg);font-size:15px;font-weight:500;color:var(--rk-n-700);transition:background var(--rk-dur) var(--rk-ease)}
|
|
408
|
+
.pf-nav-links a:hover{background:#0000000a;color:var(--rk-ink)}
|
|
409
|
+
.pf-nav-cta{display:flex;align-items:center;gap:6px}
|
|
410
|
+
.pf-nav-quiet{padding:8px 12px;font-size:15px;font-weight:500;color:var(--rk-n-700);border-radius:var(--rk-r-lg);transition:background var(--rk-dur) var(--rk-ease)}
|
|
411
|
+
.pf-nav-quiet:hover{background:#0000000a;color:var(--rk-ink)}
|
|
412
|
+
/* .pf-nav-cta prefix lifts specificity above RESKIN's .rk-root a{color:inherit}
|
|
413
|
+
(0,1,1) — a bare .pf-nav-btn (0,1,0) loses, so the white text goes dark. */
|
|
414
|
+
.pf-nav-cta .pf-nav-btn{display:inline-flex;align-items:center;gap:8px;padding:11px 20px;border-radius:var(--rk-r-full);font-size:15px;font-weight:600;color:#fff;background:var(--rk-ink);box-shadow:var(--rk-shadow-sm);white-space:nowrap;
|
|
415
|
+
transition:transform var(--rk-dur) var(--rk-ease),box-shadow var(--rk-dur) var(--rk-ease),background var(--rk-dur) var(--rk-ease)}
|
|
416
|
+
.pf-nav-cta .pf-nav-btn:hover{background:#000;color:#fff;box-shadow:var(--rk-shadow-md);transform:translateY(-1px)}
|
|
417
|
+
.pf-nav-btn .rk-arrow{transition:transform var(--rk-dur) var(--rk-ease)}
|
|
418
|
+
.pf-nav-btn:hover .rk-arrow{transform:translateX(3px)}
|
|
419
|
+
@media(max-width:820px){.pf-nav-links{display:none}}
|
|
420
|
+
@media(max-width:560px){.pf-topnav{margin-bottom:26px}.pf-nav{gap:12px}.pf-nav-quiet{display:none}}
|
|
421
|
+
/* two-column: sticky about rail on the LEFT (wider), reel on the right */
|
|
422
|
+
.pf-layout{display:grid;grid-template-columns:392px minmax(0,1fr);gap:44px;align-items:start}
|
|
423
|
+
.pf-main{min-width:0}
|
|
424
|
+
@media(max-width:980px){
|
|
425
|
+
.pf-layout{grid-template-columns:1fr;gap:26px}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/* ── page head ── */
|
|
429
|
+
.pf-head{margin-bottom:30px;display:grid;gap:8px}
|
|
430
|
+
.pf-eyebrow{color:var(--rk-gold-700)}
|
|
431
|
+
.pf-title{max-width:18ch}
|
|
432
|
+
.pf-tagline{max-width:60ch}
|
|
433
|
+
.pf-about{margin-top:6px;max-width:64ch;font-size:14.5px;color:var(--rk-text-muted);line-height:1.6}
|
|
434
|
+
|
|
435
|
+
/* ── sticky left rail (author + CTA) ── */
|
|
436
|
+
.pf-rail{position:sticky;top:22px;align-self:start}
|
|
437
|
+
@media(max-width:980px){.pf-rail{position:static}}
|
|
438
|
+
.pf-rail-inner{display:grid;gap:18px}
|
|
439
|
+
|
|
440
|
+
/* verified/available check badges */
|
|
441
|
+
.pf-badge{display:inline-flex;align-items:center;gap:7px;font-size:12px;font-weight:700;line-height:1.2;justify-self:start}
|
|
442
|
+
.pf-badge svg{flex:none}
|
|
443
|
+
.pf-badge-gray{color:var(--rk-n-600)}
|
|
444
|
+
.pf-badge-gray svg{color:var(--rk-n-400)}
|
|
445
|
+
.pf-badge-blue{color:var(--rk-ink)}
|
|
446
|
+
.pf-badge-blue svg{color:#2f80ed}
|
|
447
|
+
|
|
448
|
+
.pf-author-card{padding:22px;display:grid;gap:13px}
|
|
449
|
+
.pf-author-head{display:flex;align-items:center;gap:13px;min-width:0}
|
|
450
|
+
.pf-avatar{flex:none;width:56px;height:56px;border-radius:var(--rk-r-full);overflow:hidden;display:grid;place-items:center;
|
|
451
|
+
background:var(--rk-n-100);box-shadow:var(--rk-shadow-xs)}
|
|
452
|
+
.pf-avatar img{width:100%;height:100%;object-fit:cover;display:block}
|
|
453
|
+
.pf-avatar-mono{background:var(--rk-gold-500);color:var(--rk-ink);font-family:var(--rk-font-display);font-weight:800;font-size:1.25rem;letter-spacing:-.02em}
|
|
454
|
+
.pf-author-id{min-width:0}
|
|
455
|
+
.pf-author-name{font-family:var(--rk-font-display);font-weight:800;font-size:1.12rem;color:var(--rk-ink);letter-spacing:-.01em;line-height:1.15}
|
|
456
|
+
.pf-author-role{font-size:12.5px;font-weight:600;color:var(--rk-text-muted);margin-top:3px;line-height:1.3}
|
|
457
|
+
.pf-author-loc{display:inline-flex;align-items:center;gap:6px;font-size:12.5px;font-weight:600;color:var(--rk-text-faint)}
|
|
458
|
+
.pf-author-loc svg{color:var(--rk-gold-700)}
|
|
459
|
+
.pf-author-bio{font-size:13.5px;color:var(--rk-text-muted);line-height:1.55}
|
|
460
|
+
|
|
461
|
+
/* Certified Expert — education/credential list (each row → CTA funnel) */
|
|
462
|
+
.pf-certs{display:grid;gap:9px;padding-top:13px;border-top:1px solid var(--rk-border)}
|
|
463
|
+
.pf-certs-head{display:inline-flex;align-items:center;gap:7px;font-size:11px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:var(--rk-ink)}
|
|
464
|
+
.pf-certs-head svg{color:var(--rk-gold-700)}
|
|
465
|
+
.pf-certs-list{display:grid;gap:7px}
|
|
466
|
+
.pf-cert{display:flex;align-items:center;gap:10px;padding:9px 11px;border:1px solid var(--rk-border);border-radius:var(--rk-r-lg);background:var(--rk-n-50);color:inherit;
|
|
467
|
+
transition:border-color var(--rk-dur) var(--rk-ease),background var(--rk-dur) var(--rk-ease),transform var(--rk-dur) var(--rk-ease),box-shadow var(--rk-dur) var(--rk-ease)}
|
|
468
|
+
.pf-cert:hover{border-color:var(--rk-gold-600);background:var(--rk-gold-tint);transform:translateY(-1px);box-shadow:var(--rk-shadow-xs)}
|
|
469
|
+
.pf-cert-ic{flex:none;width:30px;height:30px;border-radius:8px;display:grid;place-items:center;background:#fff;border:1px solid var(--rk-border);color:var(--rk-gold-700)}
|
|
470
|
+
.pf-cert-main{display:grid;gap:2px;min-width:0}
|
|
471
|
+
.pf-cert-title{font-size:12.5px;font-weight:700;color:var(--rk-ink);line-height:1.25}
|
|
472
|
+
.pf-cert-meta{font-size:11px;font-weight:600;color:var(--rk-text-faint);line-height:1.35}
|
|
473
|
+
.pf-cert-grade{font-weight:800}
|
|
474
|
+
.pf-cert-grade.is-excellent{color:#1f7a4d}
|
|
475
|
+
.pf-cert-grade.is-good{color:var(--rk-gold-700)}
|
|
476
|
+
.pf-cert-grade.is-pass{color:var(--rk-text-muted)}
|
|
477
|
+
.pf-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;padding:4px 0 2px;border-top:1px solid var(--rk-border);padding-top:14px}
|
|
478
|
+
.pf-stat{display:grid;gap:2px;text-align:center}
|
|
479
|
+
.pf-stat-value{font-family:var(--rk-font-display);font-weight:800;font-size:1.02rem;color:var(--rk-ink);letter-spacing:-.01em}
|
|
480
|
+
.pf-stat-label{font-size:10.5px;font-weight:600;color:var(--rk-text-faint);text-transform:uppercase;letter-spacing:.04em;line-height:1.25}
|
|
481
|
+
.pf-contact{width:100%;justify-content:center;gap:8px;margin-top:2px}
|
|
482
|
+
.pf-contact svg{margin-top:-1px}
|
|
483
|
+
.pf-author-foot{justify-self:center;margin-top:1px;padding-top:12px;border-top:1px solid var(--rk-border);width:100%;justify-content:center}
|
|
484
|
+
|
|
485
|
+
/* cardless VidFarm CTA below the author info */
|
|
486
|
+
.pf-cta{display:grid;gap:12px;padding:2px 6px;justify-items:start}
|
|
487
|
+
.pf-cta-text{font-size:13.5px;color:var(--rk-text-muted);line-height:1.55}
|
|
488
|
+
.pf-cta-text strong{color:var(--rk-ink);font-weight:800}
|
|
489
|
+
.pf-cta-btn{align-self:start}
|
|
490
|
+
|
|
491
|
+
/* ── reel rows: video left, title + description right ── */
|
|
492
|
+
.pf-reel{display:grid;gap:18px}
|
|
493
|
+
.pf-row{display:grid;grid-template-columns:208px minmax(0,1fr);gap:24px;align-items:stretch;text-align:left;width:100%;
|
|
494
|
+
background:var(--rk-surface);border:1px solid var(--rk-border);border-radius:var(--rk-r-3xl);padding:14px;cursor:pointer;
|
|
495
|
+
box-shadow:var(--rk-shadow-card);transition:transform var(--rk-dur) var(--rk-ease),box-shadow var(--rk-dur) var(--rk-ease),border-color var(--rk-dur) var(--rk-ease)}
|
|
496
|
+
.pf-row:hover{transform:translateY(-2px);box-shadow:var(--rk-shadow-lg);border-color:var(--rk-border-strong)}
|
|
497
|
+
.pf-row:focus-visible{outline:none;border-color:var(--rk-gold-600);box-shadow:var(--rk-ring-gold)}
|
|
498
|
+
.pf-row-media{position:relative;aspect-ratio:9/16;border-radius:var(--rk-r-xl);overflow:hidden;background:#000;display:block}
|
|
499
|
+
.pf-video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;background:linear-gradient(150deg,var(--rk-n-200),var(--rk-n-100))}
|
|
500
|
+
.pf-media-chip{position:absolute;left:8px;top:8px;z-index:2;font-size:10.5px;font-weight:700;color:#fff;
|
|
501
|
+
background:rgba(0,0,0,.46);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);padding:3px 8px;border-radius:var(--rk-r-full)}
|
|
502
|
+
.pf-row-play{position:absolute;inset:0;z-index:2;display:grid;place-items:center;color:#fff;opacity:0;
|
|
503
|
+
background:rgba(0,0,0,.22);transition:opacity var(--rk-dur) var(--rk-ease)}
|
|
504
|
+
.pf-row-play svg{width:34px;height:34px;filter:drop-shadow(0 3px 10px rgba(0,0,0,.5))}
|
|
505
|
+
.pf-row:hover .pf-row-play,.pf-row:focus-visible .pf-row-play{opacity:1}
|
|
506
|
+
.pf-row-body{display:grid;gap:8px;align-content:center;min-width:0;padding:4px 8px 4px 0;position:relative}
|
|
507
|
+
.pf-row-index{font-family:var(--rk-font-display);font-weight:800;font-size:12px;color:var(--rk-gold-700);letter-spacing:.06em}
|
|
508
|
+
.pf-row-title{font-family:var(--rk-font-display);font-weight:700;font-size:1.14rem;color:var(--rk-ink);letter-spacing:-.01em;line-height:1.2}
|
|
509
|
+
.pf-row-desc{font-size:13.5px;color:var(--rk-text-muted);line-height:1.55;
|
|
510
|
+
display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
|
|
511
|
+
.pf-tags{display:flex;flex-wrap:wrap;gap:6px;margin-top:2px}
|
|
512
|
+
.pf-tag{font-size:11px;font-weight:700;color:var(--rk-n-600);background:var(--rk-n-100);border:1px solid var(--rk-border);
|
|
513
|
+
padding:3px 9px;border-radius:var(--rk-r-full)}
|
|
514
|
+
.pf-tags-light .pf-tag{color:rgba(255,255,255,.9);background:rgba(255,255,255,.12);border-color:rgba(255,255,255,.18)}
|
|
515
|
+
/* mobile: compact — smaller video, text wraps under naturally (grid stays 2-col but tighter) */
|
|
516
|
+
@media(max-width:560px){
|
|
517
|
+
.pf-row{grid-template-columns:132px minmax(0,1fr);gap:16px;padding:12px;border-radius:var(--rk-r-2xl)}
|
|
518
|
+
.pf-row-title{font-size:1.02rem}
|
|
519
|
+
.pf-row-desc{-webkit-line-clamp:2;font-size:12.5px}
|
|
520
|
+
}
|
|
521
|
+
@media(max-width:400px){
|
|
522
|
+
.pf-row{grid-template-columns:108px minmax(0,1fr);gap:13px}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/* ── fullscreen TikTok-style viewer ── */
|
|
526
|
+
html.pf-noscroll,html.pf-noscroll body{overflow:hidden}
|
|
527
|
+
.pf-viewer{position:fixed;inset:0;z-index:130}
|
|
528
|
+
.pf-viewer[hidden]{display:none}
|
|
529
|
+
.pf-viewer-scrim{position:absolute;inset:0;background:rgba(15,12,5,.9);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px)}
|
|
530
|
+
.pf-viewer-track{position:absolute;inset:0;overflow-y:auto;scroll-snap-type:y mandatory;overscroll-behavior:contain;scrollbar-width:none}
|
|
531
|
+
.pf-viewer-track::-webkit-scrollbar{display:none}
|
|
532
|
+
.pf-slide{height:100%;display:grid;place-items:center;scroll-snap-align:start;scroll-snap-stop:always;padding:24px}
|
|
533
|
+
/* the split: video left, info panel right */
|
|
534
|
+
.pf-slide-inner{display:flex;align-items:center;justify-content:center;gap:34px;max-width:1100px;width:100%;height:100%}
|
|
535
|
+
.pf-slide-video{position:relative;flex:none;height:min(86vh,760px);aspect-ratio:9/16;border-radius:var(--rk-r-3xl);overflow:hidden;
|
|
536
|
+
background:#000;box-shadow:var(--rk-shadow-xl)}
|
|
537
|
+
.pf-slide-video .pf-video{object-fit:contain;background:#000}
|
|
538
|
+
.pf-slide-info{flex:1;max-width:380px;color:#fff;display:grid;gap:14px;align-content:center}
|
|
539
|
+
.pf-slide-count{font-size:11px;font-weight:800;letter-spacing:.14em;text-transform:uppercase;color:var(--rk-gold-500)}
|
|
540
|
+
.pf-slide-meta{font-size:12.5px;font-weight:700;color:rgba(255,255,255,.66);letter-spacing:.02em}
|
|
541
|
+
.pf-slide-title{font-family:var(--rk-font-display);font-weight:800;font-size:1.7rem;line-height:1.14;letter-spacing:-.02em;color:#fff}
|
|
542
|
+
.pf-slide-desc{font-size:14.5px;line-height:1.6;color:rgba(255,255,255,.82)}
|
|
543
|
+
/* caption strip inside the video — only shown when the side panel is hidden (phones) */
|
|
544
|
+
.pf-slide-cap{position:absolute;left:0;right:0;bottom:0;z-index:2;display:none;padding:18px 18px 20px;
|
|
545
|
+
background:linear-gradient(to top,rgba(0,0,0,.82),rgba(0,0,0,.1) 78%,transparent);color:#fff;pointer-events:none}
|
|
546
|
+
.pf-slide-cap-title{font-family:var(--rk-font-display);font-weight:700;font-size:1.02rem;line-height:1.2}
|
|
547
|
+
.pf-slide-cap-desc{font-size:12.5px;line-height:1.45;color:rgba(255,255,255,.85);margin-top:4px;
|
|
548
|
+
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
|
549
|
+
|
|
550
|
+
.pf-viewer-ui{position:absolute;top:max(16px,env(safe-area-inset-top,0px));right:16px;z-index:5;display:flex;gap:10px}
|
|
551
|
+
.pf-viewer-nav{position:absolute;right:18px;top:50%;transform:translateY(-50%);z-index:5;display:grid;gap:12px}
|
|
552
|
+
.pf-viewer-btn{width:44px;height:44px;border-radius:var(--rk-r-full);border:1px solid rgba(255,255,255,.16);background:rgba(255,255,255,.12);
|
|
553
|
+
backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);color:#fff;display:grid;place-items:center;cursor:pointer;
|
|
554
|
+
transition:background var(--rk-dur) var(--rk-ease),transform var(--rk-dur) var(--rk-ease)}
|
|
555
|
+
.pf-viewer-btn:hover{background:rgba(255,255,255,.24);transform:scale(1.06)}
|
|
556
|
+
.pf-viewer-btn span{display:grid;place-items:center;line-height:0}
|
|
557
|
+
.pf-viewer .pf-m-off{display:none}
|
|
558
|
+
.pf-viewer.is-muted .pf-m-on{display:none}
|
|
559
|
+
.pf-viewer.is-muted .pf-m-off{display:grid}
|
|
560
|
+
|
|
561
|
+
/* tablet: keep the split but tighten */
|
|
562
|
+
@media(max-width:900px){
|
|
563
|
+
.pf-slide-inner{gap:24px}
|
|
564
|
+
.pf-slide-video{height:min(80vh,640px)}
|
|
565
|
+
.pf-slide-info{max-width:300px}
|
|
566
|
+
.pf-slide-title{font-size:1.4rem}
|
|
567
|
+
}
|
|
568
|
+
/* phones: just the video (compact), caption strip overlaid, side panel + nav hidden */
|
|
569
|
+
@media(max-width:640px){
|
|
570
|
+
.pf-viewer-nav{display:none}
|
|
571
|
+
.pf-slide{padding:0}
|
|
572
|
+
.pf-slide-inner{gap:0}
|
|
573
|
+
.pf-slide-info{display:none}
|
|
574
|
+
.pf-slide-video{height:100%;width:100%;max-height:none;aspect-ratio:auto;border-radius:0;box-shadow:none}
|
|
575
|
+
.pf-slide-cap{display:block}
|
|
576
|
+
}
|
|
577
|
+
`;
|
|
578
|
+
// ── inline client script ──────────────────────────────────────────────────────
|
|
579
|
+
const PORTFOLIO_SCRIPT = `
|
|
580
|
+
(function(){
|
|
581
|
+
var doc=document;
|
|
582
|
+
var viewer=doc.querySelector('[data-pf-viewer]');
|
|
583
|
+
var track=viewer?viewer.querySelector('[data-pf-track]'):null;
|
|
584
|
+
if(!viewer||!track) return;
|
|
585
|
+
var slides=Array.prototype.slice.call(track.querySelectorAll('[data-pf-slide]'));
|
|
586
|
+
var htmlEl=doc.documentElement;
|
|
587
|
+
var muted=true;
|
|
588
|
+
var current=0;
|
|
589
|
+
|
|
590
|
+
// Reel rows quietly autoplay their (muted) preview on hover; pause on leave to
|
|
591
|
+
// keep the page light. Touch devices skip this (no hover).
|
|
592
|
+
doc.querySelectorAll('.pf-row').forEach(function(row){
|
|
593
|
+
var vid=row.querySelector('video');
|
|
594
|
+
if(!vid) return;
|
|
595
|
+
row.addEventListener('mouseenter',function(){ var p=vid.play(); if(p&&p.catch)p.catch(function(){}); });
|
|
596
|
+
row.addEventListener('mouseleave',function(){ vid.pause(); });
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
function slideVideos(){ return slides.map(function(s){ return s.querySelector('video'); }); }
|
|
600
|
+
function playOnly(idx){
|
|
601
|
+
slideVideos().forEach(function(v,i){
|
|
602
|
+
if(!v) return;
|
|
603
|
+
if(i===idx){ v.muted=muted; var p=v.play(); if(p&&p.catch)p.catch(function(){ v.muted=true; v.play&&v.play(); }); }
|
|
604
|
+
else { v.pause(); try{ v.currentTime=v.currentTime; }catch(e){} }
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
function applyMuted(){
|
|
608
|
+
viewer.classList.toggle('is-muted',muted);
|
|
609
|
+
var v=slideVideos()[current];
|
|
610
|
+
if(v){ v.muted=muted; if(!muted){ var p=v.play(); if(p&&p.catch)p.catch(function(){}); } }
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function open(idx){
|
|
614
|
+
current=Math.max(0,Math.min(idx,slides.length-1));
|
|
615
|
+
viewer.hidden=false;
|
|
616
|
+
htmlEl.classList.add('pf-noscroll');
|
|
617
|
+
viewer.classList.toggle('is-muted',muted);
|
|
618
|
+
// jump to the chosen slide without animation, then start playback
|
|
619
|
+
var target=slides[current];
|
|
620
|
+
if(target){ track.scrollTop=target.offsetTop; }
|
|
621
|
+
playOnly(current);
|
|
622
|
+
}
|
|
623
|
+
function close(){
|
|
624
|
+
viewer.hidden=true;
|
|
625
|
+
htmlEl.classList.remove('pf-noscroll');
|
|
626
|
+
slideVideos().forEach(function(v){ if(v) v.pause(); });
|
|
627
|
+
}
|
|
628
|
+
function goTo(idx){
|
|
629
|
+
idx=Math.max(0,Math.min(idx,slides.length-1));
|
|
630
|
+
var target=slides[idx];
|
|
631
|
+
if(target) target.scrollIntoView({behavior:'smooth',block:'start'});
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// open from a reel row
|
|
635
|
+
doc.querySelectorAll('[data-pf-open]').forEach(function(btn){
|
|
636
|
+
btn.addEventListener('click',function(){ open(parseInt(btn.getAttribute('data-pf-open'),10)||0); });
|
|
637
|
+
});
|
|
638
|
+
// close (scrim + buttons)
|
|
639
|
+
viewer.querySelectorAll('[data-pf-close]').forEach(function(el){ el.addEventListener('click',close); });
|
|
640
|
+
// prev / next
|
|
641
|
+
var prevBtn=viewer.querySelector('[data-pf-prev]');
|
|
642
|
+
var nextBtn=viewer.querySelector('[data-pf-next]');
|
|
643
|
+
if(prevBtn) prevBtn.addEventListener('click',function(){ goTo(current-1); });
|
|
644
|
+
if(nextBtn) nextBtn.addEventListener('click',function(){ goTo(current+1); });
|
|
645
|
+
// mute toggle
|
|
646
|
+
var muteBtn=viewer.querySelector('[data-pf-mute]');
|
|
647
|
+
if(muteBtn) muteBtn.addEventListener('click',function(){ muted=!muted; applyMuted(); });
|
|
648
|
+
// tap a slide video to toggle play/pause
|
|
649
|
+
slides.forEach(function(s){
|
|
650
|
+
var v=s.querySelector('video');
|
|
651
|
+
var box=s.querySelector('.pf-slide-video');
|
|
652
|
+
if(!v||!box) return;
|
|
653
|
+
box.addEventListener('click',function(e){
|
|
654
|
+
if(e.target.closest('[data-pf-close]')) return;
|
|
655
|
+
if(v.paused){ var p=v.play(); if(p&&p.catch)p.catch(function(){}); } else { v.pause(); }
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// track which slide is in view → autoplay it, pause the rest
|
|
660
|
+
if('IntersectionObserver' in window){
|
|
661
|
+
var io=new IntersectionObserver(function(entries){
|
|
662
|
+
entries.forEach(function(en){
|
|
663
|
+
if(en.isIntersecting && en.intersectionRatio>=0.6){
|
|
664
|
+
var idx=slides.indexOf(en.target);
|
|
665
|
+
if(idx>=0 && idx!==current){ current=idx; }
|
|
666
|
+
playOnly(current);
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
},{root:track,threshold:[0.6]});
|
|
670
|
+
slides.forEach(function(s){ io.observe(s); });
|
|
671
|
+
} else {
|
|
672
|
+
track.addEventListener('scroll',function(){
|
|
673
|
+
var idx=Math.round(track.scrollTop/track.clientHeight);
|
|
674
|
+
if(idx!==current){ current=idx; playOnly(current); }
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// keyboard: ESC closes, ↑/↓ navigate
|
|
679
|
+
doc.addEventListener('keydown',function(e){
|
|
680
|
+
if(viewer.hidden) return;
|
|
681
|
+
if(e.key==='Escape'){ e.preventDefault(); close(); }
|
|
682
|
+
else if(e.key==='ArrowDown'||e.key==='PageDown'){ e.preventDefault(); goTo(current+1); }
|
|
683
|
+
else if(e.key==='ArrowUp'||e.key==='PageUp'){ e.preventDefault(); goTo(current-1); }
|
|
684
|
+
});
|
|
685
|
+
})();
|
|
686
|
+
`;
|
|
687
|
+
//# sourceMappingURL=portfolio-page.js.map
|