diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 4fd9e9f..38aba7b 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -3,9 +3,11 @@ from fastapi import APIRouter from app.controllers.image import ImageController -from app.controllers.messages import MessagesController +from app.controllers.image_pair import ImagePairController +from app.controllers.project import ProjectController from app.services.image import ImageService -from app.services.messages import MessagesService +from app.services.image_pair import ImagePairService +from app.services.project import ProjectService log = logging.getLogger(__name__) @@ -20,18 +22,33 @@ async def status(): return {"status": "ok"} -### Messages +### Projects -def get_messages_controller_router(): - service = MessagesService() - return MessagesController(service=service).router +def get_project_controller_router(): + service = ProjectService() + return ProjectController(service=service).router router.include_router( - get_messages_controller_router(), - tags=["messages"], - prefix="/api/messages", + get_project_controller_router(), + tags=["projects"], + prefix="/api/projects", +) + + +### Image Pairs + + +def get_image_pair_controller_router(): + service = ImagePairService() + return ImagePairController(service=service).router + + +router.include_router( + get_image_pair_controller_router(), + tags=["image-pairs"], + prefix="/api/image-pairs", ) diff --git a/backend/app/controllers/image_pair.py b/backend/app/controllers/image_pair.py new file mode 100644 index 0000000..1468460 --- /dev/null +++ b/backend/app/controllers/image_pair.py @@ -0,0 +1,55 @@ +import logging + +from fastapi import APIRouter, Header, HTTPException + +from app.models.image_pair import ImagePairListResponse +from app.services.image_pair import ImagePairService +from app.utils.database import db_client + +log = logging.getLogger(__name__) + + +class ImagePairController: + def __init__(self, service: ImagePairService): + self.router = APIRouter() + self.service = service + self.setup_routes() + + def setup_routes(self): + router = self.router + + @router.get( + "/{project_id}", + response_model=ImagePairListResponse, + ) + async def get_image_pairs( + project_id: str, + authorization: str = Header(None), + ) -> ImagePairListResponse: + """ + Fetch all image pairs for a given project ID. + """ + log.info(f"Fetching image pairs for project_id: {project_id}") + try: + # Extract token from authorization header + token = authorization.replace("Bearer ", "") if authorization else "" + + # Get database client + supabase_client = await db_client(token=token) + + # Fetch image pairs + image_pairs = await self.service.get_image_pairs_by_project_id( + supabase_client=supabase_client, project_id=project_id + ) + + log.info(f"Successfully retrieved {len(image_pairs)} image pairs") + return ImagePairListResponse(image_pairs=image_pairs) + + except RuntimeError as e: + log.error(f"Service error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + except Exception as e: + log.error(f"Unexpected error: {e}") + raise HTTPException( + status_code=500, detail="An unexpected error occurred" + ) diff --git a/backend/app/controllers/messages.py b/backend/app/controllers/messages.py deleted file mode 100644 index 2e77dd5..0000000 --- a/backend/app/controllers/messages.py +++ /dev/null @@ -1,28 +0,0 @@ -import logging - -from fastapi import APIRouter - -from app.models.messages import MessageRequest, MessageResponse -from app.services.messages import MessagesService - -log = logging.getLogger(__name__) - - -class MessagesController: - def __init__(self, service: MessagesService): - self.router = APIRouter() - self.service = service - self.setup_routes() - - def setup_routes(self): - router = self.router - - @router.post( - "", - response_model=MessageResponse, - ) - async def chat(input: MessageRequest) -> MessageResponse: - log.info(f"Sending message of id {input.id} to assistant...") - response: MessageResponse = await self.service.chat(input=input) - log.info("Message to be sent back to user: %s", response.content) - return response diff --git a/backend/app/controllers/project.py b/backend/app/controllers/project.py new file mode 100644 index 0000000..dcdd818 --- /dev/null +++ b/backend/app/controllers/project.py @@ -0,0 +1,55 @@ +import logging + +from fastapi import APIRouter, Header, HTTPException + +from app.models.project import ProjectListResponse +from app.services.project import ProjectService +from app.utils.database import db_client + +log = logging.getLogger(__name__) + + +class ProjectController: + def __init__(self, service: ProjectService): + self.router = APIRouter() + self.service = service + self.setup_routes() + + def setup_routes(self): + router = self.router + + @router.get( + "/{user_id}", + response_model=ProjectListResponse, + ) + async def get_projects( + user_id: str, + authorization: str = Header(None), + ) -> ProjectListResponse: + """ + Fetch all projects for a given user ID. + """ + log.info(f"Fetching projects for user_id: {user_id}") + try: + # Extract token from authorization header + token = authorization.replace("Bearer ", "") if authorization else "" + + # Get database client + supabase_client = await db_client(token=token) + + # Fetch projects + projects = await self.service.get_projects_by_user_id( + supabase_client=supabase_client, user_id=user_id + ) + + log.info(f"Successfully retrieved {len(projects)} projects") + return ProjectListResponse(projects=projects) + + except RuntimeError as e: + log.error(f"Service error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + except Exception as e: + log.error(f"Unexpected error: {e}") + raise HTTPException( + status_code=500, detail="An unexpected error occurred" + ) diff --git a/backend/app/models/image_pair.py b/backend/app/models/image_pair.py new file mode 100644 index 0000000..5b3adf2 --- /dev/null +++ b/backend/app/models/image_pair.py @@ -0,0 +1,49 @@ +from datetime import datetime +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class ImagePair(BaseModel): + id: str = Field(description="The unique identifier for the image pair.") + project_id: str = Field(description="The project ID this image pair belongs to.") + input_url: str = Field(description="URL to the input image.") + input_mime_type: Optional[str] = Field( + default=None, description="MIME type of the input image." + ) + input_width: Optional[int] = Field( + default=None, description="Width of the input image in pixels." + ) + input_height: Optional[int] = Field( + default=None, description="Height of the input image in pixels." + ) + output_url: Optional[str] = Field( + default=None, description="URL to the output image." + ) + output_mime_type: Optional[str] = Field( + default=None, description="MIME type of the output image." + ) + output_width: Optional[int] = Field( + default=None, description="Width of the output image in pixels." + ) + output_height: Optional[int] = Field( + default=None, description="Height of the output image in pixels." + ) + prompt_text: Optional[str] = Field( + default=None, description="The prompt text used to generate the output image." + ) + metadata: Optional[Dict[str, Any]] = Field( + default=None, description="Additional metadata for the image pair." + ) + created_at: datetime = Field( + description="The timestamp when the image pair was created." + ) + updated_at: datetime = Field( + description="The timestamp when the image pair was last updated." + ) + + +class ImagePairListResponse(BaseModel): + image_pairs: List[ImagePair] = Field( + description="List of image pairs for the project." + ) diff --git a/backend/app/models/messages.py b/backend/app/models/messages.py deleted file mode 100644 index a6f0f59..0000000 --- a/backend/app/models/messages.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import Optional - -from pydantic import BaseModel, Field - - -class MessageRequest(BaseModel): - id: Optional[str] = Field( - description="The ID of the chat history which this message belongs to. It is None if this is a new chat." - ) - content: str = Field( - description="The content of the user message to be sent to the assistant." - ) - - -class MessageResponse(BaseModel): - id: str = Field( - description="The ID of the chat history which this message belongs to. This should be passed back to the server during the next request." - ) - content: str = Field( - description="The response from the assistant to be sent back to the user." - ) diff --git a/backend/app/models/project.py b/backend/app/models/project.py new file mode 100644 index 0000000..2ddabab --- /dev/null +++ b/backend/app/models/project.py @@ -0,0 +1,26 @@ +from datetime import datetime +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class Project(BaseModel): + id: str = Field(description="The unique identifier for the project.") + user_id: str = Field(description="The user ID who owns the project.") + name: Optional[str] = Field(default=None, description="The name of the project.") + description: Optional[str] = Field( + default=None, description="The description of the project." + ) + snapshot: Optional[Dict[str, Any]] = Field( + default=None, description="The snapshot data for the project." + ) + created_at: datetime = Field( + description="The timestamp when the project was created." + ) + updated_at: datetime = Field( + description="The timestamp when the project was last updated." + ) + + +class ProjectListResponse(BaseModel): + projects: List[Project] = Field(description="List of projects for the user.") diff --git a/backend/app/services/image_pair.py b/backend/app/services/image_pair.py new file mode 100644 index 0000000..1e3ba25 --- /dev/null +++ b/backend/app/services/image_pair.py @@ -0,0 +1,51 @@ +import logging +from typing import List + +from supabase._async.client import AsyncClient as Client + +from app.models.image_pair import ImagePair + +log = logging.getLogger(__name__) + + +class ImagePairService: + async def get_image_pairs_by_project_id( + self, supabase_client: Client, project_id: str + ) -> List[ImagePair]: + """ + Fetch all image pairs for a given project ID. + + Args: + supabase_client: The Supabase client instance + project_id: The project ID to fetch image pairs for + + Returns: + List of ImagePair objects for the project + """ + log.info(f"Fetching image pairs for project_id: {project_id}") + + try: + # Query the image_pairs table (uses the project_id index) + response = ( + await supabase_client.table("image_pairs") + .select("*") + .eq("project_id", project_id) + .order("created_at", desc=True) + .execute() + ) + + if not response.data: + log.info(f"No image pairs found for project_id: {project_id}") + return [] + + # Convert to ImagePair models + image_pairs = [ImagePair(**pair_data) for pair_data in response.data] + log.info( + f"Found {len(image_pairs)} image pairs for project_id: {project_id}" + ) + + return image_pairs + + except Exception as e: + log.error(f"Error fetching image pairs for project_id {project_id}: {e}") + raise RuntimeError(f"Failed to fetch image pairs: {e}") diff --git a/backend/app/services/messages.py b/backend/app/services/messages.py deleted file mode 100644 index 400ac48..0000000 --- a/backend/app/services/messages.py +++ /dev/null @@ -1,10 +0,0 @@ -import logging - -from app.models.messages import MessageRequest, MessageResponse - -log = logging.getLogger(__name__) - - -class MessagesService: - async def chat(self, input: MessageRequest) -> MessageResponse: - return MessageResponse(id="123", content=f"Why did you say {input.content}?") diff --git a/backend/app/services/project.py b/backend/app/services/project.py new file mode 100644 index 0000000..9739922 --- /dev/null +++ b/backend/app/services/project.py @@ -0,0 +1,49 @@ +import logging +from typing import List + +from supabase._async.client import AsyncClient as Client + +from app.models.project import Project + +log = logging.getLogger(__name__) + + +class ProjectService: + async def get_projects_by_user_id( + self, supabase_client: Client, user_id: str + ) -> List[Project]: + """ + Fetch all projects for a given user ID. + + Args: + supabase_client: The Supabase client instance + user_id: The user ID to fetch projects for + + Returns: + List of Project objects for the user + """ + log.info(f"Fetching projects for user_id: {user_id}") + + try: + # Query the projects table + response = ( + await supabase_client.table("projects") + .select("*") + .eq("user_id", user_id) + .order("updated_at", desc=True) + .execute() + ) + + if not response.data: + log.info(f"No projects found for user_id: {user_id}") + return [] + + # Convert to Project models + projects = [Project(**project_data) for project_data in response.data] + log.info(f"Found {len(projects)} projects for user_id: {user_id}") + + return projects + + except Exception as e: + log.error(f"Error fetching projects for user_id {user_id}: {e}") + raise RuntimeError(f"Failed to fetch projects: {e}") diff --git a/backend/app/utils/database.py b/backend/app/utils/database.py index 81b4619..cb37e76 100644 --- a/backend/app/utils/database.py +++ b/backend/app/utils/database.py @@ -3,7 +3,6 @@ import uuid from supabase import AsyncClientOptions - # from supabase import AsyncClientOptions from supabase._async.client import AsyncClient as Client from supabase._async.client import create_client diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 5e891cf..7fe3a01 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,7 +1,16 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { - /* config options here */ + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'mbfqdgsaeswfotcvkloz.supabase.co', + port: '', + pathname: '/storage/v1/object/public/**', + }, + ], + }, }; export default nextConfig; diff --git a/frontend/public/logo.png b/frontend/public/logo.png new file mode 100644 index 0000000..9456ce3 Binary files /dev/null and b/frontend/public/logo.png differ diff --git a/frontend/public/projects/internet.png b/frontend/public/projects/internet.png new file mode 100644 index 0000000..1b2a6b4 Binary files /dev/null and b/frontend/public/projects/internet.png differ diff --git a/frontend/src/actions/image-pairs.ts b/frontend/src/actions/image-pairs.ts new file mode 100644 index 0000000..fad0b73 --- /dev/null +++ b/frontend/src/actions/image-pairs.ts @@ -0,0 +1,39 @@ +'use client'; + +export interface ImagePair { + id: string; + project_id: string; + input_url: string; + input_mime_type?: string; + input_width?: number; + input_height?: number; + output_url?: string; + output_mime_type?: string; + output_width?: number; + output_height?: number; + prompt_text?: string; + metadata?: Record; + created_at: string; + updated_at: string; +} + +export interface ImagePairsResponse { + image_pairs: ImagePair[]; +} + +export async function fetchImagePairs(projectId: string): Promise { + const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'; + const response = await fetch(`${apiUrl}/api/image-pairs/${projectId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || 'Failed to fetch image pairs'); + } + + return response.json(); +} diff --git a/frontend/src/actions/messages.ts b/frontend/src/actions/messages.ts deleted file mode 100644 index 9f48071..0000000 --- a/frontend/src/actions/messages.ts +++ /dev/null @@ -1,19 +0,0 @@ -'use server'; - -import axios from 'axios'; - -import { MessageRequest, MessageResponse, messageResponseSchema } from '@/types/messages'; - -export async function chat(request: MessageRequest): Promise { - try { - console.log('Sending message:', request); - const response = await axios.post( - `${process.env.EXPO_PUBLIC_API_BASE_URL}/api/messages`, - request, - ); - return messageResponseSchema.parse(response.data); - } catch (error) { - console.error('Error sending message:', error); - throw error; - } -} diff --git a/frontend/src/actions/projects.ts b/frontend/src/actions/projects.ts new file mode 100644 index 0000000..0d82607 --- /dev/null +++ b/frontend/src/actions/projects.ts @@ -0,0 +1,33 @@ +'use client'; + +export const DEFAULT_USER_ID = '1824ad37-303d-4505-b210-d294295d1f95'; + +export interface Project { + id: string; + user_id: string; + name: string; + description?: string; + created_at: string; + updated_at: string; +} + +export interface ProjectsResponse { + projects: Project[]; +} + +export async function fetchProjects(userId: string): Promise { + const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'; + const response = await fetch(`${apiUrl}/api/projects/${userId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || 'Failed to fetch projects'); + } + + return response.json(); +} diff --git a/frontend/src/app/projects/[project_id]/page.tsx b/frontend/src/app/projects/[project_id]/page.tsx new file mode 100644 index 0000000..f95aca6 --- /dev/null +++ b/frontend/src/app/projects/[project_id]/page.tsx @@ -0,0 +1,80 @@ +'use client'; + +import Link from 'next/link'; +import { useParams } from 'next/navigation'; + +import { DEFAULT_USER_ID } from '@/actions/projects'; +import { useImagePairs } from '@/hooks/useImagePairs'; +import { useProject } from '@/hooks/useProject'; + +import { ImagePairCard } from '@/components/projects/image-pair-card'; + +export default function ProjectDetailPage() { + const params = useParams(); + const projectId = params.project_id as string; + + const { + data: project, + isLoading: isLoadingProject, + error: projectError, + } = useProject(projectId, DEFAULT_USER_ID); + + const { + data: imagePairsData, + isLoading: isLoadingImagePairs, + error: imagePairsError, + } = useImagePairs(projectId); + + const isLoading = isLoadingProject || isLoadingImagePairs; + const error = projectError || imagePairsError; + + return ( +
+ {/* Breadcrumb */} + + + {/* Project Header */} + {project && ( +
+

{project.name}

+ {project.description &&

{project.description}

} +
+

Created: {new Date(project.created_at).toLocaleDateString()}

+

Last Updated: {new Date(project.updated_at).toLocaleDateString()}

+
+
+ )} + + {/* Loading State */} + {isLoading &&

Loading...

} + + {/* Error State */} + {error &&

Error: {error.message}

} + + {/* Image Pairs Section */} + {imagePairsData && ( +
+

+ Image Pairs ({imagePairsData.image_pairs.length}) +

+ + {imagePairsData.image_pairs.length === 0 ? ( +

No image pairs found for this project.

+ ) : ( +
+ {imagePairsData.image_pairs.map((imagePair) => ( + + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/app/projects/page.tsx b/frontend/src/app/projects/page.tsx index 4cbb87e..902d402 100644 --- a/frontend/src/app/projects/page.tsx +++ b/frontend/src/app/projects/page.tsx @@ -1,10 +1,32 @@ 'use client'; +import { DEFAULT_USER_ID } from '@/actions/projects'; +import { useProjects } from '@/hooks/useProjects'; + +import { ProjectCard } from '@/components/projects/project-card'; + export default function ProjectsPage() { + const { data, isLoading, error } = useProjects(DEFAULT_USER_ID); + return (

Projects

-

Your drawing projects will appear here.

+ + {isLoading &&

Loading projects...

} + + {error &&

Error loading projects: {error.message}

} + + {data && data.projects.length === 0 && ( +

No projects found. Create your first drawing project!

+ )} + + {data && data.projects.length > 0 && ( +
+ {data.projects.map((project) => ( + + ))} +
+ )}
); } diff --git a/frontend/src/components/projects/image-pair-card.tsx b/frontend/src/components/projects/image-pair-card.tsx new file mode 100644 index 0000000..adab811 --- /dev/null +++ b/frontend/src/components/projects/image-pair-card.tsx @@ -0,0 +1,69 @@ +'use client'; + +import Image from 'next/image'; + +import { ImagePair } from '@/actions/image-pairs'; + +interface ImagePairCardProps { + imagePair: ImagePair; +} + +export function ImagePairCard({ imagePair }: ImagePairCardProps) { + return ( +
+
+ {/* Input Image */} +
+

Input

+
+ Input image +
+ {imagePair.input_width && imagePair.input_height && ( +

+ {imagePair.input_width} × {imagePair.input_height} +

+ )} +
+ + {/* Output Image */} +
+

Output

+ {imagePair.output_url ? ( + <> +
+ Output image +
+ {imagePair.output_width && imagePair.output_height && ( +

+ {imagePair.output_width} × {imagePair.output_height} +

+ )} + + ) : ( +
+

No output

+
+ )} +
+
+ + {/* Prompt Text */} + {imagePair.prompt_text && ( +
+

Prompt

+

{imagePair.prompt_text}

+
+ )} + + {/* Timestamp */} +

+ Created: {new Date(imagePair.created_at).toLocaleString()} +

+
+ ); +} diff --git a/frontend/src/components/projects/project-card.tsx b/frontend/src/components/projects/project-card.tsx new file mode 100644 index 0000000..ee57b48 --- /dev/null +++ b/frontend/src/components/projects/project-card.tsx @@ -0,0 +1,36 @@ +'use client'; + +import Image from 'next/image'; +import Link from 'next/link'; + +import { Project } from '@/actions/projects'; + +interface ProjectCardProps { + project: Project; +} + +export function ProjectCard({ project }: ProjectCardProps) { + return ( + +
+
+ Project thumbnail +
+
+

{project.name}

+ {project.description && ( +

{project.description}

+ )} +

+ Last Updated: {new Date(project.updated_at).toLocaleDateString()} +

+
+
+ + ); +} diff --git a/frontend/src/components/shared/sidebar.tsx b/frontend/src/components/shared/sidebar.tsx index 742bacc..0d779d3 100644 --- a/frontend/src/components/shared/sidebar.tsx +++ b/frontend/src/components/shared/sidebar.tsx @@ -1,5 +1,6 @@ 'use client'; +import Image from 'next/image'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; @@ -66,7 +67,7 @@ const SidebarItem: React.FC = ({ : 'flex h-10 w-10 items-center justify-center rounded-md md:h-8 md:w-8' } ${ isSelected - ? 'border border-blue-600/30 bg-blue-600/10 text-blue-600' + ? 'border border-rose-700/30 bg-rose-700/10 text-rose-700' : isHovered ? 'bg-gray-100 text-gray-700' : 'text-gray-600 hover:text-gray-700' @@ -163,7 +164,13 @@ const SidebarView: React.FC = ({ children }) => {
-
+ WhisprDraw Logo WhisprDraw
@@ -216,7 +223,13 @@ const SidebarView: React.FC = ({ children }) => {
{/* Collapsed header */}
-
+ WhisprDraw Logo
{/* Navigation icons */} @@ -268,7 +281,13 @@ const SidebarView: React.FC = ({ children }) => {
-
+ WhisprDraw Logo WhisprDraw
diff --git a/frontend/src/hooks/useImagePairs.ts b/frontend/src/hooks/useImagePairs.ts new file mode 100644 index 0000000..6799456 --- /dev/null +++ b/frontend/src/hooks/useImagePairs.ts @@ -0,0 +1,12 @@ +'use client'; + +import { fetchImagePairs } from '@/actions/image-pairs'; +import { useQuery } from '@tanstack/react-query'; + +export function useImagePairs(projectId: string) { + return useQuery({ + queryKey: ['image-pairs', projectId], + queryFn: () => fetchImagePairs(projectId), + enabled: !!projectId, + }); +} diff --git a/frontend/src/hooks/useProject.ts b/frontend/src/hooks/useProject.ts new file mode 100644 index 0000000..fc6164e --- /dev/null +++ b/frontend/src/hooks/useProject.ts @@ -0,0 +1,19 @@ +'use client'; + +import { DEFAULT_USER_ID, fetchProjects } from '@/actions/projects'; +import { useQuery } from '@tanstack/react-query'; + +export function useProject(projectId: string, userId: string = DEFAULT_USER_ID) { + return useQuery({ + queryKey: ['project', projectId], + queryFn: async () => { + const response = await fetchProjects(userId); + const project = response.projects.find((p) => p.id === projectId); + if (!project) { + throw new Error('Project not found'); + } + return project; + }, + enabled: !!projectId, + }); +} diff --git a/frontend/src/hooks/useProjects.ts b/frontend/src/hooks/useProjects.ts new file mode 100644 index 0000000..136161a --- /dev/null +++ b/frontend/src/hooks/useProjects.ts @@ -0,0 +1,11 @@ +'use client'; + +import { DEFAULT_USER_ID, fetchProjects } from '@/actions/projects'; +import { useQuery } from '@tanstack/react-query'; + +export function useProjects(userId: string = DEFAULT_USER_ID) { + return useQuery({ + queryKey: ['projects', userId], + queryFn: () => fetchProjects(userId), + }); +}