feat: implement tag rename and delete functionality in UI and API
This commit is contained in:
@@ -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 {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -9,13 +9,22 @@ import {
|
|||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar"
|
} 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 { SettingsDialog } from "@/components/settings-dialog"
|
||||||
import { useState } from "react"
|
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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { Badge } from "@/components/ui/badge"
|
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 = [
|
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 (
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<Input
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
onBlur={handleRename}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
autoFocus
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem className="group/tag">
|
||||||
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
isActive={isActive}
|
||||||
|
tooltip={tag.name}
|
||||||
|
className="pr-0"
|
||||||
|
>
|
||||||
|
<Link to={`/?tag=${encodeURIComponent(tag.name)}`} className="flex items-center justify-between w-full">
|
||||||
|
<Badge variant="secondary" className="text-xs px-1.5 py-0">
|
||||||
|
{tag.name}
|
||||||
|
</Badge>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 opacity-0 group-hover/tag:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-32">
|
||||||
|
<DropdownMenuItem onClick={(e) => { e.preventDefault(); setIsEditing(true); }}>
|
||||||
|
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Rename
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={(e) => { e.preventDefault(); handleDelete(); }} className="text-destructive focus:text-destructive">
|
||||||
|
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
@@ -86,19 +200,11 @@ export function AppSidebar() {
|
|||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{tags && tags.length > 0 ? (
|
{tags && tags.length > 0 ? (
|
||||||
tags.map((tag: { id: string; name: string }) => (
|
tags.map((tag: { id: string; name: string }) => (
|
||||||
<SidebarMenuItem key={tag.id}>
|
<TagItem
|
||||||
<SidebarMenuButton
|
key={tag.id}
|
||||||
asChild
|
tag={tag}
|
||||||
isActive={activeTag === tag.name}
|
isActive={activeTag === tag.name}
|
||||||
tooltip={tag.name}
|
/>
|
||||||
>
|
|
||||||
<Link to={`/?tag=${encodeURIComponent(tag.name)}`}>
|
|
||||||
<Badge variant="secondary" className="text-xs px-1.5 py-0">
|
|
||||||
{tag.name}
|
|
||||||
</Badge>
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||||
@@ -117,3 +223,4 @@ export function AppSidebar() {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -177,3 +177,29 @@ export function useTags() {
|
|||||||
queryFn: () => api.get("/tags"),
|
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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,13 @@ pub struct CreateTagRequest {
|
|||||||
pub name: String,
|
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
|
/// Login request
|
||||||
#[derive(Debug, Deserialize, Validate)]
|
#[derive(Debug, Deserialize, Validate)]
|
||||||
pub struct LoginRequest {
|
pub struct LoginRequest {
|
||||||
|
|||||||
@@ -36,5 +36,8 @@ pub fn api_v1_router() -> Router<AppState> {
|
|||||||
.route("/import", post(import_export::import_data))
|
.route("/import", post(import_export::import_data))
|
||||||
// Tag routes
|
// Tag routes
|
||||||
.route("/tags", get(tags::list_tags).post(tags::create_tag))
|
.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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
//! Tag route handlers
|
//! Tag route handlers
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
|
Json,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
Json,
|
|
||||||
};
|
};
|
||||||
use axum_login::{AuthSession, AuthUser};
|
use axum_login::{AuthSession, AuthUser};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -12,7 +12,7 @@ use validator::Validate;
|
|||||||
use notes_domain::TagService;
|
use notes_domain::TagService;
|
||||||
|
|
||||||
use crate::auth::AuthBackend;
|
use crate::auth::AuthBackend;
|
||||||
use crate::dto::{CreateTagRequest, TagResponse};
|
use crate::dto::{CreateTagRequest, RenameTagRequest, TagResponse};
|
||||||
use crate::error::{ApiError, ApiResult};
|
use crate::error::{ApiError, ApiResult};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -22,14 +22,18 @@ pub async fn list_tags(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthSession<AuthBackend>,
|
auth: AuthSession<AuthBackend>,
|
||||||
) -> ApiResult<Json<Vec<TagResponse>>> {
|
) -> ApiResult<Json<Vec<TagResponse>>> {
|
||||||
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 user_id = user.id();
|
||||||
|
|
||||||
let service = TagService::new(state.tag_repo);
|
let service = TagService::new(state.tag_repo);
|
||||||
|
|
||||||
let tags = service.list_tags(user_id).await?;
|
let tags = service.list_tags(user_id).await?;
|
||||||
let response: Vec<TagResponse> = tags.into_iter().map(TagResponse::from).collect();
|
let response: Vec<TagResponse> = tags.into_iter().map(TagResponse::from).collect();
|
||||||
|
|
||||||
Ok(Json(response))
|
Ok(Json(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,18 +44,50 @@ pub async fn create_tag(
|
|||||||
auth: AuthSession<AuthBackend>,
|
auth: AuthSession<AuthBackend>,
|
||||||
Json(payload): Json<CreateTagRequest>,
|
Json(payload): Json<CreateTagRequest>,
|
||||||
) -> ApiResult<(StatusCode, Json<TagResponse>)> {
|
) -> ApiResult<(StatusCode, Json<TagResponse>)> {
|
||||||
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 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 service = TagService::new(state.tag_repo);
|
||||||
|
|
||||||
let tag = service.create_tag(user_id, &payload.name).await?;
|
let tag = service.create_tag(user_id, &payload.name).await?;
|
||||||
|
|
||||||
Ok((StatusCode::CREATED, Json(TagResponse::from(tag))))
|
Ok((StatusCode::CREATED, Json(TagResponse::from(tag))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rename a tag
|
||||||
|
/// PATCH /api/v1/tags/:id
|
||||||
|
pub async fn rename_tag(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthSession<AuthBackend>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(payload): Json<RenameTagRequest>,
|
||||||
|
) -> ApiResult<Json<TagResponse>> {
|
||||||
|
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 a tag
|
||||||
/// DELETE /api/v1/tags/:id
|
/// DELETE /api/v1/tags/:id
|
||||||
pub async fn delete_tag(
|
pub async fn delete_tag(
|
||||||
@@ -59,12 +95,16 @@ pub async fn delete_tag(
|
|||||||
auth: AuthSession<AuthBackend>,
|
auth: AuthSession<AuthBackend>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> ApiResult<StatusCode> {
|
) -> ApiResult<StatusCode> {
|
||||||
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 user_id = user.id();
|
||||||
|
|
||||||
let service = TagService::new(state.tag_repo);
|
let service = TagService::new(state.tag_repo);
|
||||||
|
|
||||||
service.delete_tag(id, user_id).await?;
|
service.delete_tag(id, user_id).await?;
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,6 +277,40 @@ impl TagService {
|
|||||||
|
|
||||||
self.tag_repo.delete(id).await
|
self.tag_repo.delete(id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rename a tag
|
||||||
|
pub async fn rename_tag(&self, id: Uuid, user_id: Uuid, new_name: &str) -> DomainResult<Tag> {
|
||||||
|
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)
|
/// Service for User operations (OIDC-ready)
|
||||||
|
|||||||
Reference in New Issue
Block a user