1.0
This commit is contained in:
parent
2d686911e7
commit
d3ec77b828
@ -1,6 +1,5 @@
|
||||
import React, {useEffect, useRef} from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||
import BaseDivider from './BaseDivider'
|
||||
import BaseIcon from './BaseIcon'
|
||||
|
||||
179
frontend/src/helpers/modularInteractionHub.ts
Normal file
179
frontend/src/helpers/modularInteractionHub.ts
Normal file
@ -0,0 +1,179 @@
|
||||
export type HubLinkType = 'agent' | 'demo' | 'form' | 'social' | 'media';
|
||||
|
||||
export type HubLink = {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
type: HubLinkType;
|
||||
summary: string;
|
||||
eyebrow: string;
|
||||
accent: string;
|
||||
};
|
||||
|
||||
export type MediaPresetType = 'radio' | 'tv';
|
||||
export type MediaPresetMode = 'audio' | 'video' | 'embed';
|
||||
|
||||
export type MediaPreset = {
|
||||
id: string;
|
||||
type: MediaPresetType;
|
||||
title: string;
|
||||
url: string;
|
||||
notes: string;
|
||||
mode: MediaPresetMode;
|
||||
isSample?: boolean;
|
||||
};
|
||||
|
||||
export type AdminShortcut = {
|
||||
title: string;
|
||||
href: string;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export const modularInteractionLinks: HubLink[] = [
|
||||
{
|
||||
id: 'agent-prof-aliyo',
|
||||
title: 'Prof Aliyo Momot AI Agent',
|
||||
url: 'https://prof-aliyo-momot-ai-agent.onhercules.app/agent',
|
||||
type: 'agent',
|
||||
eyebrow: 'AI agent',
|
||||
summary: 'Primary conversational entry point for visitors who want guided AI interaction.',
|
||||
accent: 'from-fuchsia-500/20 via-violet-500/10 to-cyan-400/20',
|
||||
},
|
||||
{
|
||||
id: 'agent-frame-smith',
|
||||
title: 'Frame Smith AI',
|
||||
url: 'https://frame-smith-ai.lovable.app',
|
||||
type: 'agent',
|
||||
eyebrow: 'Creative workflow',
|
||||
summary: 'A second AI experience positioned for making, framing, and ideation workflows.',
|
||||
accent: 'from-sky-500/20 via-cyan-500/10 to-emerald-400/20',
|
||||
},
|
||||
{
|
||||
id: 'demo-aliyo-media',
|
||||
title: 'Aliyo Momot Media Demo',
|
||||
url: 'https://aliyo-momot-media-fb6f.dev.flatlogic.app/?stream=d5ade7f8-7b12-4b2b-997e-4f1de9180e9b',
|
||||
type: 'media',
|
||||
eyebrow: 'Media experience',
|
||||
summary: 'Existing demo destination for media-first storytelling and streaming discovery.',
|
||||
accent: 'from-amber-500/20 via-orange-500/10 to-rose-400/20',
|
||||
},
|
||||
{
|
||||
id: 'demo-copyright-revealer',
|
||||
title: 'Copyright Revealer',
|
||||
url: 'https://copyright-revealer-cc67.dev.flatlogic.app',
|
||||
type: 'demo',
|
||||
eyebrow: 'Specialized demo',
|
||||
summary: 'A supporting utility demo that expands the modular toolset around your concept.',
|
||||
accent: 'from-indigo-500/20 via-blue-500/10 to-cyan-400/20',
|
||||
},
|
||||
{
|
||||
id: 'form-1',
|
||||
title: 'Fillout Intake 01',
|
||||
url: 'https://build.fillout.com/use/fwxccezugw',
|
||||
type: 'form',
|
||||
eyebrow: 'Fillout form',
|
||||
summary: 'Capture structured visitor input without forcing them to leave your ecosystem.',
|
||||
accent: 'from-lime-500/20 via-emerald-500/10 to-cyan-400/20',
|
||||
},
|
||||
{
|
||||
id: 'form-2',
|
||||
title: 'Fillout Intake 02',
|
||||
url: 'https://build.fillout.com/use/fouwuywqjg',
|
||||
type: 'form',
|
||||
eyebrow: 'Fillout form',
|
||||
summary: 'Useful for onboarding, submissions, or modular requests from the public.',
|
||||
accent: 'from-purple-500/20 via-pink-500/10 to-rose-400/20',
|
||||
},
|
||||
{
|
||||
id: 'form-3',
|
||||
title: 'Fillout Intake 03',
|
||||
url: 'https://build.fillout.com/use/1e7hvg7j95',
|
||||
type: 'form',
|
||||
eyebrow: 'Fillout form',
|
||||
summary: 'A third intake surface that keeps lead capture visible inside the link dashboard.',
|
||||
accent: 'from-cyan-500/20 via-blue-500/10 to-violet-400/20',
|
||||
},
|
||||
{
|
||||
id: 'form-4',
|
||||
title: 'Fillout Intake 04',
|
||||
url: 'https://build.fillout.com/use/j1yzoiedhq',
|
||||
type: 'form',
|
||||
eyebrow: 'Fillout form',
|
||||
summary: 'Additional form endpoint for follow-ups, media submissions, or campaign flows.',
|
||||
accent: 'from-pink-500/20 via-rose-500/10 to-orange-400/20',
|
||||
},
|
||||
{
|
||||
id: 'social-facebook',
|
||||
title: 'Facebook Community Profile',
|
||||
url: 'https://www.facebook.com/profile.php?id=61567817647812',
|
||||
type: 'social',
|
||||
eyebrow: 'Social presence',
|
||||
summary: 'A community touchpoint for followers arriving from Facebook and public promotion.',
|
||||
accent: 'from-blue-500/20 via-indigo-500/10 to-violet-400/20',
|
||||
},
|
||||
];
|
||||
|
||||
export const starterMediaPresets: MediaPreset[] = [
|
||||
{
|
||||
id: 'sample-radio',
|
||||
type: 'radio',
|
||||
title: 'Sample Radio Placeholder',
|
||||
url: 'https://samplelib.com/lib/preview/mp3/sample-12s.mp3',
|
||||
notes: 'Replace this with your live radio stream URL when you are ready.',
|
||||
mode: 'audio',
|
||||
isSample: true,
|
||||
},
|
||||
{
|
||||
id: 'sample-tv',
|
||||
type: 'tv',
|
||||
title: 'Sample TV Placeholder',
|
||||
url: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
|
||||
notes: 'This placeholder shows the TV widget layout until you paste a real embed or video stream.',
|
||||
mode: 'video',
|
||||
isSample: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const adminShortcuts: AdminShortcut[] = [
|
||||
{
|
||||
title: 'Admin interface',
|
||||
href: '/login',
|
||||
summary: 'Sign in to manage all generated CRUD screens and protected content modules.',
|
||||
},
|
||||
{
|
||||
title: 'External links manager',
|
||||
href: '/external_links/external_links-list',
|
||||
summary: 'Use the existing CRUD to curate agents, demos, forms, and social links.',
|
||||
},
|
||||
{
|
||||
title: 'Media streams manager',
|
||||
href: '/media_streams/media_streams-list',
|
||||
summary: 'Manage stream records for radio and television sources inside the admin area.',
|
||||
},
|
||||
{
|
||||
title: 'Widgets manager',
|
||||
href: '/widgets/widgets-list',
|
||||
summary: 'Configure reusable radio, TV, AI, and embed widgets for future authenticated workflows.',
|
||||
},
|
||||
];
|
||||
|
||||
export const hubHighlights = [
|
||||
{
|
||||
title: 'Modular by design',
|
||||
description: 'Visitors can move between AI, media, and forms without losing the overall story.',
|
||||
},
|
||||
{
|
||||
title: 'Public-friendly',
|
||||
description: 'The front door is open, branded, and focused on exploration instead of admin jargon.',
|
||||
},
|
||||
{
|
||||
title: 'Admin-ready',
|
||||
description: 'Existing CRUD screens stay in place so you can manage content once you log in.',
|
||||
},
|
||||
];
|
||||
|
||||
export const experienceSteps = [
|
||||
'Open the public landing page and understand the Modular Artificial Interaction concept at a glance.',
|
||||
'Jump into the interaction hub to browse AI agents, demos, forms, and community links.',
|
||||
'Preview radio or TV in-page, or paste your own stream URL and save it as a preset in this browser.',
|
||||
];
|
||||
@ -1,5 +1,4 @@
|
||||
import React, { ReactNode, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||
import menuAside from '../menuAside'
|
||||
|
||||
@ -7,6 +7,13 @@ const menuAside: MenuAsideItem[] = [
|
||||
icon: icon.mdiViewDashboardOutline,
|
||||
label: 'Dashboard',
|
||||
},
|
||||
{
|
||||
href: '/interaction-hub',
|
||||
label: 'Interaction hub',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiAtomVariant' in icon ? icon['mdiAtomVariant' as keyof typeof icon] : icon.mdiBroadcast ?? icon.mdiViewDashboardOutline,
|
||||
},
|
||||
|
||||
{
|
||||
href: '/users/users-list',
|
||||
|
||||
@ -1,166 +1,348 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
mdiArrowRight,
|
||||
mdiBroadcast,
|
||||
mdiCubeOutline,
|
||||
mdiLogin,
|
||||
mdiOpenInNew,
|
||||
mdiPlayCircleOutline,
|
||||
mdiRobotOutline,
|
||||
mdiTelevisionPlay,
|
||||
mdiViewDashboardOutline,
|
||||
} from '@mdi/js';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import CardBox from '../components/CardBox';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import { getPageTitle } from '../config';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
||||
import {
|
||||
adminShortcuts,
|
||||
experienceSteps,
|
||||
hubHighlights,
|
||||
modularInteractionLinks,
|
||||
} from '../helpers/modularInteractionHub';
|
||||
|
||||
const moduleCards = [
|
||||
{
|
||||
title: 'AI entry points',
|
||||
description: 'Launch your existing agents from one branded, public-facing destination.',
|
||||
icon: mdiRobotOutline,
|
||||
},
|
||||
{
|
||||
title: 'Link switchboard',
|
||||
description: 'Show demos, Fillout flows, and social destinations without the page feeling scattered.',
|
||||
icon: mdiCubeOutline,
|
||||
},
|
||||
{
|
||||
title: 'Radio + TV widgets',
|
||||
description: 'Give visitors in-page media playback with presets and future room for live streams.',
|
||||
icon: mdiBroadcast,
|
||||
},
|
||||
{
|
||||
title: 'Admin-ready modules',
|
||||
description: 'Keep the generated admin, CRUD, and permissions setup intact for future growth.',
|
||||
icon: mdiViewDashboardOutline,
|
||||
},
|
||||
];
|
||||
|
||||
export default function Starter() {
|
||||
const [illustrationImage, setIllustrationImage] = useState({
|
||||
src: undefined,
|
||||
photographer: undefined,
|
||||
photographer_url: undefined,
|
||||
})
|
||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
||||
const [contentType, setContentType] = useState('video');
|
||||
const [contentPosition, setContentPosition] = useState('right');
|
||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||
const featuredLinks = modularInteractionLinks.slice(0, 6);
|
||||
|
||||
const title = 'Modular Interaction Hub'
|
||||
|
||||
// Fetch Pexels image/video
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const image = await getPexelsImage();
|
||||
const video = await getPexelsVideo();
|
||||
setIllustrationImage(image);
|
||||
setIllustrationVideo(video);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const imageBlock = (image) => (
|
||||
<div
|
||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
||||
style={{
|
||||
backgroundImage: `${
|
||||
image
|
||||
? `url(${image?.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={image?.photographer_url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Photo by {image?.photographer} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const videoBlock = (video) => {
|
||||
if (video?.video_files?.length > 0) {
|
||||
return (
|
||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||
<video
|
||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
>
|
||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={video?.user?.url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Video by {video.user.name} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
};
|
||||
const statCards = [
|
||||
{ value: '2', label: 'AI experiences' },
|
||||
{ value: '4', label: 'Fillout entry forms' },
|
||||
{ value: '2', label: 'Media widgets' },
|
||||
];
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
contentPosition === 'background'
|
||||
? {
|
||||
backgroundImage: `${
|
||||
illustrationImage
|
||||
? `url(${illustrationImage.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
<title>{getPageTitle('Modular Artificial Interaction')}</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A public-facing AI and media hub for Modular Artificial Interaction, with agent links, forms, and radio/TV widgets."
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div
|
||||
className={`flex ${
|
||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||
} min-h-screen w-full`}
|
||||
>
|
||||
{contentType === 'image' && contentPosition !== 'background'
|
||||
? imageBlock(illustrationImage)
|
||||
: null}
|
||||
{contentType === 'video' && contentPosition !== 'background'
|
||||
? videoBlock(illustrationVideo)
|
||||
: null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<CardBoxComponentTitle title="Welcome to your Modular Interaction Hub app!"/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className='text-center '>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
||||
<p className='text-center '>For guides and documentation please check
|
||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||
<div className="min-h-screen bg-[#050816] text-white">
|
||||
<div className="absolute inset-x-0 top-0 -z-0 h-[38rem] bg-[radial-gradient(circle_at_top,_rgba(96,165,250,0.28),_transparent_42%),radial-gradient(circle_at_30%_30%,_rgba(217,70,239,0.24),_transparent_32%),linear-gradient(180deg,_#0a1024,_#050816)]" />
|
||||
|
||||
<header className="sticky top-0 z-20 border-b border-white/10 bg-[#050816]/80 backdrop-blur-xl">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-cyan-400/40 bg-cyan-400/10 text-cyan-200 shadow-[0_0_40px_rgba(34,211,238,0.15)]">
|
||||
<BaseIcon path={mdiCubeOutline} size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.32em] text-cyan-200/80">Modular Artificial</p>
|
||||
<p className="text-lg font-semibold tracking-wide text-white">Interaction Hub</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-8 text-sm text-slate-300 md:flex">
|
||||
<a href="#concept" className="transition hover:text-white">
|
||||
Concept
|
||||
</a>
|
||||
<a href="#modules" className="transition hover:text-white">
|
||||
Modules
|
||||
</a>
|
||||
<a href="#switchboard" className="transition hover:text-white">
|
||||
Switchboard
|
||||
</a>
|
||||
<a href="#admin" className="transition hover:text-white">
|
||||
Admin
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<BaseButton href="/login" label="Login" color="whiteDark" className="border-white/10" />
|
||||
<BaseButton href="/interaction-hub" label="Open hub" color="info" />
|
||||
</div>
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
<main className="relative z-10">
|
||||
<section className="mx-auto grid max-w-7xl gap-12 px-6 pb-16 pt-16 lg:grid-cols-[1.15fr_0.85fr] lg:pb-24 lg:pt-24">
|
||||
<div className="space-y-8">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-fuchsia-400/25 bg-white/5 px-4 py-2 text-sm text-fuchsia-100 shadow-[0_0_30px_rgba(217,70,239,0.12)]">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-400" />
|
||||
Beautiful public front door for AI, links, and media
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<h1 className="max-w-4xl text-5xl font-semibold leading-tight text-white md:text-6xl">
|
||||
Present <span className="text-cyan-300">Modular Artificial Interaction</span> as one living experience.
|
||||
</h1>
|
||||
<p className="max-w-3xl text-lg leading-8 text-slate-300 md:text-xl">
|
||||
This first slice turns the starter app into a polished public hub: your AI agents,
|
||||
demos, Fillout links, and radio/TV widgets now feel connected, intentional, and ready
|
||||
for visitors coming from social or direct links.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<BaseButton href="/interaction-hub" label="Launch interaction hub" icon={mdiArrowRight} color="info" />
|
||||
<BaseButton href="/login" label="Admin interface" icon={mdiLogin} color="whiteDark" className="border-white/10" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{statCards.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="rounded-3xl border border-white/10 bg-white/5 p-5 backdrop-blur-xl"
|
||||
>
|
||||
<div className="text-3xl font-semibold text-white">{item.value}</div>
|
||||
<div className="mt-2 text-sm uppercase tracking-[0.22em] text-slate-400">
|
||||
{item.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 rounded-[2rem] bg-gradient-to-br from-cyan-500/20 via-fuchsia-500/10 to-transparent blur-2xl" />
|
||||
<div className="relative overflow-hidden rounded-[2rem] border border-white/10 bg-slate-950/75 p-6 shadow-[0_30px_80px_rgba(15,23,42,0.6)] backdrop-blur-xl">
|
||||
<div className="flex items-center justify-between border-b border-white/10 pb-5">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-cyan-200/70">Live concept board</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">How the experience feels</h2>
|
||||
</div>
|
||||
<div className="rounded-full border border-emerald-400/40 bg-emerald-400/10 px-3 py-1 text-xs uppercase tracking-[0.2em] text-emerald-200">
|
||||
Public ready
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
{hubHighlights.map((item, index) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className="rounded-3xl border border-white/10 bg-white/5 p-5 text-left"
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-white/10 text-cyan-200">
|
||||
<span className="text-sm font-semibold">0{index + 1}</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white">{item.title}</h3>
|
||||
</div>
|
||||
<p className="text-sm leading-7 text-slate-300">{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="concept" className="mx-auto max-w-7xl px-6 py-6">
|
||||
<div className="rounded-[2rem] border border-white/10 bg-white/[0.03] p-8 shadow-[0_20px_60px_rgba(15,23,42,0.4)]">
|
||||
<div className="grid gap-8 lg:grid-cols-[0.95fr_1.05fr] lg:items-center">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.3em] text-cyan-200/70">Concept</p>
|
||||
<h2 className="mt-4 text-3xl font-semibold text-white md:text-4xl">
|
||||
A modular stack for interaction, intake, and media.
|
||||
</h2>
|
||||
<p className="mt-4 max-w-2xl text-base leading-8 text-slate-300">
|
||||
Instead of sending people to scattered tools, the app now frames everything as a
|
||||
coherent digital venue: AI assistance, form-based capture, media playback, and
|
||||
curated launch points are all organized under one calm, futuristic visual system.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{experienceSteps.map((step, index) => (
|
||||
<div key={step} className="rounded-3xl border border-white/10 bg-slate-900/60 p-5">
|
||||
<div className="mb-4 text-sm uppercase tracking-[0.24em] text-fuchsia-200/80">
|
||||
Step {index + 1}
|
||||
</div>
|
||||
<p className="text-sm leading-7 text-slate-300">{step}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="modules" className="mx-auto max-w-7xl px-6 py-16">
|
||||
<div className="mb-10 max-w-3xl">
|
||||
<p className="text-sm uppercase tracking-[0.3em] text-cyan-200/70">Modules</p>
|
||||
<h2 className="mt-4 text-3xl font-semibold text-white md:text-4xl">
|
||||
The first branded slice is more than a landing page.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
|
||||
{moduleCards.map((item) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className="rounded-[1.75rem] border border-white/10 bg-white/[0.04] p-6 transition duration-200 hover:-translate-y-1 hover:border-cyan-300/30 hover:bg-white/[0.06]"
|
||||
>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-cyan-400/10 text-cyan-200">
|
||||
<BaseIcon path={item.icon} size={22} />
|
||||
</div>
|
||||
<h3 className="mt-6 text-xl font-medium text-white">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="switchboard" className="mx-auto max-w-7xl px-6 py-2">
|
||||
<div className="mb-10 flex flex-col gap-5 md:flex-row md:items-end md:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-sm uppercase tracking-[0.3em] text-cyan-200/70">Switchboard preview</p>
|
||||
<h2 className="mt-4 text-3xl font-semibold text-white md:text-4xl">
|
||||
Curated launch cards for the tools you already have.
|
||||
</h2>
|
||||
</div>
|
||||
<BaseButton href="/interaction-hub" label="See the full hub" icon={mdiArrowRight} color="whiteDark" className="border-white/10" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{featuredLinks.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`group overflow-hidden rounded-[1.75rem] border border-white/10 bg-gradient-to-br ${item.accent} p-[1px] transition hover:-translate-y-1 hover:border-white/20`}
|
||||
>
|
||||
<div className="flex h-full flex-col rounded-[1.7rem] bg-slate-950/90 p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs uppercase tracking-[0.22em] text-slate-300">
|
||||
{item.eyebrow}
|
||||
</span>
|
||||
<BaseIcon path={mdiOpenInNew} size={18} className="text-slate-400 transition group-hover:text-white" />
|
||||
</div>
|
||||
<h3 className="mt-6 text-xl font-medium text-white">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.summary}</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto max-w-7xl px-6 py-16">
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="rounded-[2rem] border border-white/10 bg-gradient-to-br from-cyan-500/10 via-slate-950 to-slate-950 p-8">
|
||||
<div className="flex items-center gap-3 text-cyan-200">
|
||||
<BaseIcon path={mdiPlayCircleOutline} size={24} />
|
||||
<p className="text-sm uppercase tracking-[0.25em]">Radio widget</p>
|
||||
</div>
|
||||
<h3 className="mt-5 text-2xl font-semibold text-white">In-page listening for your audience</h3>
|
||||
<p className="mt-4 max-w-xl text-sm leading-7 text-slate-300">
|
||||
The hub includes a radio player area with browser-saved presets, so you can start with
|
||||
placeholders now and swap in real stream URLs later.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-[2rem] border border-white/10 bg-gradient-to-br from-fuchsia-500/10 via-slate-950 to-slate-950 p-8">
|
||||
<div className="flex items-center gap-3 text-fuchsia-200">
|
||||
<BaseIcon path={mdiTelevisionPlay} size={24} />
|
||||
<p className="text-sm uppercase tracking-[0.25em]">TV widget</p>
|
||||
</div>
|
||||
<h3 className="mt-5 text-2xl font-semibold text-white">Flexible embed or video preview</h3>
|
||||
<p className="mt-4 max-w-xl text-sm leading-7 text-slate-300">
|
||||
Visitors can stay on your site while watching a sample video or a future live embed,
|
||||
giving the experience a richer broadcast identity.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="admin" className="mx-auto max-w-7xl px-6 pb-20 pt-4">
|
||||
<div className="rounded-[2rem] border border-white/10 bg-white/[0.04] p-8">
|
||||
<div className="mb-8 max-w-3xl">
|
||||
<p className="text-sm uppercase tracking-[0.3em] text-cyan-200/70">Admin continuity</p>
|
||||
<h2 className="mt-4 text-3xl font-semibold text-white md:text-4xl">
|
||||
Keep the admin power you already have.
|
||||
</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-slate-300">
|
||||
The new public experience works as the front door, while the generated admin remains the
|
||||
control room for managing links, streams, widgets, and future content.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
|
||||
{adminShortcuts.map((item) => (
|
||||
<Link
|
||||
key={item.title}
|
||||
href={item.href}
|
||||
className="rounded-[1.5rem] border border-white/10 bg-slate-950/70 p-6 transition hover:-translate-y-1 hover:border-cyan-300/30"
|
||||
>
|
||||
<h3 className="text-lg font-medium text-white">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.summary}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-white/10 bg-slate-950/80">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-4 px-6 py-8 text-sm text-slate-400 md:flex-row md:items-center md:justify-between">
|
||||
<p>© 2026 Modular Artificial Interaction Hub. Built as a public AI + media launch surface.</p>
|
||||
<div className="flex flex-wrap items-center gap-5">
|
||||
<Link href="/interaction-hub" className="transition hover:text-white">
|
||||
Open hub
|
||||
</Link>
|
||||
<Link href="/login" className="transition hover:text-white">
|
||||
Login
|
||||
</Link>
|
||||
<Link href="/privacy-policy" className="transition hover:text-white">
|
||||
Privacy policy
|
||||
</Link>
|
||||
<Link href="/terms-of-use" className="transition hover:text-white">
|
||||
Terms
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
HomePage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
|
||||
682
frontend/src/pages/interaction-hub.tsx
Normal file
682
frontend/src/pages/interaction-hub.tsx
Normal file
@ -0,0 +1,682 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
mdiArrowLeft,
|
||||
mdiBroadcast,
|
||||
mdiDeleteOutline,
|
||||
mdiOpenInNew,
|
||||
mdiPlus,
|
||||
mdiRadioTower,
|
||||
mdiRobotOutline,
|
||||
mdiTelevisionPlay,
|
||||
mdiViewDashboardOutline,
|
||||
} from '@mdi/js';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import CardBox from '../components/CardBox';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { getPageTitle } from '../config';
|
||||
import {
|
||||
adminShortcuts,
|
||||
MediaPreset,
|
||||
MediaPresetMode,
|
||||
MediaPresetType,
|
||||
modularInteractionLinks,
|
||||
starterMediaPresets,
|
||||
} from '../helpers/modularInteractionHub';
|
||||
|
||||
const MEDIA_PRESETS_STORAGE_KEY = 'modular-interaction-media-presets';
|
||||
|
||||
type FormState = {
|
||||
type: MediaPresetType;
|
||||
title: string;
|
||||
url: string;
|
||||
notes: string;
|
||||
mode: MediaPresetMode;
|
||||
};
|
||||
|
||||
type FormErrors = Partial<Record<keyof FormState, string>>;
|
||||
|
||||
const defaultFormState: FormState = {
|
||||
type: 'radio',
|
||||
title: '',
|
||||
url: '',
|
||||
notes: '',
|
||||
mode: 'audio',
|
||||
};
|
||||
|
||||
const sectionMeta: Record<string, { title: string; subtitle: string; icon: string }> = {
|
||||
agent: {
|
||||
title: 'AI agents',
|
||||
subtitle: 'Conversation and guidance entry points',
|
||||
icon: mdiRobotOutline,
|
||||
},
|
||||
media: {
|
||||
title: 'Media demos',
|
||||
subtitle: 'Existing media-led experiences',
|
||||
icon: mdiBroadcast,
|
||||
},
|
||||
demo: {
|
||||
title: 'Supporting demos',
|
||||
subtitle: 'Specialized utility and proof-of-concept pages',
|
||||
icon: mdiViewDashboardOutline,
|
||||
},
|
||||
form: {
|
||||
title: 'Forms & intake',
|
||||
subtitle: 'Submission endpoints for structured visitor data',
|
||||
icon: mdiPlus,
|
||||
},
|
||||
social: {
|
||||
title: 'Community',
|
||||
subtitle: 'Social touchpoints and audience handoff',
|
||||
icon: mdiOpenInNew,
|
||||
},
|
||||
};
|
||||
|
||||
const createId = () => `preset-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const isValidUrl = (value: string) => {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const inferMode = (type: MediaPresetType, url: string, fallback: MediaPresetMode) => {
|
||||
if (type === 'radio') {
|
||||
return 'audio';
|
||||
}
|
||||
|
||||
const lowered = url.toLowerCase();
|
||||
|
||||
if (
|
||||
lowered.endsWith('.mp4') ||
|
||||
lowered.endsWith('.webm') ||
|
||||
lowered.endsWith('.ogg') ||
|
||||
lowered.includes('.mp4?')
|
||||
) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
if (lowered.includes('youtube.com/watch?v=')) {
|
||||
return 'embed';
|
||||
}
|
||||
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const normalizeTvUrl = (url: string, mode: MediaPresetMode) => {
|
||||
if (mode !== 'embed') {
|
||||
return url;
|
||||
}
|
||||
|
||||
if (url.includes('youtube.com/watch?v=')) {
|
||||
const parsed = new URL(url);
|
||||
const videoId = parsed.searchParams.get('v');
|
||||
|
||||
if (videoId) {
|
||||
return `https://www.youtube.com/embed/${videoId}`;
|
||||
}
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
const getPreviewLabel = (preset: MediaPreset) => {
|
||||
if (preset.type === 'radio') {
|
||||
return 'Radio preview';
|
||||
}
|
||||
|
||||
return preset.mode === 'embed' ? 'TV embed preview' : 'TV video preview';
|
||||
};
|
||||
|
||||
export default function InteractionHubPage() {
|
||||
const [presets, setPresets] = useState<MediaPreset[]>(starterMediaPresets);
|
||||
const [selectedPresetId, setSelectedPresetId] = useState<string>(starterMediaPresets[0].id);
|
||||
const [formState, setFormState] = useState<FormState>(defaultFormState);
|
||||
const [formErrors, setFormErrors] = useState<FormErrors>({});
|
||||
const [feedbackMessage, setFeedbackMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const storedPresets = window.localStorage.getItem(MEDIA_PRESETS_STORAGE_KEY);
|
||||
|
||||
if (!storedPresets) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedPresets = JSON.parse(storedPresets) as MediaPreset[];
|
||||
|
||||
if (Array.isArray(parsedPresets) && parsedPresets.length > 0) {
|
||||
setPresets(parsedPresets);
|
||||
setSelectedPresetId(parsedPresets[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load media presets from localStorage:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(MEDIA_PRESETS_STORAGE_KEY, JSON.stringify(presets));
|
||||
}, [presets]);
|
||||
|
||||
const groupedLinks = useMemo(() => {
|
||||
return modularInteractionLinks.reduce<Record<string, typeof modularInteractionLinks>>((acc, item) => {
|
||||
const key = item.type;
|
||||
|
||||
if (!acc[key]) {
|
||||
acc[key] = [];
|
||||
}
|
||||
|
||||
acc[key].push(item);
|
||||
return acc;
|
||||
}, {});
|
||||
}, []);
|
||||
|
||||
const selectedPreset = presets.find((preset) => preset.id === selectedPresetId) ?? presets[0];
|
||||
|
||||
const radioPresets = presets.filter((preset) => preset.type === 'radio');
|
||||
const tvPresets = presets.filter((preset) => preset.type === 'tv');
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPreset && presets[0]) {
|
||||
setSelectedPresetId(presets[0].id);
|
||||
}
|
||||
}, [presets, selectedPreset]);
|
||||
|
||||
const validateForm = (values: FormState) => {
|
||||
const errors: FormErrors = {};
|
||||
|
||||
if (!values.title.trim()) {
|
||||
errors.title = 'Give this preset a short title.';
|
||||
}
|
||||
|
||||
if (!values.url.trim()) {
|
||||
errors.url = 'Paste a stream, media, or embed URL.';
|
||||
} else if (!isValidUrl(values.url.trim())) {
|
||||
errors.url = 'Use a valid http:// or https:// URL.';
|
||||
}
|
||||
|
||||
if (values.type === 'tv' && values.mode === 'embed' && !values.url.includes('embed') && !values.url.includes('youtube.com/watch?v=')) {
|
||||
errors.mode = 'Use an embeddable URL or a YouTube watch link for TV embeds.';
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
const handleFieldChange = <K extends keyof FormState>(field: K, value: FormState[K]) => {
|
||||
setFeedbackMessage('');
|
||||
setFormErrors((current) => ({ ...current, [field]: undefined }));
|
||||
setFormState((current) => {
|
||||
const nextState = { ...current, [field]: value };
|
||||
|
||||
if (field === 'type' && value === 'radio') {
|
||||
nextState.mode = 'audio';
|
||||
}
|
||||
|
||||
return nextState;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const errors = validateForm(formState);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setFormErrors(errors);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextMode = inferMode(formState.type, formState.url.trim(), formState.mode);
|
||||
const nextPreset: MediaPreset = {
|
||||
id: createId(),
|
||||
type: formState.type,
|
||||
title: formState.title.trim(),
|
||||
url: normalizeTvUrl(formState.url.trim(), nextMode),
|
||||
notes: formState.notes.trim(),
|
||||
mode: nextMode,
|
||||
};
|
||||
|
||||
const nextPresets = [nextPreset, ...presets];
|
||||
setPresets(nextPresets);
|
||||
setSelectedPresetId(nextPreset.id);
|
||||
setFeedbackMessage(`${nextPreset.title} is now saved in this browser and loaded into the preview.`);
|
||||
setFormErrors({});
|
||||
setFormState({
|
||||
...defaultFormState,
|
||||
type: formState.type,
|
||||
mode: formState.type === 'radio' ? 'audio' : 'video',
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeletePreset = (presetId: string) => {
|
||||
const nextPresets = presets.filter((preset) => preset.id !== presetId);
|
||||
setPresets(nextPresets.length > 0 ? nextPresets : []);
|
||||
setFeedbackMessage('Preset removed from this browser.');
|
||||
|
||||
if (selectedPresetId === presetId) {
|
||||
setSelectedPresetId(nextPresets[0]?.id ?? '');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Interaction Hub')}</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Browse the Modular Artificial Interaction switchboard and preview saved radio or TV presets."
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<div className="min-h-screen bg-[#060916] text-white">
|
||||
<div className="absolute inset-x-0 top-0 -z-0 h-[26rem] bg-[radial-gradient(circle_at_top_left,_rgba(34,211,238,0.22),_transparent_36%),radial-gradient(circle_at_top_right,_rgba(168,85,247,0.2),_transparent_30%),linear-gradient(180deg,_#0a1024,_#060916)]" />
|
||||
|
||||
<header className="border-b border-white/10 bg-[#060916]/80 backdrop-blur-xl">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-4 px-6 py-5 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<Link href="/" className="inline-flex items-center gap-3 text-sm uppercase tracking-[0.3em] text-cyan-200/80 transition hover:text-white">
|
||||
<BaseIcon path={mdiArrowLeft} size={18} />
|
||||
Back to landing page
|
||||
</Link>
|
||||
<h1 className="mt-4 text-3xl font-semibold text-white md:text-4xl">Interaction Hub</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-7 text-slate-300 md:text-base">
|
||||
Browse the public tool switchboard, launch your external destinations, and test radio or TV presets without leaving the page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<BaseButton href="/login" label="Admin interface" color="whiteDark" className="border-white/10" />
|
||||
<BaseButton href="/dashboard" label="Dashboard" color="info" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto grid max-w-7xl gap-6 px-6 py-8 xl:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="space-y-6">
|
||||
<CardBox className="border border-white/10 bg-slate-950/80 shadow-[0_20px_50px_rgba(15,23,42,0.35)]">
|
||||
<div className="flex flex-col gap-4 border-b border-white/10 pb-5 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-cyan-200/70">Public switchboard</p>
|
||||
<h2 className="mt-3 text-2xl font-semibold text-white">Launch your existing modules</h2>
|
||||
</div>
|
||||
<div className="rounded-full border border-emerald-400/30 bg-emerald-400/10 px-4 py-2 text-xs uppercase tracking-[0.22em] text-emerald-200">
|
||||
{modularInteractionLinks.length} live destinations
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-6">
|
||||
{Object.entries(groupedLinks).map(([key, items]) => {
|
||||
const meta = sectionMeta[key];
|
||||
|
||||
return (
|
||||
<section key={key} className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-cyan-200">
|
||||
<BaseIcon path={meta.icon} size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-white">{meta.title}</h3>
|
||||
<p className="text-sm text-slate-400">{meta.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`group overflow-hidden rounded-[1.5rem] border border-white/10 bg-gradient-to-br ${item.accent} p-[1px] transition hover:-translate-y-1 hover:border-cyan-300/30`}
|
||||
>
|
||||
<div className="flex h-full flex-col rounded-[1.45rem] bg-slate-950/90 p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] uppercase tracking-[0.2em] text-slate-300">
|
||||
{item.eyebrow}
|
||||
</span>
|
||||
<BaseIcon path={mdiOpenInNew} size={18} className="text-slate-400 transition group-hover:text-white" />
|
||||
</div>
|
||||
<h4 className="mt-5 text-lg font-medium text-white">{item.title}</h4>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.summary}</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className="border border-white/10 bg-slate-950/80">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-fuchsia-500/10 text-fuchsia-200">
|
||||
<BaseIcon path={mdiPlus} size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-white">Preset studio</h2>
|
||||
<p className="text-sm text-slate-400">Save a radio or TV source locally and push it straight into the preview.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="mt-6 space-y-5" onSubmit={handleSubmit}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{([
|
||||
{ label: 'Radio', value: 'radio' },
|
||||
{ label: 'TV', value: 'tv' },
|
||||
] as const).map((option) => {
|
||||
const isActive = formState.type === option.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => handleFieldChange('type', option.value)}
|
||||
className={`rounded-2xl border px-4 py-3 text-left transition ${
|
||||
isActive
|
||||
? 'border-cyan-300/40 bg-cyan-400/10 text-white'
|
||||
: 'border-white/10 bg-white/[0.03] text-slate-300 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm uppercase tracking-[0.2em]">{option.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-200">Preset title</label>
|
||||
<input
|
||||
value={formState.title}
|
||||
onChange={(event) => handleFieldChange('title', event.target.value)}
|
||||
placeholder="My community radio"
|
||||
className="w-full rounded-2xl border border-white/10 bg-slate-900 px-4 py-3 text-white outline-none transition focus:border-cyan-300/40"
|
||||
/>
|
||||
{formErrors.title ? <p className="mt-2 text-sm text-rose-300">{formErrors.title}</p> : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-200">
|
||||
{formState.type === 'radio' ? 'Audio stream URL' : 'Video or embed URL'}
|
||||
</label>
|
||||
<input
|
||||
value={formState.url}
|
||||
onChange={(event) => handleFieldChange('url', event.target.value)}
|
||||
placeholder={
|
||||
formState.type === 'radio'
|
||||
? 'https://example.com/live.mp3'
|
||||
: 'https://example.com/embed/live or https://example.com/video.mp4'
|
||||
}
|
||||
className="w-full rounded-2xl border border-white/10 bg-slate-900 px-4 py-3 text-white outline-none transition focus:border-cyan-300/40"
|
||||
/>
|
||||
{formErrors.url ? <p className="mt-2 text-sm text-rose-300">{formErrors.url}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formState.type === 'tv' ? (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-200">TV mode</label>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{([
|
||||
{ label: 'Direct video', value: 'video' },
|
||||
{ label: 'Embed iframe', value: 'embed' },
|
||||
] as const).map((option) => {
|
||||
const isActive = formState.mode === option.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => handleFieldChange('mode', option.value)}
|
||||
className={`rounded-2xl border px-4 py-3 text-left transition ${
|
||||
isActive
|
||||
? 'border-fuchsia-300/40 bg-fuchsia-400/10 text-white'
|
||||
: 'border-white/10 bg-white/[0.03] text-slate-300 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm uppercase tracking-[0.2em]">{option.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{formErrors.mode ? <p className="mt-2 text-sm text-rose-300">{formErrors.mode}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-200">Notes</label>
|
||||
<textarea
|
||||
value={formState.notes}
|
||||
onChange={(event) => handleFieldChange('notes', event.target.value)}
|
||||
placeholder="Optional context for this preset"
|
||||
className="min-h-[120px] w-full rounded-2xl border border-white/10 bg-slate-900 px-4 py-3 text-white outline-none transition focus:border-cyan-300/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<BaseButton type="submit" label="Save preset" icon={mdiPlus} color="info" />
|
||||
<p className="text-sm text-slate-400">Presets are stored in this browser only for now.</p>
|
||||
</div>
|
||||
|
||||
{feedbackMessage ? (
|
||||
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-100">
|
||||
{feedbackMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
</CardBox>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<CardBox className="border border-white/10 bg-slate-950/80 shadow-[0_20px_50px_rgba(15,23,42,0.35)]">
|
||||
<div className="flex flex-col gap-4 border-b border-white/10 pb-5 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-cyan-200/70">Live preview</p>
|
||||
<h2 className="mt-3 text-2xl font-semibold text-white">Media deck</h2>
|
||||
</div>
|
||||
<div className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs uppercase tracking-[0.22em] text-slate-300">
|
||||
{selectedPreset ? getPreviewLabel(selectedPreset) : 'Awaiting selection'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6">
|
||||
<div className="overflow-hidden rounded-[1.75rem] border border-white/10 bg-gradient-to-br from-cyan-500/12 via-slate-950 to-slate-950 p-5">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-cyan-400/10 text-cyan-200">
|
||||
<BaseIcon path={mdiRadioTower} size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-white">Radio widget</h3>
|
||||
<p className="text-sm text-slate-400">Audio presets and placeholder radio streams</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedPreset?.type === 'radio' ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-cyan-200/70">Now loaded</p>
|
||||
<h4 className="mt-2 text-xl font-medium text-white">{selectedPreset.title}</h4>
|
||||
{selectedPreset.notes ? <p className="mt-2 text-sm leading-7 text-slate-300">{selectedPreset.notes}</p> : null}
|
||||
</div>
|
||||
<audio key={selectedPreset.id} controls className="w-full">
|
||||
<source src={selectedPreset.url} />
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] p-5 text-sm leading-7 text-slate-300">
|
||||
Select a radio preset from the list below, or add a new one in the preset studio.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[1.75rem] border border-white/10 bg-gradient-to-br from-fuchsia-500/12 via-slate-950 to-slate-950 p-5">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-fuchsia-400/10 text-fuchsia-200">
|
||||
<BaseIcon path={mdiTelevisionPlay} size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-white">TV widget</h3>
|
||||
<p className="text-sm text-slate-400">Video or embed presets displayed in-page</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedPreset?.type === 'tv' ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-fuchsia-200/70">Now loaded</p>
|
||||
<h4 className="mt-2 text-xl font-medium text-white">{selectedPreset.title}</h4>
|
||||
{selectedPreset.notes ? <p className="mt-2 text-sm leading-7 text-slate-300">{selectedPreset.notes}</p> : null}
|
||||
</div>
|
||||
|
||||
{selectedPreset.mode === 'embed' ? (
|
||||
<div className="overflow-hidden rounded-[1.5rem] border border-white/10 bg-black/30">
|
||||
<iframe
|
||||
key={selectedPreset.id}
|
||||
src={selectedPreset.url}
|
||||
title={selectedPreset.title}
|
||||
className="aspect-video w-full"
|
||||
allow="autoplay; encrypted-media; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<video
|
||||
key={selectedPreset.id}
|
||||
controls
|
||||
className="aspect-video w-full overflow-hidden rounded-[1.5rem] border border-white/10 bg-black/30"
|
||||
>
|
||||
<source src={selectedPreset.url} />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] p-5 text-sm leading-7 text-slate-300">
|
||||
Select a TV preset from the list below, or add a new one in the preset studio.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className="border border-white/10 bg-slate-950/80">
|
||||
<div className="flex flex-col gap-4 border-b border-white/10 pb-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-white">Saved presets</h2>
|
||||
<p className="mt-2 text-sm text-slate-400">Click any card to load it into the radio or TV preview above.</p>
|
||||
</div>
|
||||
<div className="text-sm text-slate-400">
|
||||
{radioPresets.length} radio · {tvPresets.length} TV
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
{presets.length === 0 ? (
|
||||
<div className="rounded-[1.5rem] border border-dashed border-white/10 bg-white/[0.03] p-6 text-sm leading-7 text-slate-300">
|
||||
No presets saved yet. Start by adding a radio or TV source in the preset studio.
|
||||
</div>
|
||||
) : (
|
||||
presets.map((preset) => {
|
||||
const isActive = preset.id === selectedPreset?.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={preset.id}
|
||||
className={`rounded-[1.5rem] border p-5 transition ${
|
||||
isActive
|
||||
? 'border-cyan-300/35 bg-cyan-400/10'
|
||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedPresetId(preset.id)}
|
||||
className="text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-xs uppercase tracking-[0.2em] ${
|
||||
preset.type === 'radio'
|
||||
? 'bg-cyan-400/10 text-cyan-200'
|
||||
: 'bg-fuchsia-400/10 text-fuchsia-200'
|
||||
}`}
|
||||
>
|
||||
{preset.type}
|
||||
</span>
|
||||
{preset.isSample ? (
|
||||
<span className="rounded-full border border-white/10 px-3 py-1 text-[11px] uppercase tracking-[0.2em] text-slate-400">
|
||||
sample
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-medium text-white">{preset.title}</h3>
|
||||
<p className="mt-2 break-all text-sm text-slate-400">{preset.url}</p>
|
||||
{preset.notes ? <p className="mt-3 text-sm leading-7 text-slate-300">{preset.notes}</p> : null}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeletePreset(preset.id)}
|
||||
className="inline-flex items-center gap-2 self-start rounded-full border border-white/10 px-4 py-2 text-sm text-slate-300 transition hover:border-rose-300/40 hover:text-rose-200"
|
||||
>
|
||||
<BaseIcon path={mdiDeleteOutline} size={18} />
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className="border border-white/10 bg-slate-950/80">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-emerald-400/10 text-emerald-200">
|
||||
<BaseIcon path={mdiViewDashboardOutline} size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-white">Admin follow-through</h2>
|
||||
<p className="text-sm text-slate-400">When you are ready, continue from the public experience into the protected control room.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||
{adminShortcuts.map((item) => (
|
||||
<Link
|
||||
key={item.title}
|
||||
href={item.href}
|
||||
className="rounded-[1.5rem] border border-white/10 bg-white/[0.03] p-5 transition hover:-translate-y-1 hover:border-cyan-300/30"
|
||||
>
|
||||
<h3 className="text-lg font-medium text-white">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.summary}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
InteractionHubPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user