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

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
PORT=8080
NOTES_DIR=/data/notes
AUTH_USERNAME=admin
AUTH_PASSWORD_HASH=$2b$10$changemehash
ANYTHINGLLM_ROOT_API_URL=http://zimaos.bee:3086
ANYTHINGLLM_API_KEY=changeme
ANYTHINGLLM_WORKSPACE_SLUG=my-workspace

73
.gitignore vendored Normal file
View File

@@ -0,0 +1,73 @@
# Node modules
/node_modules/
jspm_packages/
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Runtime data
pids *.pid *.seed *.pid.lock
# Coverage directory
.nyc_output/
coverage/
lcov-report/
# Environment variables
.env
.env.*.local
!.env.example
# Build / output directories
/dist/
/build/
/output/
/. svelte-kit/
.svelte-kit/
.vite/
.tmp/
.cache/
# Docker files / volumes
docker-compose.override.yml
docker-compose.*.yml
docker/*-volume/
docker/*-data/
*.tar
*.img
# OS / IDE stuff
.DS_Store
Thumbs.db
desktop.ini
*.swp
.vscode/
.idea/
*.sublime-project
*.sublime-workspace
# Media / Download directories (depending on your setup)
downloads/
cache/
movie/movieData/
movie/movieData/**/subtitles/
movie/movieData/**/poster.jpg
movie/movieData/**/backdrop.jpg
/data/notes
# Generic placeholders
*.gitkeep
# Torrent / upload temp files
/uploads/
/uploads/*
*.torrent
*.part
*.temp
# Other sensitive files
/key.pem
/cert.pem
*.log

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM node:20-alpine AS frontend-build
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm install
COPY frontend .
RUN npm run build
FROM node:20-alpine AS backend-build
WORKDIR /app/backend
COPY backend/package*.json ./
RUN npm install
COPY backend .
RUN npm run build
FROM node:20-alpine AS prod
WORKDIR /app/backend
COPY backend/package*.json ./
RUN npm install --omit=dev
COPY --from=backend-build /app/backend/dist ./dist
COPY --from=frontend-build /app/frontend/dist ./dist/public
EXPOSE 8080
CMD ["node", "dist/server.js"]

42
README.md Normal file
View File

@@ -0,0 +1,42 @@
# Note AnythingLLM
iOS Memos esinli, PWA destekli ve AnythingLLM senkronizasyonlu minimal not uygulamasi.
## Gereksinimler
- Docker + Docker Compose
## Ortam Degiskenleri
`.env` dosyasi olusturun (ornek: `.env.example`).
```bash
cp .env.example .env
```
Bcrypt hash uretimi:
```bash
node -e "console.log(require('bcryptjs').hashSync('pass', 10))"
```
## Gelistirme
```bash
docker compose -f docker-compose.dev.yml up --build
```
- Frontend: http://localhost:5173
- Backend: http://localhost:8080
## Uretim
```bash
docker compose -f docker-compose.prod.yml up --build -d
```
## Notlar
- Tum `/api/*` endpointleri Basic Auth ile korunur.
- Notlar `NOTES_DIR` altinda `.md` dosyalarina kaydedilir.
- AnythingLLM akisi: upload -> update-embeddings -> cleanup queue.

21
backend/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM node:20-alpine AS dev
WORKDIR /app/backend
COPY package*.json ./
RUN npm install
COPY . .
CMD ["npm", "run", "dev"]
FROM node:20-alpine AS build
WORKDIR /app/backend
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM node:20-alpine AS prod
WORKDIR /app/backend
COPY package*.json ./
RUN npm install --omit=dev
COPY --from=build /app/backend/dist ./dist
EXPOSE 8080
CMD ["node", "dist/server.js"]

27
backend/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "note-anythingllm-backend",
"version": "1.0.0",
"type": "module",
"main": "dist/server.js",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"form-data": "^4.0.0",
"morgan": "^1.10.0",
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/morgan": "^1.9.9",
"@types/node": "^20.12.12",
"tsx": "^4.15.7",
"typescript": "^5.5.4"
}
}

View File

@@ -0,0 +1,80 @@
import { promises as fs } from "fs";
import path from "path";
import { config } from "../config.js";
import type { AnythingLLMUploadMetadata, AnythingLLMUploadResponse } from "./anythingllm.types.js";
function baseHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${config.ANYTHINGLLM_API_KEY}`
};
}
async function parseJsonResponse<T>(response: Response, label: string): Promise<T> {
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
const text = await response.text();
throw new Error(`${label} beklenmeyen yanit: ${response.status} ${text.slice(0, 200)}`);
}
return (await response.json()) as T;
}
export async function uploadDocument(filePath: string, metadata: AnythingLLMUploadMetadata): Promise<AnythingLLMUploadResponse> {
const form = new FormData();
const fileBuffer = await fs.readFile(filePath);
const fileBlob = new Blob([fileBuffer], { type: "text/markdown" });
form.append("file", fileBlob, path.basename(filePath));
form.append("addToWorkspaces", config.ANYTHINGLLM_WORKSPACE_SLUG);
form.append("metadata", JSON.stringify(metadata));
const response = await fetch(`${config.ANYTHINGLLM_ROOT_API_URL}/v1/document/upload`, {
method: "POST",
headers: {
...baseHeaders()
},
body: form
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Upload basarisiz: ${response.status} ${text}`);
}
return await parseJsonResponse<AnythingLLMUploadResponse>(response, "Upload");
}
export async function updateEmbeddings(adds: string[], deletes: string[]): Promise<void> {
const response = await fetch(
`${config.ANYTHINGLLM_ROOT_API_URL}/v1/workspace/${config.ANYTHINGLLM_WORKSPACE_SLUG}/update-embeddings`,
{
method: "POST",
headers: {
...baseHeaders(),
"Content-Type": "application/json"
},
body: JSON.stringify({ adds, deletes })
}
);
if (!response.ok) {
const text = await response.text();
throw new Error(`Embeddings guncelleme basarisiz: ${response.status} ${text}`);
}
await parseJsonResponse(response, "Embeddings guncelleme");
}
export async function removeDocuments(locations: string[]): Promise<void> {
const response = await fetch(`${config.ANYTHINGLLM_ROOT_API_URL}/v1/system/remove-documents`, {
method: "DELETE",
headers: {
...baseHeaders(),
"Content-Type": "application/json"
},
body: JSON.stringify({ names: locations })
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Dokuman temizleme basarisiz: ${response.status} ${text}`);
}
await parseJsonResponse(response, "Dokuman temizleme");
}

View File

@@ -0,0 +1,14 @@
import type { AnythingLLMUploadMetadata } from "./anythingllm.types.js";
import { removeDocuments, updateEmbeddings, uploadDocument } from "./anythingllm.client.js";
export async function uploadNote(filePath: string, metadata: AnythingLLMUploadMetadata) {
return uploadDocument(filePath, metadata);
}
export async function updateWorkspaceEmbeddings(adds: string[], deletes: string[]) {
await updateEmbeddings(adds, deletes);
}
export async function cleanupDocuments(locations: string[]) {
await removeDocuments(locations);
}

View File

@@ -0,0 +1,17 @@
export type AnythingLLMUploadMetadata = {
title: string;
docAuthor: string;
description: string;
docSource: string;
};
export type AnythingLLMUploadResponse = {
documents: Array<{
id: string;
location: string;
}>;
};
export type AnythingLLMEmbeddingsResponse = {
success: boolean;
};

View File

@@ -0,0 +1,32 @@
import type { Request, Response, NextFunction } from "express";
import bcrypt from "bcryptjs";
import { config } from "../config.js";
function unauthorized(res: Response): void {
res.status(401).json({ error: "Yetkisiz" });
}
export async function basicAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Basic ")) {
unauthorized(res);
return;
}
const encoded = authHeader.slice("Basic ".length).trim();
const decoded = Buffer.from(encoded, "base64").toString("utf-8");
const [username, password] = decoded.split(":");
if (!username || !password || username !== config.AUTH_USERNAME) {
unauthorized(res);
return;
}
const isValid = await bcrypt.compare(password, config.AUTH_PASSWORD_HASH);
if (!isValid) {
unauthorized(res);
return;
}
next();
}

24
backend/src/config.ts Normal file
View File

@@ -0,0 +1,24 @@
import dotenv from "dotenv";
import path from "path";
dotenv.config();
const PORT = Number(process.env.PORT || 8080);
const NOTES_DIR = process.env.NOTES_DIR || path.resolve("/data/notes");
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin";
const AUTH_PASSWORD_HASH = process.env.AUTH_PASSWORD_HASH || "";
const ANYTHINGLLM_ROOT_API_URL = process.env.ANYTHINGLLM_ROOT_API_URL || "";
const ANYTHINGLLM_API_KEY = process.env.ANYTHINGLLM_API_KEY || "";
const ANYTHINGLLM_WORKSPACE_SLUG = process.env.ANYTHINGLLM_WORKSPACE_SLUG || "";
export const config = {
PORT,
NOTES_DIR,
AUTH_USERNAME,
AUTH_PASSWORD_HASH,
ANYTHINGLLM_ROOT_API_URL,
ANYTHINGLLM_API_KEY,
ANYTHINGLLM_WORKSPACE_SLUG
};

View File

@@ -0,0 +1,65 @@
import { Router } from "express";
import { createNote, deleteNote, getNote, listNotes, manualSync, updateNote } from "./notes.service.js";
import type { CleanupQueue } from "../queue/queue.js";
export function notesRoutes(queue: CleanupQueue): Router {
const router = Router();
router.get("/notes", async (_req, res) => {
const notes = await listNotes();
res.json(notes);
});
router.get("/notes/:id", async (req, res) => {
const note = await getNote(req.params.id);
if (!note) {
res.status(404).json({ error: "Not bulunamadi" });
return;
}
res.json(note);
});
router.post("/notes", async (req, res) => {
const { title, content } = req.body as { title?: string; content?: string };
if (!title) {
res.status(400).json({ error: "Baslik gerekli" });
return;
}
const note = await createNote(title, content ?? "", queue);
res.status(201).json(note);
});
router.put("/notes/:id", async (req, res) => {
const { title, content } = req.body as { title?: string; content?: string };
if (!title) {
res.status(400).json({ error: "Baslik gerekli" });
return;
}
const note = await updateNote(req.params.id, title, content ?? "", queue);
if (!note) {
res.status(404).json({ error: "Not bulunamadi" });
return;
}
res.json(note);
});
router.delete("/notes/:id", async (req, res) => {
const ok = await deleteNote(req.params.id, queue);
if (!ok) {
res.status(404).json({ error: "Not bulunamadi" });
return;
}
res.status(204).send();
});
router.post("/notes/:id/sync", async (req, res) => {
const ok = await manualSync(req.params.id, queue);
if (!ok) {
res.status(404).json({ error: "Not bulunamadi" });
return;
}
res.status(202).json({ status: "syncing" });
});
return router;
}

View File

@@ -0,0 +1,174 @@
import { v4 as uuidv4 } from "uuid";
import type { NoteIndexItem, NoteContent } from "./notes.types.js";
import { readIndex, writeIndex, readNoteContent, writeNoteContent, deleteNoteFile } from "./notes.storage.js";
import { uploadDocument, updateEmbeddings } from "../anythingllm/anythingllm.client.js";
import { config } from "../config.js";
import { logError } from "../utils/logger.js";
import type { CleanupQueue } from "../queue/queue.js";
function nowIso(): string {
return new Date().toISOString();
}
function buildMetadata(title: string, content: string) {
const description = content.replace(/\s+/g, " ").slice(0, 120);
return {
title,
docAuthor: "Note App",
description,
docSource: "notes-app"
};
}
export async function listNotes(): Promise<NoteIndexItem[]> {
const index = await readIndex();
return index.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
export async function getNote(id: string): Promise<NoteContent | null> {
const index = await readIndex();
const item = index.find((note) => note.id === id);
if (!item) {
return null;
}
return readNoteContent(item.id, item.filename);
}
export async function createNote(title: string, content: string, queue: CleanupQueue): Promise<NoteIndexItem> {
const id = uuidv4();
const filename = `${id}.md`;
const timestamp = nowIso();
await writeNoteContent(filename, title, content);
const newItem: NoteIndexItem = {
id,
title,
filename,
createdAt: timestamp,
updatedAt: timestamp,
sync: {
status: "syncing"
}
};
const index = await readIndex();
index.push(newItem);
await writeIndex(index);
void syncNote(id, queue);
return newItem;
}
export async function updateNote(id: string, title: string, content: string, queue: CleanupQueue): Promise<NoteIndexItem | null> {
const index = await readIndex();
const item = index.find((note) => note.id === id);
if (!item) {
return null;
}
await writeNoteContent(item.filename, title, content);
item.title = title;
item.updatedAt = nowIso();
item.sync.status = "syncing";
item.sync.lastError = undefined;
await writeIndex(index);
void syncNote(id, queue);
return item;
}
export async function deleteNote(id: string, queue: CleanupQueue): Promise<boolean> {
const index = await readIndex();
const itemIndex = index.findIndex((note) => note.id === id);
if (itemIndex === -1) {
return false;
}
const [item] = index.splice(itemIndex, 1);
await writeIndex(index);
await deleteNoteFile(item.filename);
if (item.sync.anythingllmLocation) {
try {
await updateEmbeddings([], [item.sync.anythingllmLocation]);
await queue.enqueue({
id: item.id,
oldLocation: item.sync.anythingllmLocation,
attempts: 0,
nextRunAt: new Date().toISOString()
});
} catch (error) {
logError("Silme senkron hatasi", { error: (error as Error).message });
}
}
return true;
}
export async function manualSync(id: string, queue: CleanupQueue): Promise<boolean> {
const index = await readIndex();
const item = index.find((note) => note.id === id);
if (!item) {
return false;
}
item.sync.status = "syncing";
item.sync.lastError = undefined;
await writeIndex(index);
void syncNote(id, queue);
return true;
}
async function syncNote(id: string, queue: CleanupQueue): Promise<void> {
const index = await readIndex();
const item = index.find((note) => note.id === id);
if (!item) {
return;
}
const oldLocation = item.sync.anythingllmLocation;
try {
const note = await readNoteContent(item.id, item.filename);
const metadata = buildMetadata(note.title, note.content);
const uploadResponse = await uploadDocument(`${config.NOTES_DIR}/${item.filename}`, metadata);
const newDoc = uploadResponse.documents[0];
if (!newDoc) {
throw new Error("Upload cevabinda dokuman bulunamadi");
}
await updateEmbeddings([newDoc.location], oldLocation ? [oldLocation] : []);
item.sync.status = "synced";
item.sync.anythingllmLocation = newDoc.location;
item.sync.anythingllmDocumentId = newDoc.id;
item.sync.lastSyncedAt = nowIso();
item.sync.cleanupPending = false;
item.sync.lastError = undefined;
await writeIndex(index);
if (oldLocation) {
item.sync.cleanupPending = true;
await writeIndex(index);
await queue.enqueue({
id: item.id,
oldLocation,
attempts: 0,
nextRunAt: new Date().toISOString()
});
}
} catch (error) {
logError("Senkronizasyon hatasi", {
noteId: item.id,
error: (error as Error).message,
oldLocation: oldLocation ?? null
});
item.sync.status = "error";
item.sync.lastError = (error as Error).message;
await writeIndex(index);
}
}

View File

@@ -0,0 +1,51 @@
import path from "path";
import { promises as fs } from "fs";
import { ensureDir, readJsonFile, writeJsonFile, readTextFile, writeTextFile, deleteFileIfExists } from "../utils/fileUtils.js";
import type { NoteIndexItem, NoteContent } from "./notes.types.js";
import { config } from "../config.js";
const indexFilePath = path.join(config.NOTES_DIR, "index.json");
export async function initNotesStorage(): Promise<void> {
await ensureDir(config.NOTES_DIR);
const index = await readJsonFile<NoteIndexItem[]>(indexFilePath, []);
await writeJsonFile(indexFilePath, index);
}
export async function readIndex(): Promise<NoteIndexItem[]> {
return readJsonFile<NoteIndexItem[]>(indexFilePath, []);
}
export async function writeIndex(items: NoteIndexItem[]): Promise<void> {
await writeJsonFile(indexFilePath, items);
}
export function getNoteFilePath(filename: string): string {
return path.join(config.NOTES_DIR, filename);
}
export async function readNoteContent(id: string, filename: string): Promise<NoteContent> {
const raw = await readTextFile(getNoteFilePath(filename));
const [firstLine, ...rest] = raw.split("\n");
const title = firstLine.replace(/^#\s*/, "").trim();
const content = rest.join("\n").trimStart();
return { id, title, content };
}
export async function writeNoteContent(filename: string, title: string, content: string): Promise<void> {
const body = `# ${title}\n\n${content}`.trimEnd() + "\n";
await writeTextFile(getNoteFilePath(filename), body);
}
export async function deleteNoteFile(filename: string): Promise<void> {
await deleteFileIfExists(getNoteFilePath(filename));
}
export async function fileExists(filename: string): Promise<boolean> {
try {
await fs.access(getNoteFilePath(filename));
return true;
} catch (error) {
return false;
}
}

View File

@@ -0,0 +1,23 @@
export type SyncStatus = "pending" | "syncing" | "synced" | "error";
export type NoteIndexItem = {
id: string;
title: string;
filename: string;
createdAt: string;
updatedAt: string;
sync: {
status: SyncStatus;
lastSyncedAt?: string;
anythingllmLocation?: string;
anythingllmDocumentId?: string;
lastError?: string;
cleanupPending?: boolean;
};
};
export type NoteContent = {
id: string;
title: string;
content: string;
};

View File

@@ -0,0 +1,27 @@
import { removeDocuments } from "../anythingllm/anythingllm.client.js";
import type { CleanupJob } from "./queue.js";
import { logError } from "../utils/logger.js";
import { readIndex, writeIndex } from "../notes/notes.storage.js";
export async function handleCleanupJob(job: CleanupJob): Promise<void> {
try {
await removeDocuments([job.oldLocation]);
const index = await readIndex();
const updated = index.map((item) => {
if (item.id === job.id) {
return {
...item,
sync: {
...item.sync,
cleanupPending: false
}
};
}
return item;
});
await writeIndex(updated);
} catch (error) {
logError("Temizlik kuyrugu hatasi", { error: (error as Error).message });
throw error;
}
}

View File

@@ -0,0 +1,86 @@
import path from "path";
import { readJsonFile, writeJsonFile } from "../utils/fileUtils.js";
import { config } from "../config.js";
import { logError, logInfo } from "../utils/logger.js";
export type CleanupJob = {
id: string;
oldLocation: string;
attempts: number;
nextRunAt: string;
};
const backoffSeconds = [5, 15, 30];
export class CleanupQueue {
private jobs: CleanupJob[] = [];
private running = false;
private timer?: NodeJS.Timeout;
private readonly filePath: string;
private readonly handler: (job: CleanupJob) => Promise<void>;
constructor(handler: (job: CleanupJob) => Promise<void>) {
this.handler = handler;
this.filePath = path.join(config.NOTES_DIR, "cleanup-queue.json");
}
async load(): Promise<void> {
this.jobs = await readJsonFile<CleanupJob[]>(this.filePath, []);
}
async persist(): Promise<void> {
await writeJsonFile(this.filePath, this.jobs);
}
async enqueue(job: CleanupJob): Promise<void> {
this.jobs.push(job);
await this.persist();
}
start(): void {
if (this.timer) {
return;
}
this.timer = setInterval(() => void this.tick(), 1000);
logInfo("Temizlik kuyrugu calisiyor");
}
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = undefined;
}
}
private async tick(): Promise<void> {
if (this.running) {
return;
}
const now = Date.now();
const jobIndex = this.jobs.findIndex((job) => new Date(job.nextRunAt).getTime() <= now);
if (jobIndex === -1) {
return;
}
const job = this.jobs[jobIndex];
this.running = true;
try {
await this.handler(job);
this.jobs.splice(jobIndex, 1);
await this.persist();
logInfo("Temizlik basarili", { jobId: job.id });
} catch (error) {
job.attempts += 1;
if (job.attempts >= backoffSeconds.length) {
logError("Temizlik en fazla deneme sayisina ulasti", { jobId: job.id });
this.jobs.splice(jobIndex, 1);
} else {
const delay = backoffSeconds[job.attempts - 1] * 1000;
job.nextRunAt = new Date(Date.now() + delay).toISOString();
}
await this.persist();
} finally {
this.running = false;
}
}
}

45
backend/src/server.ts Normal file
View File

@@ -0,0 +1,45 @@
import express from "express";
import cors from "cors";
import morgan from "morgan";
import path from "path";
import { fileURLToPath } from "url";
import { config } from "./config.js";
import { basicAuth } from "./auth/basicAuth.js";
import { notesRoutes } from "./notes/notes.routes.js";
import { initNotesStorage } from "./notes/notes.storage.js";
import { CleanupQueue } from "./queue/queue.js";
import { handleCleanupJob } from "./queue/cleanup.worker.js";
import { logInfo } from "./utils/logger.js";
const app = express();
app.use(cors());
app.use(express.json({ limit: "2mb" }));
app.use(morgan("dev"));
await initNotesStorage();
const cleanupQueue = new CleanupQueue(handleCleanupJob);
await cleanupQueue.load();
cleanupQueue.start();
const apiRouter = express.Router();
apiRouter.get("/health", (_req, res) => {
res.json({ status: "ok" });
});
apiRouter.use(notesRoutes(cleanupQueue));
app.use("/api", basicAuth, apiRouter);
if (process.env.NODE_ENV === "production") {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const publicDir = path.resolve(__dirname, "public");
app.use(express.static(publicDir));
app.get("*", (_req, res) => {
res.sendFile(path.join(publicDir, "index.html"));
});
}
app.listen(config.PORT, "0.0.0.0", () => {
logInfo(`Sunucu basladi: ${config.PORT}`);
});

View File

@@ -0,0 +1,41 @@
import { promises as fs } from "fs";
import path from "path";
export async function ensureDir(dirPath: string): Promise<void> {
await fs.mkdir(dirPath, { recursive: true });
}
export async function readJsonFile<T>(filePath: string, fallback: T): Promise<T> {
try {
const raw = await fs.readFile(filePath, "utf-8");
return JSON.parse(raw) as T;
} catch (error) {
return fallback;
}
}
export async function writeJsonFile<T>(filePath: string, data: T): Promise<void> {
const dir = path.dirname(filePath);
await ensureDir(dir);
const tmpPath = `${filePath}.tmp`;
await fs.writeFile(tmpPath, JSON.stringify(data, null, 2), "utf-8");
await fs.rename(tmpPath, filePath);
}
export async function readTextFile(filePath: string): Promise<string> {
return fs.readFile(filePath, "utf-8");
}
export async function writeTextFile(filePath: string, content: string): Promise<void> {
const dir = path.dirname(filePath);
await ensureDir(dir);
await fs.writeFile(filePath, content, "utf-8");
}
export async function deleteFileIfExists(filePath: string): Promise<void> {
try {
await fs.unlink(filePath);
} catch (error) {
return;
}
}

View File

@@ -0,0 +1,15 @@
export function logInfo(message: string, meta?: Record<string, unknown>): void {
if (meta) {
console.log(`[info] ${message}`, meta);
return;
}
console.log(`[info] ${message}`);
}
export function logError(message: string, meta?: Record<string, unknown>): void {
if (meta) {
console.error(`[error] ${message}`, meta);
return;
}
console.error(`[error] ${message}`);
}

View File

@@ -0,0 +1,6 @@
export function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)+/g, "");
}

15
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src"]
}

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