diff --git a/k-notes-frontend/src/components/note-form.tsx b/k-notes-frontend/src/components/note-form.tsx index e4f25da..55b2a33 100644 --- a/k-notes-frontend/src/components/note-form.tsx +++ b/k-notes-frontend/src/components/note-form.tsx @@ -27,7 +27,7 @@ interface NoteFormProps { export function NoteForm({ defaultValues, onSubmit, isLoading, submitLabel = "Save" }: NoteFormProps) { const form = useForm({ - resolver: zodResolver(noteSchema), + resolver: zodResolver(noteSchema) as any, defaultValues: { title: "", content: "", @@ -40,9 +40,9 @@ export function NoteForm({ defaultValues, onSubmit, isLoading, submitLabel = "Sa return (
- + ( @@ -55,7 +55,7 @@ export function NoteForm({ defaultValues, onSubmit, isLoading, submitLabel = "Sa )} /> ( @@ -68,7 +68,7 @@ export function NoteForm({ defaultValues, onSubmit, isLoading, submitLabel = "Sa )} /> ( @@ -82,7 +82,7 @@ export function NoteForm({ defaultValues, onSubmit, isLoading, submitLabel = "Sa /> ( @@ -111,7 +111,7 @@ export function NoteForm({ defaultValues, onSubmit, isLoading, submitLabel = "Sa /> ( diff --git a/k-notes-frontend/src/components/settings-dialog.tsx b/k-notes-frontend/src/components/settings-dialog.tsx index 1f2db94..72611e1 100644 --- a/k-notes-frontend/src/components/settings-dialog.tsx +++ b/k-notes-frontend/src/components/settings-dialog.tsx @@ -1,9 +1,11 @@ -import { useState, useEffect } from "react"; +import { useRef, useState, useEffect } from "react"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { toast } from "sonner"; +import { api } from "@/lib/api"; +import { Separator } from "@/components/ui/separator"; interface SettingsDialogProps { open: boolean; @@ -35,6 +37,42 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { } }; + const fileInputRef = useRef(null); + + const handleExport = async () => { + try { + const blob = await api.exportData(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `k-notes-backup-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + toast.success("Export successful"); + } catch (e) { + toast.error("Export failed"); + } + }; + + const handleImport = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + try { + const text = await file.text(); + const data = JSON.parse(text); + await api.importData(data); + toast.success("Import successful. Reloading..."); + onOpenChange(false); + window.location.reload(); + } catch (e) { + console.error(e); + toast.error("Import failed"); + } + }; + return ( @@ -58,6 +96,32 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { /> + + + +
+
+

Data Management

+

+ Export your notes for backup or import from a JSON file. +

+
+
+ + + +
+
diff --git a/k-notes-frontend/src/lib/api.ts b/k-notes-frontend/src/lib/api.ts index f5eba2b..580066e 100644 --- a/k-notes-frontend/src/lib/api.ts +++ b/k-notes-frontend/src/lib/api.ts @@ -73,4 +73,12 @@ export const api = { body: JSON.stringify(body), }), delete: (endpoint: string) => fetchWithAuth(endpoint, { method: "DELETE" }), + exportData: async () => { + const response = await fetch(`${getApiUrl()}/export`, { + credentials: "include", + }); + if (!response.ok) throw new ApiError(response.status, "Failed to export data"); + return response.blob(); + }, + importData: (data: any) => api.post("/import", data), }; diff --git a/k-notes-frontend/src/types/resizable-panels-override.d.ts b/k-notes-frontend/src/types/resizable-panels-override.d.ts new file mode 100644 index 0000000..a042427 --- /dev/null +++ b/k-notes-frontend/src/types/resizable-panels-override.d.ts @@ -0,0 +1,28 @@ +declare module "react-resizable-panels" { + import * as React from "react"; + + export interface PanelGroupProps extends React.HTMLAttributes { + direction: "horizontal" | "vertical"; + autoSaveId?: string; + storage?: any; + } + export const PanelGroup: React.FC; + + export interface PanelProps extends React.HTMLAttributes { + defaultSize?: number; + minSize?: number; + maxSize?: number; + order?: number; + collapsible?: boolean; + collapsedSize?: number; + onCollapse?: (collapsed: boolean) => void; + onResize?: (size: number) => void; + } + export const Panel: React.FC; + + export interface PanelResizeHandleProps extends React.HTMLAttributes { + disabled?: boolean; + hitAreaMargins?: { fine: number; coarse: number }; + } + export const PanelResizeHandle: React.FC; +} diff --git a/notes-api/src/routes/import_export.rs b/notes-api/src/routes/import_export.rs new file mode 100644 index 0000000..b5274ab --- /dev/null +++ b/notes-api/src/routes/import_export.rs @@ -0,0 +1,88 @@ +use axum::{Json, extract::State, http::StatusCode}; +use axum_login::{AuthSession, AuthUser}; +use serde::{Deserialize, Serialize}; + +use crate::auth::AuthBackend; +use crate::error::{ApiError, ApiResult}; +use crate::state::AppState; +use notes_domain::{Note, NoteFilter, Tag}; + +#[derive(Serialize, Deserialize)] +pub struct BackupData { + pub notes: Vec, + pub tags: Vec, +} + +/// Export user data +/// GET /api/v1/export +pub async fn export_data( + State(state): State, + auth: AuthSession, +) -> ApiResult> { + let user = auth + .user + .ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized( + "Login required".to_string(), + )))?; + let user_id = user.id(); + + let notes = state + .note_repo + .find_by_user(user_id, NoteFilter::default()) + .await?; + let tags = state.tag_repo.find_by_user(user_id).await?; + + Ok(Json(BackupData { notes, tags })) +} + +/// Import user data +/// POST /api/v1/import +pub async fn import_data( + State(state): State, + auth: AuthSession, + 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(); + + // 1. Import standalone tags (to ensure even unused tags are restored) + for tag in payload.tags { + // Security check: ensure tag belongs to user + if tag.user_id != user_id { + // Skip tags from other users if malformed, or overwrite user_id? + // Safer to skip or force user_id. Let's force user_id to current user to allow migrating data between accounts. + let mut tag = tag; + tag.user_id = user_id; + state.tag_repo.save(&tag).await?; + } else { + state.tag_repo.save(&tag).await?; + } + } + + // 2. Import notes + for mut note in payload.notes { + // Security check: ensure note belongs to user + note.user_id = user_id; // Force ownership to current user + + // Save note content + state.note_repo.save(¬e).await?; + + // 3. Re-establish tag associations + // Note: note.tags contains the tags associated with this note + for mut tag in note.tags { + tag.user_id = user_id; // Force ownership + + // Ensure tag exists (upsert) - might be redundant if in payload.tags but safe + state.tag_repo.save(&tag).await?; + + // Link tag to note + state.tag_repo.add_to_note(tag.id, note.id).await?; + } + } + + Ok(StatusCode::OK) +} diff --git a/notes-api/src/routes/mod.rs b/notes-api/src/routes/mod.rs index 2a29c18..25ee54d 100644 --- a/notes-api/src/routes/mod.rs +++ b/notes-api/src/routes/mod.rs @@ -1,6 +1,7 @@ //! Route definitions and module structure pub mod auth; +pub mod import_export; pub mod notes; pub mod tags; @@ -29,6 +30,9 @@ pub fn api_v1_router() -> Router { ) // Search route .route("/search", get(notes::search_notes)) + // Import/Export routes + .route("/export", get(import_export::export_data)) + .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))