Add "The Part" — crew video/montage (YouTube + Instagram)
A skate crew is defined by its footage. Crews can now feature a headline video:
- videoEmbed.ts: safe parser accepting ONLY YouTube and Instagram URLs (full
parts on YT, clips on IG). Extracts video id / shortcode; rejects everything
else so we never render an untrusted iframe. No native/self-hosted video.
- CrewVideoSection ("The Part"): cinematic section — YouTube click-to-load
facade (poster + play button, no heavy iframe/cookies until play) and an
Instagram permalink embed, framed with skate language.
- SkateCrew gains `videoUrl` (tag `video`), validated through the embed parser.
- CrewForm: "The Part" input with live source detection (YouTube/Instagram) and
validation; publishes the `video` tag.
- Crews grid: crews with footage flex a "▶ Part" badge as a discovery signal.
- NIP.md: document the `video` tag (YouTube/Instagram only) + example.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
4f3339650d
commit
91045e17ae
7 changed files with 336 additions and 2 deletions
4
NIP.md
4
NIP.md
|
|
@ -2022,6 +2022,9 @@ bare `p` tag on the crew is only a claim until the member self-labels.
|
|||
["a", "30818:<spotFounder>:<spotDtag>"],
|
||||
["nip05", "barcelonanightcrew@bcnnightcrew.example"],
|
||||
["website", "https://bcnnightcrew.example"],
|
||||
["video", "https://www.youtube.com/watch?v=dQw4w9WgXcQ"],
|
||||
["sponsor", "30017:<shopFounder>:<shopDtag>"],
|
||||
["coordinator", "https://coordinator.bcnnightcrew.example"],
|
||||
["noffer", "noffer1qqs..."],
|
||||
["alt", "Skate crew: Barcelona Night Crew"]
|
||||
]
|
||||
|
|
@ -2058,6 +2061,7 @@ bare `p` tag on the crew is only a claim until the member self-labels.
|
|||
| `looking-for` | Optional **open call** — a Lab craft the crew is hiring for. Repeatable. Slugs: `filmer`, `photographer`, `editor`, `coach`, `builder`, `artist`, `musician`, `members`. Surfaces the crew's needs so matching Lab providers can reach out. |
|
||||
| `sponsor` | Optional **sponsoring shop** — a NIP-15 stall coordinate (`30017:pubkey:d`) for a local skate shop that backs the crew. Renders a "Sponsored by [Shop]" banner linking to the shop. Only shown when the shop resolves. |
|
||||
| `coordinator` | Optional **encrypted-chat coordinator** for the crew's private group. Shape: `["coordinator", <url>, <coordinatorPubkey?>, <operatorPubkey?>]`. `<url>` is an `http(s)`/`wss` endpoint of a **Cordn** coordinator (an MLS / RFC 9420 Delivery Service reached over ContextVM). The crew event stays public and discoverable; message content, membership secrets and MLS ratchets live client-side. Nothing crew-specific is published to public relays. `<coordinatorPubkey>` is the coordinator service pubkey (hex) for ContextVM addressing; `<operatorPubkey>` is the human (hex) who runs it, if delegated. |
|
||||
| `video` | Optional **crew video / montage** ("The Part") — a **YouTube** or **Instagram** URL, rendered as the crew's headline embed. Only these two sources are accepted (skate parts live on YouTube, clips on Instagram); any other URL is ignored. No native/self-hosted video. Clients must parse it to a known embed (YouTube video id / Instagram shortcode) and reject anything else rather than render an untrusted iframe. |
|
||||
|
||||
### Crew-Hosted Events
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { useAuthor } from '@/hooks/useAuthor';
|
|||
import { genUserName } from '@/lib/genUserName';
|
||||
import { SpotPicker } from '@/components/SpotPicker';
|
||||
import { LabLinkField } from '@/components/LabLinkField';
|
||||
import { isCrewVideoUrl, parseCrewVideo } from '@/lib/videoEmbed';
|
||||
import { NofferField } from '@/components/NofferField';
|
||||
import {
|
||||
SKATE_CREW_KIND,
|
||||
|
|
@ -36,7 +37,7 @@ import {
|
|||
import { useCrewGoal } from '@/hooks/useCrewGoal';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Lock } from 'lucide-react';
|
||||
import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Lock, Film } from 'lucide-react';
|
||||
|
||||
const FOCUS_ORDER: CrewFocus[] = ['mixed', 'builder', 'photo', 'coaching', 'art', 'music', 'wellness'];
|
||||
const FOCUS_ICONS: Record<CrewFocus, typeof Users> = {
|
||||
|
|
@ -173,6 +174,8 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
// Sponsoring shop coordinate (30017:pubkey:d) and Cordn/MLS coordinator URL.
|
||||
const [sponsorRef, setSponsorRef] = useState(existing?.sponsorRef ?? '');
|
||||
const [coordinatorUrl, setCoordinatorUrl] = useState(existing?.coordinator?.url ?? '');
|
||||
// Crew video / montage — YouTube or Instagram URL ("The Part").
|
||||
const [videoUrl, setVideoUrl] = useState(existing?.videoUrl ?? '');
|
||||
|
||||
// Crew treasury (NIP-75 zap goal). Load existing goal to prefill when editing.
|
||||
const { data: existingGoal } = useCrewGoal(existing?.goalRef);
|
||||
|
|
@ -404,6 +407,12 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
tags.push(['coordinator', coordUrl]);
|
||||
}
|
||||
|
||||
// Crew video / montage — only stored if it's a valid YouTube/Instagram URL.
|
||||
const vid = videoUrl.trim();
|
||||
if (vid && isCrewVideoUrl(vid)) {
|
||||
tags.push(['video', vid]);
|
||||
}
|
||||
|
||||
await createEvent({ kind: SKATE_CREW_KIND, content: bio.trim(), tags });
|
||||
|
||||
toast({ title: existing ? 'Crew updated!' : 'Crew created!', description: 'Published to the scene.' });
|
||||
|
|
@ -742,6 +751,39 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
{/* Linked Lab profiles (cross-link) */}
|
||||
<LabLinkField value={labRefs} onChange={setLabRefs} />
|
||||
|
||||
{/* The Part — crew video / montage */}
|
||||
<div className="space-y-2 rounded-lg border border-teal-500/30 bg-teal-500/[0.04] p-4">
|
||||
<Label htmlFor="crew-video" className="flex items-center gap-2 text-teal-400">
|
||||
<Film className="h-4 w-4" /> The Part <span className="font-normal text-gray-500">· optional</span>
|
||||
</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
Your crew's full part, montage or latest clip. Paste a{' '}
|
||||
<span className="font-semibold text-teal-300">YouTube</span> or{' '}
|
||||
<span className="font-semibold text-teal-300">Instagram</span> link — it'll play right on
|
||||
your crew page.
|
||||
</p>
|
||||
<Input
|
||||
id="crew-video"
|
||||
placeholder="https://youtube.com/watch?v=… or https://instagram.com/reel/…"
|
||||
value={videoUrl}
|
||||
onChange={(e) => setVideoUrl(e.target.value)}
|
||||
className={cn(
|
||||
'bg-gray-800 border-gray-700 text-xs',
|
||||
videoUrl.trim() && !isCrewVideoUrl(videoUrl) && 'border-red-500/50',
|
||||
)}
|
||||
/>
|
||||
{videoUrl.trim() && (
|
||||
isCrewVideoUrl(videoUrl) ? (
|
||||
<p className="flex items-center gap-1.5 text-xs font-bold text-teal-400">
|
||||
<Film className="h-3.5 w-3.5" />
|
||||
{parseCrewVideo(videoUrl)?.source === 'youtube' ? 'YouTube video detected' : 'Instagram clip detected'} ✓
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-red-400">Only YouTube and Instagram links are supported.</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sponsor shop */}
|
||||
<div className="space-y-2 rounded-lg border border-amber-500/30 bg-amber-500/[0.04] p-4">
|
||||
<Label htmlFor="crew-sponsor" className="flex items-center gap-2 text-amber-400">
|
||||
|
|
|
|||
149
src/components/crew/CrewVideoSection.tsx
Normal file
149
src/components/crew/CrewVideoSection.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { useState } from 'react';
|
||||
import { Play, Film, ExternalLink, Instagram } from 'lucide-react';
|
||||
import { parseCrewVideo, youtubeThumb, type ParsedCrewVideo } from '@/lib/videoEmbed';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* "The Part" — a crew's headline video / montage.
|
||||
*
|
||||
* Skate crews are defined by their footage: the full part on YouTube, the clip
|
||||
* on Instagram. This renders that footage as the loud, cinematic centrepiece it
|
||||
* deserves — not a link buried in a link list.
|
||||
*
|
||||
* - **YouTube**: click-to-load facade (thumbnail + big play button) so we don't
|
||||
* ship a heavy iframe / tracking cookies until the viewer actually hits play.
|
||||
* - **Instagram**: embedded permalink.
|
||||
*
|
||||
* Returns null if the URL isn't a recognised YouTube/Instagram link.
|
||||
*/
|
||||
export function CrewVideoSection({
|
||||
videoUrl,
|
||||
crewName,
|
||||
accentHex = '#14b8a6',
|
||||
}: {
|
||||
videoUrl: string | undefined;
|
||||
crewName: string;
|
||||
accentHex?: string;
|
||||
}) {
|
||||
const parsed = parseCrewVideo(videoUrl);
|
||||
if (!parsed) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="relative overflow-hidden rounded-3xl border bg-black"
|
||||
style={{ borderColor: `${accentHex}33` }}
|
||||
>
|
||||
{/* Header ribbon */}
|
||||
<div className="flex items-center justify-between gap-3 px-4 pt-4 sm:px-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl"
|
||||
style={{ background: `${accentHex}1f`, color: accentHex }}
|
||||
>
|
||||
<Film className="h-4.5 w-4.5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-black uppercase leading-none text-white" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
|
||||
The Part
|
||||
</h2>
|
||||
<p className="text-[11px] font-bold uppercase tracking-widest" style={{ color: accentHex }}>
|
||||
{parsed.source === 'youtube' ? 'Full video · montage' : 'Latest clip'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={parsed.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-full border border-white/10 px-3 py-1.5 text-xs font-bold text-gray-300 transition-colors hover:border-white/20 hover:text-white"
|
||||
>
|
||||
{parsed.source === 'instagram' ? <Instagram className="h-3.5 w-3.5" /> : <ExternalLink className="h-3.5 w-3.5" />}
|
||||
Watch
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="p-4 sm:p-5">
|
||||
{parsed.source === 'youtube' ? (
|
||||
<YouTubeFacade video={parsed} crewName={crewName} accentHex={accentHex} />
|
||||
) : (
|
||||
<InstagramEmbed video={parsed} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Click-to-load YouTube: poster + play button, swaps to iframe on demand. */
|
||||
function YouTubeFacade({
|
||||
video,
|
||||
crewName,
|
||||
accentHex,
|
||||
}: {
|
||||
video: ParsedCrewVideo;
|
||||
crewName: string;
|
||||
accentHex: string;
|
||||
}) {
|
||||
const [playing, setPlaying] = useState(false);
|
||||
|
||||
if (playing) {
|
||||
return (
|
||||
<div className="relative aspect-video w-full overflow-hidden rounded-2xl bg-black">
|
||||
<iframe
|
||||
src={`${video.embedUrl}&autoplay=1`}
|
||||
title={`${crewName} — video`}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlaying(true)}
|
||||
className="group relative block aspect-video w-full overflow-hidden rounded-2xl bg-black"
|
||||
aria-label={`Play ${crewName} video`}
|
||||
>
|
||||
{video.id && (
|
||||
<img
|
||||
src={youtubeThumb(video.id)}
|
||||
alt={`${crewName} video thumbnail`}
|
||||
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
{/* Cinematic vignette */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-black/20" />
|
||||
{/* Play button */}
|
||||
<span
|
||||
className={cn(
|
||||
'absolute left-1/2 top-1/2 flex h-16 w-16 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full shadow-2xl transition-transform duration-300 group-hover:scale-110 group-active:scale-95',
|
||||
)}
|
||||
style={{ background: accentHex, color: '#000', boxShadow: `0 0 40px ${accentHex}88` }}
|
||||
>
|
||||
<Play className="h-7 w-7 translate-x-0.5 fill-current" />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Instagram permalink embed. */
|
||||
function InstagramEmbed({ video }: { video: ParsedCrewVideo }) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md overflow-hidden rounded-2xl border border-white/10 bg-black">
|
||||
<iframe
|
||||
src={video.embedUrl}
|
||||
title="Instagram clip"
|
||||
className="h-[560px] w-full"
|
||||
allow="encrypted-media; picture-in-picture"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
loading="lazy"
|
||||
scrolling="no"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import ngeohash from 'ngeohash';
|
||||
import { parseCrewVideo } from '@/lib/videoEmbed';
|
||||
|
||||
// Community relay for all skate-specific content (spots, events, coaches, shops, etc.)
|
||||
// This ensures all users see the same content regardless of their personal relay settings
|
||||
|
|
@ -1613,6 +1614,7 @@ export interface SkateCrew {
|
|||
lookingFor: string[]; // Lab craft slugs the crew is hiring for (e.g. "filmer", "editor")
|
||||
sponsorRef?: string; // Sponsoring skate shop coordinate (30017:pubkey:d) via `sponsor` tag
|
||||
coordinator?: CrewCoordinator; // Cordn/MLS encrypted-chat coordinator for this crew
|
||||
videoUrl?: string; // Crew's headline video / montage — YouTube or Instagram URL (`video` tag)
|
||||
createdAt: number;
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
|
@ -2442,6 +2444,9 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
|
|||
operator: /^[0-9a-f]{64}$/i.test(coordinatorTag[3] ?? '') ? coordinatorTag[3] : undefined,
|
||||
}
|
||||
: undefined;
|
||||
// Crew video / montage — only kept if it parses as a YouTube/Instagram URL.
|
||||
const videoRaw = event.tags.find(([name]) => name === 'video')?.[1]?.trim();
|
||||
const videoUrl = videoRaw && parseCrewVideo(videoRaw) ? videoRaw : undefined;
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
|
|
@ -2472,6 +2477,7 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
|
|||
lookingFor,
|
||||
sponsorRef,
|
||||
coordinator,
|
||||
videoUrl,
|
||||
createdAt: event.created_at,
|
||||
event,
|
||||
};
|
||||
|
|
|
|||
113
src/lib/videoEmbed.ts
Normal file
113
src/lib/videoEmbed.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* Safe parsing of crew video links.
|
||||
*
|
||||
* Skate crews live and die by their **full part / montage**. We deliberately
|
||||
* support only the two sources that matter for skateboarding right now:
|
||||
*
|
||||
* - **YouTube** — full-length parts, montages, edits (the "video").
|
||||
* - **Instagram** — clips, reels, single tricks (the "clip").
|
||||
*
|
||||
* No native / self-hosted video. Anything else is rejected so we never render
|
||||
* an untrusted iframe.
|
||||
*/
|
||||
|
||||
export type CrewVideoSource = 'youtube' | 'instagram';
|
||||
|
||||
export interface ParsedCrewVideo {
|
||||
source: CrewVideoSource;
|
||||
/** Sandboxed embed URL safe to drop into an <iframe>. */
|
||||
embedUrl: string;
|
||||
/** Canonical watch/permalink URL (https) for "open externally". */
|
||||
url: string;
|
||||
/** YouTube video id / Instagram shortcode, when known. */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/** YouTube thumbnail (hqdefault) for a video id — nice poster before play. */
|
||||
export function youtubeThumb(id: string): string {
|
||||
return `https://i.ytimg.com/vi/${encodeURIComponent(id)}/hqdefault.jpg`;
|
||||
}
|
||||
|
||||
function extractYouTubeId(u: URL): string | null {
|
||||
const host = u.hostname.replace(/^www\./, '').toLowerCase();
|
||||
|
||||
// youtu.be/<id>
|
||||
if (host === 'youtu.be') {
|
||||
const id = u.pathname.split('/').filter(Boolean)[0];
|
||||
return isYtId(id) ? id : null;
|
||||
}
|
||||
|
||||
if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'youtube-nocookie.com') {
|
||||
// watch?v=<id>
|
||||
const v = u.searchParams.get('v');
|
||||
if (v && isYtId(v)) return v;
|
||||
// /embed/<id>, /shorts/<id>, /v/<id>, /live/<id>
|
||||
const parts = u.pathname.split('/').filter(Boolean);
|
||||
if (parts.length >= 2 && ['embed', 'shorts', 'v', 'live'].includes(parts[0])) {
|
||||
return isYtId(parts[1]) ? parts[1] : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isYtId(id: string | undefined | null): id is string {
|
||||
return !!id && /^[a-zA-Z0-9_-]{6,20}$/.test(id);
|
||||
}
|
||||
|
||||
/** Extract an Instagram post/reel shortcode. */
|
||||
function extractInstagramShortcode(u: URL): string | null {
|
||||
const host = u.hostname.replace(/^www\./, '').toLowerCase();
|
||||
if (host !== 'instagram.com' && host !== 'instagr.am') return null;
|
||||
const parts = u.pathname.split('/').filter(Boolean);
|
||||
// /p/<code>, /reel/<code>, /tv/<code>
|
||||
const idx = parts.findIndex((p) => ['p', 'reel', 'reels', 'tv'].includes(p));
|
||||
if (idx === -1) return null;
|
||||
const code = parts[idx + 1];
|
||||
if (!code || !/^[a-zA-Z0-9_-]{5,30}$/.test(code)) return null;
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a user-supplied URL into a safe crew-video embed. Returns null unless the
|
||||
* URL is a recognised YouTube or Instagram link over http(s).
|
||||
*/
|
||||
export function parseCrewVideo(raw: string | undefined | null): ParsedCrewVideo | null {
|
||||
if (!raw) return null;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
|
||||
|
||||
const ytId = extractYouTubeId(u);
|
||||
if (ytId) {
|
||||
return {
|
||||
source: 'youtube',
|
||||
id: ytId,
|
||||
embedUrl: `https://www.youtube-nocookie.com/embed/${ytId}?rel=0&modestbranding=1`,
|
||||
url: `https://www.youtube.com/watch?v=${ytId}`,
|
||||
};
|
||||
}
|
||||
|
||||
const igCode = extractInstagramShortcode(u);
|
||||
if (igCode) {
|
||||
return {
|
||||
source: 'instagram',
|
||||
id: igCode,
|
||||
embedUrl: `https://www.instagram.com/p/${igCode}/embed`,
|
||||
url: `https://www.instagram.com/p/${igCode}/`,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Cheap validity check for form inputs. */
|
||||
export function isCrewVideoUrl(raw: string | undefined | null): boolean {
|
||||
return parseCrewVideo(raw) !== null;
|
||||
}
|
||||
|
|
@ -16,6 +16,8 @@ import { CrewHero } from '@/components/crew/CrewHero';
|
|||
import { CrewLeadership } from '@/components/crew/CrewLeadership';
|
||||
import { CrewSponsorBanner } from '@/components/crew/CrewSponsorBanner';
|
||||
import { CrewCoordinatorCard } from '@/components/crew/CrewCoordinatorCard';
|
||||
import { CrewVideoSection } from '@/components/crew/CrewVideoSection';
|
||||
import { crewAccent } from '@/lib/crewHelpers';
|
||||
import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity';
|
||||
import { CrewChat } from '@/components/CrewChat';
|
||||
import { CrewAgentPanel } from '@/components/CrewAgentPanel';
|
||||
|
|
@ -300,6 +302,15 @@ export default function CrewDetailPage() {
|
|||
{/* Sponsor — a local shop backing the crew */}
|
||||
{crew.sponsorRef && <CrewSponsorBanner sponsorRef={crew.sponsorRef} />}
|
||||
|
||||
{/* The Part — crew's headline video / montage (YouTube or Instagram) */}
|
||||
{crew.videoUrl && (
|
||||
<CrewVideoSection
|
||||
videoUrl={crew.videoUrl}
|
||||
crewName={crew.name}
|
||||
accentHex={crewAccent(crew).hex}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Open call — looking for filmers/editors/members (Lab pipeline) */}
|
||||
<CrewLookingForCard lookingFor={crew.lookingFor} />
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { crewAccent, crewDistanceKm, formatDistance, type LatLng } from '@/lib/c
|
|||
import { CREW_VIBE_LABELS, CREW_FOCUS_LABELS, type SkateCrew, type CrewFocus } from '@/lib/types';
|
||||
import type { CrewActivity } from '@/lib/crewHelpers';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Users, Plus, Search, MapPin, X, HardHat, Camera, GraduationCap, Sparkles, Navigation, Loader2, Music, Palette, Dumbbell } from 'lucide-react';
|
||||
import { Users, Plus, Search, MapPin, X, HardHat, Camera, GraduationCap, Sparkles, Navigation, Loader2, Music, Palette, Dumbbell, Film } from 'lucide-react';
|
||||
|
||||
const FOCUS_FILTERS: { key: CrewFocus | 'all'; label: string; icon: typeof Users }[] = [
|
||||
{ key: 'all', label: 'All', icon: Users },
|
||||
|
|
@ -109,6 +109,15 @@ function CrewCard({
|
|||
{CREW_FOCUS_LABELS[crew.focus]}
|
||||
</span>
|
||||
)}
|
||||
{crew.videoUrl && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full border border-white/15 bg-white/[0.08] px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-white"
|
||||
title="This crew has a video part"
|
||||
>
|
||||
<Film className="h-2.5 w-2.5" />
|
||||
Part
|
||||
</span>
|
||||
)}
|
||||
{crew.bandName && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full border border-purple-500/30 bg-purple-500/15 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-purple-300"
|
||||
|
|
|
|||
Loading…
Reference in a new issue