From e1fa535c7d2312680ed649d757491a53c118e34f Mon Sep 17 00:00:00 2001 From: POM Date: Sat, 14 Feb 2026 01:06:53 +0100 Subject: [PATCH 01/38] Add Top Art Picks section and pages to /resources - New section at bottom of /resources with featured latest week (left) and 2x2 grid of previous weeks (right) - /resources/art-picks index page with paginated grid of all ~104 weeks - /resources/art-picks/:weekId detail page with intro text and 10 video slots - All content is placeholder data ready to swap in real data later - Dark theme applied to all /resources sub-routes Co-Authored-By: Claude Opus 4.6 --- src/App.tsx | 19 ++- src/layouts/MainLayout.tsx | 5 +- src/pages/Resources/ArtPicks/ArtPickCard.tsx | 42 ++++++ .../Resources/ArtPicks/ArtPicksDetail.tsx | 59 ++++++++ .../Resources/ArtPicks/ArtPicksIndex.tsx | 68 +++++++++ .../Resources/ArtPicks/ArtPicksSection.tsx | 69 +++++++++ src/pages/Resources/ArtPicks/data.ts | 72 ++++++++++ src/pages/Resources/index.tsx | 131 ++++++++++++++++++ 8 files changed, 453 insertions(+), 12 deletions(-) create mode 100644 src/pages/Resources/ArtPicks/ArtPickCard.tsx create mode 100644 src/pages/Resources/ArtPicks/ArtPicksDetail.tsx create mode 100644 src/pages/Resources/ArtPicks/ArtPicksIndex.tsx create mode 100644 src/pages/Resources/ArtPicks/ArtPicksSection.tsx create mode 100644 src/pages/Resources/ArtPicks/data.ts create mode 100644 src/pages/Resources/index.tsx diff --git a/src/App.tsx b/src/App.tsx index cd4cbbd4..4ccee0ea 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,25 +1,21 @@ import { lazy, Suspense } from 'react'; import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; import { MainLayout } from '@/layouts/MainLayout'; -import { Skeleton } from '@/components/ui/Skeleton'; import Home from '@/pages/Home'; // Lazy load non-critical pages const OwnershipPage = lazy(() => import('@/pages/OwnershipPage')); const SecondRenaissance = lazy(() => import('@/pages/SecondRenaissance')); const WrappedPage = lazy(() => import('@/pages/Wrapped')); +const Resources = lazy(() => import('@/pages/Resources')); +const ArtPicksIndex = lazy(() => import('@/pages/Resources/ArtPicks/ArtPicksIndex')); +const ArtPicksDetail = lazy(() => import('@/pages/Resources/ArtPicks/ArtPicksDetail')); const NotFound = lazy(() => import('@/pages/NotFound')); -// Loading fallback with skeleton +// Minimal loading fallback — keeps layout stable while lazy chunks load. +// Intentionally blank so page-specific skeletons (e.g. ResourceGrid) aren't preceded by a flash. const PageLoader = () => ( -
-
- - - - -
-
+
); function App() { @@ -32,6 +28,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> } /> diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index 58db3742..ad5a9ef3 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -20,9 +20,10 @@ export const MainLayout = ({ children }: MainLayoutProps) => { const isHome = pathname === '/'; const isSecondRenaissance = pathname === '/2nd-renaissance'; const isWrapped = pathname === '/1m'; + const isResources = pathname.startsWith('/resources'); const [isIOSDevice] = useState(() => isIOS()); - const theme = (isHome || isSecondRenaissance || isWrapped) ? 'dark' : 'light'; + const theme = (isHome || isSecondRenaissance || isWrapped || isResources) ? 'dark' : 'light'; if (isHome) { return ( @@ -70,7 +71,7 @@ export const MainLayout = ({ children }: MainLayoutProps) => { return ( -
+
{children} diff --git a/src/pages/Resources/ArtPicks/ArtPickCard.tsx b/src/pages/Resources/ArtPicks/ArtPickCard.tsx new file mode 100644 index 00000000..db09e791 --- /dev/null +++ b/src/pages/Resources/ArtPicks/ArtPickCard.tsx @@ -0,0 +1,42 @@ +import { Link } from 'react-router-dom'; +import type { ArtPickWeek } from './data'; + +interface ArtPickCardProps { + week: ArtPickWeek; +} + +export const ArtPickCard = ({ week }: ArtPickCardProps) => { + const dateRange = (() => { + const start = new Date(week.weekOf + 'T00:00:00'); + const end = new Date(start); + end.setDate(start.getDate() + 6); + return `${start.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} – ${end.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`; + })(); + + return ( +
+ + {/* Placeholder thumbnail */} +
+ + + +
+

+ {week.title} +

+

{dateRange}

+ + + Art picks from this week → + +
+ ); +}; diff --git a/src/pages/Resources/ArtPicks/ArtPicksDetail.tsx b/src/pages/Resources/ArtPicks/ArtPicksDetail.tsx new file mode 100644 index 00000000..0a02e9d7 --- /dev/null +++ b/src/pages/Resources/ArtPicks/ArtPicksDetail.tsx @@ -0,0 +1,59 @@ +import { Link, useParams, Navigate } from 'react-router-dom'; +import { getWeeks } from './data'; + +const ArtPicksDetail = () => { + const { weekId } = useParams<{ weekId: string }>(); + const week = getWeeks().find((w) => w.id === weekId); + + if (!week) { + return ; + } + + const startDate = new Date(week.weekOf + 'T00:00:00'); + const endDate = new Date(startDate); + endDate.setDate(startDate.getDate() + 6); + const dateRange = `${startDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric' })} – ${endDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}`; + + return ( +
+ + ← Back to Art Picks + + +

+ {week.title} +

+

{dateRange}

+ +

+ {week.introText} +

+ +
+ {week.videos.map((video, i) => ( +
+ {/* Placeholder video thumbnail */} +
+ + + +
+

+ {video.title} +

+

{video.creator}

+
+ ))} +
+
+ ); +}; + +export default ArtPicksDetail; diff --git a/src/pages/Resources/ArtPicks/ArtPicksIndex.tsx b/src/pages/Resources/ArtPicks/ArtPicksIndex.tsx new file mode 100644 index 00000000..c23ffe23 --- /dev/null +++ b/src/pages/Resources/ArtPicks/ArtPicksIndex.tsx @@ -0,0 +1,68 @@ +import { useState, useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { getWeeks } from './data'; +import { ArtPickCard } from './ArtPickCard'; + +const ITEMS_PER_PAGE = 12; + +const ArtPicksIndex = () => { + const weeks = getWeeks(); + const [page, setPage] = useState(1); + + const totalPages = Math.max(1, Math.ceil(weeks.length / ITEMS_PER_PAGE)); + const paginated = useMemo(() => { + const start = (page - 1) * ITEMS_PER_PAGE; + return weeks.slice(start, start + ITEMS_PER_PAGE); + }, [weeks, page]); + + const handlePrev = () => setPage((p) => Math.max(1, p - 1)); + const handleNext = () => setPage((p) => Math.min(totalPages, p + 1)); + + return ( +
+ + ← Back to Resources + + +

+ Top Art Picks +

+

+ Weekly art picks from the Banodoco community. +

+ +
+ {paginated.map((week) => ( + + ))} +
+ + {totalPages > 1 && ( +
+ + + {page} / {totalPages} + + +
+ )} +
+ ); +}; + +export default ArtPicksIndex; diff --git a/src/pages/Resources/ArtPicks/ArtPicksSection.tsx b/src/pages/Resources/ArtPicks/ArtPicksSection.tsx new file mode 100644 index 00000000..e476ab71 --- /dev/null +++ b/src/pages/Resources/ArtPicks/ArtPicksSection.tsx @@ -0,0 +1,69 @@ +import { Link } from 'react-router-dom'; +import { getWeeks } from './data'; +import { ArtPickCard } from './ArtPickCard'; + +export const ArtPicksSection = () => { + const weeks = getWeeks(); + const featured = weeks[0]; + const nextFour = weeks.slice(1, 5); + + const startDate = new Date(featured.weekOf + 'T00:00:00'); + const endDate = new Date(startDate); + endDate.setDate(startDate.getDate() + 6); + const dateRange = `${startDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric' })} – ${endDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}`; + + return ( +
+
+

Art From The Community

+ + View all → + +
+ + {/* Side-by-side: featured left, 2x2 grid right */} +
+ {/* Featured latest week — left half */} +
+
+ + + +
+ +
+

+ Top Art From the {featured.title} +

+

{dateRange}

+ +

+ {featured.introText} +

+ + + View all picks from this week → + +
+
+ + {/* 2x2 grid of previous weeks — right half */} +
+ {nextFour.map((week) => ( + + ))} +
+
+
+ ); +}; diff --git a/src/pages/Resources/ArtPicks/data.ts b/src/pages/Resources/ArtPicks/data.ts new file mode 100644 index 00000000..04474490 --- /dev/null +++ b/src/pages/Resources/ArtPicks/data.ts @@ -0,0 +1,72 @@ +export interface ArtPickVideo { + title: string; + creator: string; + thumbnailUrl: string | null; + videoUrl: string | null; +} + +export interface ArtPickWeek { + id: string; + weekOf: string; + title: string; + introText: string; + videos: ArtPickVideo[]; +} + +function formatWeekTitle(date: Date): string { + return `Week of ${date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`; +} + +function toISODate(date: Date): string { + return date.toISOString().slice(0, 10); +} + +function getWeekId(date: Date): string { + // ISO week number + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const weekNo = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); + return `${d.getUTCFullYear()}-w${String(weekNo).padStart(2, '0')}`; +} + +export function generateWeeks(): ArtPickWeek[] { + const weeks: ArtPickWeek[] = []; + const now = new Date(); + + // Find the most recent Monday + const current = new Date(now); + current.setHours(0, 0, 0, 0); + const day = current.getDay(); + const diff = day === 0 ? 6 : day - 1; // days since Monday + current.setDate(current.getDate() - diff); + + for (let i = 0; i < 104; i++) { + const monday = new Date(current); + monday.setDate(current.getDate() - i * 7); + + const videos: ArtPickVideo[] = Array.from({ length: 10 }, (_, j) => ({ + title: `Community Highlight #${j + 1}`, + creator: `Artist Name`, + thumbnailUrl: null, + videoUrl: null, + })); + + weeks.push({ + id: getWeekId(monday), + weekOf: toISODate(monday), + title: formatWeekTitle(monday), + introText: + 'This week\'s top art picks showcase the incredible creativity of the Banodoco community. From stunning visual effects to experimental animations, these pieces represent the cutting edge of AI-assisted video art.', + videos, + }); + } + + return weeks; +} + +let _cached: ArtPickWeek[] | null = null; +export function getWeeks(): ArtPickWeek[] { + if (!_cached) _cached = generateWeeks(); + return _cached; +} diff --git a/src/pages/Resources/index.tsx b/src/pages/Resources/index.tsx new file mode 100644 index 00000000..b270a674 --- /dev/null +++ b/src/pages/Resources/index.tsx @@ -0,0 +1,131 @@ +import { useState, useMemo, useEffect } from 'react'; +import { useResources } from './useResources'; +import { useResourceFilters } from './useResourceFilters'; +import { FilterBar } from './FilterBar'; +import { ResourceGrid } from './ResourceGrid'; +import { ResourceModal } from './ResourceModal'; +import { CommunityNewsSection } from './CommunityNews/CommunityNewsSection'; +import { ArtPicksSection } from './ArtPicks/ArtPicksSection'; +import type { Asset } from './types'; + +const ITEMS_PER_PAGE = 8; + +const Resources = () => { + const { assets, profiles, loading, error } = useResources(); + const { + filters, + searchInput, + filtered, + setFilter, + handleSearchChange, + availableBaseModels, + availableLoraTypes, + } = useResourceFilters(assets); + + const [selectedAsset, setSelectedAsset] = useState(null); + const [page, setPage] = useState(1); + + // Reset to page 1 whenever filters change + useEffect(() => { + setPage(1); + }, [filters.type, filters.status, filters.mediaType, filters.baseModel, filters.loraType, filters.search]); + + const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE)); + const paginatedAssets = useMemo(() => { + const start = (page - 1) * ITEMS_PER_PAGE; + return filtered.slice(start, start + ITEMS_PER_PAGE); + }, [filtered, page]); + + const handlePrev = () => setPage(p => Math.max(1, p - 1)); + const handleNext = () => setPage(p => Math.min(totalPages, p + 1)); + + return ( +
+ {/* Page header */} +
+

+ Resources +

+
+ + {/* Section 1: Community News */} + + + {/* Section 2: Things People Made */} +
+

Things People Made

+

+ LoRAs and workflows shared by the Banodoco community for AI video generation. +

+ + {/* Error state */} + {error && ( +
+

{error}

+
+ )} + + {/* Filters */} + {!error && ( + + )} + + {/* Grid */} + {!error && ( +
+ +
+ )} + + {/* Pagination */} + {!error && !loading && totalPages > 1 && ( +
+ + + {page} / {totalPages} + + +
+ )} +
+ + {/* Section 3: Top Art Picks */} + + + {/* Modal */} + {selectedAsset && ( + setSelectedAsset(null)} + /> + )} +
+ ); +}; + +export default Resources; From cf20b584f40e33b8bf9dd8b5d182885aa62b1d14 Mon Sep 17 00:00:00 2001 From: POM Date: Sat, 14 Feb 2026 02:55:14 +0100 Subject: [PATCH 02/38] Redesign resources page with magazine-style layout - Hero section with "Art & Intelligence" typography and spotlight carousel previewing 3 featured artists - "The Forge" section for community LoRAs/workflows with icon header - "The Gallery" section with editorial art picks layout: featured large card + sidebar with recent archives - Art picks detail page with varied-column editorial video grid - "The Archive" index page with staggered animation grid - Motion animations on section entrances - All real data hooks (Supabase) preserved, mock data only for art picks and spotlight carousel Co-Authored-By: Claude Opus 4.6 --- .../sections/Community/TopicCard.tsx | 18 +- .../sections/Community/fetchTopics.ts | 156 +++++++++ .../sections/Community/useCommunityTopics.ts | 175 +--------- src/pages/Resources/ArtPicks/ArtPickCard.tsx | 49 ++- .../Resources/ArtPicks/ArtPicksDetail.tsx | 135 +++++--- .../Resources/ArtPicks/ArtPicksIndex.tsx | 80 ++--- .../Resources/ArtPicks/ArtPicksSection.tsx | 103 +++--- src/pages/Resources/ArtPicks/data.ts | 29 +- .../CommunityNews/CommunityNewsSection.tsx | 302 ++++++++++++++++++ .../Resources/CommunityNews/DiscordCTA.tsx | 55 ++++ .../CommunityNews/useCommunityNews.ts | 124 +++++++ src/pages/Resources/FilterBar.tsx | 158 +++++++++ src/pages/Resources/HlsPlayer.tsx | 97 ++++++ src/pages/Resources/ResourceCard.tsx | 153 +++++++++ src/pages/Resources/ResourceGrid.tsx | 83 +++++ src/pages/Resources/ResourceModal.tsx | 233 ++++++++++++++ src/pages/Resources/constants.ts | 38 +++ src/pages/Resources/index.tsx | 244 ++++++++++++-- src/pages/Resources/types.ts | 42 +++ src/pages/Resources/useResourceFilters.ts | 150 +++++++++ src/pages/Resources/useResources.ts | 82 +++++ 21 files changed, 2112 insertions(+), 394 deletions(-) create mode 100644 src/components/sections/Community/fetchTopics.ts create mode 100644 src/pages/Resources/CommunityNews/CommunityNewsSection.tsx create mode 100644 src/pages/Resources/CommunityNews/DiscordCTA.tsx create mode 100644 src/pages/Resources/CommunityNews/useCommunityNews.ts create mode 100644 src/pages/Resources/FilterBar.tsx create mode 100644 src/pages/Resources/HlsPlayer.tsx create mode 100644 src/pages/Resources/ResourceCard.tsx create mode 100644 src/pages/Resources/ResourceGrid.tsx create mode 100644 src/pages/Resources/ResourceModal.tsx create mode 100644 src/pages/Resources/constants.ts create mode 100644 src/pages/Resources/types.ts create mode 100644 src/pages/Resources/useResourceFilters.ts create mode 100644 src/pages/Resources/useResources.ts diff --git a/src/components/sections/Community/TopicCard.tsx b/src/components/sections/Community/TopicCard.tsx index afaa0dc4..b8901c3f 100644 --- a/src/components/sections/Community/TopicCard.tsx +++ b/src/components/sections/Community/TopicCard.tsx @@ -24,10 +24,12 @@ interface TopicCardProps { index?: number; /** Enable snap-center for this card (desktop only, disabled for first/last to allow smooth section transitions) */ snapToCenter?: boolean; + /** Show pulsing green live dot (true) or static gray dot (false). Defaults to true. */ + isLive?: boolean; } export const TopicCard = forwardRef( - ({ topic, isActive, fullWidth = false, index = 0, snapToCenter = false }, ref) => { + ({ topic, isActive, fullWidth = false, index = 0, snapToCenter = false, isLive = true }, ref) => { const channelColor = CHANNEL_COLORS[index % CHANNEL_COLORS.length]; return ( @@ -50,11 +52,15 @@ export const TopicCard = forwardRef( "flex items-center gap-2 text-white/40 font-medium", fullWidth ? "text-xs" : "text-[10px] md:text-xs" )}> - {/* Live indicator */} - - - - + {/* Live/static indicator */} + {isLive ? ( + + + + + ) : ( + + )} {formatDate(topic.summary_date)} diff --git a/src/components/sections/Community/fetchTopics.ts b/src/components/sections/Community/fetchTopics.ts new file mode 100644 index 00000000..e35d2d4e --- /dev/null +++ b/src/components/sections/Community/fetchTopics.ts @@ -0,0 +1,156 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { TopicData, MediaUrl, RawTopic } from './types'; + +// Shape of what we actually select from the database +export interface SummaryRow { + full_summary: string; + date: string; + channel_id: string; + discord_channels: { + channel_name: string; + } | { channel_name: string }[] | null; +} + +// Valid image and video file extensions +const IMAGE_EXTENSIONS = /\.(jpg|jpeg|jfif|png|gif|webp|avif|bmp|tiff?|svg|heic|heif)(\?|$)/i; +const VIDEO_EXTENSIONS = /\.(mp4|webm|mov|avi|mkv|m4v|ogv|3gp|ts|mts|m2ts)(\?|$)/i; + +// Check if a URL is a valid image or video file +export const isValidMediaUrl = (media: MediaUrl): boolean => { + if (!media.url) return false; + const url = media.url.toLowerCase(); + + if (media.type === 'video') { + return VIDEO_EXTENSIONS.test(url); + } + if (media.type === 'image') { + return IMAGE_EXTENSIONS.test(url); + } + + return IMAGE_EXTENSIONS.test(url) || VIDEO_EXTENSIONS.test(url); +}; + +// Extract all media URLs from a topic (mainMediaUrls + subTopicMediaUrls) +// Filters to only images/videos and sorts results with videos first +export const extractMediaUrls = (rawTopic: RawTopic): MediaUrl[] => { + const urls: MediaUrl[] = []; + + if (rawTopic.mainMediaUrls && rawTopic.mainMediaUrls.length > 0) { + urls.push(...rawTopic.mainMediaUrls); + } + + if (rawTopic.subTopics) { + for (const subTopic of rawTopic.subTopics) { + if (subTopic.included_in_main && subTopic.subTopicMediaUrls) { + for (const mediaGroup of subTopic.subTopicMediaUrls) { + if (Array.isArray(mediaGroup)) { + urls.push(...mediaGroup); + } + } + } + } + } + + return urls + .filter(isValidMediaUrl) + .sort((a, b) => { + if (a.type === 'video' && b.type !== 'video') return -1; + if (a.type !== 'video' && b.type === 'video') return 1; + return 0; + }); +}; + +// Helper to fetch summaries for a specific date +export const fetchSummariesForDate = async ( + client: SupabaseClient, + dateStr: string +): Promise => { + const { data, error } = await client + .from('daily_summaries') + .select('full_summary, date, channel_id, discord_channels(channel_name)') + .eq('included_in_main_summary', true) + .eq('dev_mode', false) + .eq('date', dateStr); + + if (error) throw error; + return (data as SummaryRow[]) || []; +}; + +// Helper to parse summaries into TopicData array +export const parseSummariesToTopics = (summaries: SummaryRow[]): TopicData[] => { + const allTopics: TopicData[] = []; + + for (const summary of summaries) { + try { + const rawTopics: RawTopic[] = JSON.parse(summary.full_summary); + + const includedTopics = rawTopics + .filter((t: RawTopic) => t.included_in_main === true); + + for (const rawTopic of includedTopics) { + const mediaUrls = extractMediaUrls(rawTopic); + const channelName = + Array.isArray(summary.discord_channels) + ? summary.discord_channels[0]?.channel_name + : summary.discord_channels?.channel_name; + + allTopics.push({ + channel_id: rawTopic.channel_id || summary.channel_id, + channel_name: channelName || 'community', + topic_title: rawTopic.title, + topic_main_text: rawTopic.mainText, + topic_sub_topics: rawTopic.subTopics + .filter(st => st.included_in_main) + .map(st => ({ + text: st.text, + subTopicMediaMessageIds: st.subTopicMediaMessageIds, + message_id: st.message_id, + channel_id: st.channel_id, + included_in_main: st.included_in_main, + subTopicMediaUrls: st.subTopicMediaUrls, + })), + media_message_ids: [], + media_count: mediaUrls.length, + summary_date: summary.date, + mediaUrls: mediaUrls, + included_in_main: true, + }); + } + } catch (parseErr) { + console.error('Error parsing summary:', parseErr); + } + } + + return allTopics; +}; + +// Helper to filter and sort topics +export const filterAndSortTopics = (topics: TopicData[]): TopicData[] => { + const filteredTopics = topics.filter(topic => { + const hasSubTopics = topic.topic_sub_topics && topic.topic_sub_topics.length > 0; + const hasMedia = topic.mediaUrls && topic.mediaUrls.length > 0; + return hasSubTopics || hasMedia; + }); + + return filteredTopics.sort((a, b) => { + const aHasMedia = (a.mediaUrls?.length || 0) > 0; + const bHasMedia = (b.mediaUrls?.length || 0) > 0; + + if (aHasMedia && !bHasMedia) return -1; + if (!aHasMedia && bHasMedia) return 1; + + if (aHasMedia && bHasMedia) { + const aHasVideo = a.mediaUrls?.some(m => m.type === 'video') || false; + const bHasVideo = b.mediaUrls?.some(m => m.type === 'video') || false; + + if (aHasVideo && !bHasVideo) return -1; + if (!aHasVideo && bHasVideo) return 1; + + const aVideoCount = a.mediaUrls?.filter(m => m.type === 'video').length || 0; + const bVideoCount = b.mediaUrls?.filter(m => m.type === 'video').length || 0; + return bVideoCount - aVideoCount; + } + + return 0; + }); +}; diff --git a/src/components/sections/Community/useCommunityTopics.ts b/src/components/sections/Community/useCommunityTopics.ts index 9d327062..7b61cfa1 100644 --- a/src/components/sections/Community/useCommunityTopics.ts +++ b/src/components/sections/Community/useCommunityTopics.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { isSupabaseConfigured, supabase } from '@/lib/supabase'; -import type { TopicData, MediaUrl, RawTopic } from './types'; +import type { TopicData } from './types'; +import { fetchSummariesForDate, parseSummariesToTopics, filterAndSortTopics } from './fetchTopics'; interface UseCommunityTopicsResult { topics: TopicData[]; @@ -8,71 +9,6 @@ interface UseCommunityTopicsResult { error: string | null; } -// Shape of what we actually select from the database -interface SummaryRow { - full_summary: string; - date: string; - channel_id: string; - discord_channels: { - channel_name: string; - } | { channel_name: string }[] | null; -} - -// Valid image and video file extensions -const IMAGE_EXTENSIONS = /\.(jpg|jpeg|jfif|png|gif|webp|avif|bmp|tiff?|svg|heic|heif)(\?|$)/i; -const VIDEO_EXTENSIONS = /\.(mp4|webm|mov|avi|mkv|m4v|ogv|3gp|ts|mts|m2ts)(\?|$)/i; - -// Check if a URL is a valid image or video file -const isValidMediaUrl = (media: MediaUrl): boolean => { - if (!media.url) return false; - const url = media.url.toLowerCase(); - - // Check by type first - if (media.type === 'video') { - return VIDEO_EXTENSIONS.test(url); - } - if (media.type === 'image') { - return IMAGE_EXTENSIONS.test(url); - } - - // Fallback: check by extension if type is not set correctly - return IMAGE_EXTENSIONS.test(url) || VIDEO_EXTENSIONS.test(url); -}; - -// Extract all media URLs from a topic (mainMediaUrls + subTopicMediaUrls) -// Filters to only images/videos and sorts results with videos first -const extractMediaUrls = (rawTopic: RawTopic): MediaUrl[] => { - const urls: MediaUrl[] = []; - - // Add main media URLs if present - if (rawTopic.mainMediaUrls && rawTopic.mainMediaUrls.length > 0) { - urls.push(...rawTopic.mainMediaUrls); - } - - // Add subtopic media URLs - only from subtopics that are included_in_main - if (rawTopic.subTopics) { - for (const subTopic of rawTopic.subTopics) { - if (subTopic.included_in_main && subTopic.subTopicMediaUrls) { - for (const mediaGroup of subTopic.subTopicMediaUrls) { - if (Array.isArray(mediaGroup)) { - urls.push(...mediaGroup); - } - } - } - } - } - - // Filter to only valid image/video files, then sort with videos first - return urls - .filter(isValidMediaUrl) - .sort((a, b) => { - if (a.type === 'video' && b.type !== 'video') return -1; - if (a.type !== 'video' && b.type === 'video') return 1; - return 0; - }); -}; - - // Minimum number of topics we want to display const MIN_TOPICS_DESIRED = 3; @@ -83,108 +19,6 @@ const getDateString = (daysAgo: number = 0): string => { return date.toISOString().split('T')[0]; }; -// Helper to fetch summaries for a specific date -const fetchSummariesForDate = async ( - client: NonNullable, - dateStr: string -): Promise => { - const { data, error } = await client - .from('daily_summaries') - .select('full_summary, date, channel_id, discord_channels(channel_name)') - .eq('included_in_main_summary', true) - .eq('dev_mode', false) - .eq('date', dateStr); - - if (error) throw error; - return (data as SummaryRow[]) || []; -}; - -// Helper to parse summaries into TopicData array -const parseSummariesToTopics = (summaries: SummaryRow[]): TopicData[] => { - const allTopics: TopicData[] = []; - - for (const summary of summaries) { - try { - const rawTopics: RawTopic[] = JSON.parse(summary.full_summary); - - // Filter for topics with included_in_main: true - const includedTopics = rawTopics - .filter((t: RawTopic) => t.included_in_main === true); - - // Transform to TopicData format - for (const rawTopic of includedTopics) { - const mediaUrls = extractMediaUrls(rawTopic); - const channelName = - Array.isArray(summary.discord_channels) - ? summary.discord_channels[0]?.channel_name - : summary.discord_channels?.channel_name; - - allTopics.push({ - channel_id: rawTopic.channel_id || summary.channel_id, - channel_name: channelName || 'community', - topic_title: rawTopic.title, - topic_main_text: rawTopic.mainText, - topic_sub_topics: rawTopic.subTopics - .filter(st => st.included_in_main) - .map(st => ({ - text: st.text, - subTopicMediaMessageIds: st.subTopicMediaMessageIds, - message_id: st.message_id, - channel_id: st.channel_id, - included_in_main: st.included_in_main, - subTopicMediaUrls: st.subTopicMediaUrls, - })), - media_message_ids: [], - media_count: mediaUrls.length, - summary_date: summary.date, - mediaUrls: mediaUrls, - included_in_main: true, - }); - } - } catch (parseErr) { - console.error('Error parsing summary:', parseErr); - } - } - - return allTopics; -}; - -// Helper to filter and sort topics -const filterAndSortTopics = (topics: TopicData[]): TopicData[] => { - // Filter out topics with no bullet points AND no media (too sparse to display) - const filteredTopics = topics.filter(topic => { - const hasSubTopics = topic.topic_sub_topics && topic.topic_sub_topics.length > 0; - const hasMedia = topic.mediaUrls && topic.mediaUrls.length > 0; - return hasSubTopics || hasMedia; - }); - - // Sort topics: prioritize those with media, videos first - return filteredTopics.sort((a, b) => { - const aHasMedia = (a.mediaUrls?.length || 0) > 0; - const bHasMedia = (b.mediaUrls?.length || 0) > 0; - - // Topics with media come first - if (aHasMedia && !bHasMedia) return -1; - if (!aHasMedia && bHasMedia) return 1; - - // Among topics with media, prioritize those with videos - if (aHasMedia && bHasMedia) { - const aHasVideo = a.mediaUrls?.some(m => m.type === 'video') || false; - const bHasVideo = b.mediaUrls?.some(m => m.type === 'video') || false; - - if (aHasVideo && !bHasVideo) return -1; - if (!aHasVideo && bHasVideo) return 1; - - // If both have videos, sort by video count - const aVideoCount = a.mediaUrls?.filter(m => m.type === 'video').length || 0; - const bVideoCount = b.mediaUrls?.filter(m => m.type === 'video').length || 0; - return bVideoCount - aVideoCount; - } - - return 0; - }); -}; - export const useCommunityTopics = (): UseCommunityTopicsResult => { const [topics, setTopics] = useState([]); const [loading, setLoading] = useState(true); @@ -193,7 +27,6 @@ export const useCommunityTopics = (): UseCommunityTopicsResult => { useEffect(() => { const client = supabase; if (!isSupabaseConfigured || !client) { - // Don't hard-crash the whole site just because Supabase isn't configured. setTopics([]); setError(null); setLoading(false); @@ -205,21 +38,17 @@ export const useCommunityTopics = (): UseCommunityTopicsResult => { const todayStr = getDateString(0); const yesterdayStr = getDateString(1); - // First, fetch today's summaries const todaySummaries = await fetchSummariesForDate(client, todayStr); const todayTopics = filterAndSortTopics(parseSummariesToTopics(todaySummaries)); - // If we have enough topics from today, use them if (todayTopics.length >= MIN_TOPICS_DESIRED) { setTopics(todayTopics.slice(0, MIN_TOPICS_DESIRED)); return; } - // Otherwise, also fetch yesterday's summaries to fill remaining slots const yesterdaySummaries = await fetchSummariesForDate(client, yesterdayStr); const yesterdayTopics = filterAndSortTopics(parseSummariesToTopics(yesterdaySummaries)); - // Combine: today's topics first, then yesterday's to fill remaining slots const combinedTopics = [...todayTopics, ...yesterdayTopics]; setTopics(combinedTopics.slice(0, MIN_TOPICS_DESIRED)); } catch (err) { diff --git a/src/pages/Resources/ArtPicks/ArtPickCard.tsx b/src/pages/Resources/ArtPicks/ArtPickCard.tsx index db09e791..f8cfb428 100644 --- a/src/pages/Resources/ArtPicks/ArtPickCard.tsx +++ b/src/pages/Resources/ArtPicks/ArtPickCard.tsx @@ -1,4 +1,5 @@ import { Link } from 'react-router-dom'; +import { ChevronRight } from 'lucide-react'; import type { ArtPickWeek } from './data'; interface ArtPickCardProps { @@ -6,37 +7,25 @@ interface ArtPickCardProps { } export const ArtPickCard = ({ week }: ArtPickCardProps) => { - const dateRange = (() => { - const start = new Date(week.weekOf + 'T00:00:00'); - const end = new Date(start); - end.setDate(start.getDate() + 6); - return `${start.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} – ${end.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`; - })(); - return ( -
- - {/* Placeholder thumbnail */} -
- - - -
-

+ +
+ #{week.id.split('w')[1]} +
+ +
+

{week.title} -

-

{dateRange}

- - - Art picks from this week → - -
+

+

+ {week.videos.length} videos featured +

+
+ + + ); }; diff --git a/src/pages/Resources/ArtPicks/ArtPicksDetail.tsx b/src/pages/Resources/ArtPicks/ArtPicksDetail.tsx index 0a02e9d7..7d797652 100644 --- a/src/pages/Resources/ArtPicks/ArtPicksDetail.tsx +++ b/src/pages/Resources/ArtPicks/ArtPicksDetail.tsx @@ -1,56 +1,105 @@ -import { Link, useParams, Navigate } from 'react-router-dom'; +import { useParams, Link } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { ArrowLeft, Play, User, Sparkles } from 'lucide-react'; import { getWeeks } from './data'; const ArtPicksDetail = () => { - const { weekId } = useParams<{ weekId: string }>(); - const week = getWeeks().find((w) => w.id === weekId); + const { weekId } = useParams(); + const allWeeks = getWeeks(); + const week = allWeeks.find((w) => w.id === weekId); if (!week) { - return ; + return ( +
+

Week not found

+ + Back to Archive + +
+ ); } - const startDate = new Date(week.weekOf + 'T00:00:00'); - const endDate = new Date(startDate); - endDate.setDate(startDate.getDate() + 6); - const dateRange = `${startDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric' })} – ${endDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}`; - return ( -
- - ← Back to Art Picks - - -

- {week.title} -

-

{dateRange}

- -

- {week.introText} -

- -
- {week.videos.map((video, i) => ( -
- {/* Placeholder video thumbnail */} -
- - - +
+ {/* Header Section */} +
+
+ + Back to Archive + +
+

+ {week.title} +

+
+ + Curated by Banodoco Editorial + + + + Issue #{week.id.split('w')[1]} + +
+
+

+ {week.introText} +

+
+ +
+
+
+
+ +
+
FEATURED ISSUE
+
+ Exploration of
Temporal Consistency +
-

- {video.title} -

-

{video.creator}

- ))} +
+
+ + {/* Video Grid — Editorial Style */} +
+ {week.videos.map((video, idx) => { + const span = + idx === 0 + ? 'lg:col-span-8' + : idx === 3 || idx === 7 + ? 'lg:col-span-6' + : 'lg:col-span-4'; + + return ( + +
+ + {/* Placeholder play icon */} +
+ +
+ +
+
+ + {video.creator} +
+

{video.title}

+
+ + ); + })}
); diff --git a/src/pages/Resources/ArtPicks/ArtPicksIndex.tsx b/src/pages/Resources/ArtPicks/ArtPicksIndex.tsx index c23ffe23..c40b943a 100644 --- a/src/pages/Resources/ArtPicks/ArtPicksIndex.tsx +++ b/src/pages/Resources/ArtPicks/ArtPicksIndex.tsx @@ -1,66 +1,42 @@ -import { useState, useMemo } from 'react'; import { Link } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { ArrowLeft } from 'lucide-react'; import { getWeeks } from './data'; import { ArtPickCard } from './ArtPickCard'; -const ITEMS_PER_PAGE = 12; - const ArtPicksIndex = () => { const weeks = getWeeks(); - const [page, setPage] = useState(1); - - const totalPages = Math.max(1, Math.ceil(weeks.length / ITEMS_PER_PAGE)); - const paginated = useMemo(() => { - const start = (page - 1) * ITEMS_PER_PAGE; - return weeks.slice(start, start + ITEMS_PER_PAGE); - }, [weeks, page]); - - const handlePrev = () => setPage((p) => Math.max(1, p - 1)); - const handleNext = () => setPage((p) => Math.min(totalPages, p + 1)); return ( -
- - ← Back to Resources - - -

- Top Art Picks -

-

- Weekly art picks from the Banodoco community. -

- -
- {paginated.map((week) => ( - - ))} +
+
+ + Back to Resources + +

+ The Archive +

+

+ 104 weeks of community evolution. A chronological journey through the aesthetics, + tools, and visions of the Banodoco collective. +

- {totalPages > 1 && ( -
- - - {page} / {totalPages} - - -
- )} + + + ))} +
); }; diff --git a/src/pages/Resources/ArtPicks/ArtPicksSection.tsx b/src/pages/Resources/ArtPicks/ArtPicksSection.tsx index e476ab71..10969ad6 100644 --- a/src/pages/Resources/ArtPicks/ArtPicksSection.tsx +++ b/src/pages/Resources/ArtPicks/ArtPicksSection.tsx @@ -1,67 +1,84 @@ import { Link } from 'react-router-dom'; +import { ArrowUpRight } from 'lucide-react'; import { getWeeks } from './data'; import { ArtPickCard } from './ArtPickCard'; export const ArtPicksSection = () => { - const weeks = getWeeks(); - const featured = weeks[0]; - const nextFour = weeks.slice(1, 5); - - const startDate = new Date(featured.weekOf + 'T00:00:00'); - const endDate = new Date(startDate); - endDate.setDate(startDate.getDate() + 6); - const dateRange = `${startDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric' })} – ${endDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}`; + const allWeeks = getWeeks(); + const featuredWeek = allWeeks[0]; + const recentWeeks = allWeeks.slice(1, 4); return ( -
-
-

Art From The Community

+
+ {/* Featured Large Card */} +
- View all → - -
+
- {/* Side-by-side: featured left, 2x2 grid right */} -
- {/* Featured latest week — left half */} -
-
- - - + {/* Decorative week number */} +
+ + {featuredWeek.id.split('-')[1].toUpperCase()} +
-
-

- Top Art From the {featured.title} +
+
+ + Latest Issue + + + {featuredWeek.title} + +
+

+ THE CUTTING EDGE

-

{dateRange}

- -

- {featured.introText} +

+ {featuredWeek.introText}

+
+
+ +
+ +

+ + {/* Side Recent Weeks */} +
+
+
+ Recent Archives - View all picks from this week → + View All Issues
+ +
+ {recentWeeks.map((week) => ( + + ))} +
- {/* 2x2 grid of previous weeks — right half */} -
- {nextFour.map((week) => ( - - ))} +
+

+ Explore 2 years of community art. Each week features 10 hand-picked videos that defined the aesthetic of AI generation at that moment in time. +

+ + Browse the Archive + +
diff --git a/src/pages/Resources/ArtPicks/data.ts b/src/pages/Resources/ArtPicks/data.ts index 04474490..8f9ff09e 100644 --- a/src/pages/Resources/ArtPicks/data.ts +++ b/src/pages/Resources/ArtPicks/data.ts @@ -13,16 +13,11 @@ export interface ArtPickWeek { videos: ArtPickVideo[]; } -function formatWeekTitle(date: Date): string { - return `Week of ${date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`; -} - -function toISODate(date: Date): string { - return date.toISOString().slice(0, 10); -} +const creators = ['Aether', 'Lumina', 'Zephyr', 'Kael', 'Nyx', 'Oberon', 'Silas', 'Vesper', 'Cyrus', 'Elysia']; +const adjectives = ['Ethereal', 'Cinematic', 'Surreal', 'Gothic', 'Noir', 'Vibrant', 'Liminal', 'Hyper-realistic']; +const subjects = ['Portraits', 'Cityscapes', 'Anomalies', 'Visions', 'Dreams', 'Realms', 'Echoes', 'Frequencies']; function getWeekId(date: Date): string { - // ISO week number const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); @@ -30,7 +25,7 @@ function getWeekId(date: Date): string { return `${d.getUTCFullYear()}-w${String(weekNo).padStart(2, '0')}`; } -export function generateWeeks(): ArtPickWeek[] { +function generateWeeks(): ArtPickWeek[] { const weeks: ArtPickWeek[] = []; const now = new Date(); @@ -38,26 +33,28 @@ export function generateWeeks(): ArtPickWeek[] { const current = new Date(now); current.setHours(0, 0, 0, 0); const day = current.getDay(); - const diff = day === 0 ? 6 : day - 1; // days since Monday + const diff = day === 0 ? 6 : day - 1; current.setDate(current.getDate() - diff); for (let i = 0; i < 104; i++) { const monday = new Date(current); monday.setDate(current.getDate() - i * 7); + const dateStr = monday.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + const weekNum = 104 - i; + const videos: ArtPickVideo[] = Array.from({ length: 10 }, (_, j) => ({ - title: `Community Highlight #${j + 1}`, - creator: `Artist Name`, + title: `${adjectives[j % adjectives.length]} ${subjects[j % subjects.length]} #${weekNum}-${j + 1}`, + creator: creators[j % creators.length], thumbnailUrl: null, videoUrl: null, })); weeks.push({ id: getWeekId(monday), - weekOf: toISODate(monday), - title: formatWeekTitle(monday), - introText: - 'This week\'s top art picks showcase the incredible creativity of the Banodoco community. From stunning visual effects to experimental animations, these pieces represent the cutting edge of AI-assisted video art.', + weekOf: monday.toISOString().slice(0, 10), + title: `Week of ${dateStr}`, + introText: `A collection of the most stunning AI-generated visuals from our community members for this week. Featuring explorations into ${adjectives[i % 8].toLowerCase()} textures and ${subjects[i % 8].toLowerCase()} that push the boundaries of current generation models.`, videos, }); } diff --git a/src/pages/Resources/CommunityNews/CommunityNewsSection.tsx b/src/pages/Resources/CommunityNews/CommunityNewsSection.tsx new file mode 100644 index 00000000..35874cd5 --- /dev/null +++ b/src/pages/Resources/CommunityNews/CommunityNewsSection.tsx @@ -0,0 +1,302 @@ +import { useEffect, useRef, useState, useMemo } from 'react'; +import { TopicCard } from '@/components/sections/Community/TopicCard'; +import { Skeleton } from '@/components/ui/Skeleton'; +import { DiscordCTA } from './DiscordCTA'; +import { useCommunityNews } from './useCommunityNews'; + +/** Format month key "2025-01" to short label, with year suffix if not current year */ +const formatMonthLabel = (monthKey: string): string => { + const [yearStr, monthStr] = monthKey.split('-'); + const date = new Date(parseInt(yearStr), parseInt(monthStr) - 1); + const label = date.toLocaleDateString('en-US', { month: 'short' }); + if (parseInt(yearStr) !== new Date().getFullYear()) { + return `${label} '${yearStr.slice(2)}`; + } + return label; +}; + +/** Skeleton matching the non-fullWidth TopicCard structure */ +const TopicCardSkeleton = () => ( +
+
+ + +
+
+ {/* Mobile skeleton */} +
+
+ + + +
+ +
+ {/* Desktop skeleton */} +
+
+ + + + +
+ +
+
+
+); + +// Page background color from CSS variable --color-bg-base +const BG_COLOR = '#0b0b0f'; + +export const CommunityNewsSection = () => { + const { + availableDates, + selectedDate, + setSelectedDate, + topics, + loading, + loadingDates, + error, + } = useCommunityNews(); + + const contentRef = useRef(null); + const cardRefs = useRef<(HTMLElement | null)[]>([]); + const [activeIndex, setActiveIndex] = useState(0); + const [topFade, setTopFade] = useState(0); + const [bottomFade, setBottomFade] = useState(1); + + const todayStr = new Date().toISOString().split('T')[0]; + const yesterdayStr = useMemo(() => { + const d = new Date(); + d.setDate(d.getDate() - 1); + return d.toISOString().split('T')[0]; + }, []); + const isLive = selectedDate === todayStr; + + // Group available dates by month + const monthsMap = useMemo(() => { + const map = new Map(); + for (const date of availableDates) { + const monthKey = date.slice(0, 7); + if (!map.has(monthKey)) map.set(monthKey, []); + map.get(monthKey)!.push(date); + } + return map; + }, [availableDates]); + + const months = useMemo(() => [...monthsMap.keys()], [monthsMap]); // descending + const selectedMonth = selectedDate ? selectedDate.slice(0, 7) : (months[0] || null); + + const daysInMonth = useMemo(() => { + if (!selectedMonth) return []; + return monthsMap.get(selectedMonth) || []; + }, [selectedMonth, monthsMap]); + + const handleMonthSelect = (monthKey: string) => { + const dates = monthsMap.get(monthKey); + if (dates && dates.length > 0) { + setSelectedDate(dates[0]); // most recent date in that month + } + }; + + // Desktop: track vertical scroll for active card + gradient fades + useEffect(() => { + const el = contentRef.current; + if (!el) return; + + const handleScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = el; + + // Gradient fades + setTopFade(Math.min(1, scrollTop / 60)); + setBottomFade(Math.min(1, (scrollHeight - clientHeight - scrollTop) / 60)); + + // Find card closest to scroll center + const scrollCenter = scrollTop + clientHeight / 2; + let closestIdx = 0; + let minDiff = Infinity; + cardRefs.current.forEach((ref, idx) => { + if (!ref) return; + const cardCenter = ref.offsetTop + ref.offsetHeight / 2; + const diff = Math.abs(cardCenter - scrollCenter); + if (diff < minDiff) { + minDiff = diff; + closestIdx = idx; + } + }); + setActiveIndex(prev => (prev === closestIdx ? prev : closestIdx)); + }; + + handleScroll(); + el.addEventListener('scroll', handleScroll, { passive: true }); + return () => el.removeEventListener('scroll', handleScroll); + }, [topics.length]); + + // Scroll content to top when date changes + useEffect(() => { + setActiveIndex(0); + contentRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); + }, [selectedDate]); + + const showTopics = !loading && !error && topics.length > 0; + const showEmpty = !loading && !error && topics.length === 0 && selectedDate; + + // Shared rendering helpers + const renderMonthButton = (monthKey: string) => ( + + ); + + const renderDayButton = (date: string) => { + const day = parseInt(date.split('-')[2]); + const isSelected = date === selectedDate; + const isToday = date === todayStr; + const isYesterday = date === yesterdayStr; + + return ( + + ); + }; + + const renderCards = (desktop: boolean) => ( + <> + {(loading || loadingDates) && ( +
+ {[1, 2, 3].map(i => )} +
+ )} + {error &&
{error}
} + {showEmpty &&
No community updates for this day.
} + {showTopics && ( +
+ {topics.map((topic, idx) => ( + { cardRefs.current[idx] = el; } : undefined} + topic={topic} + isActive={desktop ? idx === activeIndex : false} + index={idx} + isLive={isLive} + /> + ))} +
+ )} + + ); + + return ( +
+

News from the Community

+

Daily highlights from our Discord, powered by AI summaries.

+ + {/* ── Desktop: three-column layout (md+) ── */} +
+ {/* Month column */} +
+ {loadingDates + ? [1, 2, 3].map(i => ) + : months.map(renderMonthButton) + } +
+ + {/* Day column */} +
+ {loadingDates + ? [1, 2, 3, 4, 5].map(i => ) + : daysInMonth.map(renderDayButton) + } +
+ + {/* Content column */} +
+
+ {renderCards(true)} +
+ + {/* Top/bottom gradient fades */} +
+
+
+
+ + {/* ── Mobile: stacked layout ( + {/* Month pills — horizontal */} +
+ {loadingDates + ? [1, 2, 3].map(i => ) + : months.map(renderMonthButton) + } +
+ + {/* Day pills — horizontal */} +
+ {loadingDates + ? [1, 2, 3, 4, 5].map(i => ) + : daysInMonth.map(renderDayButton) + } +
+ + {/* Topic cards — vertical */} +
+ {renderCards(false)} +
+
+ + {/* Discord CTA */} +
+ +
+
+ ); +}; diff --git a/src/pages/Resources/CommunityNews/DiscordCTA.tsx b/src/pages/Resources/CommunityNews/DiscordCTA.tsx new file mode 100644 index 00000000..92a8c8f5 --- /dev/null +++ b/src/pages/Resources/CommunityNews/DiscordCTA.tsx @@ -0,0 +1,55 @@ +import { useState } from 'react'; +import { DiscordIcon } from '@/components/ui/icons'; +import { DISCORD_INVITE_URL, CLAUDE_CODE_INSTRUCTIONS } from '@/lib/discord'; + +export const DiscordCTA = () => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(CLAUDE_CODE_INSTRUCTIONS); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + return ( +
+ {/* Discord link */} + + + Join the conversation on Discord + + +
+ + {/* Copy Claude Code instructions */} + +
+ ); +}; diff --git a/src/pages/Resources/CommunityNews/useCommunityNews.ts b/src/pages/Resources/CommunityNews/useCommunityNews.ts new file mode 100644 index 00000000..72c47b2a --- /dev/null +++ b/src/pages/Resources/CommunityNews/useCommunityNews.ts @@ -0,0 +1,124 @@ +import { useEffect, useState, useRef, useCallback } from 'react'; +import { isSupabaseConfigured, supabase } from '@/lib/supabase'; +import type { TopicData } from '@/components/sections/Community/types'; +import { fetchSummariesForDate, parseSummariesToTopics, filterAndSortTopics } from '@/components/sections/Community/fetchTopics'; + +interface UseCommunityNewsResult { + availableDates: string[]; + selectedDate: string | null; + setSelectedDate: (date: string) => void; + topics: TopicData[]; + loading: boolean; + loadingDates: boolean; + error: string | null; +} + +export const useCommunityNews = (): UseCommunityNewsResult => { + const [availableDates, setAvailableDates] = useState([]); + const [selectedDate, setSelectedDate] = useState(null); + const [topics, setTopics] = useState([]); + const [loading, setLoading] = useState(false); + const [loadingDates, setLoadingDates] = useState(true); + const [error, setError] = useState(null); + + // Cache: date string -> topics array + const cacheRef = useRef>(new Map()); + + // Fetch topics for a specific date (with caching) + const fetchTopicsForDate = useCallback(async (dateStr: string) => { + const client = supabase; + if (!client) return; + + // Check cache first + const cached = cacheRef.current.get(dateStr); + if (cached) { + setTopics(cached); + setLoading(false); + return; + } + + setLoading(true); + setError(null); + + try { + const summaries = await fetchSummariesForDate(client, dateStr); + const parsed = filterAndSortTopics(parseSummariesToTopics(summaries)); + cacheRef.current.set(dateStr, parsed); + setTopics(parsed); + } catch (err) { + console.error('Error fetching topics for date:', err); + setError('Failed to load community updates'); + } finally { + setLoading(false); + } + }, []); + + // On mount: fetch available dates and topics for most recent date in parallel + useEffect(() => { + const client = supabase; + if (!isSupabaseConfigured || !client) { + setLoadingDates(false); + return; + } + + const init = async () => { + try { + const ninetyDaysAgo = new Date(); + ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90); + const ninetyDaysAgoStr = ninetyDaysAgo.toISOString().split('T')[0]; + + // Fetch distinct dates with content + const { data: dateRows, error: dateError } = await client + .from('daily_summaries') + .select('date') + .eq('included_in_main_summary', true) + .eq('dev_mode', false) + .gte('date', ninetyDaysAgoStr) + .order('date', { ascending: false }); + + if (dateError) throw dateError; + + // Deduplicate dates client-side + const uniqueDates = [...new Set((dateRows || []).map(r => r.date))]; + setAvailableDates(uniqueDates); + + if (uniqueDates.length > 0) { + const mostRecent = uniqueDates[0]; + setSelectedDate(mostRecent); + + // Fetch topics for the most recent date + setLoading(true); + const summaries = await fetchSummariesForDate(client, mostRecent); + const parsed = filterAndSortTopics(parseSummariesToTopics(summaries)); + cacheRef.current.set(mostRecent, parsed); + setTopics(parsed); + setLoading(false); + } + } catch (err) { + console.error('Error initializing community news:', err); + setError('Failed to load community updates'); + setLoading(false); + } finally { + setLoadingDates(false); + } + }; + + init(); + }, []); + + // When selectedDate changes (after initial load), fetch topics + const handleSetSelectedDate = useCallback((date: string) => { + setSelectedDate(date); + fetchTopicsForDate(date); + }, [fetchTopicsForDate]); + + return { + availableDates, + selectedDate, + setSelectedDate: handleSetSelectedDate, + topics, + loading, + loadingDates, + error, + }; +}; diff --git a/src/pages/Resources/FilterBar.tsx b/src/pages/Resources/FilterBar.tsx new file mode 100644 index 00000000..e88bfdf2 --- /dev/null +++ b/src/pages/Resources/FilterBar.tsx @@ -0,0 +1,158 @@ +import { useState } from 'react'; +import { TYPE_OPTIONS, MEDIA_TYPE_OPTIONS, BASE_MODEL_MAP } from './constants'; +import type { ResourceFilters } from './types'; + +interface FilterBarProps { + filters: ResourceFilters; + searchInput: string; + resultCount: number; + availableBaseModels: string[]; + availableLoraTypes: string[]; + onFilterChange: (key: K, value: ResourceFilters[K]) => void; + onSearchChange: (value: string) => void; +} + +function getModelLabel(id: string): string { + return BASE_MODEL_MAP.get(id)?.label ?? id.toUpperCase(); +} + +export const FilterBar = ({ + filters, + searchInput, + resultCount, + availableBaseModels, + availableLoraTypes, + onFilterChange, + onSearchChange, +}: FilterBarProps) => { + const [mobileOpen, setMobileOpen] = useState(false); + const showLoraFilters = filters.type !== 'workflow'; + const hasExtraFilters = showLoraFilters && (availableBaseModels.length > 0 || availableLoraTypes.length > 0); + + return ( +
+ {/* Top row: status toggle + type pills + search */} +
+ {/* Status toggle */} +
+ + +
+ + {/* Type pills */} +
+ {TYPE_OPTIONS.map(opt => ( + + ))} +
+ + {/* Media type pills (Image / Video) */} + {showLoraFilters && ( +
+ {MEDIA_TYPE_OPTIONS.map(opt => ( + + ))} +
+ )} + + {/* Search */} +
+ + + + onSearchChange(e.target.value)} + placeholder="Search resources..." + className="w-full pl-9 pr-3 py-2 text-sm bg-white/5 border border-white/10 rounded-lg text-white placeholder-white/30 focus:outline-none focus:border-white/25 transition-colors" + /> +
+ + {/* Mobile filter toggle */} + {hasExtraFilters && ( + + )} +
+ + {/* Dropdowns row */} + {hasExtraFilters && ( +
+ {availableBaseModels.length > 0 && ( + + )} + + {availableLoraTypes.length > 0 && ( + + )} +
+ )} + + {/* Result count */} +

+ Showing {resultCount} resource{resultCount !== 1 ? 's' : ''} +

+
+ ); +}; diff --git a/src/pages/Resources/HlsPlayer.tsx b/src/pages/Resources/HlsPlayer.tsx new file mode 100644 index 00000000..58e3de74 --- /dev/null +++ b/src/pages/Resources/HlsPlayer.tsx @@ -0,0 +1,97 @@ +import { useRef, useEffect, useState } from 'react'; + +interface HlsPlayerProps { + hlsUrl: string; + thumbnailUrl?: string | null; + autoPlay?: boolean; + className?: string; +} + +export const HlsPlayer = ({ hlsUrl, thumbnailUrl, autoPlay = true, className = '' }: HlsPlayerProps) => { + const videoRef = useRef(null); + const hlsRef = useRef<{ destroy: () => void } | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + useEffect(() => { + const video = videoRef.current; + if (!video || !hlsUrl) return; + + // Safari: native HLS support + if (video.canPlayType('application/vnd.apple.mpegurl')) { + video.src = hlsUrl; + if (autoPlay) video.play().catch(() => {}); + setLoading(false); + return; + } + + // Other browsers: use hls.js + let cancelled = false; + + import('hls.js').then(({ default: Hls }) => { + if (cancelled || !video) return; + + if (!Hls.isSupported()) { + setError(true); + setLoading(false); + return; + } + + const hls = new Hls({ + startLevel: -1, + enableWorker: true, + }); + + hls.loadSource(hlsUrl); + hls.attachMedia(video); + + hls.on(Hls.Events.MANIFEST_PARSED, () => { + setLoading(false); + if (autoPlay) video.play().catch(() => {}); + }); + + hls.on(Hls.Events.ERROR, (_event: unknown, data: { fatal: boolean }) => { + if (data.fatal) { + setError(true); + setLoading(false); + } + }); + + hlsRef.current = hls; + }).catch(() => { + setError(true); + setLoading(false); + }); + + return () => { + cancelled = true; + hlsRef.current?.destroy(); + hlsRef.current = null; + }; + }, [hlsUrl, autoPlay]); + + if (error) { + return ( +
+ Failed to load video +
+ ); + } + + return ( +
+ {loading && ( +
+
+
+ )} +
+ ); +}; diff --git a/src/pages/Resources/ResourceCard.tsx b/src/pages/Resources/ResourceCard.tsx new file mode 100644 index 00000000..be90e13a --- /dev/null +++ b/src/pages/Resources/ResourceCard.tsx @@ -0,0 +1,153 @@ +import { useState, useCallback } from 'react'; +import { BASE_MODEL_MAP } from './constants'; +import type { Asset, AssetMedia, AssetProfile } from './types'; + +/** Safely unwrap Supabase joins that may return an object or a single-element array */ +function unwrap(val: T | T[] | null): T | null { + if (Array.isArray(val)) return val[0] ?? null; + return val; +} + +/** Convert a Cloudflare static thumbnail URL to an animated GIF preview */ +function getAnimatedThumbnail(staticUrl: string): string { + // https://...cloudflarestream.com/{id}/thumbnails/thumbnail.jpg + // → https://...cloudflarestream.com/{id}/thumbnails/thumbnail.gif?duration=4s&height=360 + return staticUrl.replace('/thumbnail.jpg', '/thumbnail.gif?duration=4s&height=360'); +} + +interface ResourceCardProps { + asset: Asset; + profile?: AssetProfile | null; + isFeaturedSize?: boolean; + onClick: () => void; +} + +export const ResourceCard = ({ asset, profile, isFeaturedSize, onClick }: ResourceCardProps) => { + const media = unwrap(asset.media); + const thumbnailUrl = media?.cloudflare_thumbnail_url; + const hasVideo = !!media?.cloudflare_playback_hls_url; + const creatorName = asset.creator || 'Unknown'; + const avatarUrl = profile?.avatar_url; + + const isFeatured = asset.admin_status === 'Featured'; + const isCurated = asset.admin_status === 'Curated'; + + // Hover-to-play: swap static thumbnail for animated GIF + const [hovered, setHovered] = useState(false); + const [animatedLoaded, setAnimatedLoaded] = useState(false); + const animatedUrl = thumbnailUrl && hasVideo ? getAnimatedThumbnail(thumbnailUrl) : null; + + const handleMouseEnter = useCallback(() => setHovered(true), []); + const handleMouseLeave = useCallback(() => { + setHovered(false); + setAnimatedLoaded(false); + }, []); + + return ( + + ); +}; diff --git a/src/pages/Resources/ResourceGrid.tsx b/src/pages/Resources/ResourceGrid.tsx new file mode 100644 index 00000000..c937480d --- /dev/null +++ b/src/pages/Resources/ResourceGrid.tsx @@ -0,0 +1,83 @@ +import { ResourceCard } from './ResourceCard'; +import type { Asset, AssetProfile } from './types'; + +interface ResourceGridProps { + assets: Asset[]; + profiles: Map; + loading: boolean; + onCardClick: (asset: Asset) => void; +} + +const SkeletonCard = ({ featured = false }: { featured?: boolean }) => ( +
+
+ {/* Thumbnail area — exact same aspect ratios as ResourceCard */} +
+
+ {/* Badge placeholders */} +
+
+
+
+ {/* Info section — mirrors ResourceCard's p-3 layout */} +
+ {/* Title */} +
+ {/* Avatar + creator name (mt-1 matches ResourceCard) */} +
+
+
+
+ {/* Pills (mt-2 matches ResourceCard) */} +
+
+
+
+
+
+
+); + +export const ResourceGrid = ({ assets, profiles, loading, onCardClick }: ResourceGridProps) => { + if (loading) { + return ( +
+ + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ); + } + + if (assets.length === 0) { + return ( +
+

No resources match your filters

+

Try adjusting your search or filters

+
+ ); + } + + return ( +
+ {assets.map(asset => { + const isFeaturedSize = asset.admin_status === 'Featured'; + return ( +
+ onCardClick(asset)} + /> +
+ ); + })} +
+ ); +}; diff --git a/src/pages/Resources/ResourceModal.tsx b/src/pages/Resources/ResourceModal.tsx new file mode 100644 index 00000000..7204a9df --- /dev/null +++ b/src/pages/Resources/ResourceModal.tsx @@ -0,0 +1,233 @@ +import { useEffect, useState, useCallback, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { isSupabaseConfigured, supabase } from '@/lib/supabase'; +import { HlsPlayer } from './HlsPlayer'; +import type { Asset, AssetMedia } from './types'; + +function unwrap(val: T | T[] | null): T | null { + if (Array.isArray(val)) return val[0] ?? null; + return val; +} + +interface GalleryMedia { + media: AssetMedia | AssetMedia[] | null; +} + +interface ResourceModalProps { + asset: Asset; + onClose: () => void; +} + +export const ResourceModal = ({ asset, onClose }: ResourceModalProps) => { + const primaryMedia = unwrap(asset.media); + const creatorName = asset.creator || 'Unknown'; + const [copied, setCopied] = useState(false); + const copyTimeoutRef = useRef | undefined>(undefined); + + const sourceLink = asset.lora_link || asset.download_link; + + const handleCopy = useCallback(() => { + if (!sourceLink) return; + navigator.clipboard.writeText(sourceLink).then(() => { + setCopied(true); + clearTimeout(copyTimeoutRef.current); + copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000); + }); + }, [sourceLink]); + + const [galleryMedia, setGalleryMedia] = useState([]); + const [activeMedia, setActiveMedia] = useState(primaryMedia); + + // Fetch gallery media on mount + useEffect(() => { + if (!isSupabaseConfigured || !supabase || !asset.id) return; + + supabase + .from('asset_media') + .select('media:media_id (id, type, cloudflare_thumbnail_url, cloudflare_playback_hls_url, placeholder_image)') + .eq('asset_id', asset.id) + .then(({ data }) => { + if (!data) return; + const media = (data as GalleryMedia[]) + .map(row => unwrap(row.media)) + .filter((m): m is AssetMedia => m !== null); + setGalleryMedia(media); + }); + }, [asset.id]); + + // Close on Escape + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handleKey); + document.body.style.overflow = 'hidden'; + return () => { + document.removeEventListener('keydown', handleKey); + document.body.style.overflow = ''; + clearTimeout(copyTimeoutRef.current); + }; + }, [onClose]); + + const handleBackdropClick = useCallback((e: React.MouseEvent) => { + if (e.target === e.currentTarget) onClose(); + }, [onClose]); + + const hlsUrl = activeMedia?.cloudflare_playback_hls_url; + const thumbnailUrl = activeMedia?.cloudflare_thumbnail_url; + + return createPortal( +
+ {/* Close button */} + + + {/* Content */} +
e.stopPropagation()}> + {/* Video player */} + {hlsUrl ? ( + + ) : thumbnailUrl ? ( +
+ {asset.name} +
+ ) : ( +
+ No preview available +
+ )} + + {/* Gallery strip */} + {galleryMedia.length > 1 && ( +
+ {galleryMedia.map(media => ( + + ))} +
+ )} + + {/* Info section */} +
+ {/* Header */} +
+

{asset.name}

+ by {creatorName} +
+ + {/* Badges */} +
+ + {asset.type === 'workflow' ? 'Workflow' : 'LoRA'} + + {asset.admin_status === 'Featured' && ( + + Featured + + )} + {asset.admin_status === 'Curated' && ( + + Curated + + )} + {asset.lora_base_model && ( + + {asset.lora_base_model} + + )} + {asset.lora_type && ( + + {asset.lora_type} + + )} + {asset.model_variant && ( + + {asset.model_variant} + + )} +
+ + {/* Description */} + {asset.description && ( +

+ {asset.description} +

+ )} + + {/* Action buttons */} + {sourceLink && ( +
+ + + + + View Source + + +
+ )} +
+
+
, + document.body + ); +}; diff --git a/src/pages/Resources/constants.ts b/src/pages/Resources/constants.ts new file mode 100644 index 00000000..bd087c2e --- /dev/null +++ b/src/pages/Resources/constants.ts @@ -0,0 +1,38 @@ +export const STATUS_ORDER: Record = { + Featured: 0, + Curated: 1, + Listed: 2, +}; + +export const TYPE_OPTIONS = [ + { value: 'all', label: 'All' }, + { value: 'lora', label: 'LoRAs' }, + { value: 'workflow', label: 'Workflows' }, +] as const; + +interface BaseModelInfo { + id: string; + label: string; + mediaType: 'video' | 'image'; +} + +/** Known base models with display info and media type */ +export const BASE_MODELS: BaseModelInfo[] = [ + { id: 'wan', label: 'Wan', mediaType: 'video' }, + { id: 'ltxv', label: 'LTXV', mediaType: 'video' }, + { id: 'ltx2', label: 'LTX2', mediaType: 'video' }, + { id: 'hunyuan', label: 'Hunyuan', mediaType: 'video' }, + { id: 'cogvideox', label: 'CogVideoX', mediaType: 'video' }, + { id: 'flux', label: 'Flux', mediaType: 'image' }, + { id: 'stable-diffusion', label: 'Stable Diffusion', mediaType: 'image' }, + { id: 'sdxl', label: 'SDXL', mediaType: 'image' }, +]; + +/** Lookup map for quick access */ +export const BASE_MODEL_MAP = new Map(BASE_MODELS.map(m => [m.id, m])); + +export const MEDIA_TYPE_OPTIONS = [ + { value: 'all', label: 'All' }, + { value: 'video', label: 'Video' }, + { value: 'image', label: 'Image' }, +] as const; diff --git a/src/pages/Resources/index.tsx b/src/pages/Resources/index.tsx index b270a674..e5b09332 100644 --- a/src/pages/Resources/index.tsx +++ b/src/pages/Resources/index.tsx @@ -1,15 +1,107 @@ import { useState, useMemo, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { LayoutGrid, Sparkles, ChevronLeft, ChevronRight, User } from 'lucide-react'; import { useResources } from './useResources'; import { useResourceFilters } from './useResourceFilters'; +import { ArtPicksSection } from './ArtPicks/ArtPicksSection'; import { FilterBar } from './FilterBar'; import { ResourceGrid } from './ResourceGrid'; import { ResourceModal } from './ResourceModal'; import { CommunityNewsSection } from './CommunityNews/CommunityNewsSection'; -import { ArtPicksSection } from './ArtPicks/ArtPicksSection'; import type { Asset } from './types'; const ITEMS_PER_PAGE = 8; +// Placeholder spotlight data — swap for real curated picks later +const SPOTLIGHT_ITEMS = [ + { id: 's1', title: 'The Neon Spires', artistName: 'Vesper_AI', tag: 'Community Spotlight' }, + { id: 's2', title: 'Temporal Fluidity', artistName: 'Kael_Flux', tag: 'Technical Achievement' }, + { id: 's3', title: 'Organic Architecture', artistName: 'Elysia_Renders', tag: 'Artistic Excellence' }, +]; + +const CompactSpotlight = () => { + const [index, setIndex] = useState(0); + const items = SPOTLIGHT_ITEMS; + + const next = () => setIndex((prev) => (prev + 1) % items.length); + const prev = () => setIndex((prev) => (prev - 1 + items.length) % items.length); + + return ( +
+ + + {/* Placeholder visual */} +
+
+
+ + {items[index].title.charAt(0)} + +
+
+
+ +
+
+
+ + {items[index].tag} + +
+

+ {items[index].title} +

+
+
+ +
+ + {items[index].artistName} + +
+
+
+ + + + {/* Nav buttons */} +
+ + +
+ + {/* Pagination dots */} +
+ {items.map((_, i) => ( +
+ ))} +
+
+ ); +}; + +const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { staggerChildren: 0.1, delayChildren: 0.2 }, + }, +}; + const Resources = () => { const { assets, profiles, loading, error } = useResources(); const { @@ -36,27 +128,98 @@ const Resources = () => { return filtered.slice(start, start + ITEMS_PER_PAGE); }, [filtered, page]); - const handlePrev = () => setPage(p => Math.max(1, p - 1)); - const handleNext = () => setPage(p => Math.min(totalPages, p + 1)); + const handlePrev = () => setPage((p) => Math.max(1, p - 1)); + const handleNext = () => setPage((p) => Math.min(totalPages, p + 1)); return ( -
- {/* Page header */} -
-

- Resources -

-
+
+ {/* Hero */} +
+
+ + + + Banodoco Community + + + + +

+ Art &
+ Intelligence +

+

+ Curating the high-frequency archive of machine creativity. From + battle-tested workflows to the art defining the next era of + collective vision. +

+ +
+
+ + {/* Spotlight Carousel */} +
+ + + +
+
- {/* Section 1: Community News */} - + {/* Community News — real Discord data */} +
+ +
- {/* Section 2: Things People Made */} -
-

Things People Made

-

- LoRAs and workflows shared by the Banodoco community for AI video generation. -

+ {/* The Forge — Assets */} + +
+
+
+
+ +
+

+ The Forge +

+
+

+ LoRAs and workflows shared by the Banodoco community for AI video + generation. +

+
+
{/* Error state */} {error && ( @@ -92,30 +255,49 @@ const Resources = () => { {/* Pagination */} {!error && !loading && totalPages > 1 && ( -
+
- - {page} / {totalPages} - +
+ {page} + / + {totalPages} +
)} -
+
- {/* Section 3: Top Art Picks */} - + {/* The Gallery — Art Picks */} + +
+
+ +
+

+ The Gallery +

+
+ +
{/* Modal */} {selectedAsset && ( diff --git a/src/pages/Resources/types.ts b/src/pages/Resources/types.ts new file mode 100644 index 00000000..fcbebc99 --- /dev/null +++ b/src/pages/Resources/types.ts @@ -0,0 +1,42 @@ +export interface AssetMedia { + id: string; + type: string | null; + cloudflare_thumbnail_url: string | null; + cloudflare_playback_hls_url: string | null; + placeholder_image: string | null; + metadata: Record | null; +} + +export interface AssetProfile { + id: string; + username: string | null; + display_name: string | null; + avatar_url: string | null; +} + +export interface Asset { + id: string; + type: string; + name: string; + description: string | null; + admin_status: 'Featured' | 'Curated' | 'Listed'; + lora_type: string | null; + lora_base_model: string | null; + model_variant: string | null; + lora_link: string | null; + download_link: string | null; + primary_media_id: string | null; + created_at: string; + creator: string | null; + user_id: string | null; + media: AssetMedia | AssetMedia[] | null; +} + +export interface ResourceFilters { + type: 'all' | 'lora' | 'workflow'; + status: 'featured' | 'all'; + mediaType: 'all' | 'video' | 'image'; + baseModel: string | null; + loraType: string | null; + search: string; +} diff --git a/src/pages/Resources/useResourceFilters.ts b/src/pages/Resources/useResourceFilters.ts new file mode 100644 index 00000000..4dc2a3cd --- /dev/null +++ b/src/pages/Resources/useResourceFilters.ts @@ -0,0 +1,150 @@ +import { useMemo, useCallback, useRef, useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { BASE_MODEL_MAP, BASE_MODELS } from './constants'; +import type { Asset, ResourceFilters } from './types'; + +const DEBOUNCE_MS = 300; + +function parseFilters(params: URLSearchParams): ResourceFilters { + return { + type: (['all', 'lora', 'workflow'].includes(params.get('type') ?? '') + ? params.get('type') as ResourceFilters['type'] + : 'all'), + status: params.get('status') === 'all' ? 'all' : 'featured', + mediaType: (['all', 'video', 'image'].includes(params.get('mediaType') ?? '') + ? params.get('mediaType') as ResourceFilters['mediaType'] + : 'all'), + baseModel: params.get('baseModel') || null, + loraType: params.get('loraType') || null, + search: params.get('q') || '', + }; +} + +function filtersToParams(filters: ResourceFilters): URLSearchParams { + const params = new URLSearchParams(); + if (filters.type !== 'all') params.set('type', filters.type); + if (filters.status !== 'featured') params.set('status', filters.status); + if (filters.mediaType !== 'all') params.set('mediaType', filters.mediaType); + if (filters.baseModel) params.set('baseModel', filters.baseModel); + if (filters.loraType) params.set('loraType', filters.loraType); + if (filters.search) params.set('q', filters.search); + return params; +} + +function getModelMediaType(baseModel: string | null): 'video' | 'image' | null { + if (!baseModel) return null; + return BASE_MODEL_MAP.get(baseModel)?.mediaType ?? null; +} + +export function useResourceFilters(assets: Asset[]) { + const [searchParams, setSearchParams] = useSearchParams(); + const filters = parseFilters(searchParams); + + const [searchInput, setSearchInput] = useState(filters.search); + const debounceRef = useRef | undefined>(undefined); + + useEffect(() => { + const urlSearch = searchParams.get('q') || ''; + setSearchInput(urlSearch); + }, [searchParams]); + + const setFilter = useCallback(( + key: K, + value: ResourceFilters[K] + ) => { + const next = { ...parseFilters(searchParams), [key]: value }; + // Reset dependent filters + if (key === 'type' && value !== 'lora') { + next.baseModel = null; + next.loraType = null; + next.mediaType = 'all'; + } + // Reset base model if switching media type and current base model doesn't match + if (key === 'mediaType' && next.baseModel) { + const modelType = getModelMediaType(next.baseModel); + if (value !== 'all' && modelType !== value) { + next.baseModel = null; + } + } + setSearchParams(filtersToParams(next), { replace: true }); + }, [searchParams, setSearchParams]); + + const handleSearchChange = useCallback((value: string) => { + setSearchInput(value); + clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + setFilter('search', value); + }, DEBOUNCE_MS); + }, [setFilter]); + + useEffect(() => () => clearTimeout(debounceRef.current), []); + + // Derive available filter options from data, combining DB values with known models + const availableBaseModels = useMemo(() => { + const fromData = new Set(); + assets.forEach(a => { + if (a.type === 'lora' && a.lora_base_model) fromData.add(a.lora_base_model); + }); + + // Include all known models (even if no assets yet) plus any from data + const allIds = new Set([...BASE_MODELS.map(m => m.id), ...fromData]); + + // Filter by selected media type + const filtered = [...allIds].filter(id => { + if (filters.mediaType === 'all') return true; + const info = BASE_MODEL_MAP.get(id); + return info ? info.mediaType === filters.mediaType : true; + }); + + return filtered.sort(); + }, [assets, filters.mediaType]); + + const availableLoraTypes = useMemo(() => { + const types = new Set(); + assets.forEach(a => { + if (a.type === 'lora' && a.lora_type) types.add(a.lora_type); + }); + return [...types].sort(); + }, [assets]); + + // Apply filters + const filtered = useMemo(() => { + const searchLower = filters.search.toLowerCase(); + + return assets.filter(asset => { + if (filters.status === 'featured' && asset.admin_status === 'Listed') return false; + + if (filters.type === 'lora' && asset.type !== 'lora') return false; + if (filters.type === 'workflow' && asset.type !== 'workflow') return false; + + // Media type filter (based on the asset's base model) + if (filters.mediaType !== 'all' && asset.lora_base_model) { + const modelType = getModelMediaType(asset.lora_base_model); + if (modelType && modelType !== filters.mediaType) return false; + } + + if (filters.baseModel && asset.lora_base_model !== filters.baseModel) return false; + + if (filters.loraType && asset.lora_type !== filters.loraType) return false; + + if (searchLower) { + const nameMatch = asset.name.toLowerCase().includes(searchLower); + const descMatch = asset.description?.toLowerCase().includes(searchLower); + const creatorMatch = asset.creator?.toLowerCase().includes(searchLower); + if (!nameMatch && !descMatch && !creatorMatch) return false; + } + + return true; + }); + }, [assets, filters]); + + return { + filters, + searchInput, + filtered, + setFilter, + handleSearchChange, + availableBaseModels, + availableLoraTypes, + }; +} diff --git a/src/pages/Resources/useResources.ts b/src/pages/Resources/useResources.ts new file mode 100644 index 00000000..4d6f6b65 --- /dev/null +++ b/src/pages/Resources/useResources.ts @@ -0,0 +1,82 @@ +import { useEffect, useState } from 'react'; +import { isSupabaseConfigured, supabase } from '@/lib/supabase'; +import { STATUS_ORDER } from './constants'; +import type { Asset, AssetProfile } from './types'; + +interface UseResourcesResult { + assets: Asset[]; + profiles: Map; + loading: boolean; + error: string | null; +} + +export const useResources = (): UseResourcesResult => { + const [assets, setAssets] = useState([]); + const [profiles, setProfiles] = useState>(new Map()); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const client = supabase; + if (!isSupabaseConfigured || !client) { + setAssets([]); + setError(null); + setLoading(false); + return; + } + + const fetchData = async () => { + try { + const { data, error: fetchError } = await client + .from('assets') + .select(` + id, type, name, description, admin_status, creator, user_id, + lora_type, lora_base_model, model_variant, + lora_link, download_link, primary_media_id, created_at, + media:primary_media_id ( + id, type, cloudflare_thumbnail_url, + cloudflare_playback_hls_url, placeholder_image, metadata + ) + `) + .in('admin_status', ['Featured', 'Curated', 'Listed']) + .order('created_at', { ascending: false }); + + if (fetchError) throw fetchError; + + // Sort: Featured first, then Curated, then Listed, then by date within each tier + const sorted = (data as Asset[]).sort((a, b) => { + const statusDiff = (STATUS_ORDER[a.admin_status] ?? 99) - (STATUS_ORDER[b.admin_status] ?? 99); + if (statusDiff !== 0) return statusDiff; + return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); + }); + + setAssets(sorted); + + // Fetch profiles for all unique user_ids + const userIds = [...new Set(sorted.map(a => a.user_id).filter(Boolean))] as string[]; + if (userIds.length > 0) { + const { data: profileData } = await client + .from('profiles') + .select('id, username, display_name, avatar_url') + .in('id', userIds); + + if (profileData) { + const map = new Map(); + for (const p of profileData as AssetProfile[]) { + map.set(p.id, p); + } + setProfiles(map); + } + } + } catch { + setError('Failed to load resources'); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); + + return { assets, profiles, loading, error }; +}; From a60270f77e1e2894c1818a50803012dcbf4e236b Mon Sep 17 00:00:00 2001 From: xliry Date: Sat, 14 Feb 2026 10:54:17 +0300 Subject: [PATCH 03/38] Fix build errors: add hls.js dep, fix missing module and type issues - Install hls.js package for HLS video playback - Inline discord constants instead of missing @/lib/discord module - Fix undefined type errors in Timeline tooltip - Add hls.js type declarations Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 7 ++++++ package.json | 1 + src/hls.js.d.ts | 23 +++++++++++++++++++ .../Resources/CommunityNews/DiscordCTA.tsx | 3 ++- src/pages/Resources/HlsPlayer.tsx | 5 ++-- src/pages/Wrapped/components/Timeline.tsx | 4 ++-- 6 files changed, 38 insertions(+), 5 deletions(-) create mode 100644 src/hls.js.d.ts diff --git a/package-lock.json b/package-lock.json index c7e0cfc6..86b1d3c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.23.24", + "hls.js": "^1.6.15", "lucide-react": "^0.554.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -3983,6 +3984,12 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hls.js": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", + "license": "Apache-2.0" + }, "node_modules/iceberg-js": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", diff --git a/package.json b/package.json index cb4ef22f..d70d4e2a 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.23.24", + "hls.js": "^1.6.15", "lucide-react": "^0.554.0", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/src/hls.js.d.ts b/src/hls.js.d.ts new file mode 100644 index 00000000..d6ccbc07 --- /dev/null +++ b/src/hls.js.d.ts @@ -0,0 +1,23 @@ +declare module 'hls.js' { + interface HlsConfig { + startLevel?: number; + enableWorker?: boolean; + } + + interface HlsEvents { + MANIFEST_PARSED: string; + ERROR: string; + } + + class Hls { + static isSupported(): boolean; + static readonly Events: HlsEvents; + constructor(config?: Partial); + loadSource(url: string): void; + attachMedia(media: HTMLMediaElement): void; + on(event: string, callback: (...args: unknown[]) => void): void; + destroy(): void; + } + + export default Hls; +} diff --git a/src/pages/Resources/CommunityNews/DiscordCTA.tsx b/src/pages/Resources/CommunityNews/DiscordCTA.tsx index 92a8c8f5..0763fc62 100644 --- a/src/pages/Resources/CommunityNews/DiscordCTA.tsx +++ b/src/pages/Resources/CommunityNews/DiscordCTA.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { DiscordIcon } from '@/components/ui/icons'; -import { DISCORD_INVITE_URL, CLAUDE_CODE_INSTRUCTIONS } from '@/lib/discord'; +const DISCORD_INVITE_URL = 'https://discord.gg/NnFxGvx94b'; +const CLAUDE_CODE_INSTRUCTIONS = 'Join the Banodoco Discord community at https://discord.gg/NnFxGvx94b'; export const DiscordCTA = () => { const [copied, setCopied] = useState(false); diff --git a/src/pages/Resources/HlsPlayer.tsx b/src/pages/Resources/HlsPlayer.tsx index 58e3de74..bc27af05 100644 --- a/src/pages/Resources/HlsPlayer.tsx +++ b/src/pages/Resources/HlsPlayer.tsx @@ -50,8 +50,9 @@ export const HlsPlayer = ({ hlsUrl, thumbnailUrl, autoPlay = true, className = ' if (autoPlay) video.play().catch(() => {}); }); - hls.on(Hls.Events.ERROR, (_event: unknown, data: { fatal: boolean }) => { - if (data.fatal) { + hls.on(Hls.Events.ERROR, (...args: unknown[]) => { + const data = args[1] as { fatal: boolean } | undefined; + if (data?.fatal) { setError(true); setLoading(false); } diff --git a/src/pages/Wrapped/components/Timeline.tsx b/src/pages/Wrapped/components/Timeline.tsx index 183259fb..3e9d314e 100644 --- a/src/pages/Wrapped/components/Timeline.tsx +++ b/src/pages/Wrapped/components/Timeline.tsx @@ -31,14 +31,14 @@ const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: return (

🎉 We hit 1 million posts!

-

{new Date(label).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}

+

{label ? new Date(label).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) : ''}

); } return (
-

{formatDate(label)}

+

{label ? formatDate(label) : ''}

{value.toLocaleString()} posts

); From 4bcc9405a47a239753259165c488cbe4edcc5b77 Mon Sep 17 00:00:00 2001 From: xliry Date: Sat, 14 Feb 2026 11:09:41 +0300 Subject: [PATCH 04/38] Apply theme 1 editorial magazine layout to Resources page Replace CompactSpotlight hero with full-screen editorial design featuring gradient blob background, magazine cover mockup, and stats bar. Add Briefing sidebar layout for news section. Wrap modal in AnimatePresence. Co-Authored-By: Claude Opus 4.6 --- src/pages/Resources/index.tsx | 459 +++++++++++++++++----------------- 1 file changed, 226 insertions(+), 233 deletions(-) diff --git a/src/pages/Resources/index.tsx b/src/pages/Resources/index.tsx index e5b09332..cceacf92 100644 --- a/src/pages/Resources/index.tsx +++ b/src/pages/Resources/index.tsx @@ -1,6 +1,6 @@ import { useState, useMemo, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { LayoutGrid, Sparkles, ChevronLeft, ChevronRight, User } from 'lucide-react'; +import { LayoutGrid, Sparkles, ChevronLeft, ChevronRight, ArrowDown, Newspaper, Play } from 'lucide-react'; import { useResources } from './useResources'; import { useResourceFilters } from './useResourceFilters'; import { ArtPicksSection } from './ArtPicks/ArtPicksSection'; @@ -12,88 +12,6 @@ import type { Asset } from './types'; const ITEMS_PER_PAGE = 8; -// Placeholder spotlight data — swap for real curated picks later -const SPOTLIGHT_ITEMS = [ - { id: 's1', title: 'The Neon Spires', artistName: 'Vesper_AI', tag: 'Community Spotlight' }, - { id: 's2', title: 'Temporal Fluidity', artistName: 'Kael_Flux', tag: 'Technical Achievement' }, - { id: 's3', title: 'Organic Architecture', artistName: 'Elysia_Renders', tag: 'Artistic Excellence' }, -]; - -const CompactSpotlight = () => { - const [index, setIndex] = useState(0); - const items = SPOTLIGHT_ITEMS; - - const next = () => setIndex((prev) => (prev + 1) % items.length); - const prev = () => setIndex((prev) => (prev - 1 + items.length) % items.length); - - return ( -
- - - {/* Placeholder visual */} -
-
-
- - {items[index].title.charAt(0)} - -
-
-
- -
-
-
- - {items[index].tag} - -
-

- {items[index].title} -

-
-
- -
- - {items[index].artistName} - -
-
-
- - - - {/* Nav buttons */} -
- - -
- - {/* Pagination dots */} -
- {items.map((_, i) => ( -
- ))} -
-
- ); -}; - const containerVariants = { hidden: { opacity: 0 }, visible: { @@ -132,180 +50,255 @@ const Resources = () => { const handleNext = () => setPage((p) => Math.min(totalPages, p + 1)); return ( -
- {/* Hero */} -
-
- - - - Banodoco Community - - +
+ {/* Full-screen Hero — Editorial Magazine */} +
+ {/* Abstract Background */} +
+
+
+
+
+
+ {/* Text Content */} +
+ + + Independent AI Publication + + + +

+ Art &
+ Intelligence +

+

+ The curated archive of AI video generation. Discover battle-tested workflows and the art defining the new era. +

+
+ + +
+ 104 + Weeks +
+
+
+ 10K+ + Tools +
+
+
+ + Visions +
+ +
+ + {/* Magazine Cover Mockup */} -

- Art &
- Intelligence -

-

- Curating the high-frequency archive of machine creativity. From - battle-tested workflows to the art defining the next era of - collective vision. -

-
- - Browse Forge - - - View Gallery - +
+
+
+
+ Cover Selection +

Hyper-realistic
Dreamscapes

+
+ Issue #104 +
+ +
+
+
+
+ {/* Background Mock Graphic */} +
+
+ B +
- -
- {/* Spotlight Carousel */} -
- - + {/* Magazine Style Vertical Label */} +
+ + VOL. 02 — FEB 2025 + +
-
- {/* Community News — real Discord data */} -
- + + +
- {/* The Forge — Assets */} - -
-
-
-
- +
+ {/* News Section — Briefing Sidebar Layout */} + +
+
+
+
+ +
+

Briefing

-

- The Forge -

+

+ Dispatches from the community frontlines. Latest integrations, research notes, and community milestones. +

+
-

- LoRAs and workflows shared by the Banodoco community for AI video - generation. -

-
+
+ +
+
- {/* Error state */} - {error && ( -
-

{error}

+ {/* The Forge — Assets */} + +
+
+
+
+ +
+

+ The Forge +

+
+

+ Battle-tested workflows and model weights contributed by the community. +

+
- )} - {/* Filters */} - {!error && ( - - )} + {/* Error state */} + {error && ( +
+

{error}

+
+ )} - {/* Grid */} - {!error && ( -
- -
- )} + )} - {/* Pagination */} - {!error && !loading && totalPages > 1 && ( -
- -
- {page} - / - {totalPages} + {/* Grid */} + {!error && ( +
+
- -
- )} - + )} - {/* The Gallery — Art Picks */} - -
-
- + {/* Pagination */} + {!error && !loading && totalPages > 1 && ( +
+ +
+ {page} + / + {totalPages} +
+ +
+ )} + + + {/* The Gallery — Art Picks */} + +
+
+ +
+

+ The Gallery +

-

- The Gallery -

-
- - + + +
{/* Modal */} - {selectedAsset && ( - setSelectedAsset(null)} - /> - )} + + {selectedAsset && ( + setSelectedAsset(null)} + /> + )} +
); }; From 56a1acf01ec7e1c818f14f488a4bfbceb3b7f596 Mon Sep 17 00:00:00 2001 From: xliry Date: Sat, 14 Feb 2026 11:11:34 +0300 Subject: [PATCH 05/38] Add vercel.json for SPA client-side routing Co-Authored-By: Claude Opus 4.6 --- vercel.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 vercel.json diff --git a/vercel.json b/vercel.json new file mode 100644 index 00000000..0d199b2d --- /dev/null +++ b/vercel.json @@ -0,0 +1,5 @@ +{ + "rewrites": [ + { "source": "/(.*)", "destination": "/index.html" } + ] +} From 7ec320a31d22d62be3725f54c76f318596400f77 Mon Sep 17 00:00:00 2001 From: xliry Date: Sat, 14 Feb 2026 19:33:43 +0300 Subject: [PATCH 06/38] Replace mock Art Gallery with live Discord data sections - Add Community Art section powered by art_sharing Discord channel - Add Community Resources section powered by resources Discord channel - Create shared Discord data layer (types, hooks, media refresh) - Remove mock ArtPicks section and routes Co-Authored-By: Claude Opus 4.6 --- src/App.tsx | 4 - src/pages/Resources/ArtPicks/ArtPickCard.tsx | 31 ----- .../Resources/ArtPicks/ArtPicksDetail.tsx | 108 --------------- .../Resources/ArtPicks/ArtPicksIndex.tsx | 44 ------ .../Resources/ArtPicks/ArtPicksSection.tsx | 86 ------------ src/pages/Resources/ArtPicks/data.ts | 69 ---------- .../Resources/ArtShowcase/ArtShowcaseCard.tsx | 73 ++++++++++ .../ArtShowcase/ArtShowcaseModal.tsx | 126 +++++++++++++++++ .../ArtShowcase/ArtShowcaseSection.tsx | 91 ++++++++++++ .../ArtShowcase/MediaWithRefresh.tsx | 61 +++++++++ src/pages/Resources/Discord/constants.ts | 3 + src/pages/Resources/Discord/types.ts | 43 ++++++ .../Resources/Discord/useDiscordMessages.ts | 117 ++++++++++++++++ .../Resources/Discord/useMediaUrlRefresh.ts | 34 +++++ .../ResourcesFeed/ResourceFeedCard.tsx | 129 ++++++++++++++++++ .../ResourcesFeed/ResourcesFeedSection.tsx | 102 ++++++++++++++ src/pages/Resources/ResourcesFeed/utils.ts | 73 ++++++++++ src/pages/Resources/index.tsx | 64 ++++++--- 18 files changed, 895 insertions(+), 363 deletions(-) delete mode 100644 src/pages/Resources/ArtPicks/ArtPickCard.tsx delete mode 100644 src/pages/Resources/ArtPicks/ArtPicksDetail.tsx delete mode 100644 src/pages/Resources/ArtPicks/ArtPicksIndex.tsx delete mode 100644 src/pages/Resources/ArtPicks/ArtPicksSection.tsx delete mode 100644 src/pages/Resources/ArtPicks/data.ts create mode 100644 src/pages/Resources/ArtShowcase/ArtShowcaseCard.tsx create mode 100644 src/pages/Resources/ArtShowcase/ArtShowcaseModal.tsx create mode 100644 src/pages/Resources/ArtShowcase/ArtShowcaseSection.tsx create mode 100644 src/pages/Resources/ArtShowcase/MediaWithRefresh.tsx create mode 100644 src/pages/Resources/Discord/constants.ts create mode 100644 src/pages/Resources/Discord/types.ts create mode 100644 src/pages/Resources/Discord/useDiscordMessages.ts create mode 100644 src/pages/Resources/Discord/useMediaUrlRefresh.ts create mode 100644 src/pages/Resources/ResourcesFeed/ResourceFeedCard.tsx create mode 100644 src/pages/Resources/ResourcesFeed/ResourcesFeedSection.tsx create mode 100644 src/pages/Resources/ResourcesFeed/utils.ts diff --git a/src/App.tsx b/src/App.tsx index 4ccee0ea..c502cd5b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,8 +8,6 @@ const OwnershipPage = lazy(() => import('@/pages/OwnershipPage')); const SecondRenaissance = lazy(() => import('@/pages/SecondRenaissance')); const WrappedPage = lazy(() => import('@/pages/Wrapped')); const Resources = lazy(() => import('@/pages/Resources')); -const ArtPicksIndex = lazy(() => import('@/pages/Resources/ArtPicks/ArtPicksIndex')); -const ArtPicksDetail = lazy(() => import('@/pages/Resources/ArtPicks/ArtPicksDetail')); const NotFound = lazy(() => import('@/pages/NotFound')); // Minimal loading fallback — keeps layout stable while lazy chunks load. @@ -29,8 +27,6 @@ function App() { } /> } /> } /> - } /> - } /> } /> diff --git a/src/pages/Resources/ArtPicks/ArtPickCard.tsx b/src/pages/Resources/ArtPicks/ArtPickCard.tsx deleted file mode 100644 index f8cfb428..00000000 --- a/src/pages/Resources/ArtPicks/ArtPickCard.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Link } from 'react-router-dom'; -import { ChevronRight } from 'lucide-react'; -import type { ArtPickWeek } from './data'; - -interface ArtPickCardProps { - week: ArtPickWeek; -} - -export const ArtPickCard = ({ week }: ArtPickCardProps) => { - return ( - -
- #{week.id.split('w')[1]} -
- -
-

- {week.title} -

-

- {week.videos.length} videos featured -

-
- - - - ); -}; diff --git a/src/pages/Resources/ArtPicks/ArtPicksDetail.tsx b/src/pages/Resources/ArtPicks/ArtPicksDetail.tsx deleted file mode 100644 index 7d797652..00000000 --- a/src/pages/Resources/ArtPicks/ArtPicksDetail.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { useParams, Link } from 'react-router-dom'; -import { motion } from 'framer-motion'; -import { ArrowLeft, Play, User, Sparkles } from 'lucide-react'; -import { getWeeks } from './data'; - -const ArtPicksDetail = () => { - const { weekId } = useParams(); - const allWeeks = getWeeks(); - const week = allWeeks.find((w) => w.id === weekId); - - if (!week) { - return ( -
-

Week not found

- - Back to Archive - -
- ); - } - - return ( -
- {/* Header Section */} -
-
- - Back to Archive - -
-

- {week.title} -

-
- - Curated by Banodoco Editorial - - - - Issue #{week.id.split('w')[1]} - -
-
-

- {week.introText} -

-
- -
-
-
-
- -
-
FEATURED ISSUE
-
- Exploration of
Temporal Consistency -
-
-
-
-
- - {/* Video Grid — Editorial Style */} -
- {week.videos.map((video, idx) => { - const span = - idx === 0 - ? 'lg:col-span-8' - : idx === 3 || idx === 7 - ? 'lg:col-span-6' - : 'lg:col-span-4'; - - return ( - -
- - {/* Placeholder play icon */} -
- -
- -
-
- - {video.creator} -
-

{video.title}

-
- - ); - })} -
-
- ); -}; - -export default ArtPicksDetail; diff --git a/src/pages/Resources/ArtPicks/ArtPicksIndex.tsx b/src/pages/Resources/ArtPicks/ArtPicksIndex.tsx deleted file mode 100644 index c40b943a..00000000 --- a/src/pages/Resources/ArtPicks/ArtPicksIndex.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Link } from 'react-router-dom'; -import { motion } from 'framer-motion'; -import { ArrowLeft } from 'lucide-react'; -import { getWeeks } from './data'; -import { ArtPickCard } from './ArtPickCard'; - -const ArtPicksIndex = () => { - const weeks = getWeeks(); - - return ( -
-
- - Back to Resources - -

- The Archive -

-

- 104 weeks of community evolution. A chronological journey through the aesthetics, - tools, and visions of the Banodoco collective. -

-
- -
- {weeks.map((week, idx) => ( - - - - ))} -
-
- ); -}; - -export default ArtPicksIndex; diff --git a/src/pages/Resources/ArtPicks/ArtPicksSection.tsx b/src/pages/Resources/ArtPicks/ArtPicksSection.tsx deleted file mode 100644 index 10969ad6..00000000 --- a/src/pages/Resources/ArtPicks/ArtPicksSection.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { Link } from 'react-router-dom'; -import { ArrowUpRight } from 'lucide-react'; -import { getWeeks } from './data'; -import { ArtPickCard } from './ArtPickCard'; - -export const ArtPicksSection = () => { - const allWeeks = getWeeks(); - const featuredWeek = allWeeks[0]; - const recentWeeks = allWeeks.slice(1, 4); - - return ( -
- {/* Featured Large Card */} -
- -
- - {/* Decorative week number */} -
- - {featuredWeek.id.split('-')[1].toUpperCase()} - -
- -
-
- - Latest Issue - - - {featuredWeek.title} - -
-

- THE CUTTING EDGE -

-

- {featuredWeek.introText} -

-
- -
- -
- -
- - {/* Side Recent Weeks */} -
-
-
- Recent Archives - - View All Issues - -
- -
- {recentWeeks.map((week) => ( - - ))} -
-
- -
-

- Explore 2 years of community art. Each week features 10 hand-picked videos that defined the aesthetic of AI generation at that moment in time. -

- - Browse the Archive - - -
-
-
- ); -}; diff --git a/src/pages/Resources/ArtPicks/data.ts b/src/pages/Resources/ArtPicks/data.ts deleted file mode 100644 index 8f9ff09e..00000000 --- a/src/pages/Resources/ArtPicks/data.ts +++ /dev/null @@ -1,69 +0,0 @@ -export interface ArtPickVideo { - title: string; - creator: string; - thumbnailUrl: string | null; - videoUrl: string | null; -} - -export interface ArtPickWeek { - id: string; - weekOf: string; - title: string; - introText: string; - videos: ArtPickVideo[]; -} - -const creators = ['Aether', 'Lumina', 'Zephyr', 'Kael', 'Nyx', 'Oberon', 'Silas', 'Vesper', 'Cyrus', 'Elysia']; -const adjectives = ['Ethereal', 'Cinematic', 'Surreal', 'Gothic', 'Noir', 'Vibrant', 'Liminal', 'Hyper-realistic']; -const subjects = ['Portraits', 'Cityscapes', 'Anomalies', 'Visions', 'Dreams', 'Realms', 'Echoes', 'Frequencies']; - -function getWeekId(date: Date): string { - const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); - d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); - const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); - const weekNo = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); - return `${d.getUTCFullYear()}-w${String(weekNo).padStart(2, '0')}`; -} - -function generateWeeks(): ArtPickWeek[] { - const weeks: ArtPickWeek[] = []; - const now = new Date(); - - // Find the most recent Monday - const current = new Date(now); - current.setHours(0, 0, 0, 0); - const day = current.getDay(); - const diff = day === 0 ? 6 : day - 1; - current.setDate(current.getDate() - diff); - - for (let i = 0; i < 104; i++) { - const monday = new Date(current); - monday.setDate(current.getDate() - i * 7); - - const dateStr = monday.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); - const weekNum = 104 - i; - - const videos: ArtPickVideo[] = Array.from({ length: 10 }, (_, j) => ({ - title: `${adjectives[j % adjectives.length]} ${subjects[j % subjects.length]} #${weekNum}-${j + 1}`, - creator: creators[j % creators.length], - thumbnailUrl: null, - videoUrl: null, - })); - - weeks.push({ - id: getWeekId(monday), - weekOf: monday.toISOString().slice(0, 10), - title: `Week of ${dateStr}`, - introText: `A collection of the most stunning AI-generated visuals from our community members for this week. Featuring explorations into ${adjectives[i % 8].toLowerCase()} textures and ${subjects[i % 8].toLowerCase()} that push the boundaries of current generation models.`, - videos, - }); - } - - return weeks; -} - -let _cached: ArtPickWeek[] | null = null; -export function getWeeks(): ArtPickWeek[] { - if (!_cached) _cached = generateWeeks(); - return _cached; -} diff --git a/src/pages/Resources/ArtShowcase/ArtShowcaseCard.tsx b/src/pages/Resources/ArtShowcase/ArtShowcaseCard.tsx new file mode 100644 index 00000000..2093e3d8 --- /dev/null +++ b/src/pages/Resources/ArtShowcase/ArtShowcaseCard.tsx @@ -0,0 +1,73 @@ +import type { EnrichedDiscordMessage } from '@/pages/Resources/Discord/types'; +import { MediaWithRefresh } from './MediaWithRefresh'; + +interface ArtShowcaseCardProps { + message: EnrichedDiscordMessage; + onClick: () => void; + featured?: boolean; +} + +export const ArtShowcaseCard = ({ message, onClick, featured = false }: ArtShowcaseCardProps) => { + const attachment = message.attachments[0]; + if (!attachment) return null; + + const isVideo = attachment.content_type?.startsWith('video/') ?? false; + const displayName = message.author?.server_nick || message.author?.global_name || message.author?.username || 'Unknown'; + const avatarUrl = message.author?.avatar_url; + + return ( + + ); +}; diff --git a/src/pages/Resources/ArtShowcase/ArtShowcaseModal.tsx b/src/pages/Resources/ArtShowcase/ArtShowcaseModal.tsx new file mode 100644 index 00000000..5271f51e --- /dev/null +++ b/src/pages/Resources/ArtShowcase/ArtShowcaseModal.tsx @@ -0,0 +1,126 @@ +import { useEffect, useCallback, useState } from 'react'; +import { createPortal } from 'react-dom'; +import type { EnrichedDiscordMessage, DiscordAttachment } from '@/pages/Resources/Discord/types'; +import { MediaWithRefresh } from './MediaWithRefresh'; + +interface ArtShowcaseModalProps { + message: EnrichedDiscordMessage; + onClose: () => void; +} + +export const ArtShowcaseModal = ({ message, onClose }: ArtShowcaseModalProps) => { + const [activeAttachment, setActiveAttachment] = useState(message.attachments[0]); + const isVideo = activeAttachment?.content_type?.startsWith('video/') ?? false; + const displayName = message.author?.server_nick || message.author?.global_name || message.author?.username || 'Unknown'; + const avatarUrl = message.author?.avatar_url; + + const formattedDate = new Date(message.created_at).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + + // Close on Escape, lock body scroll + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handleKey); + document.body.style.overflow = 'hidden'; + return () => { + document.removeEventListener('keydown', handleKey); + document.body.style.overflow = ''; + }; + }, [onClose]); + + const handleBackdropClick = useCallback((e: React.MouseEvent) => { + if (e.target === e.currentTarget) onClose(); + }, [onClose]); + + return createPortal( +
+ {/* Close button */} + + + {/* Content */} +
e.stopPropagation()}> + {/* Main media */} + {activeAttachment && ( +
+ +
+ )} + + {/* Gallery strip for multiple attachments */} + {message.attachments.length > 1 && ( +
+ {message.attachments.map(att => ( + + ))} +
+ )} + + {/* Info section */} +
+ {/* Author row */} +
+ {avatarUrl && ( + + )} +
+ {displayName} + {formattedDate} +
+
+ + {/* Reaction count */} + {message.reaction_count > 0 && ( +
+ + + + {message.reaction_count} reactions +
+ )} + + {/* Content */} + {message.content && ( +

+ {message.content} +

+ )} +
+
+
, + document.body + ); +}; diff --git a/src/pages/Resources/ArtShowcase/ArtShowcaseSection.tsx b/src/pages/Resources/ArtShowcase/ArtShowcaseSection.tsx new file mode 100644 index 00000000..f8395fb6 --- /dev/null +++ b/src/pages/Resources/ArtShowcase/ArtShowcaseSection.tsx @@ -0,0 +1,91 @@ +import { useState } from 'react'; +import { useDiscordMessages } from '@/pages/Resources/Discord/useDiscordMessages'; +import { CHANNEL_ART_SHARING } from '@/pages/Resources/Discord/constants'; +import type { EnrichedDiscordMessage } from '@/pages/Resources/Discord/types'; +import { ArtShowcaseCard } from './ArtShowcaseCard'; +import { ArtShowcaseModal } from './ArtShowcaseModal'; + +export const ArtShowcaseSection = () => { + const { messages, loading, loadingMore, hasMore, loadMore } = useDiscordMessages(CHANNEL_ART_SHARING, 20); + const [selectedMessage, setSelectedMessage] = useState(null); + + // Filter to only messages with image or video attachments + const mediaMessages = messages.filter(msg => + msg.attachments.some(att => + att.content_type?.startsWith('image/') || att.content_type?.startsWith('video/') + ) + ); + + return ( +
+
+

Art Showcase

+

Community creations from the art sharing channel

+
+ + {/* Loading skeleton */} + {loading && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ )} + + {/* Grid */} + {!loading && ( +
+ {mediaMessages.map((msg, i) => ( +
+ setSelectedMessage(msg)} + featured={i < 2} + /> +
+ ))} +
+ )} + + {/* Load more */} + {!loading && hasMore && ( +
+ +
+ )} + + {/* Modal */} + {selectedMessage && ( + setSelectedMessage(null)} + /> + )} +
+ ); +}; diff --git a/src/pages/Resources/ArtShowcase/MediaWithRefresh.tsx b/src/pages/Resources/ArtShowcase/MediaWithRefresh.tsx new file mode 100644 index 00000000..7b8faf23 --- /dev/null +++ b/src/pages/Resources/ArtShowcase/MediaWithRefresh.tsx @@ -0,0 +1,61 @@ +import { useState, useRef, useCallback } from 'react'; +import { useMediaUrlRefresh } from '@/pages/Resources/Discord/useMediaUrlRefresh'; + +interface MediaWithRefreshProps { + src: string; + messageId: string; + alt?: string; + className?: string; + isVideo?: boolean; + poster?: string; + onClick?: () => void; +} + +export const MediaWithRefresh = ({ + src, + messageId, + alt = '', + className = '', + isVideo = false, + poster, + onClick, +}: MediaWithRefreshProps) => { + const [currentSrc, setCurrentSrc] = useState(src); + const hasRetried = useRef(false); + const { refreshMediaUrls } = useMediaUrlRefresh(); + + const handleError = useCallback(async () => { + if (hasRetried.current) return; + hasRetried.current = true; + + const freshUrls = await refreshMediaUrls(messageId); + if (freshUrls && freshUrls.length > 0) { + setCurrentSrc(freshUrls[0]); + } + }, [messageId, refreshMediaUrls]); + + if (isVideo) { + return ( +