fixed bugs
This commit is contained in:
parent
a993498b5f
commit
82c313eb69
@ -1,5 +1,5 @@
|
|||||||
import { Hono, MiddlewareHandler } from 'hono';
|
import { Hono, MiddlewareHandler } from 'hono';
|
||||||
import { AppContext, UserData } from '../utils';
|
import { AppContext, UserData, authMiddleware } from '../utils';
|
||||||
|
|
||||||
// Define types based on schema for clarity
|
// Define types based on schema for clarity
|
||||||
type Post = {
|
type Post = {
|
||||||
@ -24,18 +24,15 @@ const EMOTION_TAGS = ['Sadness', 'Rage', 'Joy', 'Anxiety', 'Love', 'Grief', 'Exc
|
|||||||
|
|
||||||
const publicRoutes = new Hono<AppContext>();
|
const publicRoutes = new Hono<AppContext>();
|
||||||
|
|
||||||
// Middleware to extract and validate the anonymous user ID
|
// We no longer use anonymousUserMiddleware, we use authMiddleware from utils
|
||||||
const anonymousUserMiddleware: MiddlewareHandler<AppContext> = async (c, next) => {
|
|
||||||
const anonymousUserId = c.req.header('X-Anonymous-User-ID');
|
|
||||||
if (!anonymousUserId) {
|
|
||||||
return c.json({ success: false, error: 'X-Anonymous-User-ID header is required.' }, 400);
|
|
||||||
}
|
|
||||||
c.set('anonymousUserId', anonymousUserId);
|
|
||||||
await next();
|
|
||||||
};
|
|
||||||
|
|
||||||
|
publicRoutes.post('/user/check-in', authMiddleware, async (c) => {
|
||||||
|
// authMiddleware ensures the user is valid and inserted into the DB
|
||||||
|
const user = c.get('user');
|
||||||
|
return c.json({ success: true, user });
|
||||||
|
});
|
||||||
// CRITICAL: R2 File upload route (as per general instructions)
|
// CRITICAL: R2 File upload route (as per general instructions)
|
||||||
publicRoutes.post('/upload', anonymousUserMiddleware, async (c) => {
|
publicRoutes.post('/upload', authMiddleware, async (c) => {
|
||||||
const body = await c.req.parseBody();
|
const body = await c.req.parseBody();
|
||||||
const file = body['file'];
|
const file = body['file'];
|
||||||
|
|
||||||
@ -91,8 +88,9 @@ publicRoutes.get('/media/:key', async (c) => {
|
|||||||
|
|
||||||
|
|
||||||
// 2. Create Post
|
// 2. Create Post
|
||||||
publicRoutes.post('/posts', anonymousUserMiddleware, async (c) => {
|
publicRoutes.post('/posts', authMiddleware, async (c) => {
|
||||||
const anonymousUserId = c.get('anonymousUserId');
|
const user = c.get('user');
|
||||||
|
const userId = user.email; // Map authenticated user to anonymous_user_id
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
|
|
||||||
const { contentType, contentData, emotionTag } = body;
|
const { contentType, contentData, emotionTag } = body;
|
||||||
@ -123,7 +121,7 @@ publicRoutes.post('/posts', anonymousUserMiddleware, async (c) => {
|
|||||||
const { success } = await c.env.DB.prepare(
|
const { success } = await c.env.DB.prepare(
|
||||||
`INSERT INTO posts (id, anonymous_user_id, content_type, content_data, emotion_tag, created_at, expires_at)
|
`INSERT INTO posts (id, anonymous_user_id, content_type, content_data, emotion_tag, created_at, expires_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||||
).bind(postId, anonymousUserId, contentType, contentData, emotionTag, createdAt, expiresAtISO).run();
|
).bind(postId, userId, contentType, contentData, emotionTag, createdAt, expiresAtISO).run();
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return c.json({ success: false, error: 'Failed to create post.' }, 500);
|
return c.json({ success: false, error: 'Failed to create post.' }, 500);
|
||||||
@ -183,15 +181,16 @@ publicRoutes.get('/posts', async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 4. Get User's Own Active Posts
|
// 4. Get User's Own Active Posts
|
||||||
publicRoutes.get('/posts/me', anonymousUserMiddleware, async (c) => {
|
publicRoutes.get('/posts/me', authMiddleware, async (c) => {
|
||||||
const anonymousUserId = c.get('anonymousUserId');
|
const user = c.get('user');
|
||||||
|
const userId = user.email;
|
||||||
try {
|
try {
|
||||||
const { results } = await c.env.DB.prepare(
|
const { results } = await c.env.DB.prepare(
|
||||||
`SELECT p.*, (SELECT COUNT(*) FROM holds h WHERE h.post_id = p.id) as holdCount
|
`SELECT p.*, (SELECT COUNT(*) FROM holds h WHERE h.post_id = p.id) as holdCount
|
||||||
FROM posts p
|
FROM posts p
|
||||||
WHERE p.anonymous_user_id = ? AND p.expires_at > CURRENT_TIMESTAMP
|
WHERE p.anonymous_user_id = ? AND p.expires_at > CURRENT_TIMESTAMP
|
||||||
ORDER BY p.created_at DESC`
|
ORDER BY p.created_at DESC`
|
||||||
).bind(anonymousUserId).all<Post & { holdCount: number }>();
|
).bind(userId).all<Post & { holdCount: number }>();
|
||||||
|
|
||||||
return c.json({ posts: results || [] });
|
return c.json({ posts: results || [] });
|
||||||
|
|
||||||
@ -202,8 +201,9 @@ publicRoutes.get('/posts/me', anonymousUserMiddleware, async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 5. Hold a Post
|
// 5. Hold a Post
|
||||||
publicRoutes.post('/posts/:postId/hold', anonymousUserMiddleware, async (c) => {
|
publicRoutes.post('/posts/:postId/hold', authMiddleware, async (c) => {
|
||||||
const anonymousUserId = c.get('anonymousUserId');
|
const user = c.get('user');
|
||||||
|
const userId = user.email;
|
||||||
const { postId } = c.req.param();
|
const { postId } = c.req.param();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -215,7 +215,7 @@ publicRoutes.post('/posts/:postId/hold', anonymousUserMiddleware, async (c) => {
|
|||||||
|
|
||||||
const { success } = await c.env.DB.prepare(
|
const { success } = await c.env.DB.prepare(
|
||||||
'INSERT INTO holds (post_id, anonymous_user_id) VALUES (?, ?)'
|
'INSERT INTO holds (post_id, anonymous_user_id) VALUES (?, ?)'
|
||||||
).bind(postId, anonymousUserId).run();
|
).bind(postId, userId).run();
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
// This likely means a UNIQUE constraint violation
|
// This likely means a UNIQUE constraint violation
|
||||||
@ -236,8 +236,9 @@ publicRoutes.post('/posts/:postId/hold', anonymousUserMiddleware, async (c) => {
|
|||||||
|
|
||||||
|
|
||||||
// 6. Report a Post
|
// 6. Report a Post
|
||||||
publicRoutes.post('/posts/:postId/report', anonymousUserMiddleware, async (c) => {
|
publicRoutes.post('/posts/:postId/report', authMiddleware, async (c) => {
|
||||||
const anonymousUserId = c.get('anonymousUserId');
|
const user = c.get('user');
|
||||||
|
const userId = user.email;
|
||||||
const { postId } = c.req.param();
|
const { postId } = c.req.param();
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const reason = body.reason || null;
|
const reason = body.reason || null;
|
||||||
@ -249,7 +250,7 @@ publicRoutes.post('/posts/:postId/report', anonymousUserMiddleware, async (c) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const existingReport = await c.env.DB.prepare("SELECT id FROM reports WHERE post_id = ? AND reporter_anonymous_user_id = ?")
|
const existingReport = await c.env.DB.prepare("SELECT id FROM reports WHERE post_id = ? AND reporter_anonymous_user_id = ?")
|
||||||
.bind(postId, anonymousUserId)
|
.bind(postId, userId)
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (existingReport) {
|
if (existingReport) {
|
||||||
@ -258,7 +259,7 @@ publicRoutes.post('/posts/:postId/report', anonymousUserMiddleware, async (c) =>
|
|||||||
|
|
||||||
await c.env.DB.prepare(
|
await c.env.DB.prepare(
|
||||||
'INSERT INTO reports (post_id, reporter_anonymous_user_id, reason) VALUES (?, ?, ?)'
|
'INSERT INTO reports (post_id, reporter_anonymous_user_id, reason) VALUES (?, ?, ?)'
|
||||||
).bind(postId, anonymousUserId, reason).run();
|
).bind(postId, userId, reason).run();
|
||||||
|
|
||||||
return c.body(null, 202);
|
return c.body(null, 202);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@ -268,15 +269,16 @@ publicRoutes.post('/posts/:postId/report', anonymousUserMiddleware, async (c) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 7. Get Post Summaries
|
// 7. Get Post Summaries
|
||||||
publicRoutes.get('/posts/summaries', anonymousUserMiddleware, async (c) => {
|
publicRoutes.get('/posts/summaries', authMiddleware, async (c) => {
|
||||||
const anonymousUserId = c.get('anonymousUserId');
|
const user = c.get('user');
|
||||||
|
const userId = user.email;
|
||||||
try {
|
try {
|
||||||
const { results } = await c.env.DB.prepare(
|
const { results } = await c.env.DB.prepare(
|
||||||
`SELECT id, emotion_tag, hold_count, dissolved_at
|
`SELECT id, emotion_tag, hold_count, dissolved_at
|
||||||
FROM post_summaries
|
FROM post_summaries
|
||||||
WHERE anonymous_user_id = ?
|
WHERE anonymous_user_id = ?
|
||||||
ORDER BY dissolved_at DESC`
|
ORDER BY dissolved_at DESC`
|
||||||
).bind(anonymousUserId).all<PostSummary>();
|
).bind(userId).all<PostSummary>();
|
||||||
|
|
||||||
return c.json({ summaries: results || [] });
|
return c.json({ summaries: results || [] });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@ -286,8 +288,9 @@ publicRoutes.get('/posts/summaries', anonymousUserMiddleware, async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 8. Clear Post Summaries
|
// 8. Clear Post Summaries
|
||||||
publicRoutes.post('/posts/summaries/clear', anonymousUserMiddleware, async (c) => {
|
publicRoutes.post('/posts/summaries/clear', authMiddleware, async (c) => {
|
||||||
const anonymousUserId = c.get('anonymousUserId');
|
const user = c.get('user');
|
||||||
|
const userId = user.email;
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const summaryIds = body.summaryIds;
|
const summaryIds = body.summaryIds;
|
||||||
|
|
||||||
@ -297,7 +300,7 @@ publicRoutes.post('/posts/summaries/clear', anonymousUserMiddleware, async (c) =
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const query = `DELETE FROM post_summaries WHERE anonymous_user_id = ? AND id IN (${summaryIds.map(() => '?').join(',')})`;
|
const query = `DELETE FROM post_summaries WHERE anonymous_user_id = ? AND id IN (${summaryIds.map(() => '?').join(',')})`;
|
||||||
await c.env.DB.prepare(query).bind(anonymousUserId, ...summaryIds).run();
|
await c.env.DB.prepare(query).bind(userId, ...summaryIds).run();
|
||||||
|
|
||||||
return c.body(null, 204);
|
return c.body(null, 204);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
{
|
{
|
||||||
"binding": "DB",
|
"binding": "DB",
|
||||||
"database_name": "db-xd1uom",
|
"database_name": "db-xd1uom",
|
||||||
"database_id": "23b00c58-c2c5-4f98-abba-ce49c8cfaa0c"
|
"database_id": "c998402c-77a7-4d05-b963-11fe654161b9"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"r2_buckets": [
|
"r2_buckets": [
|
||||||
|
|||||||
2790
frontend/package-lock.json
generated
2790
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -2,11 +2,10 @@ import { Routes, Route } from 'react-router-dom';
|
|||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import { Layout } from '@/components/Layout';
|
import { Layout } from '@/components/Layout';
|
||||||
import { StreamPage, CreatePostPage, MyDissolvesPage, NotFoundPage } from '@/pages';
|
import { StreamPage, CreatePostPage, MyDissolvesPage, NotFoundPage } from '@/pages';
|
||||||
import { AnonymousIdProvider } from '@/context/AnonymousIdContext';
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<AnonymousIdProvider>
|
<>
|
||||||
<Layout>
|
<Layout>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<StreamPage />} />
|
<Route path="/" element={<StreamPage />} />
|
||||||
@ -16,7 +15,7 @@ function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
<Toaster position="bottom-center" toastOptions={{ className: 'font-sans' }} />
|
<Toaster position="bottom-center" toastOptions={{ className: 'font-sans' }} />
|
||||||
</AnonymousIdProvider>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
export const API_URL = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
|
export const API_URL = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' || window.location.hostname.startsWith('192.168.'))
|
||||||
? "http://127.0.0.1:8787"
|
? `http://${window.location.hostname}:8787`
|
||||||
: "https://app-xd1uom.laflabs-tech.workers.dev";
|
: "https://app-xd1uom.mworld-projects.workers.dev";
|
||||||
|
|
||||||
export const api = axios.create({
|
export const api = axios.create({
|
||||||
baseURL: API_URL
|
baseURL: API_URL
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export function AudioPlayer({ src }: AudioPlayerProps) {
|
|||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [hasError, setHasError] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
@ -24,17 +25,36 @@ export function AudioPlayer({ src }: AudioPlayerProps) {
|
|||||||
const handleTimeUpdate = () => setCurrentTime(audio.currentTime);
|
const handleTimeUpdate = () => setCurrentTime(audio.currentTime);
|
||||||
const handleEnded = () => setIsPlaying(false);
|
const handleEnded = () => setIsPlaying(false);
|
||||||
const handleCanPlay = () => setIsLoading(false);
|
const handleCanPlay = () => setIsLoading(false);
|
||||||
|
const handleError = () => {
|
||||||
|
console.error("Error loading audio file:", audio.error);
|
||||||
|
setIsLoading(false);
|
||||||
|
setHasError(true);
|
||||||
|
};
|
||||||
|
|
||||||
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
|
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
audio.addEventListener('timeupdate', handleTimeUpdate);
|
audio.addEventListener('timeupdate', handleTimeUpdate);
|
||||||
audio.addEventListener('ended', handleEnded);
|
audio.addEventListener('ended', handleEnded);
|
||||||
audio.addEventListener('canplay', handleCanPlay);
|
audio.addEventListener('canplay', handleCanPlay);
|
||||||
|
audio.addEventListener('error', handleError);
|
||||||
|
|
||||||
|
// Check if audio has already loaded enough data before listeners were attached
|
||||||
|
if (audio.readyState >= 1) {
|
||||||
|
handleLoadedMetadata();
|
||||||
|
}
|
||||||
|
if (audio.readyState >= 3) {
|
||||||
|
handleCanPlay();
|
||||||
|
}
|
||||||
|
if (audio.error) {
|
||||||
|
handleError();
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
audio.removeEventListener('timeupdate', handleTimeUpdate);
|
audio.removeEventListener('timeupdate', handleTimeUpdate);
|
||||||
|
audio.removeEventListener('timeupdate', handleTimeUpdate);
|
||||||
audio.removeEventListener('ended', handleEnded);
|
audio.removeEventListener('ended', handleEnded);
|
||||||
audio.removeEventListener('canplay', handleCanPlay);
|
audio.removeEventListener('canplay', handleCanPlay);
|
||||||
|
audio.removeEventListener('error', handleError);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -54,11 +74,13 @@ export function AudioPlayer({ src }: AudioPlayerProps) {
|
|||||||
<audio ref={audioRef} src={src} preload="metadata"></audio>
|
<audio ref={audioRef} src={src} preload="metadata"></audio>
|
||||||
<button
|
<button
|
||||||
onClick={togglePlayPause}
|
onClick={togglePlayPause}
|
||||||
disabled={isLoading}
|
disabled={isLoading || hasError}
|
||||||
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-primary text-on-primary disabled:bg-primary-disabled"
|
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 ? (
|
{isLoading ? (
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
) : hasError ? (
|
||||||
|
<span className="text-xs">!</span>
|
||||||
) : isPlaying ? (
|
) : isPlaying ? (
|
||||||
<Pause className="h-5 w-5" />
|
<Pause className="h-5 w-5" />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { NavLink, useLocation, Link } from 'react-router-dom';
|
import { NavLink, useLocation, Link } from 'react-router-dom';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus, LogIn, LogOut, User } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@ -9,11 +10,12 @@ interface LayoutProps {
|
|||||||
|
|
||||||
export function Layout({ children }: LayoutProps) {
|
export function Layout({ children }: LayoutProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { isAuthenticated, login, logout, user } = useAuth();
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: '/', label: 'The Stream' },
|
{ path: '/', label: 'The Stream', public: true },
|
||||||
{ path: '/my-dissolves', label: 'My Dissolves' },
|
{ path: '/my-dissolves', label: 'My Dissolves', public: false },
|
||||||
];
|
].filter(item => item.public || isAuthenticated);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-canvas font-sans">
|
<div className="min-h-screen bg-canvas font-sans">
|
||||||
@ -42,9 +44,28 @@ export function Layout({ children }: LayoutProps) {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-4">
|
||||||
<Plus className="mr-2 h-4 w-4" /> Share a Feeling
|
{isAuthenticated ? (
|
||||||
</Link>
|
<>
|
||||||
|
<Link to="/create" className="hidden md: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 className="flex items-center gap-3 border-l border-hairline pl-4 ml-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-ink font-medium">
|
||||||
|
<User className="h-4 w-4 text-muted" />
|
||||||
|
<span className="hidden sm:inline">{user?.fullName || 'User'}</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={logout} className="p-2 text-muted hover:text-ink transition-colors" aria-label="Log out">
|
||||||
|
<LogOut className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button onClick={login} className="inline-flex items-center justify-center whitespace-nowrap rounded-full text-sm font-medium h-10 px-6 border border-hairline text-ink hover:bg-surface-soft transition-colors">
|
||||||
|
<LogIn className="mr-2 h-4 w-4" /> Log In
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="container-full py-8 md:py-12">
|
<main className="container-full py-8 md:py-12">
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { toast } from 'sonner';
|
|||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useApi } from '@/hooks/useApi';
|
import { useApi } from '@/hooks/useApi';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import type { Post } from '@/types';
|
import type { Post } from '@/types';
|
||||||
import { AudioPlayer } from './AudioPlayer';
|
import { AudioPlayer } from './AudioPlayer';
|
||||||
import { ReportModal } from './ReportModal';
|
import { ReportModal } from './ReportModal';
|
||||||
@ -21,8 +22,13 @@ export function PostCard({ post }: PostCardProps) {
|
|||||||
const isHeld = useIsPostHeld(post.id) ?? false;
|
const isHeld = useIsPostHeld(post.id) ?? false;
|
||||||
|
|
||||||
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
|
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
|
||||||
|
const { isAuthenticated, login } = useAuth();
|
||||||
|
|
||||||
const handleHold = () => {
|
const handleHold = () => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
login();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isHeld) return;
|
if (isHeld) return;
|
||||||
holdPost(post.id, {
|
holdPost(post.id, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@ -81,7 +87,13 @@ export function PostCard({ post }: PostCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsReportModalOpen(true)}
|
onClick={() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
login();
|
||||||
|
} else {
|
||||||
|
setIsReportModalOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-full text-muted transition-colors hover:bg-hairline hover:text-ink"
|
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"
|
aria-label="Report post"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,40 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@ -1,129 +1,123 @@
|
|||||||
import { useMutation, useQuery, useInfiniteQuery, QueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useInfiniteQuery, QueryClient } from '@tanstack/react-query';
|
||||||
import { api, API_URL } from '../api';
|
import { api, API_URL } from '../api';
|
||||||
import { useAnonymousId } from '@/context/AnonymousIdContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import type { EmotionTag, Post, PostSummary, PostContentType } from '@/types';
|
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, data?: any): Promise<{ data: T }> => {
|
||||||
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') {
|
if (method === 'post' || method === 'delete') {
|
||||||
return api[method]<T>(url, data, { headers });
|
return api[method]<T>(url, data);
|
||||||
}
|
}
|
||||||
return api.get<T>(url, { headers });
|
return api.get<T>(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useApi = () => {
|
export const useApi = () => {
|
||||||
const { anonymousId } = useAnonymousId();
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
// Stream
|
// Stream
|
||||||
const useGetPosts = (emotion?: EmotionTag) => {
|
const useGetPosts = (emotion?: EmotionTag) => {
|
||||||
return useInfiniteQuery(
|
return useInfiniteQuery({
|
||||||
['posts', emotion],
|
queryKey: ['posts', emotion],
|
||||||
async ({ pageParam: cursor }) => {
|
queryFn: async ({ pageParam: cursor }) => {
|
||||||
if (!anonymousId) throw new Error('Anonymous ID not available');
|
|
||||||
let url = '/api/posts?';
|
let url = '/api/posts?';
|
||||||
if (emotion) url += `emotion=${emotion}&`;
|
if (emotion) url += `emotion=${emotion}&`;
|
||||||
if (cursor) url += `cursor=${cursor}`;
|
if (cursor) url += `cursor=${cursor}`;
|
||||||
const res = await callApi<{posts: Post[], nextCursor: string | null}>('get', url, anonymousId);
|
const res = await callApi<{posts: Post[], nextCursor: string | null}>('get', url);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
{
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
enabled: true, // Stream is public, no authentication required to view
|
||||||
enabled: !!anonymousId,
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create
|
// Create
|
||||||
const useCreatePost = (queryClient: QueryClient) => {
|
const useCreatePost = (queryClient: QueryClient) => {
|
||||||
return useMutation(
|
return useMutation({
|
||||||
({ contentType, contentData, emotionTag }: { contentType: PostContentType, contentData: string, emotionTag: EmotionTag }) => {
|
mutationFn: ({ contentType, contentData, emotionTag }: { contentType: PostContentType, contentData: string, emotionTag: EmotionTag }) => {
|
||||||
if (!anonymousId) throw new Error('Anonymous ID not available');
|
if (!isAuthenticated) throw new Error('Must be logged in to post');
|
||||||
return callApi('post', '/api/posts', anonymousId, { contentType, contentData, emotionTag });
|
return callApi('post', '/api/posts', { contentType, contentData, emotionTag });
|
||||||
},
|
},
|
||||||
{
|
onSuccess: () => {
|
||||||
onSuccess: () => {
|
queryClient.invalidateQueries({ queryKey: ['my-posts'] });
|
||||||
queryClient.invalidateQueries(['my-posts']);
|
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
||||||
queryClient.invalidateQueries(['posts']);
|
},
|
||||||
},
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const useUploadFile = () => {
|
const useUploadFile = () => {
|
||||||
return useMutation(
|
return useMutation({
|
||||||
(file: File) => {
|
mutationFn: (file: File) => {
|
||||||
if (!anonymousId) throw new Error('Anonymous ID not available');
|
if (!isAuthenticated) throw new Error('Must be logged in to upload');
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
return callApi<{url: string}>('post', '/api/upload', anonymousId, formData);
|
return callApi<{url: string}>('post', '/api/upload', formData);
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// My Dissolves
|
// My Dissolves
|
||||||
const useGetMyPosts = () => {
|
const useGetMyPosts = () => {
|
||||||
return useQuery(
|
return useQuery({
|
||||||
['my-posts'],
|
queryKey: ['my-posts'],
|
||||||
() => {
|
queryFn: () => {
|
||||||
if (!anonymousId) throw new Error('Anonymous ID not available');
|
if (!isAuthenticated) throw new Error('Must be logged in');
|
||||||
return callApi<{posts: Post[]}>('get', '/api/posts/me', anonymousId).then(res => res.data);
|
return callApi<{posts: Post[]}>('get', '/api/posts/me').then(res => res.data);
|
||||||
},
|
},
|
||||||
{ enabled: !!anonymousId }
|
enabled: isAuthenticated
|
||||||
);
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const useGetSummaries = () => {
|
const useGetSummaries = () => {
|
||||||
return useQuery(
|
return useQuery({
|
||||||
['summaries'],
|
queryKey: ['summaries'],
|
||||||
() => {
|
queryFn: () => {
|
||||||
if (!anonymousId) throw new Error('Anonymous ID not available');
|
if (!isAuthenticated) throw new Error('Must be logged in');
|
||||||
return callApi<{summaries: PostSummary[]}>('get', '/api/posts/summaries', anonymousId).then(res => res.data);
|
return callApi<{summaries: PostSummary[]}>('get', '/api/posts/summaries').then(res => res.data);
|
||||||
},
|
},
|
||||||
{ enabled: !!anonymousId }
|
enabled: isAuthenticated
|
||||||
);
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const useClearSummaries = (queryClient: QueryClient) => {
|
const useClearSummaries = (queryClient: QueryClient) => {
|
||||||
return useMutation(
|
return useMutation({
|
||||||
(summaryIds: string[]) => {
|
mutationFn: (summaryIds: string[]) => {
|
||||||
if (!anonymousId) throw new Error('Anonymous ID not available');
|
if (!isAuthenticated) throw new Error('Must be logged in');
|
||||||
return callApi('post', '/api/posts/summaries/clear', anonymousId, { summaryIds });
|
return callApi('post', '/api/posts/summaries/clear', { summaryIds });
|
||||||
},
|
},
|
||||||
{
|
onSuccess: () => {
|
||||||
onSuccess: () => {
|
queryClient.invalidateQueries({ queryKey: ['summaries'] });
|
||||||
queryClient.invalidateQueries(['summaries']);
|
},
|
||||||
},
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Interactions
|
// Interactions
|
||||||
const useHoldPost = (queryClient: QueryClient) => {
|
const useHoldPost = (queryClient: QueryClient) => {
|
||||||
return useMutation(
|
return useMutation({
|
||||||
(postId: string) => {
|
mutationFn: (postId: string) => {
|
||||||
if (!anonymousId) throw new Error('Anonymous ID not available');
|
if (!isAuthenticated) throw new Error('Must be logged in');
|
||||||
return callApi('post', `/api/posts/${postId}/hold`, anonymousId);
|
return callApi('post', `/api/posts/${postId}/hold`);
|
||||||
},
|
},
|
||||||
{
|
onSuccess: (_data, postId) => {
|
||||||
onSuccess: (_data, postId) => {
|
queryClient.setQueryData(['held-posts', postId], true);
|
||||||
queryClient.setQueryData(['held-posts', postId], true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const useReportPost = () => {
|
const useReportPost = () => {
|
||||||
return useMutation(
|
return useMutation({
|
||||||
({ postId, reason }: { postId: string, reason: string }) => {
|
mutationFn: ({ postId, reason }: { postId: string, reason: string }) => {
|
||||||
if (!anonymousId) throw new Error('Anonymous ID not available');
|
if (!isAuthenticated) throw new Error('Must be logged in');
|
||||||
return callApi('post', `/api/posts/${postId}/report`, anonymousId, { reason });
|
return callApi('post', `/api/posts/${postId}/report`, { reason });
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const useIsPostHeld = (postId: string) => {
|
const useIsPostHeld = (postId: string) => {
|
||||||
const {data} = useQuery(['held-posts', postId], () => false, { staleTime: Infinity, cacheTime: Infinity });
|
const {data} = useQuery({
|
||||||
|
queryKey: ['held-posts', postId],
|
||||||
|
queryFn: () => false,
|
||||||
|
staleTime: Infinity,
|
||||||
|
cacheTime: Infinity
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,3 +130,4 @@ export const useApi = () => {
|
|||||||
|
|
||||||
return { useGetPosts, useCreatePost, useUploadFile, useGetMyPosts, useGetSummaries, useClearSummaries, useHoldPost, useReportPost, useIsPostHeld, getMediaUrl };
|
return { useGetPosts, useCreatePost, useUploadFile, useGetMyPosts, useGetSummaries, useClearSummaries, useHoldPost, useReportPost, useIsPostHeld, getMediaUrl };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -22,7 +22,6 @@ export const useCountdown = (targetDate: string | Date) => {
|
|||||||
format: ['hours', 'minutes', 'seconds'],
|
format: ['hours', 'minutes', 'seconds'],
|
||||||
zero: true,
|
zero: true,
|
||||||
delimiter: ':',
|
delimiter: ':',
|
||||||
pad: { hours: 2, minutes: 2, seconds: 2 },
|
|
||||||
});
|
});
|
||||||
// Special handling for formatDuration output
|
// Special handling for formatDuration output
|
||||||
const parts = formatted.split(':').map(p => p.padStart(2, '0'));
|
const parts = formatted.split(':').map(p => p.padStart(2, '0'));
|
||||||
|
|||||||
@ -1,14 +1,9 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@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 {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--canvas: 0 0% 100%; /* #ffffff */
|
--canvas: 0 0% 100%; /* #ffffff */
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@ -18,6 +19,15 @@ const contentTypes: { type: PostContentType, label: string, icon: React.ElementT
|
|||||||
|
|
||||||
export default function CreatePostPage() {
|
export default function CreatePostPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
navigate('/');
|
||||||
|
toast.error('You must be logged in to share a feeling.');
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, navigate]);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { useCreatePost, useUploadFile } = useApi();
|
const { useCreatePost, useUploadFile } = useApi();
|
||||||
const createPostMutation = useCreatePost(queryClient);
|
const createPostMutation = useCreatePost(queryClient);
|
||||||
@ -53,8 +63,10 @@ export default function CreatePostPage() {
|
|||||||
audioChunksRef.current = [];
|
audioChunksRef.current = [];
|
||||||
recorder.ondataavailable = (event) => audioChunksRef.current.push(event.data);
|
recorder.ondataavailable = (event) => audioChunksRef.current.push(event.data);
|
||||||
recorder.onstop = () => {
|
recorder.onstop = () => {
|
||||||
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
|
const mimeType = recorder.mimeType || 'audio/webm';
|
||||||
const audioFile = new File([audioBlob], 'recording.webm', { type: 'audio/webm' });
|
const ext = mimeType.includes('mp4') ? 'mp4' : 'webm';
|
||||||
|
const audioBlob = new Blob(audioChunksRef.current, { type: mimeType });
|
||||||
|
const audioFile = new File([audioBlob], `recording.${ext}`, { type: mimeType });
|
||||||
setFileData(audioFile);
|
setFileData(audioFile);
|
||||||
stream.getTracks().forEach(track => track.stop()); // Stop microphone access
|
stream.getTracks().forEach(track => track.stop()); // Stop microphone access
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
@ -10,6 +12,16 @@ import { MyPostCard } from '@/components/MyPostCard';
|
|||||||
import type { PostSummary } from '@/types';
|
import type { PostSummary } from '@/types';
|
||||||
|
|
||||||
export default function MyDissolvesPage() {
|
export default function MyDissolvesPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
navigate('/');
|
||||||
|
toast.error('You must be logged in to view your dissolves.');
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, navigate]);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { useGetMyPosts, useGetSummaries, useClearSummaries } = useApi();
|
const { useGetMyPosts, useGetSummaries, useClearSummaries } = useApi();
|
||||||
const { data: myPostsData, isLoading: isLoadingPosts } = useGetMyPosts();
|
const { data: myPostsData, isLoading: isLoadingPosts } = useGetMyPosts();
|
||||||
|
|||||||
@ -32,7 +32,7 @@ module.exports = {
|
|||||||
full: '9999px',
|
full: '9999px',
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['Airbnb Cereal VF', 'Circular', 'sans-serif'],
|
sans: ['Inter', 'sans-serif'],
|
||||||
},
|
},
|
||||||
boxShadow: {
|
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: '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)',
|
||||||
|
|||||||
@ -17,7 +17,11 @@
|
|||||||
"moduleResolution": "Node",
|
"moduleResolution": "Node",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "react-jsx"
|
"jsx": "react-jsx",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src"
|
"src"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user