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:
9
.env.example
Normal file
9
.env.example
Normal 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
73
.gitignore
vendored
Normal 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
22
Dockerfile
Normal 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
42
README.md
Normal 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
21
backend/Dockerfile
Normal 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
27
backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
80
backend/src/anythingllm/anythingllm.client.ts
Normal file
80
backend/src/anythingllm/anythingllm.client.ts
Normal 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");
|
||||
}
|
||||
14
backend/src/anythingllm/anythingllm.service.ts
Normal file
14
backend/src/anythingllm/anythingllm.service.ts
Normal 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);
|
||||
}
|
||||
17
backend/src/anythingllm/anythingllm.types.ts
Normal file
17
backend/src/anythingllm/anythingllm.types.ts
Normal 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;
|
||||
};
|
||||
32
backend/src/auth/basicAuth.ts
Normal file
32
backend/src/auth/basicAuth.ts
Normal 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
24
backend/src/config.ts
Normal 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
|
||||
};
|
||||
65
backend/src/notes/notes.routes.ts
Normal file
65
backend/src/notes/notes.routes.ts
Normal 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;
|
||||
}
|
||||
174
backend/src/notes/notes.service.ts
Normal file
174
backend/src/notes/notes.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
51
backend/src/notes/notes.storage.ts
Normal file
51
backend/src/notes/notes.storage.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
23
backend/src/notes/notes.types.ts
Normal file
23
backend/src/notes/notes.types.ts
Normal 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;
|
||||
};
|
||||
27
backend/src/queue/cleanup.worker.ts
Normal file
27
backend/src/queue/cleanup.worker.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
86
backend/src/queue/queue.ts
Normal file
86
backend/src/queue/queue.ts
Normal 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
45
backend/src/server.ts
Normal 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}`);
|
||||
});
|
||||
41
backend/src/utils/fileUtils.ts
Normal file
41
backend/src/utils/fileUtils.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
15
backend/src/utils/logger.ts
Normal file
15
backend/src/utils/logger.ts
Normal 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}`);
|
||||
}
|
||||
6
backend/src/utils/slugify.ts
Normal file
6
backend/src/utils/slugify.ts
Normal 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
15
backend/tsconfig.json
Normal 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
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