feat: not uygulaması ve altyapısını ekle
- iOS Memos benzeri PWA ön yüz eklendi (React, Tailwind) - Express tabanlı arka uç, AnythingLLM API entegrasyonu ve senkronizasyon kuyruğu oluşturuldu - Docker, TypeScript ve proje konfigürasyonları tanımlandı
This commit is contained in:
13
frontend/Dockerfile
Normal file
13
frontend/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM node:20-alpine AS dev
|
||||
WORKDIR /app/frontend
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
|
||||
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app/frontend
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
16
frontend/index.html
Normal file
16
frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="tr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#f4efe7" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>Memos Notes</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
30
frontend/package.json
Normal file
30
frontend/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "note-anythingllm-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"zustand": "^4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.41",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.0",
|
||||
"vite-plugin-pwa": "^0.20.5"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 105 B |
BIN
frontend/public/icons/apple-touch-icon.png
Normal file
BIN
frontend/public/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 563 B |
BIN
frontend/public/icons/icon-192.png
Normal file
BIN
frontend/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 594 B |
BIN
frontend/public/icons/icon-512.png
Normal file
BIN
frontend/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
25
frontend/public/manifest.webmanifest
Normal file
25
frontend/public/manifest.webmanifest
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "Memos Notes",
|
||||
"short_name": "Memos",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f4efe7",
|
||||
"theme_color": "#f4efe7",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/apple-touch-icon.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
79
frontend/src/App.tsx
Normal file
79
frontend/src/App.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import AppShell from "./components/layout/AppShell";
|
||||
import Sidebar from "./components/layout/Sidebar";
|
||||
import MobileHeader from "./components/layout/MobileHeader";
|
||||
import EditorPane from "./components/editor/EditorPane";
|
||||
import { useNotesStore } from "./store/notesStore";
|
||||
import LoginPage from "./components/auth/LoginPage";
|
||||
import { clearAuthHeader, getAuthHeader } from "./auth/authStore";
|
||||
|
||||
export default function App() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [route, setRoute] = useState(window.location.pathname);
|
||||
const [isAuthed, setIsAuthed] = useState(Boolean(getAuthHeader()));
|
||||
const loadNotes = useNotesStore((state) => state.loadNotes);
|
||||
const notes = useNotesStore((state) => state.notes);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePop = () => setRoute(window.location.pathname);
|
||||
window.addEventListener("popstate", handlePop);
|
||||
return () => window.removeEventListener("popstate", handlePop);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthed) {
|
||||
return;
|
||||
}
|
||||
void loadNotes();
|
||||
}, [loadNotes, isAuthed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthed) {
|
||||
return;
|
||||
}
|
||||
const hasSyncing = notes.some((note) => note.sync.status === "syncing");
|
||||
if (!hasSyncing) {
|
||||
return;
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
void loadNotes();
|
||||
}, 2000);
|
||||
return () => clearInterval(timer);
|
||||
}, [isAuthed, notes, loadNotes]);
|
||||
|
||||
const navigate = (path: string) => {
|
||||
window.history.pushState({}, "", path);
|
||||
setRoute(path);
|
||||
};
|
||||
|
||||
const handleLoginSuccess = () => {
|
||||
setIsAuthed(true);
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
clearAuthHeader();
|
||||
setIsAuthed(false);
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
if (!isAuthed || route === "/login") {
|
||||
return <LoginPage onSuccess={handleLoginSuccess} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
sidebar={<Sidebar onNoteSelect={() => setSidebarOpen(false)} />}
|
||||
header={
|
||||
<MobileHeader
|
||||
onToggleSidebar={() => setSidebarOpen((prev) => !prev)}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
sidebarOpen={sidebarOpen}
|
||||
onCloseSidebar={() => setSidebarOpen(false)}
|
||||
>
|
||||
<EditorPane />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
34
frontend/src/api/apiClient.ts
Normal file
34
frontend/src/api/apiClient.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { clearAuthHeader, getAuthHeader } from "../auth/authStore";
|
||||
|
||||
export async function apiFetch<T>(input: string, init?: RequestInit): Promise<T> {
|
||||
const base = import.meta.env.VITE_API_BASE ?? "/api";
|
||||
const authHeader = getAuthHeader();
|
||||
if (!authHeader) {
|
||||
throw new Error("Kimlik bilgileri gerekli");
|
||||
}
|
||||
|
||||
const response = await fetch(`${base}${input}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: authHeader,
|
||||
...(init?.headers ?? {})
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
clearAuthHeader();
|
||||
window.location.href = "/login";
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || "API hatasi");
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
59
frontend/src/api/notesApi.ts
Normal file
59
frontend/src/api/notesApi.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { apiFetch } from "./apiClient";
|
||||
|
||||
export type NoteSync = {
|
||||
status: "pending" | "syncing" | "synced" | "error";
|
||||
lastSyncedAt?: string;
|
||||
anythingllmLocation?: string;
|
||||
anythingllmDocumentId?: string;
|
||||
lastError?: string;
|
||||
cleanupPending?: boolean;
|
||||
};
|
||||
|
||||
export type NoteIndexItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
filename: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
sync: NoteSync;
|
||||
};
|
||||
|
||||
export type NoteContent = {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export async function fetchNotes(): Promise<NoteIndexItem[]> {
|
||||
return apiFetch<NoteIndexItem[]>("/notes");
|
||||
}
|
||||
|
||||
export async function fetchNote(id: string): Promise<NoteContent> {
|
||||
return apiFetch<NoteContent>(`/notes/${id}`);
|
||||
}
|
||||
|
||||
export async function createNote(payload: { title: string; content: string }): Promise<NoteIndexItem> {
|
||||
return apiFetch<NoteIndexItem>("/notes", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateNote(id: string, payload: { title: string; content: string }): Promise<NoteIndexItem> {
|
||||
return apiFetch<NoteIndexItem>(`/notes/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteNote(id: string): Promise<void> {
|
||||
await apiFetch<void>(`/notes/${id}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
}
|
||||
|
||||
export async function syncNote(id: string): Promise<void> {
|
||||
await apiFetch<void>(`/notes/${id}/sync`, {
|
||||
method: "POST"
|
||||
});
|
||||
}
|
||||
14
frontend/src/auth/authStore.ts
Normal file
14
frontend/src/auth/authStore.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
const AUTH_KEY = "notes-auth";
|
||||
|
||||
export function getAuthHeader(): string | null {
|
||||
return localStorage.getItem(AUTH_KEY);
|
||||
}
|
||||
|
||||
export function setAuthHeader(username: string, password: string): void {
|
||||
const token = btoa(`${username}:${password}`);
|
||||
localStorage.setItem(AUTH_KEY, `Basic ${token}`);
|
||||
}
|
||||
|
||||
export function clearAuthHeader(): void {
|
||||
localStorage.removeItem(AUTH_KEY);
|
||||
}
|
||||
79
frontend/src/components/auth/LoginPage.tsx
Normal file
79
frontend/src/components/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useState } from "react";
|
||||
import { clearAuthHeader, getAuthHeader, setAuthHeader } from "../../auth/authStore";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
type LoginPageProps = {
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
export default function LoginPage({ onSuccess }: LoginPageProps) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!username.trim()) {
|
||||
setError("Kullanici adi gerekli.");
|
||||
return;
|
||||
}
|
||||
setAuthHeader(username.trim(), password);
|
||||
try {
|
||||
const base = import.meta.env.VITE_API_BASE ?? "/api";
|
||||
const authHeader = getAuthHeader();
|
||||
const response = await fetch(`${base}/health`, {
|
||||
headers: {
|
||||
Authorization: authHeader ?? ""
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Yetkisiz");
|
||||
}
|
||||
setError(null);
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
clearAuthHeader();
|
||||
setError("Giris basarisiz. Kullanici adi veya sifre hatali.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--paper)] px-4 py-10">
|
||||
<div className="mx-auto w-full max-w-md rounded-3xl bg-white/80 p-8 shadow-xl">
|
||||
<h1 className="font-display text-3xl text-[var(--ink)]">Memos</h1>
|
||||
<p className="mt-2 text-sm text-[var(--muted)]">Notlarina erismek icin giris yap.</p>
|
||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||
<label className="block text-xs font-semibold uppercase text-[var(--muted)]">
|
||||
Kullanici adi
|
||||
<input
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
className="mt-2 w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-[var(--accent)]"
|
||||
placeholder="admin"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs font-semibold uppercase text-[var(--muted)]">
|
||||
Sifre
|
||||
<input
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
type="password"
|
||||
className="mt-2 w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-[var(--accent)]"
|
||||
placeholder="••••••"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
<Button
|
||||
type="submit"
|
||||
radius="2xl"
|
||||
className="w-full bg-[var(--accent)] py-3 text-sm font-semibold text-white"
|
||||
>
|
||||
Giris Yap
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
frontend/src/components/editor/EditorPane.tsx
Normal file
81
frontend/src/components/editor/EditorPane.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useNotesStore } from "../../store/notesStore";
|
||||
import TitleInput from "./TitleInput";
|
||||
import Toolbar from "./Toolbar";
|
||||
import MarkdownEditor from "./MarkdownEditor";
|
||||
import PreviewPane from "./PreviewPane";
|
||||
import SyncBadge from "./SyncBadge";
|
||||
import SaveButton from "./SaveButton";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
export default function EditorPane() {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { activeNote, saveActive, previewMode, togglePreview, notes, activeId, error } = useNotesStore();
|
||||
const [tab, setTab] = useState<"edit" | "preview">("edit");
|
||||
|
||||
const activeIndex = notes.find((note) => note.id === activeId);
|
||||
|
||||
if (!activeNote) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center text-center text-sm text-[var(--muted)]">
|
||||
Not sec veya yeni bir not olustur.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<TitleInput />
|
||||
<p className="mt-1 text-xs text-[var(--muted)]">
|
||||
Son guncelleme: {new Date(activeIndex?.updatedAt ?? Date.now()).toLocaleString("tr-TR")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="hidden items-center gap-3 lg:flex">
|
||||
<SyncBadge note={activeIndex} />
|
||||
<SaveButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Toolbar textareaRef={textareaRef} />
|
||||
|
||||
<div className="mt-6 hidden gap-4 lg:block">
|
||||
{previewMode ? <PreviewPane /> : <MarkdownEditor ref={textareaRef} />}
|
||||
<Button
|
||||
className="mt-4 border border-slate-200 px-4 py-2 text-xs"
|
||||
onClick={togglePreview}
|
||||
>
|
||||
{previewMode ? "Duzenle" : "Onizleme"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 lg:hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => setTab("edit")}
|
||||
className={`px-3 py-1 text-xs ${tab === "edit" ? "bg-[var(--accent)] text-white" : "bg-white"}`}
|
||||
>
|
||||
Duzenle
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setTab("preview")}
|
||||
className={`px-3 py-1 text-xs ${
|
||||
tab === "preview" ? "bg-[var(--accent)] text-white" : "bg-white"
|
||||
}`}
|
||||
>
|
||||
Onizleme
|
||||
</Button>
|
||||
</div>
|
||||
{tab === "edit" ? <MarkdownEditor ref={textareaRef} /> : <PreviewPane />}
|
||||
</div>
|
||||
|
||||
{error && <p className="mt-4 text-xs text-red-600">{error}</p>}
|
||||
|
||||
<div className="fixed bottom-4 left-1/2 z-40 flex -translate-x-1/2 items-center gap-3 lg:hidden">
|
||||
<SyncBadge note={activeIndex} />
|
||||
<SaveButton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
frontend/src/components/editor/MarkdownEditor.tsx
Normal file
26
frontend/src/components/editor/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { forwardRef } from "react";
|
||||
import { useNotesStore } from "../../store/notesStore";
|
||||
import Textarea from "../ui/Textarea";
|
||||
|
||||
const MarkdownEditor = forwardRef<HTMLTextAreaElement>((_props, ref) => {
|
||||
const activeNote = useNotesStore((state) => state.activeNote);
|
||||
const updateActive = useNotesStore((state) => state.updateActive);
|
||||
|
||||
if (!activeNote) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
ref={ref}
|
||||
className="mt-4 min-h-[50vh] w-full border border-[#DAD9D5] bg-white/70 p-4 text-sm leading-relaxed text-[var(--ink)] outline-none"
|
||||
value={activeNote.content}
|
||||
onChange={(event) => updateActive(activeNote.title, event.target.value)}
|
||||
placeholder="Notunu yaz..."
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
MarkdownEditor.displayName = "MarkdownEditor";
|
||||
|
||||
export default MarkdownEditor;
|
||||
17
frontend/src/components/editor/PreviewPane.tsx
Normal file
17
frontend/src/components/editor/PreviewPane.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import { useNotesStore } from "../../store/notesStore";
|
||||
|
||||
export default function PreviewPane() {
|
||||
const activeNote = useNotesStore((state) => state.activeNote);
|
||||
|
||||
if (!activeNote) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="prose prose-sm mt-4 max-w-none rounded-3xl bg-white/70 p-4 shadow-sm">
|
||||
<ReactMarkdown rehypePlugins={[rehypeRaw]}>{`# ${activeNote.title}\n\n${activeNote.content}`}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
frontend/src/components/editor/SaveButton.tsx
Normal file
19
frontend/src/components/editor/SaveButton.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useNotesStore } from "../../store/notesStore";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
export default function SaveButton() {
|
||||
const saveActive = useNotesStore((state) => state.saveActive);
|
||||
const isSaving = useNotesStore((state) => state.isSaving);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={`min-w-[120px] px-5 py-2 text-sm font-semibold text-white shadow-lg shadow-orange-200 ${
|
||||
isSaving ? "bg-[#F2A28A]" : "bg-[var(--accent)]"
|
||||
}`}
|
||||
onClick={() => saveActive()}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Kaydet
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
37
frontend/src/components/editor/SyncBadge.tsx
Normal file
37
frontend/src/components/editor/SyncBadge.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useNotesStore } from "../../store/notesStore";
|
||||
import type { NoteIndexItem } from "../../api/notesApi";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
type SyncBadgeProps = {
|
||||
note?: NoteIndexItem;
|
||||
};
|
||||
|
||||
export default function SyncBadge({ note }: SyncBadgeProps) {
|
||||
if (!note) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (note.sync.status !== "synced" && note.sync.status !== "syncing") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const statusLabel = note.sync.status === "synced" ? "Synced" : "Syncing";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className={`rounded-full px-2 py-1 ${
|
||||
note.sync.status === "synced"
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: note.sync.status === "syncing"
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: note.sync.status === "error"
|
||||
? "bg-red-100 text-red-700"
|
||||
: "bg-slate-100 text-slate-600"
|
||||
}`}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
frontend/src/components/editor/TitleInput.tsx
Normal file
19
frontend/src/components/editor/TitleInput.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useNotesStore } from "../../store/notesStore";
|
||||
|
||||
export default function TitleInput() {
|
||||
const activeNote = useNotesStore((state) => state.activeNote);
|
||||
const updateActive = useNotesStore((state) => state.updateActive);
|
||||
|
||||
if (!activeNote) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
className="w-full bg-transparent font-display text-3xl text-[var(--ink)] outline-none"
|
||||
value={activeNote.title}
|
||||
onChange={(event) => updateActive(event.target.value, activeNote.content)}
|
||||
placeholder="Baslik"
|
||||
/>
|
||||
);
|
||||
}
|
||||
109
frontend/src/components/editor/Toolbar.tsx
Normal file
109
frontend/src/components/editor/Toolbar.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { RefObject } from "react";
|
||||
import { useNotesStore } from "../../store/notesStore";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faBars } from "@fortawesome/free-solid-svg-icons";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
const textColors = ["#1f2937", "#1d4ed8", "#b91c1c", "#15803d", "#7c3aed", "#c2410c"];
|
||||
const highlightColors = ["#fef08a", "#bae6fd", "#fecaca", "#bbf7d0", "#e9d5ff", "#fed7aa"];
|
||||
|
||||
type ToolbarProps = {
|
||||
textareaRef: RefObject<HTMLTextAreaElement>;
|
||||
};
|
||||
|
||||
export default function Toolbar({ textareaRef }: ToolbarProps) {
|
||||
const activeNote = useNotesStore((state) => state.activeNote);
|
||||
const updateActive = useNotesStore((state) => state.updateActive);
|
||||
|
||||
if (!activeNote) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const applyWrap = (before: string, after = before) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const value = activeNote.content;
|
||||
const selected = value.slice(start, end) || "metin";
|
||||
const nextValue = `${value.slice(0, start)}${before}${selected}${after}${value.slice(end)}`;
|
||||
updateActive(activeNote.title, nextValue);
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus();
|
||||
textarea.selectionStart = start + before.length;
|
||||
textarea.selectionEnd = start + before.length + selected.length;
|
||||
});
|
||||
};
|
||||
|
||||
const applyLinePrefix = (prefix: string) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
const value = activeNote.content;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const before = value.slice(0, start);
|
||||
const selection = value.slice(start, end) || "Satir";
|
||||
const lines = selection.split("\n").map((line) => `${prefix}${line}`);
|
||||
const nextValue = `${before}${lines.join("\n")}${value.slice(end)}`;
|
||||
updateActive(activeNote.title, nextValue);
|
||||
};
|
||||
|
||||
const applyColor = (hex: string) => applyWrap(`<span style=\"color:${hex}\">`, "</span>");
|
||||
const applyHighlight = (hex: string) => applyWrap(`<mark style=\"background:${hex}\">`, "</mark>");
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
className="border border-slate-200 px-3 py-1 text-xs"
|
||||
onClick={() => applyWrap("**")}
|
||||
>
|
||||
Kalin
|
||||
</Button>
|
||||
<Button
|
||||
className="border border-slate-200 px-3 py-1 text-xs"
|
||||
onClick={() => applyWrap("~~")}
|
||||
>
|
||||
Ust
|
||||
</Button>
|
||||
<Button
|
||||
className="border border-slate-200 px-3 py-1 text-xs"
|
||||
onClick={() => applyLinePrefix("- ")}
|
||||
aria-label="Liste"
|
||||
>
|
||||
<FontAwesomeIcon icon={faBars} className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
className="border border-slate-200 px-3 py-1 text-xs"
|
||||
onClick={() => applyLinePrefix("1. ")}
|
||||
>
|
||||
Numara
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{textColors.map((hex) => (
|
||||
<Button
|
||||
key={hex}
|
||||
className="h-6 w-6 border border-white shadow"
|
||||
style={{ background: hex }}
|
||||
onClick={() => applyColor(hex)}
|
||||
aria-label={`Renk ${hex}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{highlightColors.map((hex) => (
|
||||
<Button
|
||||
key={hex}
|
||||
className="h-6 w-6 border border-white shadow"
|
||||
style={{ background: hex }}
|
||||
onClick={() => applyHighlight(hex)}
|
||||
aria-label={`Vurgula ${hex}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/layout/AppShell.tsx
Normal file
35
frontend/src/components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type AppShellProps = {
|
||||
sidebar: ReactNode;
|
||||
header: ReactNode;
|
||||
children: ReactNode;
|
||||
sidebarOpen: boolean;
|
||||
onCloseSidebar: () => void;
|
||||
};
|
||||
|
||||
export default function AppShell({ sidebar, header, children, sidebarOpen, onCloseSidebar }: AppShellProps) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{header}
|
||||
<div className="flex-1 grid lg:grid-cols-[320px_1fr]">
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-40 w-72 border-r border-[#DAD9D5] bg-[var(--card)] p-4 transition-transform lg:static lg:translate-x-0 lg:w-full ${
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
>
|
||||
{sidebar}
|
||||
</aside>
|
||||
{sidebarOpen && (
|
||||
<button
|
||||
className="fixed inset-0 z-30 bg-black/30 lg:hidden"
|
||||
onClick={onCloseSidebar}
|
||||
aria-label="Menüyü kapat"
|
||||
type="button"
|
||||
/>
|
||||
)}
|
||||
<main className="relative px-4 pb-24 pt-6 lg:px-8 lg:pb-8">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
frontend/src/components/layout/MobileHeader.tsx
Normal file
37
frontend/src/components/layout/MobileHeader.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import SyncBadge from "../editor/SyncBadge";
|
||||
import { useNotesStore } from "../../store/notesStore";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faBars } from "@fortawesome/free-solid-svg-icons";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
type MobileHeaderProps = {
|
||||
onToggleSidebar: () => void;
|
||||
onLogout: () => void;
|
||||
};
|
||||
|
||||
export default function MobileHeader({ onToggleSidebar, onLogout }: MobileHeaderProps) {
|
||||
const notes = useNotesStore((state) => state.notes);
|
||||
const activeId = useNotesStore((state) => state.activeId);
|
||||
const activeNote = notes.find((note) => note.id === activeId);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 flex items-center justify-between bg-[var(--paper)]/80 px-4 py-3 backdrop-blur lg:hidden">
|
||||
<Button
|
||||
onClick={onToggleSidebar}
|
||||
className="bg-white px-3 py-2 text-sm font-semibold text-[var(--ink)]"
|
||||
aria-label="Menüyü aç"
|
||||
>
|
||||
<FontAwesomeIcon icon={faBars} className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<SyncBadge note={activeNote} />
|
||||
<Button
|
||||
onClick={onLogout}
|
||||
className="border border-slate-200 px-2 py-1 text-xs text-[var(--ink)]"
|
||||
>
|
||||
Cikis
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/layout/Sidebar.tsx
Normal file
35
frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import NoteSearch from "../notes/NoteSearch";
|
||||
import NotesList from "../notes/NotesList";
|
||||
import { useNotesStore } from "../../store/notesStore";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
type SidebarProps = {
|
||||
onNoteSelect?: () => void;
|
||||
};
|
||||
|
||||
export default function Sidebar({ onNoteSelect }: SidebarProps) {
|
||||
const createNew = useNotesStore((state) => state.createNew);
|
||||
const isSaving = useNotesStore((state) => state.isSaving);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-display text-2xl text-[var(--ink)]">Memos</h2>
|
||||
<Button
|
||||
className="bg-[var(--accent)] px-3 py-1 text-xs font-semibold text-white"
|
||||
onClick={() => {
|
||||
createNew();
|
||||
onNoteSelect?.();
|
||||
}}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Yeni
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<NoteSearch />
|
||||
</div>
|
||||
<NotesList onSelect={onNoteSelect} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
frontend/src/components/notes/NoteListItem.tsx
Normal file
50
frontend/src/components/notes/NoteListItem.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { NoteIndexItem } from "../../api/notesApi";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
type NoteListItemProps = {
|
||||
item: NoteIndexItem;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
export default function NoteListItem({ item, active, onClick, onDelete }: NoteListItemProps) {
|
||||
return (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
radius="2xl"
|
||||
className={`w-full px-3 py-3 text-left transition ${
|
||||
active ? "bg-white shadow" : "bg-transparent hover:bg-white/70"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-[var(--ink)] line-clamp-1">{item.title}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-[10px] uppercase tracking-wide ${
|
||||
item.sync.status === "synced"
|
||||
? "text-emerald-600"
|
||||
: item.sync.status === "syncing"
|
||||
? "text-amber-500"
|
||||
: item.sync.status === "error"
|
||||
? "text-red-500"
|
||||
: "text-[var(--muted)]"
|
||||
}`}
|
||||
>
|
||||
{item.sync.status}
|
||||
</span>
|
||||
<Button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="border border-slate-200 px-2 py-1 text-[10px] text-slate-500 hover:text-red-600"
|
||||
>
|
||||
Sil
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[var(--muted)]">{new Date(item.updatedAt).toLocaleString("tr-TR")}</p>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
62
frontend/src/components/notes/NoteSearch.tsx
Normal file
62
frontend/src/components/notes/NoteSearch.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useState } from "react";
|
||||
import { useNotesStore } from "../../store/notesStore";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faChevronDown, faMagnifyingGlass, faSliders } from "@fortawesome/free-solid-svg-icons";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
export default function NoteSearch() {
|
||||
const search = useNotesStore((state) => state.search);
|
||||
const setSearch = useNotesStore((state) => state.setSearch);
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-3 rounded-full border border-slate-200 bg-white/70 px-4 py-2 text-[var(--muted)] shadow-sm">
|
||||
<FontAwesomeIcon icon={faMagnifyingGlass} className="h-4 w-4" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="flex-1 bg-transparent text-sm text-[var(--ink)] outline-none placeholder:text-[var(--muted)]"
|
||||
placeholder="Search memos..."
|
||||
aria-label="Not ara"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setShowMenu((prev) => !prev)}
|
||||
className="text-[var(--muted)] hover:text-[var(--ink)]"
|
||||
aria-label="Ayarlar"
|
||||
aria-expanded={showMenu}
|
||||
radius="none"
|
||||
>
|
||||
<FontAwesomeIcon icon={faSliders} className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{showMenu && (
|
||||
<div className="absolute right-0 z-40 mt-3 w-64 rounded-2xl border border-slate-200 bg-white p-4 shadow-lg">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-medium text-[var(--ink)]">Direction</span>
|
||||
<Button
|
||||
type="button"
|
||||
radius="xl"
|
||||
className="flex items-center gap-2 border border-slate-200 px-3 py-1 text-sm text-[var(--ink)]"
|
||||
>
|
||||
Descending
|
||||
<FontAwesomeIcon icon={faChevronDown} className="h-3 w-3 text-[var(--muted)]" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-medium text-[var(--ink)]">Layout</span>
|
||||
<Button
|
||||
type="button"
|
||||
radius="xl"
|
||||
className="flex items-center gap-2 border border-slate-200 px-3 py-1 text-sm text-[var(--ink)]"
|
||||
>
|
||||
List
|
||||
<FontAwesomeIcon icon={faChevronDown} className="h-3 w-3 text-[var(--muted)]" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
frontend/src/components/notes/NotesList.tsx
Normal file
36
frontend/src/components/notes/NotesList.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useNotesStore } from "../../store/notesStore";
|
||||
import NoteListItem from "./NoteListItem";
|
||||
|
||||
type NotesListProps = {
|
||||
onSelect?: () => void;
|
||||
};
|
||||
|
||||
export default function NotesList({ onSelect }: NotesListProps) {
|
||||
const notes = useNotesStore((state) => state.notes);
|
||||
const activeId = useNotesStore((state) => state.activeId);
|
||||
const search = useNotesStore((state) => state.search.toLowerCase());
|
||||
const selectNote = useNotesStore((state) => state.selectNote);
|
||||
const removeById = useNotesStore((state) => state.removeById);
|
||||
|
||||
const filtered = notes.filter((note) => note.title.toLowerCase().includes(search));
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-2 overflow-y-auto pb-6">
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-sm text-[var(--muted)]">Not bulunamadi.</p>
|
||||
)}
|
||||
{filtered.map((note) => (
|
||||
<NoteListItem
|
||||
key={note.id}
|
||||
item={note}
|
||||
active={note.id === activeId}
|
||||
onClick={() => {
|
||||
selectNote(note.id);
|
||||
onSelect?.();
|
||||
}}
|
||||
onDelete={() => removeById(note.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
frontend/src/components/ui/Button.tsx
Normal file
10
frontend/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import { radiusClass, type Radius } from "./radius";
|
||||
|
||||
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
radius?: Radius;
|
||||
};
|
||||
|
||||
export default function Button({ radius = "lg", className = "", type = "button", ...props }: ButtonProps) {
|
||||
return <button type={type} className={`${radiusClass(radius)} ${className}`.trim()} {...props} />;
|
||||
}
|
||||
16
frontend/src/components/ui/Textarea.tsx
Normal file
16
frontend/src/components/ui/Textarea.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { forwardRef, type TextareaHTMLAttributes } from "react";
|
||||
import { radiusClass, type Radius } from "./radius";
|
||||
|
||||
type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
||||
radius?: Radius;
|
||||
};
|
||||
|
||||
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ radius = "lg", className = "", ...props }, ref) => {
|
||||
return <textarea ref={ref} className={`${radiusClass(radius)} ${className}`.trim()} {...props} />;
|
||||
}
|
||||
);
|
||||
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export default Textarea;
|
||||
21
frontend/src/components/ui/radius.ts
Normal file
21
frontend/src/components/ui/radius.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type Radius = "pill" | "3xl" | "2xl" | "xl" | "lg" | "md" | "none";
|
||||
|
||||
export const radiusClass = (radius: Radius) => {
|
||||
switch (radius) {
|
||||
case "pill":
|
||||
return "rounded-full";
|
||||
case "3xl":
|
||||
return "rounded-3xl";
|
||||
case "2xl":
|
||||
return "rounded-2xl";
|
||||
case "xl":
|
||||
return "rounded-xl";
|
||||
case "lg":
|
||||
return "rounded-lg";
|
||||
case "md":
|
||||
return "rounded-md";
|
||||
case "none":
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
146
frontend/src/store/notesStore.ts
Normal file
146
frontend/src/store/notesStore.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { create } from "zustand";
|
||||
import type { NoteContent, NoteIndexItem } from "../api/notesApi";
|
||||
import { createNote, deleteNote, fetchNote, fetchNotes, syncNote, updateNote } from "../api/notesApi";
|
||||
|
||||
type NotesState = {
|
||||
notes: NoteIndexItem[];
|
||||
activeId?: string;
|
||||
activeNote?: NoteContent;
|
||||
search: string;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
previewMode: boolean;
|
||||
error?: string;
|
||||
loadNotes: () => Promise<void>;
|
||||
selectNote: (id: string) => Promise<void>;
|
||||
createNew: () => Promise<void>;
|
||||
updateActive: (title: string, content: string) => void;
|
||||
saveActive: () => Promise<void>;
|
||||
removeActive: () => Promise<void>;
|
||||
removeById: (id: string) => Promise<void>;
|
||||
retrySync: () => Promise<void>;
|
||||
setSearch: (value: string) => void;
|
||||
togglePreview: () => void;
|
||||
};
|
||||
|
||||
export const useNotesStore = create<NotesState>((set, get) => ({
|
||||
notes: [],
|
||||
search: "",
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
previewMode: false,
|
||||
loadNotes: async () => {
|
||||
set({ isLoading: true, error: undefined });
|
||||
try {
|
||||
const notes = await fetchNotes();
|
||||
set({ notes });
|
||||
if (!get().activeId && notes.length > 0) {
|
||||
await get().selectNote(notes[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
set({ error: (error as Error).message });
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
selectNote: async (id) => {
|
||||
set({ isLoading: true, error: undefined });
|
||||
try {
|
||||
const note = await fetchNote(id);
|
||||
set({ activeId: id, activeNote: note });
|
||||
} catch (error) {
|
||||
set({ error: (error as Error).message });
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
createNew: async () => {
|
||||
set({ isSaving: true, error: undefined });
|
||||
try {
|
||||
const newItem = await createNote({ title: "Yeni Not", content: "" });
|
||||
const notes = await fetchNotes();
|
||||
set({ notes, activeId: newItem.id, activeNote: { id: newItem.id, title: newItem.title, content: "" } });
|
||||
} catch (error) {
|
||||
set({ error: (error as Error).message });
|
||||
} finally {
|
||||
set({ isSaving: false });
|
||||
}
|
||||
},
|
||||
updateActive: (title, content) => {
|
||||
const activeId = get().activeId;
|
||||
if (!activeId) {
|
||||
return;
|
||||
}
|
||||
set({ activeNote: { id: activeId, title, content } });
|
||||
},
|
||||
saveActive: async () => {
|
||||
const { activeId, activeNote } = get();
|
||||
if (!activeId || !activeNote) {
|
||||
return;
|
||||
}
|
||||
set({ isSaving: true, error: undefined });
|
||||
try {
|
||||
await updateNote(activeId, { title: activeNote.title, content: activeNote.content });
|
||||
const notes = await fetchNotes();
|
||||
set({ notes });
|
||||
} catch (error) {
|
||||
set({ error: (error as Error).message });
|
||||
} finally {
|
||||
set({ isSaving: false });
|
||||
}
|
||||
},
|
||||
removeActive: async () => {
|
||||
const { activeId } = get();
|
||||
if (!activeId) {
|
||||
return;
|
||||
}
|
||||
set({ isSaving: true, error: undefined });
|
||||
try {
|
||||
await deleteNote(activeId);
|
||||
const notes = await fetchNotes();
|
||||
set({ notes, activeId: undefined, activeNote: undefined });
|
||||
if (notes.length > 0) {
|
||||
await get().selectNote(notes[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
set({ error: (error as Error).message });
|
||||
} finally {
|
||||
set({ isSaving: false });
|
||||
}
|
||||
},
|
||||
removeById: async (id) => {
|
||||
const { activeId } = get();
|
||||
if (id === activeId) {
|
||||
await get().removeActive();
|
||||
return;
|
||||
}
|
||||
set({ isSaving: true, error: undefined });
|
||||
try {
|
||||
await deleteNote(id);
|
||||
const notes = await fetchNotes();
|
||||
set({ notes });
|
||||
} catch (error) {
|
||||
set({ error: (error as Error).message });
|
||||
} finally {
|
||||
set({ isSaving: false });
|
||||
}
|
||||
},
|
||||
retrySync: async () => {
|
||||
const { activeId } = get();
|
||||
if (!activeId) {
|
||||
return;
|
||||
}
|
||||
set({ isSaving: true, error: undefined });
|
||||
try {
|
||||
await syncNote(activeId);
|
||||
const notes = await fetchNotes();
|
||||
set({ notes });
|
||||
} catch (error) {
|
||||
set({ error: (error as Error).message });
|
||||
} finally {
|
||||
set({ isSaving: false });
|
||||
}
|
||||
},
|
||||
setSearch: (value) => set({ search: value }),
|
||||
togglePreview: () => set((state) => ({ previewMode: !state.previewMode }))
|
||||
}));
|
||||
35
frontend/src/styles.css
Normal file
35
frontend/src/styles.css
Normal file
@@ -0,0 +1,35 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Fraunces:wght@600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap");
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--paper: #faf9f6;
|
||||
--ink: #1a1917;
|
||||
--accent: #e65a2b;
|
||||
--muted: #b9b1a3;
|
||||
--card: #faf9f6;
|
||||
--surface: #faf9f6;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
background: #faf9f6;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: #f3b38d;
|
||||
color: #1a1917;
|
||||
}
|
||||
|
||||
.note-shadow {
|
||||
box-shadow: 0 10px 40px rgba(26, 25, 23, 0.08);
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
13
frontend/tailwind.config.js
Normal file
13
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
display: ["Fraunces", "serif"],
|
||||
sans: ["Space Grotesk", "sans-serif"]
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
16
frontend/tsconfig.json
Normal file
16
frontend/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
48
frontend/vite.config.ts
Normal file
48
frontend/vite.config.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
includeAssets: ["favicon.ico", "icons/icon-192.png", "icons/icon-512.png", "icons/apple-touch-icon.png"],
|
||||
manifest: {
|
||||
name: "Memos Notes",
|
||||
short_name: "Memos",
|
||||
description: "Minimal not uygulamasi",
|
||||
theme_color: "#f4efe7",
|
||||
background_color: "#f4efe7",
|
||||
display: "standalone",
|
||||
icons: [
|
||||
{
|
||||
src: "/icons/icon-192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png"
|
||||
},
|
||||
{
|
||||
src: "/icons/icon-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png"
|
||||
},
|
||||
{
|
||||
src: "/icons/apple-touch-icon.png",
|
||||
sizes: "180x180",
|
||||
type: "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
],
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://backend:8080",
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user