diff --git a/k-notes-frontend/src/components/app-sidebar.tsx b/k-notes-frontend/src/components/app-sidebar.tsx index ccb86a2..d8b164c 100644 --- a/k-notes-frontend/src/components/app-sidebar.tsx +++ b/k-notes-frontend/src/components/app-sidebar.tsx @@ -1,4 +1,4 @@ -import { Home, Archive, Settings, Tag, ChevronRight } from "lucide-react" +import { Home, Archive, Settings, Tag, ChevronRight, Pencil, Trash2, MoreHorizontal } from "lucide-react" import { Sidebar, SidebarContent, @@ -9,13 +9,22 @@ import { SidebarMenuButton, SidebarMenuItem, } from "@/components/ui/sidebar" -import { Link, useLocation, useSearchParams } from "react-router-dom" +import { Link, useLocation, useSearchParams, useNavigate } from "react-router-dom" import { SettingsDialog } from "@/components/settings-dialog" import { useState } from "react" -import { useTags } from "@/hooks/use-notes" +import { useTags, useDeleteTag, useRenameTag } from "@/hooks/use-notes" import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" import { ScrollArea } from "@/components/ui/scroll-area" import { Badge } from "@/components/ui/badge" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { toast } from "sonner" const items = [ { @@ -30,6 +39,111 @@ const items = [ }, ] +interface TagItemProps { + tag: { id: string; name: string }; + isActive: boolean; +} + +function TagItem({ tag, isActive }: TagItemProps) { + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(tag.name); + const { mutate: deleteTag } = useDeleteTag(); + const { mutate: renameTag } = useRenameTag(); + const navigate = useNavigate(); + + const handleDelete = () => { + if (confirm(`Delete tag "${tag.name}"? Notes will keep their content.`)) { + deleteTag(tag.id, { + onSuccess: () => { + toast.success("Tag deleted"); + navigate("/"); + }, + onError: (err: any) => toast.error(err.message) + }); + } + }; + + const handleRename = () => { + if (editName.trim() && editName.trim() !== tag.name) { + renameTag({ id: tag.id, name: editName.trim() }, { + onSuccess: () => { + toast.success("Tag renamed"); + setIsEditing(false); + }, + onError: (err: any) => { + toast.error(err.message); + setEditName(tag.name); + } + }); + } else { + setIsEditing(false); + setEditName(tag.name); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleRename(); + } else if (e.key === "Escape") { + setIsEditing(false); + setEditName(tag.name); + } + }; + + if (isEditing) { + return ( + + setEditName(e.target.value)} + onBlur={handleRename} + onKeyDown={handleKeyDown} + autoFocus + className="h-7 text-xs" + /> + + ); + } + + return ( + + + + + {tag.name} + + + e.preventDefault()}> + + + + { e.preventDefault(); setIsEditing(true); }}> + + Rename + + { e.preventDefault(); handleDelete(); }} className="text-destructive focus:text-destructive"> + + Delete + + + + + + + ); +} + export function AppSidebar() { const location = useLocation(); const [searchParams] = useSearchParams(); @@ -86,19 +200,11 @@ export function AppSidebar() { {tags && tags.length > 0 ? ( tags.map((tag: { id: string; name: string }) => ( - - - - - {tag.name} - - - - + )) ) : (
@@ -117,3 +223,4 @@ export function AppSidebar() { ) } + diff --git a/k-notes-frontend/src/hooks/use-notes.ts b/k-notes-frontend/src/hooks/use-notes.ts index 27475d2..06a51eb 100644 --- a/k-notes-frontend/src/hooks/use-notes.ts +++ b/k-notes-frontend/src/hooks/use-notes.ts @@ -177,3 +177,29 @@ export function useTags() { queryFn: () => api.get("/tags"), }); } + +export function useDeleteTag() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string) => api.delete(`/tags/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tags"] }); + queryClient.invalidateQueries({ queryKey: ["notes"] }); + }, + }); +} + +export function useRenameTag() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, name }: { id: string; name: string }) => + api.patch(`/tags/${id}`, { name }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tags"] }); + queryClient.invalidateQueries({ queryKey: ["notes"] }); + }, + }); +} + diff --git a/notes-api/src/dto.rs b/notes-api/src/dto.rs index 5aaf823..c47efde 100644 --- a/notes-api/src/dto.rs +++ b/notes-api/src/dto.rs @@ -110,6 +110,13 @@ pub struct CreateTagRequest { pub name: String, } +/// Request to rename a tag +#[derive(Debug, Deserialize, Validate)] +pub struct RenameTagRequest { + #[validate(length(min = 1, max = 50, message = "Tag name must be 1-50 characters"))] + pub name: String, +} + /// Login request #[derive(Debug, Deserialize, Validate)] pub struct LoginRequest { diff --git a/notes-api/src/routes/mod.rs b/notes-api/src/routes/mod.rs index 7da7636..770d18f 100644 --- a/notes-api/src/routes/mod.rs +++ b/notes-api/src/routes/mod.rs @@ -36,5 +36,8 @@ pub fn api_v1_router() -> Router { .route("/import", post(import_export::import_data)) // Tag routes .route("/tags", get(tags::list_tags).post(tags::create_tag)) - .route("/tags/{id}", delete(tags::delete_tag)) + .route( + "/tags/{id}", + delete(tags::delete_tag).patch(tags::rename_tag), + ) } diff --git a/notes-api/src/routes/tags.rs b/notes-api/src/routes/tags.rs index fc8ab32..0e959bf 100644 --- a/notes-api/src/routes/tags.rs +++ b/notes-api/src/routes/tags.rs @@ -1,9 +1,9 @@ //! Tag route handlers use axum::{ + Json, extract::{Path, State}, http::StatusCode, - Json, }; use axum_login::{AuthSession, AuthUser}; use uuid::Uuid; @@ -12,7 +12,7 @@ use validator::Validate; use notes_domain::TagService; use crate::auth::AuthBackend; -use crate::dto::{CreateTagRequest, TagResponse}; +use crate::dto::{CreateTagRequest, RenameTagRequest, TagResponse}; use crate::error::{ApiError, ApiResult}; use crate::state::AppState; @@ -22,14 +22,18 @@ pub async fn list_tags( State(state): State, auth: AuthSession, ) -> ApiResult>> { - let user = auth.user.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized("Login required".to_string())))?; + let user = auth + .user + .ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized( + "Login required".to_string(), + )))?; let user_id = user.id(); let service = TagService::new(state.tag_repo); - + let tags = service.list_tags(user_id).await?; let response: Vec = tags.into_iter().map(TagResponse::from).collect(); - + Ok(Json(response)) } @@ -40,18 +44,50 @@ pub async fn create_tag( auth: AuthSession, Json(payload): Json, ) -> ApiResult<(StatusCode, Json)> { - let user = auth.user.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized("Login required".to_string())))?; + let user = auth + .user + .ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized( + "Login required".to_string(), + )))?; let user_id = user.id(); - payload.validate().map_err(|e| ApiError::validation(e.to_string()))?; - + payload + .validate() + .map_err(|e| ApiError::validation(e.to_string()))?; + let service = TagService::new(state.tag_repo); - + let tag = service.create_tag(user_id, &payload.name).await?; - + Ok((StatusCode::CREATED, Json(TagResponse::from(tag)))) } +/// Rename a tag +/// PATCH /api/v1/tags/:id +pub async fn rename_tag( + State(state): State, + auth: AuthSession, + Path(id): Path, + Json(payload): Json, +) -> ApiResult> { + let user = auth + .user + .ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized( + "Login required".to_string(), + )))?; + let user_id = user.id(); + + payload + .validate() + .map_err(|e| ApiError::validation(e.to_string()))?; + + let service = TagService::new(state.tag_repo); + + let tag = service.rename_tag(id, user_id, &payload.name).await?; + + Ok(Json(TagResponse::from(tag))) +} + /// Delete a tag /// DELETE /api/v1/tags/:id pub async fn delete_tag( @@ -59,12 +95,16 @@ pub async fn delete_tag( auth: AuthSession, Path(id): Path, ) -> ApiResult { - let user = auth.user.ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized("Login required".to_string())))?; + let user = auth + .user + .ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized( + "Login required".to_string(), + )))?; let user_id = user.id(); let service = TagService::new(state.tag_repo); - + service.delete_tag(id, user_id).await?; - + Ok(StatusCode::NO_CONTENT) } diff --git a/notes-domain/src/services.rs b/notes-domain/src/services.rs index 40daab3..c8f240a 100644 --- a/notes-domain/src/services.rs +++ b/notes-domain/src/services.rs @@ -277,6 +277,40 @@ impl TagService { self.tag_repo.delete(id).await } + + /// Rename a tag + pub async fn rename_tag(&self, id: Uuid, user_id: Uuid, new_name: &str) -> DomainResult { + let new_name = new_name.trim().to_lowercase(); + if new_name.is_empty() { + return Err(DomainError::validation("Tag name cannot be empty")); + } + + // Find the existing tag + let mut tag = self + .tag_repo + .find_by_id(id) + .await? + .ok_or(DomainError::TagNotFound(id))?; + + // Authorization check + if tag.user_id != user_id { + return Err(DomainError::unauthorized( + "Cannot rename another user's tag", + )); + } + + // Check if new name already exists (and it's not the same tag) + if let Some(existing) = self.tag_repo.find_by_name(user_id, &new_name).await? { + if existing.id != id { + return Err(DomainError::TagAlreadyExists(new_name)); + } + } + + // Update the name + tag.name = new_name; + self.tag_repo.save(&tag).await?; + Ok(tag) + } } /// Service for User operations (OIDC-ready)