Initial project setup: monorepo structure with frontend and backend
- Set up pnpm workspace with frontend (React + Vite) and backend (Express + Prisma) - Configure TypeScript, ESLint, and Prettier - Add Prisma schema for database models (User, Course, Lesson, Progress, etc.) - Create basic frontend structure with Tailwind CSS and shadcn/ui - Add environment configuration files - Update README with project overview and setup instructions - Complete Phase 0: Project initialization
This commit is contained in:
25
packages/backend/.env.example
Normal file
25
packages/backend/.env.example
Normal file
@@ -0,0 +1,25 @@
|
||||
# Server
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# Database
|
||||
DATABASE_URL="mysql://user:password@192.168.1.74:3306/znakovni"
|
||||
|
||||
# Session
|
||||
SESSION_SECRET=your-super-secret-session-key-change-in-production
|
||||
|
||||
# OAuth - Google
|
||||
GOOGLE_CLIENT_ID=your-google-client-id
|
||||
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||
GOOGLE_CALLBACK_URL=http://localhost:3000/api/auth/google/callback
|
||||
|
||||
# OAuth - Microsoft
|
||||
MICROSOFT_CLIENT_ID=your-microsoft-client-id
|
||||
MICROSOFT_CLIENT_SECRET=your-microsoft-client-secret
|
||||
MICROSOFT_CALLBACK_URL=http://localhost:3000/api/auth/microsoft/callback
|
||||
|
||||
# File Upload
|
||||
UPLOAD_DIR=./uploads
|
||||
MAX_FILE_SIZE=104857600
|
||||
|
||||
18
packages/backend/.eslintrc.cjs
Normal file
18
packages/backend/.eslintrc.cjs
Normal file
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { node: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
};
|
||||
|
||||
52
packages/backend/package.json
Normal file
52
packages/backend/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"lint": "eslint . --ext .ts",
|
||||
"type-check": "tsc --noEmit",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:studio": "prisma studio",
|
||||
"prisma:seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"@prisma/client": "^5.8.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-microsoft": "^1.0.0",
|
||||
"express-session": "^1.17.3",
|
||||
"express-mysql-session": "^3.0.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"zod": "^3.22.4",
|
||||
"helmet": "^7.1.0",
|
||||
"express-rate-limit": "^7.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.11.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/passport": "^1.0.16",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"@types/passport-google-oauth20": "^2.0.14",
|
||||
"@types/express-session": "^1.17.10",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||
"@typescript-eslint/parser": "^6.19.0",
|
||||
"eslint": "^8.56.0",
|
||||
"prisma": "^5.8.1",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
232
packages/backend/prisma/schema.prisma
Normal file
232
packages/backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,232 @@
|
||||
// prisma/schema.prisma
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["fullTextIndex"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AUTHENTICATION & USERS
|
||||
// ============================================
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
displayName String? @map("display_name")
|
||||
passwordHash String? @map("password_hash")
|
||||
authProvider String @default("local") @map("auth_provider") // local, google, microsoft
|
||||
providerId String? @map("provider_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
documents Document[]
|
||||
comments Comment[]
|
||||
bugReports BugReport[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DICTIONARY / TERMS
|
||||
// ============================================
|
||||
|
||||
model Term {
|
||||
id String @id @default(uuid())
|
||||
wordText String @map("word_text") @db.VarChar(255)
|
||||
normalizedText String @map("normalized_text") @db.VarChar(255)
|
||||
language String @default("hr") @db.VarChar(10)
|
||||
wordType WordType @map("word_type")
|
||||
cefrLevel CefrLevel @map("cefr_level")
|
||||
shortDescription String? @map("short_description") @db.Text
|
||||
tags String? @db.Text // JSON array stored as text
|
||||
iconAssetId String? @map("icon_asset_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
media TermMedia[]
|
||||
examples TermExample[]
|
||||
sentenceTokens SentenceToken[]
|
||||
|
||||
@@index([normalizedText])
|
||||
@@fulltext([wordText, normalizedText])
|
||||
@@map("terms")
|
||||
}
|
||||
|
||||
model TermMedia {
|
||||
id String @id @default(uuid())
|
||||
termId String @map("term_id")
|
||||
kind MediaKind
|
||||
url String @db.VarChar(500)
|
||||
durationMs Int? @map("duration_ms")
|
||||
width Int?
|
||||
height Int?
|
||||
checksum String? @db.VarChar(64)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
term Term @relation(fields: [termId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([termId])
|
||||
@@map("term_media")
|
||||
}
|
||||
|
||||
model TermExample {
|
||||
id String @id @default(uuid())
|
||||
termId String @map("term_id")
|
||||
exampleText String @map("example_text") @db.Text
|
||||
notes String? @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
term Term @relation(fields: [termId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([termId])
|
||||
@@map("term_examples")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DOCUMENTS & SENTENCES (ZNAKOPIS)
|
||||
// ============================================
|
||||
|
||||
model Document {
|
||||
id String @id @default(uuid())
|
||||
ownerUserId String? @map("owner_user_id")
|
||||
title String @db.VarChar(255)
|
||||
description String? @db.Text
|
||||
visibility Visibility @default(PRIVATE)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
owner User? @relation(fields: [ownerUserId], references: [id], onDelete: SetNull)
|
||||
pages DocumentPage[]
|
||||
|
||||
@@index([ownerUserId])
|
||||
@@map("documents")
|
||||
}
|
||||
|
||||
model DocumentPage {
|
||||
id String @id @default(uuid())
|
||||
documentId String @map("document_id")
|
||||
pageIndex Int @map("page_index")
|
||||
title String? @db.VarChar(255)
|
||||
|
||||
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
sentences Sentence[]
|
||||
|
||||
@@unique([documentId, pageIndex])
|
||||
@@index([documentId])
|
||||
@@map("document_pages")
|
||||
}
|
||||
|
||||
model Sentence {
|
||||
id String @id @default(uuid())
|
||||
documentPageId String @map("document_page_id")
|
||||
sentenceIndex Int @map("sentence_index")
|
||||
rawText String? @map("raw_text") @db.Text
|
||||
|
||||
documentPage DocumentPage @relation(fields: [documentPageId], references: [id], onDelete: Cascade)
|
||||
tokens SentenceToken[]
|
||||
|
||||
@@unique([documentPageId, sentenceIndex])
|
||||
@@index([documentPageId])
|
||||
@@map("sentences")
|
||||
}
|
||||
|
||||
model SentenceToken {
|
||||
id String @id @default(uuid())
|
||||
sentenceId String @map("sentence_id")
|
||||
tokenIndex Int @map("token_index")
|
||||
termId String? @map("term_id")
|
||||
displayText String @map("display_text") @db.VarChar(255)
|
||||
isPunctuation Boolean @default(false) @map("is_punctuation")
|
||||
|
||||
sentence Sentence @relation(fields: [sentenceId], references: [id], onDelete: Cascade)
|
||||
term Term? @relation(fields: [termId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([sentenceId, tokenIndex])
|
||||
@@index([sentenceId])
|
||||
@@index([termId])
|
||||
@@map("sentence_tokens")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// COMMUNITY & SUPPORT
|
||||
// ============================================
|
||||
|
||||
model Comment {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
content String @db.Text
|
||||
context String? @db.VarChar(255) // e.g., "term:uuid" or "general"
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@map("comments")
|
||||
}
|
||||
|
||||
model BugReport {
|
||||
id String @id @default(uuid())
|
||||
userId String? @map("user_id")
|
||||
title String @db.VarChar(255)
|
||||
description String @db.Text
|
||||
status BugStatus @default(OPEN)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
@@map("bug_reports")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ENUMS
|
||||
// ============================================
|
||||
|
||||
enum WordType {
|
||||
NOUN
|
||||
VERB
|
||||
ADJECTIVE
|
||||
ADVERB
|
||||
PRONOUN
|
||||
PREPOSITION
|
||||
CONJUNCTION
|
||||
INTERJECTION
|
||||
PHRASE
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum CefrLevel {
|
||||
A1
|
||||
A2
|
||||
B1
|
||||
B2
|
||||
C1
|
||||
C2
|
||||
}
|
||||
|
||||
enum MediaKind {
|
||||
VIDEO
|
||||
IMAGE
|
||||
ILLUSTRATION
|
||||
}
|
||||
|
||||
enum Visibility {
|
||||
PRIVATE
|
||||
SHARED
|
||||
PUBLIC
|
||||
}
|
||||
|
||||
enum BugStatus {
|
||||
OPEN
|
||||
IN_PROGRESS
|
||||
RESOLVED
|
||||
CLOSED
|
||||
}
|
||||
|
||||
31
packages/backend/prisma/seed.ts
Normal file
31
packages/backend/prisma/seed.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('🌱 Starting database seed...');
|
||||
|
||||
// Create a sample user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: 'demo@znakovni.hr',
|
||||
displayName: 'Demo User',
|
||||
authProvider: 'local',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ Created demo user:', user.email);
|
||||
|
||||
// Add sample terms here in future phases
|
||||
console.log('✅ Seed completed successfully!');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ Seed failed:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
44
packages/backend/src/server.ts
Normal file
44
packages/backend/src/server.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Middleware
|
||||
app.use(helmet());
|
||||
app.use(cors({
|
||||
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
credentials: true,
|
||||
}));
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Health check route
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
message: 'Backend is running!',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// Root route
|
||||
app.get('/', (_req, res) => {
|
||||
res.json({
|
||||
message: 'Znakovni.hr API',
|
||||
version: '1.0.0',
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 Server running on http://localhost:${PORT}`);
|
||||
console.log(`📝 Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
||||
25
packages/backend/tsconfig.json
Normal file
25
packages/backend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020"],
|
||||
"moduleResolution": "bundler",
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user