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:
2025-12-28 23:37:38 +03:00
commit 05bbe307e0
58 changed files with 2142 additions and 0 deletions

13
frontend/Dockerfile Normal file
View 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
View 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
View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View 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
View 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>
);
}

View 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;
}

View 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"
});
}

View 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);
}

View 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>
);
}

View 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>
);
}

View 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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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"
/>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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} />;
}

View 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;

View 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
View 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>
);

View 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
View 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);
}

View 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
View 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
View 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
}
}
}
});