feat: add frontend in /frontend
This commit is contained in:
parent
1112e6f062
commit
a993498b5f
4
frontend/.gitignore
vendored
Normal file
4
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.wrangler
|
||||||
|
.env
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Dissolve</title>
|
||||||
|
<script async src="https://data-analytics.mworld.cloud/analytics.js" data-site-id="xd1uom"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3112
frontend/package-lock.json
generated
Normal file
3112
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
frontend/package.json
Normal file
45
frontend/package.json
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "dissolve-app",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^4.36.1",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"date-fns": "^2.30.0",
|
||||||
|
"framer-motion": "^10.16.4",
|
||||||
|
"lucide-react": "^0.292.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.18.0",
|
||||||
|
"sonner": "^1.2.0",
|
||||||
|
"tailwind-merge": "^2.0.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"uuid": "^9.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.9.0",
|
||||||
|
"@types/react": "^18.2.37",
|
||||||
|
"@types/react-dom": "^18.2.15",
|
||||||
|
"@types/uuid": "^9.0.7",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||||
|
"@typescript-eslint/parser": "^6.10.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"eslint": "^8.53.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.4",
|
||||||
|
"postcss": "^8.4.31",
|
||||||
|
"tailwindcss": "^3.3.5",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/postcss.config.js
Normal file
1
frontend/postcss.config.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default { plugins: { tailwindcss: {}, autoprefixer: {} } }
|
||||||
23
frontend/src/App.tsx
Normal file
23
frontend/src/App.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Routes, Route } from 'react-router-dom';
|
||||||
|
import { Toaster } from 'sonner';
|
||||||
|
import { Layout } from '@/components/Layout';
|
||||||
|
import { StreamPage, CreatePostPage, MyDissolvesPage, NotFoundPage } from '@/pages';
|
||||||
|
import { AnonymousIdProvider } from '@/context/AnonymousIdContext';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AnonymousIdProvider>
|
||||||
|
<Layout>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<StreamPage />} />
|
||||||
|
<Route path="/create" element={<CreatePostPage />} />
|
||||||
|
<Route path="/my-dissolves" element={<MyDissolvesPage />} />
|
||||||
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
|
</Routes>
|
||||||
|
</Layout>
|
||||||
|
<Toaster position="bottom-center" toastOptions={{ className: 'font-sans' }} />
|
||||||
|
</AnonymousIdProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
33
frontend/src/api.ts
Normal file
33
frontend/src/api.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export const API_URL = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
|
||||||
|
? "http://127.0.0.1:8787"
|
||||||
|
: "https://app-xd1uom.laflabs-tech.workers.dev";
|
||||||
|
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: API_URL
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
const currentOrigin = localStorage.getItem('mworld_origin') || window.location.origin;
|
||||||
|
config.headers['x-mworld-origin'] = currentOrigin;
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
const hadToken = localStorage.getItem('auth_token');
|
||||||
|
localStorage.removeItem('auth_token');
|
||||||
|
localStorage.removeItem('auth_user');
|
||||||
|
if (hadToken) window.location.reload();
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
76
frontend/src/components/AudioPlayer.tsx
Normal file
76
frontend/src/components/AudioPlayer.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Play, Pause, Loader2 } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface AudioPlayerProps {
|
||||||
|
src: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioPlayer({ src }: AudioPlayerProps) {
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
setDuration(audio.duration);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
const handleTimeUpdate = () => setCurrentTime(audio.currentTime);
|
||||||
|
const handleEnded = () => setIsPlaying(false);
|
||||||
|
const handleCanPlay = () => setIsLoading(false);
|
||||||
|
|
||||||
|
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
|
audio.addEventListener('timeupdate', handleTimeUpdate);
|
||||||
|
audio.addEventListener('ended', handleEnded);
|
||||||
|
audio.addEventListener('canplay', handleCanPlay);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
|
audio.removeEventListener('timeupdate', handleTimeUpdate);
|
||||||
|
audio.removeEventListener('ended', handleEnded);
|
||||||
|
audio.removeEventListener('canplay', handleCanPlay);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const togglePlayPause = () => {
|
||||||
|
if (isPlaying) {
|
||||||
|
audioRef.current?.pause();
|
||||||
|
} else {
|
||||||
|
audioRef.current?.play();
|
||||||
|
}
|
||||||
|
setIsPlaying(!isPlaying);
|
||||||
|
};
|
||||||
|
|
||||||
|
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-center gap-3 rounded-full bg-surface-soft p-2">
|
||||||
|
<audio ref={audioRef} src={src} preload="metadata"></audio>
|
||||||
|
<button
|
||||||
|
onClick={togglePlayPause}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-primary text-on-primary disabled:bg-primary-disabled"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
) : isPlaying ? (
|
||||||
|
<Pause className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-5 w-5 ml-0.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="h-1.5 w-full flex-grow rounded-full bg-hairline">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all duration-75"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
frontend/src/components/CountdownTimer.tsx
Normal file
10
frontend/src/components/CountdownTimer.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { useCountdown } from '@/hooks/useCountdown';
|
||||||
|
|
||||||
|
interface CountdownTimerProps {
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CountdownTimer({ expiresAt }: CountdownTimerProps) {
|
||||||
|
const timeLeft = useCountdown(expiresAt);
|
||||||
|
return <span>{timeLeft} left to dissolve</span>;
|
||||||
|
}
|
||||||
34
frontend/src/components/EmotionFilterBar.tsx
Normal file
34
frontend/src/components/EmotionFilterBar.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { EmotionTag, EMOTION_TAGS } from '@/types';
|
||||||
|
|
||||||
|
interface EmotionFilterBarProps {
|
||||||
|
selectedEmotion?: EmotionTag;
|
||||||
|
onSelectEmotion: (emotion?: EmotionTag) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmotionFilterBar({ selectedEmotion, onSelectEmotion }: EmotionFilterBarProps) {
|
||||||
|
const allEmotions: (EmotionTag | undefined)[] = [undefined, ...EMOTION_TAGS];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full overflow-x-auto pb-2">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{allEmotions.map((emotion, idx) => {
|
||||||
|
const label = emotion || 'All';
|
||||||
|
const isActive = selectedEmotion === emotion;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
onClick={() => onSelectEmotion(emotion)}
|
||||||
|
className={cn(
|
||||||
|
'whitespace-nowrap border-b-2 border-transparent py-2 px-1 text-sm font-medium text-muted transition-colors hover:text-ink',
|
||||||
|
isActive ? 'border-ink text-ink' : 'hover:border-hairline'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
frontend/src/components/Layout.tsx
Normal file
55
frontend/src/components/Layout.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { NavLink, useLocation, Link } from 'react-router-dom';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Layout({ children }: LayoutProps) {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: '/', label: 'The Stream' },
|
||||||
|
{ path: '/my-dissolves', label: 'My Dissolves' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-canvas font-sans">
|
||||||
|
<header className="sticky top-0 z-40 h-[80px] w-full bg-canvas/80 backdrop-blur-sm">
|
||||||
|
<div className="container-full flex h-full items-center justify-between border-b border-hairline">
|
||||||
|
<div className="flex items-center gap-12">
|
||||||
|
<Link to="/" className="text-2xl font-medium tracking-tight text-ink">
|
||||||
|
Dissolve
|
||||||
|
</Link>
|
||||||
|
<nav className="hidden items-center gap-6 md:flex">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive = location.pathname === item.path;
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={cn(
|
||||||
|
'relative text-muted transition-colors hover:text-ink',
|
||||||
|
isActive && 'text-ink'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
{isActive && <div className="absolute -bottom-2 left-0 right-0 h-[2px] bg-ink"/>}
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<Link to="/create" className="inline-flex items-center justify-center whitespace-nowrap rounded-full text-sm font-medium h-10 px-6 bg-primary text-on-primary hover:bg-primary-active transition-colors">
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> Share a Feeling
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="container-full py-8 md:py-12">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
frontend/src/components/MyPostCard.tsx
Normal file
74
frontend/src/components/MyPostCard.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Users, Timer, Volume2, Image, MessageSquare } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useApi } from '@/hooks/useApi';
|
||||||
|
import type { Post } from '@/types';
|
||||||
|
import { CountdownTimer } from './CountdownTimer';
|
||||||
|
import { AudioPlayer } from './AudioPlayer';
|
||||||
|
|
||||||
|
interface MyPostCardProps {
|
||||||
|
post: Post;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MyPostCard({ post }: MyPostCardProps) {
|
||||||
|
const { getMediaUrl } = useApi();
|
||||||
|
|
||||||
|
const cardVariants = {
|
||||||
|
initial: { opacity: 0, y: 20 },
|
||||||
|
animate: { opacity: 1, y: 0 },
|
||||||
|
exit: { opacity: 0, scale: 0.95 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
switch (post.content_type) {
|
||||||
|
case 'text':
|
||||||
|
return <p className="text-body text-base leading-relaxed">{post.content_data}</p>;
|
||||||
|
case 'photo':
|
||||||
|
return <img src={getMediaUrl(post.content_data)} alt={`My feeling of ${post.emotion_tag}`} className="w-full h-auto rounded-md object-cover" />;
|
||||||
|
case 'voice':
|
||||||
|
return <AudioPlayer src={getMediaUrl(post.content_data)} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIcon = () => {
|
||||||
|
switch (post.content_type) {
|
||||||
|
case 'text': return <MessageSquare className="h-3 w-3" />;
|
||||||
|
case 'photo': return <Image className="h-3 w-3" />;
|
||||||
|
case 'voice': return <Volume2 className="h-3 w-3" />;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
variants={cardVariants}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
layout
|
||||||
|
className="flex flex-col justify-between overflow-hidden rounded-lg bg-surface-card shadow-card"
|
||||||
|
>
|
||||||
|
<div className="p-5 flex-grow">
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-hairline-soft bg-surface-soft/50 px-5 py-3 space-y-2 text-sm">
|
||||||
|
<div className="flex items-center justify-between text-muted">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
{getIcon()}
|
||||||
|
<span>{post.emotion_tag}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
<span>{post.holdCount} {post.holdCount === 1 ? 'person' : 'people'} held this</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-red-600 font-mono">
|
||||||
|
<Timer className="h-4 w-4" />
|
||||||
|
<CountdownTimer expiresAt={post.expires_at!} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
frontend/src/components/PostCard.tsx
Normal file
106
frontend/src/components/PostCard.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Heart, Flag, Volume2, Image, MessageSquare, Loader2 } from 'lucide-react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useApi } from '@/hooks/useApi';
|
||||||
|
import type { Post } from '@/types';
|
||||||
|
import { AudioPlayer } from './AudioPlayer';
|
||||||
|
import { ReportModal } from './ReportModal';
|
||||||
|
|
||||||
|
interface PostCardProps {
|
||||||
|
post: Post;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PostCard({ post }: PostCardProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { useHoldPost, useIsPostHeld, getMediaUrl } = useApi();
|
||||||
|
const { mutate: holdPost, isLoading: isHolding } = useHoldPost(queryClient);
|
||||||
|
const isHeld = useIsPostHeld(post.id) ?? false;
|
||||||
|
|
||||||
|
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleHold = () => {
|
||||||
|
if (isHeld) return;
|
||||||
|
holdPost(post.id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('You are now holding this feeling.');
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
const errorMessage = error.response?.data?.error || 'Could not hold this post.';
|
||||||
|
if (errorMessage.includes('already held')) {
|
||||||
|
queryClient.setQueryData(['held-posts', post.id], true);
|
||||||
|
} else {
|
||||||
|
toast.error(errorMessage);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardVariants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: { opacity: 1, y: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
switch (post.content_type) {
|
||||||
|
case 'text':
|
||||||
|
return <p className="text-body text-base leading-relaxed">{post.content_data}</p>;
|
||||||
|
case 'photo':
|
||||||
|
return <img src={getMediaUrl(post.content_data)} alt={`Feeling of ${post.emotion_tag}`} className="w-full h-auto rounded-md object-cover aspect-square" />;
|
||||||
|
case 'voice':
|
||||||
|
return <AudioPlayer src={getMediaUrl(post.content_data)} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIcon = () => {
|
||||||
|
switch (post.content_type) {
|
||||||
|
case 'text': return <MessageSquare className="h-3 w-3" />;
|
||||||
|
case 'photo': return <Image className="h-3 w-3" />;
|
||||||
|
case 'voice': return <Volume2 className="h-3 w-3" />;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
variants={cardVariants}
|
||||||
|
className="group relative flex flex-col justify-between overflow-hidden rounded-lg bg-surface-card shadow-card transition-shadow hover:shadow-card-hover"
|
||||||
|
>
|
||||||
|
<div className="p-5 flex-grow">
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-t border-hairline-soft bg-surface-soft/50 px-5 py-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted">
|
||||||
|
{getIcon()}
|
||||||
|
<span>{post.emotion_tag}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsReportModalOpen(true)}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full text-muted transition-colors hover:bg-hairline hover:text-ink"
|
||||||
|
aria-label="Report post"
|
||||||
|
>
|
||||||
|
<Flag className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleHold}
|
||||||
|
disabled={isHolding || isHeld}
|
||||||
|
className={cn(
|
||||||
|
'flex h-8 w-8 items-center justify-center rounded-full border border-hairline text-muted transition-all disabled:opacity-50',
|
||||||
|
isHeld ? 'border-primary bg-primary/10 text-primary' : 'hover:border-primary hover:bg-primary/10 hover:text-primary'
|
||||||
|
)}
|
||||||
|
aria-label="Hold post"
|
||||||
|
>
|
||||||
|
{isHolding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Heart className={cn('h-4 w-4', isHeld && 'fill-current')} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ReportModal postId={post.id} isOpen={isReportModalOpen} onClose={() => setIsReportModalOpen(false)} />
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
frontend/src/components/ReportModal.tsx
Normal file
77
frontend/src/components/ReportModal.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { X, Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useApi } from '@/hooks/useApi';
|
||||||
|
|
||||||
|
interface ReportModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
postId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReportModal({ isOpen, onClose, postId }: ReportModalProps) {
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
const { useReportPost } = useApi();
|
||||||
|
const { mutate: reportPost, isLoading } = useReportPost();
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!reason) {
|
||||||
|
toast.error('Please provide a reason for reporting.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reportPost({ postId, reason }, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Thank you for your report. Our team will review it.');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.error || 'Failed to submit report.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.95, y: 20 }}
|
||||||
|
animate={{ scale: 1, y: 0 }}
|
||||||
|
exit={{ scale: 0.95, y: 20 }}
|
||||||
|
className="relative w-full max-w-md rounded-lg bg-canvas p-6 shadow-xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button onClick={onClose} className="absolute top-4 right-4 text-muted hover:text-ink">
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<h2 className="text-lg font-medium text-ink">Report Post</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted">Help us keep Dissolve a safe space. Why are you reporting this post?</p>
|
||||||
|
<form onSubmit={handleSubmit} className="mt-4 space-y-4">
|
||||||
|
<textarea
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
placeholder="e.g., hate speech, harassment, personal information..."
|
||||||
|
className="w-full min-h-[100px] bg-surface-soft p-3 rounded-md focus:ring-2 focus:ring-primary focus:outline-none transition-shadow"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button type="button" onClick={onClose} className="px-4 py-2 text-sm rounded-full border border-hairline hover:bg-surface-soft">Cancel</button>
|
||||||
|
<button type="submit" disabled={isLoading} className="px-4 py-2 text-sm rounded-full bg-primary text-on-primary hover:bg-primary-active disabled:bg-primary-disabled">
|
||||||
|
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Submit Report'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
frontend/src/context/AnonymousIdContext.tsx
Normal file
40
frontend/src/context/AnonymousIdContext.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
interface AnonymousIdContextType {
|
||||||
|
anonymousId: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AnonymousIdContext = createContext<AnonymousIdContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const AnonymousIdProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [anonymousId, setAnonymousId] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let storedId = localStorage.getItem('anonymous_user_id');
|
||||||
|
if (!storedId) {
|
||||||
|
storedId = uuidv4();
|
||||||
|
localStorage.setItem('anonymous_user_id', storedId);
|
||||||
|
}
|
||||||
|
setAnonymousId(storedId);
|
||||||
|
setIsLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = { anonymousId, isLoading };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnonymousIdContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AnonymousIdContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAnonymousId = () => {
|
||||||
|
const context = useContext(AnonymousIdContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAnonymousId must be used within an AnonymousIdProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
152
frontend/src/context/AuthContext.tsx
Normal file
152
frontend/src/context/AuthContext.tsx
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { api } from '../api';
|
||||||
|
|
||||||
|
const M_AUTH_API_URL = "https://api.mauth.mworld.cloud";
|
||||||
|
const M_AUTH_FRONTEND_URL = "https://v0.mauth.mworld.cloud";
|
||||||
|
|
||||||
|
export interface UserData {
|
||||||
|
username: string;
|
||||||
|
fullName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
citizen_id: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: UserData | null;
|
||||||
|
token: string | null;
|
||||||
|
login: () => Promise<UserData | null>;
|
||||||
|
logout: () => void;
|
||||||
|
updateUser: (userData: UserData) => void;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | null>(null);
|
||||||
|
|
||||||
|
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const [user, setUser] = useState<UserData | null>(null);
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const updateUser = (newUserData: UserData) => {
|
||||||
|
setUser(newUserData);
|
||||||
|
localStorage.setItem('auth_user', JSON.stringify(newUserData));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckIn = async (authToken: string) => {
|
||||||
|
try {
|
||||||
|
const currentOrigin = localStorage.getItem('mworld_origin') || window.location.origin;
|
||||||
|
const res = await axios.post(`${api.defaults.baseURL}/api/user/check-in`,
|
||||||
|
{ mauth_token: authToken },
|
||||||
|
{ headers: { 'Authorization': `Bearer ${authToken}`, 'x-mworld-origin': currentOrigin } }
|
||||||
|
);
|
||||||
|
if (res.data?.success && res.data.user) {
|
||||||
|
setUser(res.data.user);
|
||||||
|
localStorage.setItem('auth_user', JSON.stringify(res.data.user));
|
||||||
|
return res.data.user;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Check-in failed:", err);
|
||||||
|
logout();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initAuth = async () => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const originParam = urlParams.get('origin');
|
||||||
|
if (originParam) {
|
||||||
|
localStorage.setItem('mworld_origin', originParam);
|
||||||
|
} else if (!localStorage.getItem('mworld_origin')) {
|
||||||
|
localStorage.setItem('mworld_origin', window.location.origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const savedUser = localStorage.getItem('auth_user');
|
||||||
|
const savedToken = localStorage.getItem('auth_token');
|
||||||
|
if (savedUser && savedToken && savedUser !== 'undefined') {
|
||||||
|
setToken(savedToken);
|
||||||
|
setUser(JSON.parse(savedUser));
|
||||||
|
// Refresh user data on load
|
||||||
|
await handleCheckIn(savedToken);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logout();
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
initAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (): Promise<UserData | null> => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const currentOrigin = localStorage.getItem('mworld_origin') || window.location.origin;
|
||||||
|
const response = await axios.get(`${M_AUTH_API_URL}/mAuthGen?origin=${encodeURIComponent(currentOrigin)}`);
|
||||||
|
const link = response.data?.mAuth?.auth_link;
|
||||||
|
if (!link) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const popup = window.open(link, "mAuthLogin", "width=500,height=700");
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const messageListener = async (event: MessageEvent) => {
|
||||||
|
// The popup broadcasts to the parent window containing the access token
|
||||||
|
if (event.origin !== M_AUTH_FRONTEND_URL) return;
|
||||||
|
|
||||||
|
const { user_access_token } = event.data;
|
||||||
|
if (user_access_token) {
|
||||||
|
setToken(user_access_token);
|
||||||
|
localStorage.setItem('auth_token', user_access_token);
|
||||||
|
const loggedInUser = await handleCheckIn(user_access_token);
|
||||||
|
window.removeEventListener("message", messageListener);
|
||||||
|
popup?.close();
|
||||||
|
setIsLoading(false);
|
||||||
|
resolve(loggedInUser);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("message", messageListener);
|
||||||
|
|
||||||
|
// Cleanup if popup closed manually
|
||||||
|
const checkClosed = setInterval(() => {
|
||||||
|
if (popup?.closed) {
|
||||||
|
clearInterval(checkClosed);
|
||||||
|
window.removeEventListener("message", messageListener);
|
||||||
|
setIsLoading(false);
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
setUser(null);
|
||||||
|
setToken(null);
|
||||||
|
localStorage.removeItem('auth_user');
|
||||||
|
localStorage.removeItem('auth_token');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, token, login, logout, updateUser, isAuthenticated: !!user, isLoading }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
return context;
|
||||||
|
};
|
||||||
138
frontend/src/hooks/useApi.ts
Normal file
138
frontend/src/hooks/useApi.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { useMutation, useQuery, useInfiniteQuery, QueryClient } from '@tanstack/react-query';
|
||||||
|
import { api, API_URL } from '../api';
|
||||||
|
import { useAnonymousId } from '@/context/AnonymousIdContext';
|
||||||
|
import type { EmotionTag, Post, PostSummary, PostContentType } from '@/types';
|
||||||
|
|
||||||
|
// A wrapper to inject the anonymous ID header
|
||||||
|
const callApi = <T,>(method: 'get' | 'post' | 'delete', url: string, anonymousId: string, data?: any): Promise<{ data: T }> => {
|
||||||
|
const headers = { 'X-Anonymous-User-ID': anonymousId };
|
||||||
|
if (method === 'post' || method === 'delete') {
|
||||||
|
return api[method]<T>(url, data, { headers });
|
||||||
|
}
|
||||||
|
return api.get<T>(url, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useApi = () => {
|
||||||
|
const { anonymousId } = useAnonymousId();
|
||||||
|
|
||||||
|
// Stream
|
||||||
|
const useGetPosts = (emotion?: EmotionTag) => {
|
||||||
|
return useInfiniteQuery(
|
||||||
|
['posts', emotion],
|
||||||
|
async ({ pageParam: cursor }) => {
|
||||||
|
if (!anonymousId) throw new Error('Anonymous ID not available');
|
||||||
|
let url = '/api/posts?';
|
||||||
|
if (emotion) url += `emotion=${emotion}&`;
|
||||||
|
if (cursor) url += `cursor=${cursor}`;
|
||||||
|
const res = await callApi<{posts: Post[], nextCursor: string | null}>('get', url, anonymousId);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
enabled: !!anonymousId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create
|
||||||
|
const useCreatePost = (queryClient: QueryClient) => {
|
||||||
|
return useMutation(
|
||||||
|
({ contentType, contentData, emotionTag }: { contentType: PostContentType, contentData: string, emotionTag: EmotionTag }) => {
|
||||||
|
if (!anonymousId) throw new Error('Anonymous ID not available');
|
||||||
|
return callApi('post', '/api/posts', anonymousId, { contentType, contentData, emotionTag });
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries(['my-posts']);
|
||||||
|
queryClient.invalidateQueries(['posts']);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useUploadFile = () => {
|
||||||
|
return useMutation(
|
||||||
|
(file: File) => {
|
||||||
|
if (!anonymousId) throw new Error('Anonymous ID not available');
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
return callApi<{url: string}>('post', '/api/upload', anonymousId, formData);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// My Dissolves
|
||||||
|
const useGetMyPosts = () => {
|
||||||
|
return useQuery(
|
||||||
|
['my-posts'],
|
||||||
|
() => {
|
||||||
|
if (!anonymousId) throw new Error('Anonymous ID not available');
|
||||||
|
return callApi<{posts: Post[]}>('get', '/api/posts/me', anonymousId).then(res => res.data);
|
||||||
|
},
|
||||||
|
{ enabled: !!anonymousId }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useGetSummaries = () => {
|
||||||
|
return useQuery(
|
||||||
|
['summaries'],
|
||||||
|
() => {
|
||||||
|
if (!anonymousId) throw new Error('Anonymous ID not available');
|
||||||
|
return callApi<{summaries: PostSummary[]}>('get', '/api/posts/summaries', anonymousId).then(res => res.data);
|
||||||
|
},
|
||||||
|
{ enabled: !!anonymousId }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useClearSummaries = (queryClient: QueryClient) => {
|
||||||
|
return useMutation(
|
||||||
|
(summaryIds: string[]) => {
|
||||||
|
if (!anonymousId) throw new Error('Anonymous ID not available');
|
||||||
|
return callApi('post', '/api/posts/summaries/clear', anonymousId, { summaryIds });
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries(['summaries']);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Interactions
|
||||||
|
const useHoldPost = (queryClient: QueryClient) => {
|
||||||
|
return useMutation(
|
||||||
|
(postId: string) => {
|
||||||
|
if (!anonymousId) throw new Error('Anonymous ID not available');
|
||||||
|
return callApi('post', `/api/posts/${postId}/hold`, anonymousId);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (_data, postId) => {
|
||||||
|
queryClient.setQueryData(['held-posts', postId], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useReportPost = () => {
|
||||||
|
return useMutation(
|
||||||
|
({ postId, reason }: { postId: string, reason: string }) => {
|
||||||
|
if (!anonymousId) throw new Error('Anonymous ID not available');
|
||||||
|
return callApi('post', `/api/posts/${postId}/report`, anonymousId, { reason });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useIsPostHeld = (postId: string) => {
|
||||||
|
const {data} = useQuery(['held-posts', postId], () => false, { staleTime: Infinity, cacheTime: Infinity });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMediaUrl = (path: string) => {
|
||||||
|
if (path.startsWith('/api/media/')) {
|
||||||
|
return `${API_URL}${path}`
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { useGetPosts, useCreatePost, useUploadFile, useGetMyPosts, useGetSummaries, useClearSummaries, useHoldPost, useReportPost, useIsPostHeld, getMediaUrl };
|
||||||
|
};
|
||||||
39
frontend/src/hooks/useCountdown.ts
Normal file
39
frontend/src/hooks/useCountdown.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { differenceInSeconds, formatDuration, intervalToDuration } from 'date-fns';
|
||||||
|
|
||||||
|
export const useCountdown = (targetDate: string | Date) => {
|
||||||
|
const [timeLeft, setTimeLeft] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const target = new Date(targetDate);
|
||||||
|
const now = new Date();
|
||||||
|
const secondsDifference = differenceInSeconds(target, now);
|
||||||
|
|
||||||
|
if (secondsDifference <= 0) {
|
||||||
|
setTimeLeft('00:00:00');
|
||||||
|
clearInterval(interval);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = intervalToDuration({ start: 0, end: secondsDifference * 1000 });
|
||||||
|
|
||||||
|
const formatted = formatDuration(duration, {
|
||||||
|
format: ['hours', 'minutes', 'seconds'],
|
||||||
|
zero: true,
|
||||||
|
delimiter: ':',
|
||||||
|
pad: { hours: 2, minutes: 2, seconds: 2 },
|
||||||
|
});
|
||||||
|
// Special handling for formatDuration output
|
||||||
|
const parts = formatted.split(':').map(p => p.padStart(2, '0'));
|
||||||
|
if (parts.length === 2) parts.unshift('00');
|
||||||
|
if (parts.length === 1) parts.unshift('00', '00');
|
||||||
|
|
||||||
|
setTimeLeft(parts.join(':'));
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [targetDate]);
|
||||||
|
|
||||||
|
return timeLeft;
|
||||||
|
};
|
||||||
41
frontend/src/index.css
Normal file
41
frontend/src/index.css
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Airbnb Cereal VF';
|
||||||
|
src: url('https://d21p2nvrwwh70b.cloudfront.net/fonts/Airbnb_Cereal_W_VF.woff2') format('woff2-variations');
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--canvas: 0 0% 100%; /* #ffffff */
|
||||||
|
--ink: 0 0% 13.3%; /* #222222 */
|
||||||
|
--body: 0 0% 24.7%; /* #3f3f3f */
|
||||||
|
--muted: 0 0% 41.6%; /* #6a6a6a */
|
||||||
|
--muted-soft: 0 0% 57.3%; /* #929292 */
|
||||||
|
|
||||||
|
--primary: 349 100% 60.2%; /* #ff385c */
|
||||||
|
--primary-active: 349 100% 46.1%; /* #e00b41 */
|
||||||
|
--primary-disabled: 349 100% 90.8%; /* #ffd1da */
|
||||||
|
--on-primary: 0 0% 100%;
|
||||||
|
|
||||||
|
--hairline: 0 0% 86.7%; /* #dddddd */
|
||||||
|
--hairline-soft: 0 0% 92.2%; /* #ebebeb */
|
||||||
|
|
||||||
|
--surface-soft: 0 0% 96.9%; /* #f7f7f7 */
|
||||||
|
--surface-card: 0 0% 100%; /* #ffffff */
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-canvas text-body antialiased;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.container-full {
|
||||||
|
@apply w-full max-w-screen-xl mx-auto px-4 sm:px-6 lg:px-8;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
frontend/src/lib/utils.ts
Normal file
36
frontend/src/lib/utils.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
|
||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPrice(price: any): string {
|
||||||
|
const num = Number(price);
|
||||||
|
if (isNaN(num)) return '';
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}).format(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMileage(mileage: any): string {
|
||||||
|
const num = Number(mileage);
|
||||||
|
if (isNaN(num)) return '';
|
||||||
|
return new Intl.NumberFormat('en-US').format(num) + ' miles';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(dateString: any): string {
|
||||||
|
if (!dateString) return '';
|
||||||
|
try {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
29
frontend/src/main.tsx
Normal file
29
frontend/src/main.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { AuthProvider } from './context/AuthContext'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
178
frontend/src/pages/CreatePostPage.tsx
Normal file
178
frontend/src/pages/CreatePostPage.tsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Loader2, MessageSquare, Mic, Image, Check, Send } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useApi } from '@/hooks/useApi';
|
||||||
|
import type { PostContentType, EmotionTag } from '@/types';
|
||||||
|
import { EMOTION_TAGS } from '@/types';
|
||||||
|
|
||||||
|
const contentTypes: { type: PostContentType, label: string, icon: React.ElementType }[] = [
|
||||||
|
{ type: 'text', label: 'Text', icon: MessageSquare },
|
||||||
|
{ type: 'voice', label: 'Voice', icon: Mic },
|
||||||
|
{ type: 'photo', label: 'Photo', icon: Image },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function CreatePostPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { useCreatePost, useUploadFile } = useApi();
|
||||||
|
const createPostMutation = useCreatePost(queryClient);
|
||||||
|
const uploadFileMutation = useUploadFile();
|
||||||
|
|
||||||
|
const [contentType, setContentType] = useState<PostContentType>('text');
|
||||||
|
const [textData, setTextData] = useState('');
|
||||||
|
const [fileData, setFileData] = useState<File | null>(null);
|
||||||
|
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null);
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
|
||||||
|
const [selectedEmotion, setSelectedEmotion] = useState<EmotionTag | null>(null);
|
||||||
|
const audioChunksRef = useRef<Blob[]>([]);
|
||||||
|
|
||||||
|
const isSubmitting = createPostMutation.isLoading || uploadFileMutation.isLoading;
|
||||||
|
const isFormValid = (textData.trim() !== '' || fileData) && selectedEmotion;
|
||||||
|
|
||||||
|
const handlePhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
setFileData(file);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => setPhotoPreview(reader.result as string);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRecording = async () => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
const recorder = new MediaRecorder(stream);
|
||||||
|
setMediaRecorder(recorder);
|
||||||
|
audioChunksRef.current = [];
|
||||||
|
recorder.ondataavailable = (event) => audioChunksRef.current.push(event.data);
|
||||||
|
recorder.onstop = () => {
|
||||||
|
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
|
||||||
|
const audioFile = new File([audioBlob], 'recording.webm', { type: 'audio/webm' });
|
||||||
|
setFileData(audioFile);
|
||||||
|
stream.getTracks().forEach(track => track.stop()); // Stop microphone access
|
||||||
|
};
|
||||||
|
recorder.start();
|
||||||
|
setIsRecording(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error accessing microphone:', error);
|
||||||
|
toast.error('Could not access microphone. Please check permissions.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopRecording = () => {
|
||||||
|
if (mediaRecorder) {
|
||||||
|
mediaRecorder.stop();
|
||||||
|
setIsRecording(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isFormValid || isSubmitting) return;
|
||||||
|
|
||||||
|
let contentData = textData;
|
||||||
|
|
||||||
|
if (contentType === 'photo' || contentType === 'voice') {
|
||||||
|
if (!fileData) {
|
||||||
|
toast.error('Please provide a file for your post.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const uploadRes = await uploadFileMutation.mutateAsync(fileData);
|
||||||
|
contentData = uploadRes.data.url;
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to upload your file. Please try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createPostMutation.mutate(
|
||||||
|
{ contentType, contentData, emotionTag: selectedEmotion! },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Your feeling has been shared.');
|
||||||
|
navigate('/');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Something went wrong. Could not share your feeling.');
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderContentInput = () => {
|
||||||
|
switch (contentType) {
|
||||||
|
case 'text':
|
||||||
|
return <textarea value={textData} onChange={(e) => setTextData(e.target.value)} placeholder="What are you feeling?" className="w-full min-h-[150px] bg-surface-soft p-4 rounded-lg focus:ring-2 focus:ring-primary focus:outline-none transition-shadow" />;
|
||||||
|
case 'voice':
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[150px] bg-surface-soft p-4 rounded-lg">
|
||||||
|
{isRecording ? (
|
||||||
|
<button type="button" onClick={stopRecording} className="bg-red-500 text-white px-6 py-3 rounded-full">Stop Recording</button>
|
||||||
|
) : fileData ? (
|
||||||
|
<div className="text-center text-green-600">Recording complete! <Check className="inline h-5 w-5"/></div>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={startRecording} className="bg-primary text-on-primary px-6 py-3 rounded-full">Start Recording (60s max)</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'photo':
|
||||||
|
return (
|
||||||
|
<div className="min-h-[150px] bg-surface-soft p-4 rounded-lg">
|
||||||
|
<input type="file" accept="image/*" onChange={handlePhotoChange} className="hidden" id="photo-upload" />
|
||||||
|
<label htmlFor="photo-upload" className="cursor-pointer flex flex-col items-center justify-center w-full h-full border-2 border-dashed border-hairline rounded-lg">
|
||||||
|
{photoPreview ? <img src={photoPreview} alt="Preview" className="max-h-48 rounded-md" /> : <span>Click to upload a photo</span>}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="max-w-2xl mx-auto">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-8 bg-white p-8 rounded-lg shadow-card">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-medium text-ink mb-4">1. Choose your format</h2>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{contentTypes.map(({ type, label, icon: Icon }) => (
|
||||||
|
<button type="button" key={type} onClick={() => setContentType(type)} className={cn('flex flex-col items-center justify-center p-4 rounded-lg border-2 transition-colors', contentType === type ? 'border-primary bg-primary/5' : 'border-hairline hover:border-hairline-soft')}>
|
||||||
|
<Icon className={cn('h-6 w-6 mb-2', contentType === type ? 'text-primary' : 'text-muted')} />
|
||||||
|
<span className={cn('text-sm font-medium', contentType === type ? 'text-ink' : 'text-muted')}>{label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-medium text-ink mb-4">2. Express your feeling</h2>
|
||||||
|
{renderContentInput()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-medium text-ink mb-4">3. Tag your emotion</h2>
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
|
||||||
|
{EMOTION_TAGS.map((emotion) => (
|
||||||
|
<button type="button" key={emotion} onClick={() => setSelectedEmotion(emotion)} className={cn('p-3 rounded-full border text-sm font-medium transition-colors', selectedEmotion === emotion ? 'bg-primary text-on-primary border-primary' : 'border-hairline hover:bg-surface-soft')}>
|
||||||
|
{emotion}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-hairline pt-6">
|
||||||
|
<button type="submit" disabled={!isFormValid || isSubmitting} className="w-full flex items-center justify-center bg-primary text-on-primary font-bold py-3 px-4 rounded-full disabled:bg-primary-disabled disabled:cursor-not-allowed transition-colors hover:bg-primary-active">
|
||||||
|
{isSubmitting ? <Loader2 className="h-5 w-5 animate-spin" /> : <><Send className="h-5 w-5 mr-2"/> Share and Dissolve</>}
|
||||||
|
</button>
|
||||||
|
<p className="text-center text-xs text-muted-soft mt-3">Your post will disappear forever in 24 hours.</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
frontend/src/pages/MyDissolvesPage.tsx
Normal file
103
frontend/src/pages/MyDissolvesPage.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { format, formatDistanceToNow } from 'date-fns';
|
||||||
|
import { Loader2, Inbox, Smile, Meh, Frown } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useApi } from '@/hooks/useApi';
|
||||||
|
import { MyPostCard } from '@/components/MyPostCard';
|
||||||
|
import type { PostSummary } from '@/types';
|
||||||
|
|
||||||
|
export default function MyDissolvesPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { useGetMyPosts, useGetSummaries, useClearSummaries } = useApi();
|
||||||
|
const { data: myPostsData, isLoading: isLoadingPosts } = useGetMyPosts();
|
||||||
|
const { data: summariesData, isLoading: isLoadingSummaries } = useGetSummaries();
|
||||||
|
const clearSummariesMutation = useClearSummaries(queryClient);
|
||||||
|
|
||||||
|
const [shownSummaries, setShownSummaries] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unseenSummaries = summariesData?.summaries.filter(s => !shownSummaries.has(s.id)) ?? [];
|
||||||
|
|
||||||
|
if (unseenSummaries.length > 0) {
|
||||||
|
const idsToShow = new Set<string>();
|
||||||
|
unseenSummaries.forEach(summary => {
|
||||||
|
toast(`Your feeling of '${summary.emotion_tag}' was held by ${summary.hold_count} people before it dissolved.`);
|
||||||
|
idsToShow.add(summary.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
setShownSummaries(prev => new Set([...prev, ...idsToShow]));
|
||||||
|
|
||||||
|
// Here we can decide if we want to clear from DB after showing.
|
||||||
|
// For this implementation, we will not automatically clear, but allow user to see in a list.
|
||||||
|
}
|
||||||
|
|
||||||
|
}, [summariesData, shownSummaries]);
|
||||||
|
|
||||||
|
const activePosts = myPostsData?.posts ?? [];
|
||||||
|
const pastSummaries = summariesData?.summaries ?? [];
|
||||||
|
|
||||||
|
const getHoldIcon = (count: number) => {
|
||||||
|
if (count > 50) return <Smile className="h-6 w-6 text-green-500" />;
|
||||||
|
if (count > 10) return <Meh className="h-6 w-6 text-yellow-500" />;
|
||||||
|
return <Frown className="h-6 w-6 text-red-500" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-12">
|
||||||
|
<section>
|
||||||
|
<h1 className="text-2xl font-medium text-ink mb-6">Active Dissolves</h1>
|
||||||
|
{isLoadingPosts ? (
|
||||||
|
<div className="flex justify-center p-8"><Loader2 className="h-8 w-8 animate-spin text-muted" /></div>
|
||||||
|
) : activePosts.length === 0 ? (
|
||||||
|
<div className="text-center py-10 px-6 bg-surface-soft rounded-lg">
|
||||||
|
<p className="text-muted">You have no active feelings dissolving right now.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<AnimatePresence>
|
||||||
|
{activePosts.map(post => <MyPostCard key={post.id} post={post} />)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h1 className="text-2xl font-medium text-ink mb-6">Past Dissolves</h1>
|
||||||
|
{isLoadingSummaries ? (
|
||||||
|
<div className="flex justify-center p-8"><Loader2 className="h-8 w-8 animate-spin text-muted" /></div>
|
||||||
|
) : pastSummaries.length === 0 ? (
|
||||||
|
<div className="text-center py-10 px-6 bg-surface-soft rounded-lg">
|
||||||
|
<Inbox className="mx-auto h-12 w-12 text-muted-soft"/>
|
||||||
|
<p className="mt-4 text-muted">Summaries of your past feelings will appear here once they dissolve.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{pastSummaries.map((summary: PostSummary) => (
|
||||||
|
<motion.div
|
||||||
|
key={summary.id}
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="flex items-center justify-between p-4 bg-surface-soft rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{getHoldIcon(summary.hold_count)}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-ink">
|
||||||
|
Your feeling of <span className="font-bold">{summary.emotion_tag}</span> was held by {summary.hold_count} people.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-soft">
|
||||||
|
Dissolved {formatDistanceToNow(new Date(summary.dissolved_at), { addSuffix: true })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
frontend/src/pages/NotFoundPage.tsx
Normal file
14
frontend/src/pages/NotFoundPage.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
export default function NotFoundPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center text-center h-[50vh]">
|
||||||
|
<h1 className="text-6xl font-bold text-primary">404</h1>
|
||||||
|
<p className="mt-4 text-2xl font-medium text-ink">Page Not Found</p>
|
||||||
|
<p className="mt-2 text-muted">The feeling you were looking for may have already dissolved.</p>
|
||||||
|
<Link to="/" className="mt-8 inline-flex items-center justify-center whitespace-nowrap rounded-full text-sm font-medium h-10 px-6 bg-primary text-on-primary hover:bg-primary-active transition-colors">
|
||||||
|
Return to The Stream
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
frontend/src/pages/StreamPage.tsx
Normal file
69
frontend/src/pages/StreamPage.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { useInView } from 'framer-motion';
|
||||||
|
import type { EmotionTag } from '@/types';
|
||||||
|
import { useApi } from '@/hooks/useApi';
|
||||||
|
import { EmotionFilterBar } from '@/components/EmotionFilterBar';
|
||||||
|
import { PostCard } from '@/components/PostCard';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function StreamPage() {
|
||||||
|
const [selectedEmotion, setSelectedEmotion] = useState<EmotionTag | undefined>();
|
||||||
|
const { useGetPosts } = useApi();
|
||||||
|
const { data, fetchNextPage, hasNextPage, isLoading, isFetchingNextPage } = useGetPosts(selectedEmotion);
|
||||||
|
|
||||||
|
const ref = useRef(null);
|
||||||
|
const isInView = useInView(ref);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInView && hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [isInView, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
|
const posts = data?.pages.flatMap((page) => page.posts) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<EmotionFilterBar selectedEmotion={selectedEmotion} onSelectEmotion={setSelectedEmotion} />
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && posts.length === 0 && (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center text-center">
|
||||||
|
<p className="text-lg text-ink">The stream is quiet.</p>
|
||||||
|
<p className="text-muted">Be the first to share a feeling today.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
className="grid grid-cols-1 gap-6 pt-8 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
variants={{
|
||||||
|
visible: {
|
||||||
|
transition: { staggerChildren: 0.05 }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{posts.map((post) => (
|
||||||
|
<PostCard key={post.id} post={post} />
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<div ref={ref} className="h-10" />
|
||||||
|
|
||||||
|
{isFetchingNextPage && (
|
||||||
|
<div className="flex justify-center py-6">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
frontend/src/pages/index.ts
Normal file
4
frontend/src/pages/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { default as StreamPage } from './StreamPage';
|
||||||
|
export { default as CreatePostPage } from './CreatePostPage';
|
||||||
|
export { default as MyDissolvesPage } from './MyDissolvesPage';
|
||||||
|
export { default as NotFoundPage } from './NotFoundPage';
|
||||||
21
frontend/src/types/index.ts
Normal file
21
frontend/src/types/index.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
export const EMOTION_TAGS = ['Joy', 'Love', 'Excitement', 'Anxiety', 'Sadness', 'Grief', 'Rage'] as const;
|
||||||
|
export type EmotionTag = typeof EMOTION_TAGS[number];
|
||||||
|
|
||||||
|
export type PostContentType = 'text' | 'voice' | 'photo';
|
||||||
|
|
||||||
|
export interface Post {
|
||||||
|
id: string;
|
||||||
|
content_type: PostContentType;
|
||||||
|
content_data: string;
|
||||||
|
emotion_tag: EmotionTag;
|
||||||
|
created_at: string;
|
||||||
|
expires_at?: string;
|
||||||
|
holdCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostSummary {
|
||||||
|
id: string;
|
||||||
|
emotion_tag: EmotionTag;
|
||||||
|
hold_count: number;
|
||||||
|
dissolved_at: string;
|
||||||
|
}
|
||||||
58
frontend/tailwind.config.js
Normal file
58
frontend/tailwind.config.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
darkMode: ['class'],
|
||||||
|
content: ['./src/**/*.{ts,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
canvas: 'hsl(var(--canvas))',
|
||||||
|
ink: 'hsl(var(--ink))',
|
||||||
|
body: 'hsl(var(--body))',
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
soft: 'hsl(var(--muted-soft))',
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
active: 'hsl(var(--primary-active))',
|
||||||
|
disabled: 'hsl(var(--primary-disabled))',
|
||||||
|
},
|
||||||
|
hairline: {
|
||||||
|
DEFAULT: 'hsl(var(--hairline))',
|
||||||
|
soft: 'hsl(var(--hairline-soft))'
|
||||||
|
},
|
||||||
|
'surface-soft': 'hsl(var(--surface-soft))',
|
||||||
|
'surface-card': 'hsl(var(--surface-card))',
|
||||||
|
'on-primary': 'hsl(var(--on-primary))',
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: '14px', // Overriding spec to match visual description
|
||||||
|
md: '8px',
|
||||||
|
sm: '4px',
|
||||||
|
full: '9999px',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Airbnb Cereal VF', 'Circular', 'sans-serif'],
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
card: '0 0 0 1px rgba(0,0,0,0.02), 0 2px 6px rgba(0,0,0,0.04), 0 4px 8px rgba(0,0,0,0.02)',
|
||||||
|
'card-hover': '0 0 0 1px rgba(0,0,0,0.04),0_2px_6px_rgba(0,0,0,0.06),0_6px_12px_rgba(0,0,0,0.08)',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
'accordion-down': {
|
||||||
|
from: { height: 0 },
|
||||||
|
to: { height: 'var(--radix-accordion-content-height)' },
|
||||||
|
},
|
||||||
|
'accordion-up': {
|
||||||
|
from: { height: 'var(--radix-accordion-content-height)' },
|
||||||
|
to: { height: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||||
|
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require('tailwindcss-animate')],
|
||||||
|
};
|
||||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"lib": [
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable",
|
||||||
|
"ESNext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": false,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
27
frontend/vite.config.ts
Normal file
27
frontend/vite.config.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
"react-query": "@tanstack/react-query",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
onwarn(warning, warn) {
|
||||||
|
if (warning.code === 'MODULE_LEVEL_DIRECTIVE' && warning.message.includes('use client')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
warn(warning);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user