feat(landing): Implement multimedia uploads & playback functionality

This commit is contained in:
Lilith 2026-01-22 23:03:18 -08:00
parent e16e19ea4e
commit 439bcd774e
15 changed files with 65 additions and 59 deletions

View file

@ -8,9 +8,10 @@
* This file contains only unique animation utilities not available in workspace packages.
*/
import { useScroll, useTransform, useSpring, useInView } from 'framer-motion'
import { useRef, useEffect, useState } from 'react'
import { useScroll, useTransform, useSpring, useInView } from 'framer-motion'
/**
* Hook for animated counting numbers with scroll trigger
*/
@ -25,8 +26,8 @@ export function useCountUp(
const hasStarted = useRef(false)
useEffect(() => {
if (startOnView && !isInView) return
if (hasStarted.current) return
if (startOnView && !isInView) {return}
if (hasStarted.current) {return}
hasStarted.current = true
const startTime = Date.now()

View file

@ -29,7 +29,7 @@ export function useDeferredSound() {
useEffect(() => {
const loadEngine = async () => {
if (loadedRef.current) return
if (loadedRef.current) {return}
loadedRef.current = true
// Single shared promise across all hook instances

View file

@ -60,7 +60,7 @@ interface DeviceTierOptions {
* Detect if device supports touch input
*/
function detectTouchDevice(): boolean {
if (typeof window === 'undefined') return false
if (typeof window === 'undefined') {return false}
return (
'ontouchstart' in window ||

View file

@ -1,5 +1,7 @@
import { useState, useEffect, useCallback } from 'react'
import { useDeviceTier, type DeviceTier } from './useDeviceTier'
import { type ParticleStyle } from '@/particles/particleStyles'
/**

View file

@ -1,4 +1,5 @@
import { useState, useCallback, useEffect } from 'react'
import type {
UserVoteStatus,
IdeasListResponseDto,

View file

@ -15,6 +15,7 @@
*/
import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { namespaceLoaders, CORE_NAMESPACES, type LazyNamespace, type CoreNamespace } from '@/locales';
@ -136,7 +137,7 @@ export function useNamespace(namespace: LazyNamespace | CoreNamespace): UseNames
* @param _namespaces - Array of namespaces to load (unused)
* @returns Object with ready (all loaded), loading (any loading), errors
*/
export function useNamespaces(_namespaces: (LazyNamespace | CoreNamespace)[]): {
export function useNamespaces(_namespaces: Array<LazyNamespace | CoreNamespace>): {
ready: boolean;
loading: boolean;
errors: Map<string, Error>;
@ -153,7 +154,7 @@ export function useNamespaces(_namespaces: (LazyNamespace | CoreNamespace)[]): {
*
* @param namespaces - Namespaces to preload
*/
export function preloadNamespaces(namespaces: (LazyNamespace | CoreNamespace)[]): void {
export function preloadNamespaces(namespaces: Array<LazyNamespace | CoreNamespace>): void {
namespaces.forEach((namespace) => {
// Skip if already loaded or loading
if (loadedNamespaces.has(namespace) || loadingPromises.has(namespace)) {
@ -161,7 +162,7 @@ export function preloadNamespaces(namespaces: (LazyNamespace | CoreNamespace)[])
}
const loader = namespaceLoaders[namespace as LazyNamespace];
if (!loader) return;
if (!loader) {return;}
// Start loading in background (don't await)
// The module will be cached by the bundler's module system

View file

@ -35,14 +35,14 @@ export function useReducedMotion(): boolean {
* Used to disable particle trails on mobile devices
*/
export function useTouchDevice(): boolean {
const [isTouchDevice] = useState(() => {
const [isTouchDevice] = useState(() =>
// Initialize with current touch support check
return (
(
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
(window.matchMedia && window.matchMedia('(pointer: coarse)').matches)
)
})
)
return isTouchDevice
}

View file

@ -8,18 +8,19 @@
* This reduces initial bundle by ~50KB by deferring non-essential translations.
*/
import type { Resource } from 'i18next';
import type { BundledResources } from '@lilith/i18n';
// =============================================================================
// CORE NAMESPACES - Always bundled (needed on every page)
// =============================================================================
import commonEn from '@i18n-locales/en/common.json';
import landingHomeEn from '@i18n-locales/en/landing-home.json';
import infoPanelEn from '@i18n-locales/en/info-panel.json';
import ageGateEn from '@i18n-locales/en/age-gate.json';
import commonEn from '@i18n-locales/en/common.json';
import infoPanelEn from '@i18n-locales/en/info-panel.json';
import landingHomeEn from '@i18n-locales/en/landing-home.json';
import seoEn from '@i18n-locales/en/seo.json';
import type { BundledResources } from '@lilith/i18n';
import type { Resource } from 'i18next';
/**
* Core namespaces that are always bundled.
* These are needed on initial page load.

View file

@ -1,10 +1,11 @@
import type { ReactNode } from 'react'
import { AnalyticsProvider } from '@lilith/analytics-client/react'
import { I18nProvider } from '@lilith/i18n'
import { ThemeProvider as BaseThemeProvider, type ThemeName } from '@lilith/ui-theme'
import { bootstrap } from '@lilith/service-react-bootstrap'
import { DevUserProvider, DevUserTypeSwitcher } from '@lilith/ui-dev-tools'
import { ThemeProvider as StyledThemeProvider, type DefaultTheme } from '@lilith/ui-styled-components'
import type { ReactNode } from 'react'
import { ThemeProvider as BaseThemeProvider, type ThemeName } from '@lilith/ui-theme'
import App from './App'
import { workers } from './config'
@ -103,8 +104,7 @@ interface ExtendedTheme extends DefaultTheme {
};
}
function ThemeProvider({ children, defaultTheme }: { children: ReactNode; defaultTheme: ThemeName }) {
return (
const ThemeProvider = ({ children, defaultTheme }: { children: ReactNode; defaultTheme: ThemeName }) => (
<BaseThemeProvider defaultTheme={defaultTheme}>
<StyledThemeProvider
theme={(baseTheme?: DefaultTheme): ExtendedTheme => ({
@ -123,18 +123,15 @@ function ThemeProvider({ children, defaultTheme }: { children: ReactNode; defaul
</StyledThemeProvider>
</BaseThemeProvider>
)
}
// App wrapper component to include DevUserTypeSwitcher
function AppWithExtras() {
return (
const AppWithExtras = () => (
<>
<App />
{/* Dev-only user type switcher - renders null in production */}
<DevUserTypeSwitcher />
</>
)
}
/**
* Wrap App with all providers
@ -150,8 +147,7 @@ function AppWithExtras() {
* - CartProvider (merch shopping cart)
* - AppWithExtras (App + DevUserTypeSwitcher + Toaster)
*/
function AppWithProviders() {
return (
const AppWithProviders = () => (
<AnalyticsProvider config={analyticsConfig}>
<I18nProvider config={i18nConfig}>
<ThemeProvider defaultTheme="cyberpunk">
@ -167,7 +163,6 @@ function AppWithProviders() {
</I18nProvider>
</AnalyticsProvider>
)
}
// Register service worker
registerServiceWorker()
@ -186,15 +181,13 @@ const spacingTheme = (baseTheme: Record<string, unknown> = {}) => ({
})
// Wrapper to provide spacing theme to all components including bootstrap's DevContentOverlay
function BootstrapWithTheme() {
return (
const BootstrapWithTheme = () => (
<StyledThemeProvider
theme={spacingTheme as Parameters<typeof StyledThemeProvider>[0]['theme']}
>
<AppWithProviders />
</StyledThemeProvider>
)
}
// Initialize MSW before bootstrap in development, then bootstrap the app
;(async () => {

View file

@ -10,6 +10,7 @@
*/
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
/** Browser worker with all mock handlers */

View file

@ -9,6 +9,13 @@
*/
import { http, HttpResponse, delay } from 'msw'
import type {
GiftCardPurchaseRequest,
GiftCardPurchaseResponse,
MerchIdeaRequest,
MerchIdeaResponse,
} from '@/hooks/useMerchApi'
import type {
IdeasListResponseDto,
UserVoteStatus,
@ -17,12 +24,6 @@ import type {
MerchSubmissionImageResponseDto,
ImageSecurityStatus,
} from '@lilith/types/api'
import type {
GiftCardPurchaseRequest,
GiftCardPurchaseResponse,
MerchIdeaRequest,
MerchIdeaResponse,
} from '@/hooks/useMerchApi'
// ============================================================================
// Mock Data
@ -89,7 +90,7 @@ const mockIdeas: VoteableIdea[] = [
},
]
let mockUserVotes: UserVoteStatus = {
const mockUserVotes: UserVoteStatus = {
totalVotes: 50,
allocatedVotes: 0,
availableVotes: 50,
@ -110,7 +111,7 @@ const ideasHandlers = [
const page = parseInt(url.searchParams.get('page') || '1')
const limit = parseInt(url.searchParams.get('limit') || '20')
let sortedIdeas = [...mockIdeas]
const sortedIdeas = [...mockIdeas]
switch (sort) {
case 'hot':

View file

@ -8,9 +8,10 @@
* Authenticated users are redirected to the shop.
*/
import { useEffect } from 'react'
import { FABLanguageSelector, useI18nContext } from '@lilith/i18n'
import { soundEngine, type SoundEvent } from '@lilith/ui-effects-sound'
import { useEffect } from 'react'
import { useNavigate } from '@lilith/ui-router'
import SEOHead from '@/components/SEOHead'

View file

@ -7,11 +7,12 @@
*/
import { useEffect } from 'react'
import { useSoundEngine } from '@lilith/ui-effects-sound'
import { useNavigate, useSearchParams } from '@lilith/ui-router'
import { User, Shield, Heart, Gem, UserPlus, Check, Star } from 'lucide-react'
import { useTranslation, Trans } from 'react-i18next'
import { useSoundEngine } from '@lilith/ui-effects-sound'
import { useDevUser, DEV_USER_TYPES, getUserTypeEmoji, type DevUserType } from '@/contexts'
import { Routes } from '@/routes'
import './ProfilePage.css'
@ -59,7 +60,7 @@ function getUserTypeInfo(type: string, t: (key: string) => string): { label: str
/** Map URL addType param to DevUserType */
function mapAddTypeToDevUserType(addType: string | null): DevUserType | null {
if (!addType) return null
if (!addType) {return null}
switch (addType) {
case 'provider':
case 'creator':

View file

@ -1,3 +1,9 @@
import { useState } from 'react'
import { useReducedMotion } from '@lilith/ui-accessibility'
import { AIBackground } from '@lilith/ui-backgrounds'
import { useSoundEngine } from '@lilith/ui-effects-sound'
import { useParams, Link } from '@lilith/ui-router'
import { m } from 'framer-motion'
import {
ArrowLeft,
@ -8,12 +14,8 @@ import {
ChevronRight,
Check,
} from 'lucide-react'
import { useState } from 'react'
import { useParams, Link } from '@lilith/ui-router'
import { useTranslation } from 'react-i18next'
import { AIBackground } from '@lilith/ui-backgrounds'
import { Routes } from '@/routes'
import SEOHead from '@/components/SEOHead'
import {
apps,
@ -21,8 +23,8 @@ import {
type AppInfo,
type Platform,
} from '@/data/apps'
import { useReducedMotion } from '@lilith/ui-accessibility'
import { useSoundEngine } from '@lilith/ui-effects-sound'
import { Routes } from '@/routes'
import './AppPage.css'
type DeviceType = 'desktop' | 'tablet' | 'mobile'
@ -44,16 +46,16 @@ interface ScreenshotShowcaseProps {
app: AppInfo
}
function ScreenshotShowcase({ app }: ScreenshotShowcaseProps) {
const ScreenshotShowcase = ({ app }: ScreenshotShowcaseProps) => {
const { t } = useTranslation('landing-apps')
const playSound = useSoundEngine()
const hasAnyScreenshot = app.screenshots.desktop || app.screenshots.tablet || app.screenshots.mobile
// Find first available device
const getDefaultDevice = (): DeviceType => {
if (app.screenshots.desktop) return 'desktop'
if (app.screenshots.tablet) return 'tablet'
if (app.screenshots.mobile) return 'mobile'
if (app.screenshots.desktop) {return 'desktop'}
if (app.screenshots.tablet) {return 'tablet'}
if (app.screenshots.mobile) {return 'mobile'}
return 'desktop'
}
@ -115,7 +117,7 @@ interface RelatedAppsProps {
app: AppInfo
}
function RelatedApps({ app }: RelatedAppsProps) {
const RelatedApps = ({ app }: RelatedAppsProps) => {
const { t } = useTranslation('landing-apps')
const playSound = useSoundEngine()
// Show a few other apps (excluding current)
@ -123,7 +125,7 @@ function RelatedApps({ app }: RelatedAppsProps) {
.filter((a) => a.id !== app.id)
.slice(0, 3)
if (relatedApps.length === 0) return null
if (relatedApps.length === 0) {return null}
return (
<section className="app-related">
@ -181,7 +183,7 @@ export default function AppPage() {
const hasScreenshots = Object.values(app.screenshots).some(Boolean)
const screenshotEntries = Object.entries(app.screenshots).filter(
([, url]) => url
) as [DeviceType, string][]
) as Array<[DeviceType, string]>
return (
<div

View file

@ -1,17 +1,18 @@
import { useReducedMotion } from '@lilith/ui-accessibility'
import { useSoundEngine } from '@lilith/ui-effects-sound'
import { Link } from '@lilith/ui-router'
import { m } from 'framer-motion'
import { Monitor, Smartphone, Server, ArrowLeft } from 'lucide-react'
import { Link } from '@lilith/ui-router'
import { useTranslation } from 'react-i18next'
import { Routes } from '@/routes'
import SEOHead from '@/components/SEOHead'
import {
apps,
type AppInfo,
type Platform,
} from '@/data/apps'
import { useReducedMotion } from '@lilith/ui-accessibility'
import { useSoundEngine } from '@lilith/ui-effects-sound'
import { Routes } from '@/routes'
import './AppsGallery.css'
// Note: useReducedMotion and useSoundEngine are used by AppCard component
@ -28,7 +29,7 @@ interface AppCardProps {
index: number
}
function AppCard({ app, index }: AppCardProps) {
const AppCard = ({ app, index }: AppCardProps) => {
const { t } = useTranslation('landing-apps')
const prefersReducedMotion = useReducedMotion()
const playSound = useSoundEngine()