feat: add frontend in /frontend

This commit is contained in:
MWorld Deployer 2026-07-15 20:35:58 +05:30
parent 1112e6f062
commit a993498b5f
31 changed files with 4647 additions and 0 deletions

4
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
.wrangler
.env

13
frontend/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

45
frontend/package.json Normal file
View 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"
}
}

View File

@ -0,0 +1 @@
export default { plugins: { tailwindcss: {}, autoprefixer: {} } }

23
frontend/src/App.tsx Normal file
View 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
View 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);
}
);

View 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>
);
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
};

View 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;
};

View 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 };
};

View 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
View 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
View 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
View 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>,
)

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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';

View 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;
}

View 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
View 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"
]
}

View 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
View 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);
},
},
},
})