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:
2026-01-17 11:58:26 +01:00
parent e38194a44c
commit a11e2acb23
29 changed files with 6794 additions and 9 deletions

View 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

View 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: '^_' }],
},
};

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

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

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

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

View 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"]
}

View File

@@ -0,0 +1,2 @@
VITE_API_URL=http://localhost:3000

View File

@@ -0,0 +1,19 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
};

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="hr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Znakovni.hr - Hrvatski znakovni jezik</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,50 @@
{
"name": "frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"type-check": "tsc --noEmit"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.1",
"zustand": "^4.4.7",
"axios": "^1.6.5",
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.0",
"class-variance-authority": "^0.7.0",
"lucide-react": "^0.303.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-label": "^2.0.2",
"plyr-react": "^5.3.0",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.0.11"
}
}

View File

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

View File

@@ -0,0 +1,15 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Home from './pages/Home';
function App() {
return (
<Router>
<Routes>
<Route path="/" element={<Home />} />
</Routes>
</Router>
);
}
export default App;

View File

@@ -0,0 +1,60 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,7 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,20 @@
function Home() {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
<div className="text-center">
<h1 className="text-4xl font-bold text-gray-900 mb-4">
Znakovni.hr
</h1>
<p className="text-xl text-gray-600">
Hrvatski znakovni jezik - Rječnik i platforma za učenje
</p>
<p className="text-sm text-gray-500 mt-4">
Frontend is running!
</p>
</div>
</div>
);
}
export default Home;

2
packages/frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,51 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ['class'],
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [],
};

View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
});