diff --git a/backend/migrations/006_add_owner_address_to_projects.sql b/backend/migrations/006_add_owner_address_to_projects.sql new file mode 100644 index 0000000..d6a67ca --- /dev/null +++ b/backend/migrations/006_add_owner_address_to_projects.sql @@ -0,0 +1,5 @@ +ALTER TABLE projects + ADD COLUMN IF NOT EXISTS owner_address VARCHAR(42) + GENERATED ALWAYS AS (creator) STORED; + +CREATE INDEX IF NOT EXISTS idx_projects_owner_address ON projects(owner_address); \ No newline at end of file diff --git a/backend/src/application/commands/create_project.rs b/backend/src/application/commands/create_project.rs index a157f7d..712153e 100644 --- a/backend/src/application/commands/create_project.rs +++ b/backend/src/application/commands/create_project.rs @@ -38,13 +38,15 @@ pub async fn create_project( .await .map_err(|e| e.to_string())?; - // Return response + let creator_str = project.creator.to_string(); + Ok(ProjectResponse { id: project.id.value().to_string(), name: project.name, description: project.description, status: project.status, - creator: project.creator.to_string(), + owner_address: creator_str.clone(), + creator: creator_str, created_at: project.created_at, updated_at: project.updated_at, }) diff --git a/backend/src/application/commands/update_project.rs b/backend/src/application/commands/update_project.rs index ebc2e90..805a81c 100644 --- a/backend/src/application/commands/update_project.rs +++ b/backend/src/application/commands/update_project.rs @@ -15,15 +15,12 @@ pub async fn update_project( project_id: String, request: UpdateProjectRequest, ) -> Result { - // Parse project ID let id = Uuid::parse_str(&project_id).map_err(|_| "Invalid project ID".to_string())?; let project_id = ProjectId::from_uuid(id); - // Validate requester address let requester = WalletAddress::new(requester_address) .map_err(|e| format!("Invalid wallet address: {}", e))?; - // Get existing project let mut project = repository .find_by_id(&project_id) .await @@ -35,25 +32,23 @@ pub async fn update_project( return Err("Only the creator can update this project".to_string()); } - // Update project project.update_info(request.name, request.description, request.status); - - // Validate updated project project.validate()?; - // Save to repository repository .update(&project) .await .map_err(|e| e.to_string())?; - // Return response + let creator_str = project.creator.to_string(); + Ok(ProjectResponse { id: project.id.value().to_string(), name: project.name, description: project.description, status: project.status, - creator: project.creator.to_string(), + owner_address: creator_str.clone(), + creator: creator_str, created_at: project.created_at, updated_at: project.updated_at, }) diff --git a/backend/src/application/dtos/project_dtos.rs b/backend/src/application/dtos/project_dtos.rs index d8af93a..6551495 100644 --- a/backend/src/application/dtos/project_dtos.rs +++ b/backend/src/application/dtos/project_dtos.rs @@ -27,6 +27,7 @@ pub struct ProjectResponse { pub description: String, pub status: ProjectStatus, pub creator: String, + pub owner_address: String, pub created_at: DateTime, pub updated_at: DateTime, } diff --git a/backend/src/application/queries/get_all_projects.rs b/backend/src/application/queries/get_all_projects.rs index 73fd935..e8ff758 100644 --- a/backend/src/application/queries/get_all_projects.rs +++ b/backend/src/application/queries/get_all_projects.rs @@ -15,7 +15,6 @@ pub async fn get_all_projects( limit: Option, offset: Option, ) -> Result, String> { - // Parse status if provided let status_filter = if let Some(status_str) = status { Some( status_str @@ -26,7 +25,6 @@ pub async fn get_all_projects( None }; - // Parse creator if provided let creator_filter = if let Some(creator_str) = creator { Some( WalletAddress::new(creator_str) @@ -36,27 +34,28 @@ pub async fn get_all_projects( None }; - // Validate and limit pagination let limit = limit.map(|l| l.clamp(1, 100)); let offset = offset.map(|o| o.max(0)); - // Get projects let projects = repository .find_all(status_filter, creator_filter.as_ref(), limit, offset) .await .map_err(|e| e.to_string())?; - // Convert to responses Ok(projects .into_iter() - .map(|project| ProjectResponse { - id: project.id.value().to_string(), - name: project.name, - description: project.description, - status: project.status, - creator: project.creator.to_string(), - created_at: project.created_at, - updated_at: project.updated_at, + .map(|project| { + let creator_str = project.creator.to_string(); + ProjectResponse { + id: project.id.value().to_string(), + name: project.name, + description: project.description, + status: project.status, + owner_address: creator_str.clone(), + creator: creator_str, + created_at: project.created_at, + updated_at: project.updated_at, + } }) .collect()) } diff --git a/backend/src/application/queries/get_project.rs b/backend/src/application/queries/get_project.rs index 60e7c1a..749bc87 100644 --- a/backend/src/application/queries/get_project.rs +++ b/backend/src/application/queries/get_project.rs @@ -10,24 +10,24 @@ pub async fn get_project( repository: Arc, project_id: String, ) -> Result { - // Parse project ID let id = Uuid::parse_str(&project_id).map_err(|_| "Invalid project ID".to_string())?; let project_id = ProjectId::from_uuid(id); - // Get project let project = repository .find_by_id(&project_id) .await .map_err(|e| e.to_string())? .ok_or_else(|| "Project not found".to_string())?; - // Return response + let creator_str = project.creator.to_string(); + Ok(ProjectResponse { id: project.id.value().to_string(), name: project.name, description: project.description, status: project.status, - creator: project.creator.to_string(), + owner_address: creator_str.clone(), + creator: creator_str, created_at: project.created_at, updated_at: project.updated_at, }) diff --git a/backend/src/application/queries/get_projects_by_creator.rs b/backend/src/application/queries/get_projects_by_creator.rs index c6dc605..9723eb4 100644 --- a/backend/src/application/queries/get_projects_by_creator.rs +++ b/backend/src/application/queries/get_projects_by_creator.rs @@ -9,27 +9,28 @@ pub async fn get_projects_by_creator( repository: Arc, creator_address: String, ) -> Result, String> { - // Validate creator address let creator = WalletAddress::new(creator_address) .map_err(|e| format!("Invalid wallet address: {}", e))?; - // Get projects let projects = repository .find_by_creator(&creator) .await .map_err(|e| e.to_string())?; - // Convert to responses Ok(projects .into_iter() - .map(|project| ProjectResponse { - id: project.id.value().to_string(), - name: project.name, - description: project.description, - status: project.status, - creator: project.creator.to_string(), - created_at: project.created_at, - updated_at: project.updated_at, + .map(|project| { + let creator_str = project.creator.to_string(); + ProjectResponse { + id: project.id.value().to_string(), + name: project.name, + description: project.description, + status: project.status, + owner_address: creator_str.clone(), + creator: creator_str, + created_at: project.created_at, + updated_at: project.updated_at, + } }) .collect()) } diff --git a/frontend/.astro/data-store.json b/frontend/.astro/data-store.json index 805c3fe..4af4b92 100644 --- a/frontend/.astro/data-store.json +++ b/frontend/.astro/data-store.json @@ -1 +1 @@ -[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.14.1","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"server\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":\"0.0.0.0\",\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\",\"entrypoint\":\"astro/assets/endpoint/node\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\":false,\"csp\":false,\"staticImportMetaEnv\":false,\"chromeDevtoolsWorkspace\":false,\"failOnPrerenderConflict\":false},\"legacy\":{\"collections\":false},\"session\":{\"driver\":\"fs-lite\",\"options\":{\"base\":\"/app/node_modules/.astro/sessions\"}}}"] \ No newline at end of file +[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.14.1","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"server\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":\"0.0.0.0\",\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\",\"entrypoint\":\"astro/assets/endpoint/node\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\":false,\"csp\":false,\"staticImportMetaEnv\":false,\"chromeDevtoolsWorkspace\":false,\"failOnPrerenderConflict\":false},\"legacy\":{\"collections\":false},\"session\":{\"driver\":\"fs-lite\",\"options\":{\"base\":\"/home/phoebe/TheGuildGenesis/frontend/node_modules/.astro/sessions\"}}}"] \ No newline at end of file diff --git a/frontend/.astro/settings.json b/frontend/.astro/settings.json index af96ba4..822e808 100644 --- a/frontend/.astro/settings.json +++ b/frontend/.astro/settings.json @@ -1,6 +1,6 @@ { "_variables": { - "lastUpdateCheck": 1770638442880 + "lastUpdateCheck": 1770210836100 }, "eslint.validate": [ "javascript", diff --git a/frontend/src/components/AppSidebar.tsx b/frontend/src/components/AppSidebar.tsx index 870225d..b172a0a 100644 --- a/frontend/src/components/AppSidebar.tsx +++ b/frontend/src/components/AppSidebar.tsx @@ -1,4 +1,4 @@ -import { Smile, BadgeCheck, Home, BookOpen , Table2 } from "lucide-react"; +import { Smile, BadgeCheck, Home, BookOpen , Table2, FolderKanban } from "lucide-react"; import { Sidebar, @@ -38,6 +38,11 @@ const items = [ url: "/leaderboard", icon: Table2, }, + { + title: "Projects", + url: "/projects", + icon: FolderKanban, + }, ]; export function AppSidebar() { diff --git a/frontend/src/components/pages/ProjectPage.tsx b/frontend/src/components/pages/ProjectPage.tsx new file mode 100644 index 0000000..103c1fc --- /dev/null +++ b/frontend/src/components/pages/ProjectPage.tsx @@ -0,0 +1,12 @@ +import { AppWrapper } from "@/components/AppWrapper"; +import { ProjectMain } from "@/components/projects/project-page/ProjectMain"; + +type Props = { id?: string }; + +export default function ProjectPage({ id }: Props) { + return ( + + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/pages/ProjectsPage.tsx b/frontend/src/components/pages/ProjectsPage.tsx new file mode 100644 index 0000000..ac9ba0d --- /dev/null +++ b/frontend/src/components/pages/ProjectsPage.tsx @@ -0,0 +1,20 @@ +import { AppWrapper } from "@/components/AppWrapper"; +import { ProjectsMain } from "@/components/projects/ProjectsMain"; + +export function ProjectsPage() { + return ( + +
+

+ Projects +

+

+ Showcase your work. Projects can be created by anyone and are managed by their owners. +

+ +
+
+ ); +} + +export default ProjectsPage; \ No newline at end of file diff --git a/frontend/src/components/projects/CreateProjectModal.tsx b/frontend/src/components/projects/CreateProjectModal.tsx new file mode 100644 index 0000000..c64d151 --- /dev/null +++ b/frontend/src/components/projects/CreateProjectModal.tsx @@ -0,0 +1,155 @@ +import React, { useState, useEffect } from 'react'; +import { useCreateProject } from '@/hooks/projects/useprojects'; +import { Button } from '@/components/ui/button'; +import { Loader2, X } from 'lucide-react'; + +interface CreateProjectModalProps { + isOpen: boolean; + onClose: () => void; +} + +export default function CreateProjectModal({ isOpen, onClose }: CreateProjectModalProps) { + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [validationError, setValidationError] = useState(''); + + const createProjectMutation = useCreateProject(); + + useEffect(() => { + if (isOpen) { + setName(''); + setDescription(''); + setValidationError(''); + } + }, [isOpen]); + + useEffect(() => { + if (createProjectMutation.isSuccess) { + onClose(); + } + }, [createProjectMutation.isSuccess, onClose]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setValidationError(''); + + // Validation + if (!name.trim()) { + setValidationError('Project name is required'); + return; + } + + if (name.length > 100) { + setValidationError('Project name must be 100 characters or less'); + return; + } + + if (!description.trim()) { + setValidationError('Project description is required'); + return; + } + + if (description.length > 500) { + setValidationError('Description must be 500 characters or less'); + return; + } + + createProjectMutation.mutate({ input: { name: name.trim(), description: description.trim() } }); + }; + + if (!isOpen) return null; + + return ( +
+
+
+

Create Project

+ +
+ +
+ {/* Project Name */} +
+ + setName(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="My Awesome Project" + maxLength={100} + disabled={createProjectMutation.isPending} + /> +

{name.length}/100 characters

+
+ + {/* Description */} +
+ +