basic structure setup

This commit is contained in:
echo 2026-01-10 19:41:04 +01:00
commit 71dfd86187
89 changed files with 41490 additions and 0 deletions

92
.gitignore vendored Normal file
View File

@ -0,0 +1,92 @@
# Dependencies
node_modules/
.pnp/
.pnp.js
# Build outputs
dist/
dist-ssr/
build/
*.local
next-env.d.ts
# Environment variables
.env
.env.*
!.env.example
# Database
*.sqlite
*.sqlite-journal
*.db
*.db-shm
*.db-wal
database.sqlite
data/
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
server.log
error.log
# Testing
coverage/
.nyc_output/
*.lcov
.vscode-test/
# Cache
.cache/
.turbo/
.vite/
.tsbuildinfo
.eslintcache
.stylelintcache
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS
Thumbs.db
.DS_Store
.AppleDouble
.LSOverride
# Strapi specific
cms/cms/.tmp/
cms/cms/.cache/
cms/cms/node_modules/
cms/cms/.env
cms/cms/dist/
cms/cms/public/uploads/
cms/cms/admin/
# Frontend specific
frontend/node_modules/
frontend/dist/
frontend/.next/
frontend/.turbo/
frontend/.cache/
# Backend specific
backend/node_modules/
backend/dist/
backend/database.sqlite
backend/data/

151
AGENTS.md Normal file
View File

@ -0,0 +1,151 @@
# Agent Guidelines for placebo.mk
## Project Overview
News site in Macedonia with sarcastic tone. Minimalistic design using TanStack stack.
## Tech Stack
- **Frontend**: TanStack (React 19, Query, Router) + Vite + Tailwind CSS + shadcn/ui
- **Backend**: NestJS + TypeORM + SQLite
- **CMS**: Strapi (in /cms)
## Build Commands
### Backend (NestJS)
```bash
cd backend
npm install
npm run start:dev # Watch mode
npm run build
npm run start:prod
npm run lint # Lint
npm run lint:fix # Auto-fix
npm run type-check # TypeScript check
npm test # All tests
npm test app.service.spec.ts # Single file
npm test -t "should" # By test name
npm run test:cov # Coverage
npm run test:e2e # E2E tests
```
### Frontend (TanStack)
```bash
cd frontend
npm install
npm run dev # Vite dev server
npm run build
npm run preview
npm run lint
npm run lint:fix
npm run type-check
npm test # All tests
npm test Header.test.tsx # Single file
npm test -t "renders" # By name
npm run test:ui # Vitest UI
```
## Code Style
### Formatting & TypeScript
- Use Prettier (2 spaces, single quotes, trailing commas, 100 char max)
- Strict TypeScript - no implicit `any`, avoid `any`, use `unknown`
- Explicit return types for public methods
- Prefer interfaces over types for objects
- Use `readonly` for immutable properties
### Naming Conventions
- **Files**: kebab-case (`user-profile.ts`, `auth.service.ts`)
- **Classes**: PascalCase (`UserService`, `AuthGuard`)
- **Functions/Variables**: camelCase (`getUser`, `isLoading`)
- **Constants**: UPPER_SNAKE_CASE (`MAX_RETRIES`)
- **Private members**: underscore prefix (`_cache`, `_validate()`)
- **Interfaces**: PascalCase, no 'I' prefix (`User`, `ApiResponse`)
### Import Order
- External libraries first, then internal modules, then relative imports
### File Structure
```
backend/src/
modules/feature-name/
feature-name.module.ts
feature-name.controller.ts
feature-name.service.ts
feature-name.entity.ts
feature-name.dto.ts
common/{decorators,filters,guards,interceptors,pipes}
config/
frontend/src/
components/{ui,layout,features}/
hooks/
lib/
queries/
routes/
types/
utils/
```
### Error Handling
- **Backend**: Use NestJS exceptions (`NotFoundException`, `BadRequestException`), custom exceptions, Logger
- **Frontend**: Error boundaries, try-catch with user-friendly messages
### API Conventions
- REST endpoints: `/api/v1/resources`
- JSON fields: snake_case
- Pagination: `page`, `limit` query params
- Response format:
```typescript
{
data: T,
meta: { total, page, limit },
error?: { code, message }
}
```
### Testing
- Unit tests for services/hooks (AAA pattern: Arrange-Act-Assert)
- Integration tests for API endpoints
- E2E tests for critical user flows
- Descriptive test names that explain what is tested
- Mock external dependencies (use Jest for backend, Vitest for frontend)
- Run `npm test` for all tests or target specific files/names
### Git Workflow
- Conventional commits: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`
- Keep commits atomic and focused
- Write clear commit messages in imperative mood
### Component Guidelines
- Keep components small and focused (< 200 lines)
- Use functional components with hooks
- Prefer composition over inheritance
- Extract complex logic into custom hooks
- Use TanStack Query for server state management
- Use TanStack Router for routing
- Validate props with TypeScript
### Database
- Use TypeORM for SQLite
- Define entities with proper relationships
- Use migrations for schema changes
- Seed data with factory functions
- Use TypeORM repositories for data access
### UI Components
- Use shadcn/ui components from `components/ui/`
- Use class-variance-authority (CVA) for component variants
- Use clsx and tailwind-merge for class composition
- Follow shadcn patterns for new component creation
## Environment Variables
- Backend: `@nestjs/config` with `.env` files
- Frontend: Vite env vars (`VITE_API_URL`)
- Never commit secrets to the repository.
- Example frontend env: `VITE_API_URL=http://localhost:3000`
- Example backend env: `DATABASE_PATH=./data.db`
- Store sensitive config in `.env` files
- Use `DATABASE_PATH` for SQLite location

219
FRONTEND_FINALIZED.md Normal file
View File

@ -0,0 +1,219 @@
# Frontend Setup Finalized ✅
## What's Working
- ✅ Tailwind CSS v4 (CSS-first approach - no config file needed)
- ✅ Shadcn/ui Card component with Tailwind classes
- ✅ Responsive grid layout (1/2/3 columns)
- ✅ Article data fetching from backend
- ✅ Loading, error, and empty states
- ✅ TypeScript path aliases (`@/`)
- ✅ shadcn/ui configuration (components.json)
## Configuration
### Tailwind CSS (v4 CSS-First)
All Tailwind configuration is in `src/index.css` using directives:
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
@theme {
@custom-colors;
@dark-mode;
}
```
**No `tailwind.config.js` file needed** - this is the v4 CSS-first approach.
### Shadcn/ui
- **components.json** - shadcn/ui configuration file
- **components/ui/card.tsx** - Card component with all sub-components
- **components/ui/button.tsx** - Button component (installed but unused)
- **utils.ts** - `cn()` utility for merging classes
### TypeScript
- Path aliases configured in `tsconfig.json`:
```json
"paths": {
"@/*": ["./src/*"]
}
```
## Current Files
### Core App Files
- `src/App.tsx` - Main app component with article grid
- `src/main.tsx` - App entry point
- `src/lib/api.ts` - Backend API functions
- `src/lib/query-client.ts` - TanStack Query configuration
### UI Components
- `src/components/ui/card.tsx` - Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter
- `src/components/ui/button.tsx` - Button component (ready to use)
### Configuration
- `src/index.css` - Tailwind v4 CSS-first configuration
- `components.json` - shadcn/ui config
- `tsconfig.json` - TypeScript config with path aliases
- `vite.config.ts` - Vite configuration
## Restart Required
**Restart dev server:**
```bash
cd frontend
# Press Ctrl+C to stop current server
npm run dev
```
**Refresh browser:** Ctrl+Shift+R (hard refresh)
## What You Should See
- Clean white/light gray background
- "Placebo.mk" header in large bold text
- "Sarcastic news from Macedonia" subtitle
- Grid of article cards (responsive: 1/2/3 columns)
- Each card has:
- White background with border and subtle shadow
- Title (truncated to 2 lines)
- Excerpt (truncated to 3 lines)
- Content preview (truncated to 4 lines)
- Footer with date and view count
- Hover effect adds shadow to cards
## Adding More Shadcn/ui Components
### Using CLI (Recommended)
```bash
cd frontend
npx shadcn@latest add [component-name]
```
### Available Components
- `badge` - For article tags
- `button` - Interactive elements (already installed)
- `input` - Search input
- `dialog` - Article detail modal
- `dropdown-menu` - Menus and filters
- `tabs` - Content organization
- `separator` - Visual separation
- `skeleton` - Loading placeholder
- `pagination` - Navigate articles
- `alert` - Notifications
### Manual Example
If CLI doesn't work, manual component setup:
1. Visit https://ui.shadcn.com/docs/components/[component]
2. Copy component code
3. Create: `frontend/src/components/ui/[component].tsx`
4. Import and use: `import { Component } from '@/components/ui/component'`
## Styling Customization
### Theme Colors (Edit `src/index.css`)
```css
:root {
--background: 0 0% 100%; /* Background color */
--foreground: 222.2 84% 4.9%; /* Text color */
--primary: 222.2 47.4% 11.2%; /* Primary brand color */
--card: 0 0% 100%; /* Card background */
/* ... more variables */
}
```
### Tailwind v4 CSS-First Configuration
The `@theme` directive allows you to customize Tailwind directly in CSS:
```css
@theme {
--color-primary: oklch(0.647 0.22 0.23);
--font-sans: "Inter", sans-serif;
--animate-fade-in: fade-in 0.5s ease-out;
}
```
## Known Limitations (Current Setup)
- No article detail page (clicking on cards doesn't navigate)
- No search functionality
- No category/tag filtering
- No dark mode toggle
- No pagination
- No article creation/editing (that's Strapi CMS)
## Quick Start Checklist
After restarting dev server:
- [ ] See styled article cards
- [ ] Check responsiveness (resize browser)
- [ ] Test dark mode (add toggle later)
- [ ] Add more articles in Strapi
- [ ] Add shadcn/ui components as needed
## Adding Article Detail Page
If you want to add navigation when clicking on cards:
1. Create `src/components/ArticleDetail.tsx`:
```tsx
import { useQuery } from '@tanstack/react-query'
import * as api from './lib/api'
import { Card, CardHeader, CardTitle, CardContent } from './components/ui/card'
import { Button } from './components/ui/button'
export function ArticleDetail({ articleId }: { articleId: string }) {
const { data, isLoading, error } = useQuery({
queryKey: ['article', articleId],
queryFn: () => api.fetchArticleById(articleId),
enabled: !!articleId,
})
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error</div>
if (!data) return null
return (
<Card className="max-w-3xl mx-auto">
<CardHeader>
<CardTitle>{data.title}</CardTitle>
</CardHeader>
<CardContent>
<div dangerouslySetInnerHTML={{ __html: data.content }} />
</CardContent>
<Button onClick={() => window.history.back()}>
Back to Articles
</Button>
</Card>
)
}
```
2. Update `App.tsx` to track selected article:
```tsx
const [selectedArticle, setSelectedArticle] = useState<string | null>(null)
```
## Next Steps
1. **Restart dev server and verify styling works**
2. **Add more articles** in Strapi CMS and sync
3. **Add shadcn/ui components** as needed
4. **Implement article detail view**
5. **Add search and filtering**
6. **Add dark mode toggle**
7. **Add pagination**
## Resources
- [Tailwind CSS v4 Docs](https://tailwindcss.com/docs/installation/using-postcss-css)
- [Shadcn/ui Documentation](https://ui.shadcn.com)
- [TanStack Query Docs](https://tanstack.com/query/latest)
- [Radix UI](https://www.radix-ui.com/)

348
FRONTEND_GUIDE.md Normal file
View File

@ -0,0 +1,348 @@
# Frontend Setup Complete - TanStack Router + Tailwind 4 ✅
## Quick Start
```bash
cd frontend
npm run dev
```
Visit:
- Home: http://localhost:5173/
- Articles: http://localhost:5173/articles
## Architecture
### Tech Stack
- **React 19** - UI framework
- **TanStack Router** - File-based routing
- **TanStack Query** - Server state management
- **Tailwind CSS 4** - CSS-first (v4 no config file)
- **Shadcn/ui** - Pre-built components
- **TypeScript** - Type safety
### Project Structure
```
frontend/
├── src/
│ ├── components/
│ │ └── ui/ # shadcn/ui components
│ │ ├── button.tsx
│ │ └── card.tsx
│ ├── lib/
│ │ ├── api.ts # Backend API functions
│ │ ├── query-client.ts # TanStack Query config
│ │ └── utils.ts # cn() utility
│ ├── routes/ # TanStack Router routes
│ │ ├── root.tsx # Root layout
│ │ ├── index.tsx # Home page
│ │ └── articles.tsx # Articles list
│ ├── styles.css # Tailwind CSS + theme
│ └── main.tsx # App entry
├── components.json # shadcn/ui config
├── vite.config.ts # Vite + Tailwind CSS plugin
└── package.json
```
## Pages & Routes
### Home Page (`/`)
- Welcome message about Placebo.mk
- "Welcome" card with intro
- "Get Started" card with feature list
- Centered layout with max-width container
### Articles Page (`/articles`)
- Grid of article cards (responsive: 1/2/3 columns)
- Each card shows:
- Title (truncated to 2 lines)
- Excerpt (truncated to 3 lines)
- Content preview (truncated to 4 lines)
- Creation date
- View count
- Loading state
- Error state
- Empty state when no articles
### Root Layout
- Header with navigation (Home, Articles links)
- Main content area with `<Outlet />`
- Footer with copyright
- SEO meta tags
- Tailwind CSS import
## Components
### Card (`src/components/ui/card.tsx`)
- Card - Main container
- CardHeader - Header section
- CardTitle - Title (h3 element)
- CardDescription - Description (p element)
- CardContent - Content area
- CardFooter - Footer area
### Button (`src/components/ui/button.tsx`)
- Variants: default, destructive, outline, secondary, ghost, link
- Sizes: default, sm, lg, icon
- Uses shadcn/ui theming system
- Ready to use (not used yet)
## Utilities
### cn() Function (`src/lib/utils.ts`)
```tsx
import { cn } from '@/lib/utils'
// Merge Tailwind classes with conditional logic
<div className={cn(
'base-class',
isActive && 'active-class',
className
)}>
```
## Styling
### Tailwind CSS 4 (CSS-First)
Configuration in `src/styles.css`:
```css
@import "tailwindcss";
@theme {
--color-primary: oklch(0.647 0.22 0.23);
--font-sans: "Inter", sans-serif;
}
```
### Shadcn/ui Theme
CSS variables in `src/styles.css`:
- `--background` - Background color
- `--foreground` - Text color
- `--card` - Card background
- `--primary` - Primary brand color
- `--muted` - Muted text color
- Dark mode support with `.dark` class
## Data Flow
### API Layer (`src/lib/api.ts`)
- `fetchArticles()` - Get all articles with filters
- `fetchArticleById()` - Get single article by ID
- `fetchArticleBySlug()` - Get article by slug
### TanStack Query
- Automatic caching
- Refetch on window focus (disabled)
- Stale time: 5 minutes
- Retry: 1 attempt
- Query client shared across app
## Backend Integration
### API Configuration
```typescript
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1'
```
### Environment Variables
Create `frontend/.env`:
```bash
VITE_API_URL=http://localhost:3000/api/v1
```
## Adding New Pages
### Method 1: File-Based Routes (Current)
Create new route file in `src/routes/`:
```tsx
// src/routes/about.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/about')({
component: () => <div>About Page</div>,
})
```
Add to `src/main.tsx`:
```tsx
import { aboutRoute } from './routes/about'
const routeTree = rootRoute.addChildren([indexRoute, articlesRoute, aboutRoute])
```
### Method 2: Using TanStack Router
Create routes dynamically in existing route files.
## Adding Shadcn/ui Components
### Using CLI (Recommended)
```bash
cd frontend
npx shadcn@latest add [component-name]
```
### Recommended Components
```bash
npx shadcn@latest add badge # Article tags
npx shadcn@latest add input # Search box
npx shadcn@latest add dialog # Article detail modal
npx shadcn@latest add tabs # Content organization
npx shadcn@latest add separator # Visual separation
npx shadcn@latest add skeleton # Loading placeholder
npx shadcn@latest add pagination # Article list navigation
npx shadcn@latest add dropdown-menu # Menus and filters
npx shadcn@latest add select # Dropdown selectors
```
## Customization
### Changing Brand Colors
Edit `src/styles.css`:
```css
@theme {
--color-primary: oklch(0.7 0.5 0.2); /* Your brand color */
--font-sans: "Your Font", sans-serif;
}
```
### Dark Mode
Add toggle button to header:
```tsx
// src/routes/root.tsx
const toggleDarkMode = () => {
document.documentElement.classList.toggle('dark')
localStorage.setItem('theme',
document.documentElement.classList.contains('dark') ? 'dark' : 'light'
)
}
// In component
<button onClick={toggleDarkMode}>Toggle Theme</button>
```
### Typography
Edit `src/styles.css`:
```css
@theme {
--font-sans: "Your Custom Font", sans-serif;
}
@layer base {
* {
@apply font-sans;
}
}
```
## Troubleshooting
### Routes Not Loading
1. Check route files exist in `src/routes/`
2. Check exports: `export const Route = createFileRoute(...)`
3. Check route tree in `src/main.tsx`
4. Restart dev server
### Styles Not Applying
1. Check `src/styles.css` has Tailwind directives
2. Check `vite.config.ts` has `tailwindcss()` plugin
3. Restart dev server
4. Hard refresh browser (Ctrl+Shift+R)
### Component Imports Failing
1. Check `tsconfig.json` has `@/` paths
2. Check import paths are correct: `@/components/ui/component`
3. Restart TypeScript server (VSCode: Cmd+Shift+P)
### Data Not Fetching
1. Check backend is running: `http://localhost:3000`
2. Check `.env` file exists and has correct API URL
3. Check browser network tab for failed requests
4. Check CORS in backend (`backend/src/main.ts`)
### TypeScript Errors
1. Run: `npm run type-check`
2. Restart TypeScript server (VSCode)
3. Clear Vite cache: `rm -rf node_modules/.vite`
## Performance Tips
1. **Code Splitting**: Use lazy() for large components
2. **Image Optimization**: Add loading="lazy" to article images
3. **Query Caching**: TanStack Query caches automatically
4. **Bundle Size**: Run `npm run build` and check size
## Development Workflow
### Adding New Article
1. Create article in Strapi CMS: http://localhost:1337/admin
2. Publish article
3. Sync to backend: `curl -X POST http://localhost:3000/api/v1/webhooks/strapi/sync/all`
4. Refresh frontend to see new article
### Running All Services
```bash
# Terminal 1 - Backend
cd backend
npm run start:dev
# Terminal 2 - Frontend
cd frontend
npm run dev
# Terminal 3 - Strapi CMS (optional)
cd cms/cms
npm run develop
```
## Testing
### Manual Testing
```bash
# Test API
curl http://localhost:3000/api/v1/articles
# Test frontend
npm run dev
# Open http://localhost:5173
```
### Type Checking
```bash
cd frontend
npm run type-check
```
## Next Steps
1. Add article detail page with `$id` parameter
2. Add search input for filtering articles
3. Add category/tag filtering
4. Add pagination for article lists
5. Add dark mode toggle
6. Add "about" page with site information
7. Add "contact" page or form
8. Add article comments system
9. Add social sharing buttons
10. Add analytics for article views
## Files to Edit
| File | Purpose |
|-------|---------|
| `src/styles.css` | Theme colors, fonts, CSS variables |
| `vite.config.ts` | Vite plugins, path aliases |
| `components.json` | shadcn/ui configuration |
| `tsconfig.json` | TypeScript paths |
| `.env` | Environment variables |
## Resources
- [TanStack Router Docs](https://tanstack.com/router/latest)
- [Tailwind CSS 4 Docs](https://tailwindcss.com/docs/installation/using-postcss)
- [Shadcn/ui Documentation](https://ui.shadcn.com)
- [TanStack Query Docs](https://tanstack.com/query/latest)

65
FRONTEND_READY.md Normal file
View File

@ -0,0 +1,65 @@
# Frontend Setup Complete ✅
## What's Ready
### Frontend with Shadcn/ui
- Clean, minimalistic design
- Responsive grid layout for articles
- Loading and error states
- Card-based article display
- Dark mode support (configured)
- TypeScript + Tailwind CSS
### Components Available
- `Card` - Display article content
- `Button` - Interactive elements
### Routing
- Single page app with all articles on home page
- Easy to extend with more routes when needed
## How to Run
```bash
cd frontend
npm run dev
```
Visit: http://localhost:5173
## Adding More Components
Use shadcn/ui CLI:
```bash
npx shadcn@latest add [component]
```
Available components: badge, input, dialog, dropdown-menu, select, tabs, etc.
See `SHADCN_SETUP.md` for full documentation.
## Current State
- **Home page**: Displays all published articles as cards
- **Backend**: Fetches from `http://localhost:3000/api/v1/articles`
- **Strapi integration**: Articles sync from Strapi CMS
- **Articles**: Currently showing 1 article "first article"
## Next Steps for You
1. Add more articles in Strapi CMS
2. Run sync: `curl -X POST http://localhost:3000/api/v1/webhooks/strapi/sync/all`
3. Refresh frontend to see new articles
4. Add shadcn/ui components as needed:
```bash
npx shadcn@latest add badge input dialog
```
## Design Customization
Edit `frontend/src/index.css`:
```css
--primary: 222.2 47.4% 11.2%; /* Primary color */
--background: 0 0% 100%; /* Background */
--foreground: 222.2 84% 4.9%; /* Text color */
```

89
FRONTEND_WORKING.md Normal file
View File

@ -0,0 +1,89 @@
# Frontend Setup - Working ✅
## Current Status
Frontend is working with:
- ✅ React 19
- ✅ TanStack Query for data fetching from backend
- ✅ Inline styles (currently working)
- ✅ Article grid layout
- ✅ Loading and error states
- ✅ Shadcn/ui components installed but not configured yet
## Running the App
```bash
cd frontend
npm run dev
```
Visit: http://localhost:5173
## Current Data Flow
1. Frontend fetches from: `http://localhost:3000/api/v1/articles`
2. Backend (NestJS) returns articles from SQLite
3. Articles synced from Strapi CMS
## Next Steps for Styling
### Option 1: Keep Inline Styles (Simple)
Continue using inline styles in `App.tsx` - currently working fine.
### Option 2: Set Up Tailwind CSS (Recommended)
If you want to use shadcn/ui components properly:
1. Install shadcn CLI:
```bash
npx shadcn@latest init
```
This will configure Tailwind properly for your project.
2. Add components:
```bash
npx shadcn@latest add button card badge
```
3. Use components in `App.tsx`
```tsx
import { Button } from "@/components/ui/button"
import { Card, CardHeader, CardTitle } from "@/components/ui/card"
```
## Adding More Articles
In Strapi CMS:
1. Create new article
2. Publish it
3. Sync to backend:
```bash
curl -X POST http://localhost:3000/api/v1/webhooks/strapi/sync/all
```
4. Refresh frontend
## Services Running
- **Backend**: http://localhost:3000/api/v1
- **Frontend**: http://localhost:5173
- **Strapi CMS**: http://localhost:1337/admin (if running)
## Known Files
- `frontend/src/App.tsx` - Main app with article list
- `frontend/src/lib/api.ts` - API functions
- `frontend/src/lib/query-client.ts` - TanStack Query client
- `frontend/src/components/ui/card.tsx` - Card component
- `frontend/src/components/ui/button.tsx` - Button component
## To Revert to Inline Styles Only
If you want to remove Tailwind dependencies later:
```bash
cd frontend
npm uninstall tailwindcss tailwindcss-animate clsx tailwind-merge class-variance-authority lucide-react @radix-ui/react-slot
rm -rf src/components/ui
rm components.json tailwind.config.js
```
Then update `App.tsx` to use inline styles only.

74
INTEGRATION_COMPLETE.md Normal file
View File

@ -0,0 +1,74 @@
# Strapi Integration - Complete ✅
## What Was Added
### Backend Updates
1. **Fixed SQLite enum issue**: Changed Article.status from enum type to text type (SQLite doesn't support enum)
2. **Fixed entity initialization order**: Moved Author and Category definitions before Article to avoid circular reference errors
3. **Fixed SQLite array issue**: Changed tags from simple-array to text type (SQLite doesn't support arrays)
4. **Added Strapi integration**:
- `strapi.service.ts` - Service for syncing content from Strapi
- `strapi.controller.ts` - Webhook endpoint controller
- `strapi.module.ts` - Module containing Strapi functionality
5. **Installed dependencies**: `@nestjs/axios` for HTTP requests to Strapi API
6. **Updated CORS**: Added Strapi URL to allowed origins
7. **Updated environment variables**: Added STRAPI_URL and STRAPI_API_TOKEN
### Database Schema
- Authors table with id, name, slug, bio, avatar, isActive, timestamps
- Categories table with id, name, slug, description, parentId, order, timestamps
- Articles table with id, title, content, excerpt, slug, featuredImage, tags (text), status (text), views, strapiId, authorId, categoryId, timestamps
### Configuration
- Updated `backend/src/app.module.ts` to include StrapiModule
- Updated `backend/.env.example` with Strapi configuration
- Created `STRAPI_INTEGRATION.md` with detailed setup instructions
- Updated `SETUP_COMPLETE.md` with Strapi integration info
## Next Steps
1. **Start all services**:
```bash
# Terminal 1 - Backend
cd backend
npm run start:dev
# Terminal 2 - Frontend
cd frontend
npm run dev
# Terminal 3 - Strapi
cd cms/cms
npm run develop
```
2. **Configure Strapi** (see STRAPI_INTEGRATION.md):
- Create content types: Article, Author, Category, Tag
- Create API token
- Configure webhook pointing to backend
- Add API token to backend `.env`
3. **Test the integration**:
- Create an article in Strapi
- Publish it
- Check backend: `curl http://localhost:3000/api/v1/articles`
- Verify it appears in frontend
## Webhook Flow
1. Content created/updated in Strapi → 2. Strapi sends webhook to backend → 3. Backend fetches content from Strapi API → 4. Backend syncs to SQLite database → 5. Frontend queries backend API
## API Endpoints Added
- `POST /api/v1/webhooks/strapi/article` - Handle Strapi webhooks
- `POST /api/v1/webhooks/strapi/sync/all` - Manual full sync trigger
## Fixed Issues
✅ SQLite enum type not supported - changed to text
✅ Circular entity reference - reordered definitions
✅ Frontend routeTree not exported - added export
✅ All TypeScript errors resolved
✅ All linting errors resolved
All services are ready to run!

385
ROUTER_TAILWIND_SETUP.md Normal file
View File

@ -0,0 +1,385 @@
# TanStack Router + Tailwind 4 Setup Complete ✅
## What's Been Configured
### 1. TanStack Router with File-Based Routing
- **Routes directory**: `frontend/src/routes/`
- **Root route**: `root.tsx` - Layout with header, footer, and outlet
- **Home route**: `index.tsx` - Welcome page with getting started cards
- **Articles route**: `articles.tsx` - Article list with card grid
- **Router configuration**: Uses `createRouter` with route tree
### 2. Tailwind CSS 4 (CSS-First)
- **Configuration**: In `src/styles.css` using `@import` and `@theme` directives
- **Vite integration**: `tailwindcss()` plugin in `vite.config.ts`
- **No config file needed**: Tailwind 4 CSS-first doesn't use `tailwind.config.js`
- **Shadcn/ui compatible**: CSS variables for theming
### 3. Routing Structure
```
frontend/src/routes/
├── root.tsx # Main layout (header, footer, <Outlet />)
├── index.tsx # Home page (/)
└── articles.tsx # Articles list (/articles)
```
### 4. Page Templates
#### Root Layout (`root.tsx`)
- Header with navigation (Home, Articles)
- Main content area with `<Outlet />`
- Footer with copyright
- Meta tags for SEO
- CSS import for Tailwind
#### Home Page (`index.tsx`)
- Welcome cards with:
- "Welcome" - Introduction
- "Get Started" - Feature list
- Centered layout with max-width container
#### Articles Page (`articles.tsx`)
- Grid of article cards (responsive: 1/2/3 columns)
- Loading state
- Error state
- Empty state (no articles)
- Each card shows:
- Featured image (if available)
- Title (truncated to 2 lines)
- Excerpt (truncated to 3 lines)
- Content preview (truncated to 4 lines)
- Date and view count
## How It Works
### 1. Route Configuration
```tsx
// src/routes/root.tsx
import { createRootRoute, Link, Outlet } from '@tanstack/react-router'
export const rootRoute = createRootRoute({
head: () => ({
meta: [
{
title: 'Placebo.mk - Sarcastic News from Macedonia',
description: 'Latest news and articles from Macedonia with a sarcastic twist',
},
],
links: [{ rel: 'stylesheet', href: '../../styles.css' }],
}),
component: () => (
<div>
<header>...</header>
<main><Outlet /></main>
<footer>...</footer>
</div>
),
})
```
### 2. Route Tree
```tsx
// src/main.tsx
import { createRouter } from '@tanstack/react-router'
import { rootRoute } from './routes/root'
import { indexRoute } from './routes/index'
import { articlesRoute } from './routes/articles'
const routeTree = rootRoute.addChildren([indexRoute, articlesRoute])
const router = createRouter({ routeTree, context: { queryClient } })
```
### 3. File-Based Routes
```tsx
// src/routes/index.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/')({
component: () => <div>Home</div>,
})
```
```tsx
// src/routes/articles.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/articles')({
component: () => <div>Articles</div>,
})
```
### 4. Tailwind 4 CSS-First Setup
#### In `src/styles.css`:
```css
@import "tailwindcss";
@theme {
--color-primary: oklch(0.647 0.22 0.23);
--font-sans: "Inter", sans-serif;
}
```
#### In `vite.config.ts`:
```ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from 'tailwindcss'
import path from 'path'
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})
```
## Running the App
```bash
cd frontend
npm run dev
```
Visit:
- Home: http://localhost:5173/
- Articles: http://localhost:5173/articles
## Adding New Routes
### Method 1: File-Based Routes (Recommended)
Create a new file in `src/routes/`:
```tsx
// src/routes/article-detail.tsx
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import * as api from '../lib/api'
export const Route = createFileRoute('/articles/$id')({
component: () => {
const { id } = Route.useParams()
const { data } = useQuery({
queryKey: ['article', id],
queryFn: () => api.fetchArticleById(id),
})
return (
<div>
<h1>{data?.title}</h1>
<div dangerouslySetInnerHTML={{ __html: data?.content || '' }} />
</div>
)
},
})
```
Then add to `src/main.tsx`:
```tsx
import { articleDetailRoute } from './routes/article-detail'
const routeTree = rootRoute.addChildren([indexRoute, articlesRoute, articleDetailRoute])
```
### Method 2: Using Link for Navigation
```tsx
import { Link } from '@tanstack/react-router'
// In your component
<Link to="/article/123">Read Article</Link>
```
## Adding Shadcn/ui Components
### Using the CLI (Recommended)
```bash
cd frontend
npx shadcn@latest add [component-name]
```
### Common Components to Add
```bash
npx shadcn@latest add button # (already installed)
npx shadcn@latest add badge # For article tags
npx shadcn@latest add input # For search
npx shadcn@latest add dialog # For article details
npx shadcn@latest add dropdown-menu
npx shadcn@latest add select
npx shadcn@latest add tabs # For content organization
npx shadcn@latest add separator # For visual separation
npx shadcn@latest add skeleton # For loading states
npx shadcn@latest add pagination # For article list navigation
```
### Using Components in Routes
```tsx
import { Button } from '@/components/ui/button'
export const Route = createFileRoute('/articles')({
component: () => (
<div>
<Button variant="default">Click me</Button>
</div>
),
})
```
## Customization
### Theme Colors
Edit `src/styles.css`:
```css
@theme {
--color-primary: oklch(0.647 0.22 0.23);
--color-secondary: oklch(0.8 0.15 0.1);
--font-sans: "Your Font", sans-serif;
}
```
### Adding Fonts
Edit `src/styles.css`:
```css
@theme {
--font-sans: "Inter", sans-serif;
}
@layer base {
* {
@apply font-sans;
}
}
```
### Dark Mode
Add dark mode toggle to header:
```tsx
import { useEffect } from 'react'
export const rootRoute = createRootRoute({
component: () => {
const toggleDarkMode = () => {
document.documentElement.classList.toggle('dark')
localStorage.setItem('theme',
document.documentElement.classList.contains('dark') ? 'dark' : 'light'
)
}
return (
<div>
<header>
<button onClick={toggleDarkMode}>
Toggle Theme
</button>
</header>
<main><Outlet /></main>
</div>
)
},
})
```
## File Structure
```
frontend/
├── src/
│ ├── components/
│ │ └── ui/ # shadcn/ui components
│ ├── lib/
│ │ ├── api.ts # Backend API functions
│ │ ├── query-client.ts # TanStack Query configuration
│ │ └── utils.ts # cn() utility (for shadcn/ui)
│ ├── routes/ # TanStack Router routes
│ │ ├── root.tsx # Root layout
│ │ ├── index.tsx # Home page
│ │ └── articles.tsx # Articles list
│ ├── styles.css # Tailwind CSS + theme
│ └── main.tsx # App entry point
├── components.json # shadcn/ui configuration
├── vite.config.ts # Vite + Tailwind CSS plugin
├── tailwind.config.js # (Not used - CSS-first approach)
└── package.json
```
## Troubleshooting
### Routes Not Working
1. Check that route files are in `src/routes/`
2. Check export: `export const Route = createFileRoute(...)`
3. Check imports in `src/main.tsx`
4. Restart dev server
### Styles Not Applying
1. Check `src/styles.css` has `@import "tailwindcss"`
2. Check `vite.config.ts` has `tailwindcss()` plugin
3. Restart dev server
4. Hard refresh browser (Ctrl+Shift+R)
### Component Imports Not Working
1. Check `tsconfig.json` has `@/` paths configured
2. Check `vite.config.ts` has resolve alias for `@/`
3. Restart TypeScript server (VSCode: Cmd+Shift+P → "TypeScript: Restart TS Server")
### TanStack Query Not Fetching
1. Check backend is running: `http://localhost:3000`
2. Check `.env` has correct API URL
3. Check browser network tab for failed requests
4. Check CORS configuration in backend
## Best Practices
### 1. Route Organization
- Keep routes in `src/routes/` directory
- Use file-based routing for simple pages
- Use nested routes for related content
### 2. Component Reusability
- Create reusable components in `src/components/`
- Use shadcn/ui for consistent styling
- Extract common patterns (loading states, error states)
### 3. Data Fetching
- Use TanStack Query for all server state
- Implement proper loading and error states
- Use query keys consistently
### 4. Performance
- Use `lazy()` for code splitting large components
- Implement image lazy loading
- Use `defer` for non-critical resources
### 5. SEO
- Add meta tags to root route
- Add Open Graph tags
- Implement structured data
## Next Steps
1. **Restart dev server** to load new configuration
2. **Visit home page**: http://localhost:5173/
3. **Visit articles page**: http://localhost:5173/articles
4. **Add article detail page** with `$id` parameter
5. **Add shadcn/ui components** as needed
6. **Add dark mode toggle**
7. **Add search functionality**
8. **Add category/tag filtering**
## Resources
- [TanStack Router Docs](https://tanstack.com/router/latest/docs/framework/react/start/file-based-routing)
- [Tailwind CSS 4 Docs](https://tailwindcss.com/docs/installation/using-postcss)
- [Shadcn/ui Documentation](https://ui.shadcn.com)
- [TanStack Query Docs](https://tanstack.com/query/latest)

119
SETUP_COMPLETE.md Normal file
View File

@ -0,0 +1,119 @@
# Placebo.mk - Basic Structure Complete
## ✅ What's Been Built
### Backend (NestJS)
- [x] NestJS project initialized with TypeScript
- [x] TypeORM configured with SQLite database
- [x] Entities created: Article, Author, Category
- [x] Articles module with full CRUD operations
- [x] DTOs with validation (CreateArticleDto, UpdateArticleDto, FindArticlesDto)
- [x] Service layer with business logic
- [x] REST API endpoints
- [x] CORS configured for frontend and Strapi
- [x] Strapi integration service with webhook support
- [x] HTTP client for fetching Strapi content
- [x] Content sync from Strapi to local database
- [x] Environment variables setup
- [x] Type checking configured
- [x] Linting configured
### Frontend (TanStack)
- [x] Vite + React + TypeScript project initialized
- [x] TanStack Router configured
- [x] TanStack Query configured
- [x] Basic routes created: home, articles list, article detail
- [x] API client with TypeScript interfaces
- [x] Custom hooks for data fetching
- [x] Environment variables setup
- [x] Type checking configured
- [x] Linting configured
### CMS (Strapi)
- [x] Strapi installed in cms/cms/ directory
- [x] Project initialized with TypeScript
### Project Structure
- [x] Monorepo structure with backend, frontend, cms directories
- [x] .gitignore for all projects
- [x] AGENTS.md with comprehensive guidelines
- [x] STRUCTURE.md with project overview
- [x] STRAPI_INTEGRATION.md with setup instructions
- [x] Environment variable templates (.env.example)
## 🚀 Ready to Use
### Backend
```bash
cd backend
npm install
cp .env.example .env
# Edit .env to add STRAPI_API_TOKEN
npm run start:dev
# API available at http://localhost:3000/api/v1
```
### Frontend
```bash
cd frontend
npm install
cp .env.example .env
npm run dev
# App available at http://localhost:5173
```
### Strapi CMS
```bash
cd cms/cms
npm run develop
# Admin panel available at http://localhost:1337/admin
```
## 📦 Strapi Integration
The backend now includes:
- **StrapiService**: Fetches and syncs content from Strapi
- **StrapiController**: Handles webhooks from Strapi
- **Manual sync endpoint**: POST `/api/v1/webhooks/strapi/sync/all`
- **Webhook endpoint**: POST `/api/v1/webhooks/strapi/article`
### Setup Steps:
1. Create content types in Strapi: Article, Author, Category, Tag (see STRAPI_INTEGRATION.md)
2. Create API token in Strapi admin panel
3. Add token to backend `.env` as `STRAPI_API_TOKEN`
4. Configure webhook in Strapi to point to backend
5. Start all three services
## 🔜 Next Steps
1. **Configure Strapi content types** following STRAPI_INTEGRATION.md
2. **Create webhook** in Strapi for automatic sync
3. **Implement frontend UI components** for displaying articles
4. **Add authentication** for the CMS
5. **Set up image handling** with Strapi media library
6. **Implement search** and filtering in the frontend
7. **Add analytics** for article views
8. **Set up deployment** for all three services
## 📝 API Endpoints Available
### Articles
- `GET /api/v1/articles` - List articles with pagination and filters
- `GET /api/v1/articles/:id` - Get article by ID
- `GET /api/v1/articles/slug/:slug` - Get article by slug
- `POST /api/v1/articles` - Create article (for Strapi sync)
- `PUT /api/v1/articles/:id` - Update article
- `DELETE /api/v1/articles/:id` - Delete article
### Webhooks & Sync
- `POST /api/v1/webhooks/strapi/article` - Handle Strapi webhooks
- `POST /api/v1/webhooks/strapi/sync/all` - Trigger full sync
All article endpoints support query parameters:
- `category` - Filter by category slug
- `author` - Filter by author slug
- `tag` - Filter by tag
- `status` - Filter by status (draft/published/archived)
- `search` - Full-text search in title and content
- `page` - Page number (default: 1)
- `limit` - Items per page (default: 10)

188
SHADCN_SETUP.md Normal file
View File

@ -0,0 +1,188 @@
# Shadcn/ui Setup Complete ✅
## What's Been Added
### 1. Core Dependencies Installed
- `tailwindcss-animate` - Animations for shadcn/ui
- `class-variance-authority` - Variant management
- `clsx` - Conditional class names
- `tailwind-merge` - Tailwind class merging
- `lucide-react` - Icons
- `@radix-ui/react-slot` - Headless UI primitives
### 2. Configuration Files Created
- `components.json` - shadcn/ui configuration
- `tailwind.config.js` - Tailwind CSS config with shadcn theme
- `tsconfig.json` - TypeScript path aliases (`@/`)
- `vite.config.ts` - Vite path resolution
### 3. CSS Setup
- Updated `src/index.css` with shadcn/ui:
- CSS variables for theming
- Dark mode support
- Tailwind directives
### 4. Utilities
- `src/lib/utils.ts` - `cn()` function for merging classes
### 5. UI Components
- `src/components/ui/card.tsx` - Card component with:
- Card
- CardHeader
- CardTitle
- CardDescription
- CardContent
- CardFooter
- `src/components/ui/button.tsx` - Button component with variants:
- Default, destructive, outline, secondary, ghost, link
- Sizes: default, sm, lg, icon
### 6. Home Page
- `src/routes/__root.tsx` - Article grid with:
- Cards displaying articles
- Loading and error states
- Responsive grid layout (1/2/3 columns)
- Empty state for no articles
### 7. Routing
- Simplified to single route (home page)
- All articles displayed on home page as cards
## How to Use
### Adding New UI Components
Use the shadcn/ui CLI to add components:
```bash
npx shadcn@latest add [component-name]
```
For example:
```bash
npx shadcn@latest add badge
npx shadcn@latest add input
npx shadcn@latest add dialog
```
### Using Components
```tsx
import { Button } from "@/components/ui/button"
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
export function MyComponent() {
return (
<Card>
<CardHeader>
<CardTitle>Title</CardTitle>
</CardHeader>
<CardContent>
<p>Content goes here</p>
</CardContent>
<Button>Click me</Button>
</Card>
)
}
```
### Using Utility Function
```tsx
import { cn } from "@/lib/utils"
export function MyComponent() {
return (
<div className={cn(
"base-class",
"conditional-class" && someCondition && "active-class"
)}>
Content
</div>
)
}
```
## Customization
### Theme Colors
Edit `src/index.css` to change color scheme:
```css
--primary: 222.2 47.4% 11.2%; /* Change primary color */
--background: 0 0% 100%; /* Change background */
--foreground: 222.2 84% 4.9%; /* Change text color */
```
### Tailwind Config
Edit `tailwind.config.js` to:
- Add custom colors
- Add custom spacing
- Add custom breakpoints
- Add custom animations
## Next Steps
1. **Add more shadcn/ui components**:
- Badge for article tags
- Input for search
- Dialog for article details
- Skeleton for loading states
2. **Add article detail page**:
- Click on article to view full content
- Add back button
- Add related articles
3. **Add filters**:
- Category filter
- Tag filter
- Date range filter
4. **Add dark mode toggle**:
- Add button to toggle dark mode
- Persist preference in localStorage
## Troubleshooting
### Styles Not Applying
1. Check Tailwind is running (look for CSS in browser DevTools)
2. Restart dev server
3. Check `vite.config.ts` has correct path aliases
### Imports Not Working
1. Verify `tsconfig.json` has `@/` paths configured
2. Verify `vite.config.ts` has path resolution
3. Restart TypeScript server (if using VSCode)
### Component Not Found
1. Check file path: `src/components/ui/[component].tsx`
2. Verify export name: `export const ComponentName = ...`
3. Check import path: `@/components/ui/component`
## File Structure
```
frontend/
├── src/
│ ├── components/
│ │ └── ui/ # shadcn/ui components
│ │ ├── button.tsx
│ │ └── card.tsx
│ ├── lib/
│ │ ├── api.ts # API functions
│ │ ├── query-client.ts
│ │ └── utils.ts # cn() utility
│ ├── routes/
│ │ └── __root.tsx # Home page with articles
│ ├── index.css # Tailwind + shadcn styles
│ └── ...
├── components.json # shadcn configuration
└── tailwind.config.js # Tailwind configuration
```
## Resources
- [shadcn/ui Documentation](https://ui.shadcn.com/)
- [Tailwind CSS](https://tailwindcss.com/)
- [Radix UI](https://www.radix-ui.com/)
- [Lucide Icons](https://lucide.dev/)

104
STRAPI_INTEGRATION.md Normal file
View File

@ -0,0 +1,104 @@
# Strapi Integration Guide
## Setup
1. Start Strapi CMS:
```bash
cd cms/cms
npm run develop
```
2. Configure environment variables:
- In `backend/.env`, add your Strapi API token (create one in Strapi admin panel)
## Content Types
Create these content types in Strapi admin panel:
### Article
- Fields:
- `title` (Text, required)
- `description` (Text)
- `content` (Rich Text, required)
- `slug` (UID, target field: title)
- `publishedAt` (DateTime)
- `author` (Relation with Author)
- `category` (Relation with Category)
- `tags` (Relation with Tag)
- Settings: Draft & publish enabled
### Author
- Fields:
- `name` (Text, required)
- `bio` (Text)
- `avatar` (Media)
### Category
- Fields:
- `name` (Text, required)
- `description` (Text)
- `parent` (Relation with Category, self-referential)
### Tag
- Fields:
- `name` (Text, required)
## Webhook Configuration
In Strapi admin panel, go to Settings → Webhooks and create a webhook:
### Article Sync Webhook
- **Name**: Backend Article Sync
- **URL**: `http://localhost:3000/api/v1/webhooks/strapi/article`
- **Events**:
- ✓ entry.create
- ✓ entry.update
- ✓ entry.delete
- **Headers**: None required for now
- **Send headers**: No
### API Token Creation
1. Go to Settings → API Tokens → Create new API Token
2. Name: Backend Integration
3. Duration: Unlimited
4. Token Type: Full access (or customize as needed)
5. Copy the generated token to `backend/.env` as `STRAPI_API_TOKEN`
## Manual Sync
Trigger a full sync manually:
```bash
curl -X POST http://localhost:3000/api/v1/webhooks/strapi/sync/all
```
## Data Flow
1. Content is created/updated in Strapi
2. Strapi sends webhook to backend
3. Backend fetches the updated content from Strapi API
4. Backend syncs content to local SQLite database
5. Frontend queries backend API for public content
## Testing the Integration
1. Create an article in Strapi
2. Publish it
3. Check if it appears in backend: `curl http://localhost:3000/api/v1/articles`
4. Frontend should now display the article
## Troubleshooting
### Webhook not triggering
- Check Strapi webhook logs in admin panel
- Verify backend is running on the correct URL
- Check network connectivity
### Sync not working
- Verify STRAPI_API_TOKEN is correct
- Check Strapi content type permissions
- Review backend logs for errors
- Test Strapi API directly: `curl http://localhost:1337/api/articles`
### CORS errors
- Ensure backend CORS includes Strapi URL
- Update `backend/src/main.ts` CORS configuration

70
STRUCTURE.md Normal file
View File

@ -0,0 +1,70 @@
# Placebo.mk - Project Structure
## Monorepo Structure
```
placeboMk/
├── backend/ # NestJS API server
│ ├── src/
│ │ ├── modules/ # Feature modules
│ │ ├── main.ts # Application entry point
│ │ └── app.module.ts # Root module
│ ├── .env.example
│ └── package.json
├── frontend/ # TanStack React application
│ ├── src/
│ │ ├── routes/ # TanStack Router routes
│ │ ├── components/ # React components
│ │ ├── queries/ # TanStack Query hooks
│ │ ├── lib/ # Utilities and API client
│ │ ├── types/ # TypeScript types
│ │ └── main.tsx # Application entry point
│ ├── .env.example
│ └── package.json
├── cms/ # Strapi CMS (to be initialized)
└── AGENTS.md # Agent guidelines
```
## Getting Started
### Backend (NestJS)
```bash
cd backend
npm install
cp .env.example .env
npm run start:dev
```
### Frontend (TanStack)
```bash
cd frontend
npm install
cp .env.example .env
npm run dev
```
### CMS (Strapi)
To be initialized with `npm create strapi-app@latest cms`
## API Endpoints
- `GET /api/v1/articles` - List articles with filters
- `GET /api/v1/articles/:id` - Get article by ID
- `GET /api/v1/articles/slug/:slug` - Get article by slug
- `POST /api/v1/articles` - Create article
- `PUT /api/v1/articles/:id` - Update article
- `DELETE /api/v1/articles/:id` - Delete article
## Database
SQLite database with entities:
- Articles (title, content, status, tags, etc.)
- Authors (name, bio, avatar, etc.)
- Categories (name, description, parent-child relations)
## Tech Stack
- **Frontend**: TanStack (React, Query, Router) + Vite
- **Backend**: NestJS + TypeORM + SQLite
- **CMS**: Strapi
- **Language**: TypeScript

262
TAILWIND_SHADCN_SETUP.md Normal file
View File

@ -0,0 +1,262 @@
# Tailwind 4 + Shadcn/ui Setup Complete ✅
## What's Been Configured
### 1. Tailwind CSS 4 (CSS-First Approach)
- Using `@import` directive (no PostCSS required)
- Clean CSS variables for theming
- Dark mode support built-in
- Responsive utilities
### 2. Shadcn/ui Components
- **Card**: Display article content
- CardHeader
- CardTitle
- CardDescription
- CardContent
- CardFooter
- **Button**: Interactive elements (available but not used yet)
### 3. Utilities
- `cn()` function for merging Tailwind classes
- Uses `clsx` for conditional classes
- Uses `tailwind-merge` for Tailwind-specific merging
### 4. TypeScript Configuration
- Path aliases configured: `@/``./src/`
- shadcn/ui aliases: `@/components`, `@/utils`
## How to Use
### Using Existing Components
```tsx
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
export function MyComponent() {
return (
<Card>
<CardHeader>
<CardTitle>Title</CardTitle>
<CardDescription>Description</CardDescription>
</CardHeader>
<CardContent>
<p>Content goes here</p>
</CardContent>
<CardFooter>
<span>Footer content</span>
</CardFooter>
</Card>
)
}
```
### Using Tailwind Classes
```tsx
export function MyComponent() {
return (
<div className="p-4 bg-background text-foreground">
<h1 className="text-2xl font-bold">Title</h1>
<p className="text-muted-foreground">Description</p>
</div>
)
}
```
### Using the cn() Utility
```tsx
import { cn } from '@/lib/utils'
export function MyComponent({ className, isActive }: Props) {
return (
<div className={cn(
"base-class p-4",
isActive && "active-class",
className
)}>
Content
</div>
)
}
```
## Adding More Shadcn/ui Components
### Using the CLI
```bash
cd frontend
npx shadcn@latest add [component-name]
```
### Recommended Components to Add
```bash
npx shadcn@latest add badge # For article tags
npx shadcn@latest add input # For search
npx shadcn@latest add dialog # For article details modal
npx shadcn@latest add tabs # For organizing content
npx shadcn@latest add separator # For visual separation
npx shadcn@latest add skeleton # For loading states
npx shadcn@latest add pagination # For article list navigation
```
### Manual Installation
If CLI doesn't work, you can manually create components:
1. Visit [ui.shadcn.com](https://ui.shadcn.com)
2. Find the component you want
3. Copy the code
4. Create file: `frontend/src/components/ui/[component].tsx`
5. Import and use
## Customization
### Theme Colors
Edit `src/index.css`:
```css
:root {
--background: 0 0% 100%; /* Background color */
--foreground: 222.2 84% 4.9%; /* Text color */
--primary: 222.2 47.4% 11.2%; /* Primary color */
--card: 0 0% 100%; /* Card background */
/* ... more variables */
}
```
### Adding Custom Colors to Tailwind
Edit `tailwind.config.js`:
```js
theme: {
extend: {
colors: {
brand: {
50: '#f0f9ff',
100: '#e0e7ff',
500: '#3b82f6',
// ... more shades
}
}
}
}
```
## Current Implementation
### App.tsx
The main app displays:
- Header with "Placebo.mk" title
- Subtitle "Sarcastic news from Macedonia"
- Responsive grid of article cards (1/2/3 columns)
- Each card shows:
- Featured image (if available)
- Title (truncated to 2 lines)
- Excerpt (truncated to 3 lines)
- Content preview (truncated to 4 lines)
- Creation date
- View count
- Loading and error states
- Empty state when no articles
## Dark Mode
To enable dark mode, add a toggle:
```tsx
import { useEffect } from 'react'
export function ThemeToggle() {
useEffect(() => {
const isDark = localStorage.getItem('theme') === 'dark'
document.documentElement.classList.toggle('dark', isDark)
}, [])
const toggleTheme = () => {
const isDark = document.documentElement.classList.toggle('dark')
localStorage.setItem('theme', isDark ? 'dark' : 'light')
}
return (
<button onClick={toggleTheme}>
Toggle Theme
</button>
)
}
```
## Troubleshooting
### Tailwind Classes Not Applying
1. Check that `@tailwind` directives are in `src/index.css`
2. Restart dev server: `npm run dev`
3. Hard refresh browser: Ctrl+Shift+R
4. Check browser DevTools → Elements → Computed Styles
### Component Not Found
1. Check file exists: `frontend/src/components/ui/[component].tsx`
2. Check export name matches: `export const ComponentName = ...`
3. Check import path: `@/components/ui/component`
4. Restart TypeScript server (VSCode: Cmd+Shift+P → "TypeScript: Restart TS Server")
### TypeScript Errors
1. Check `tsconfig.json` has `@/` paths configured
2. Restart TypeScript server
3. Run: `npm run type-check`
4. Check `components.json` aliases
## Performance Tips
1. **Image Optimization**: Add `loading="lazy"` to article images
2. **Code Splitting**: Use `lazy()` for large components
3. **Query Caching**: TanStack Query caches automatically
4. **Bundle Analysis**: Run `npm run build` and check bundle size
## File Structure
```
frontend/
├── src/
│ ├── components/
│ │ └── ui/ # shadcn/ui components
│ │ ├── button.tsx
│ │ └── card.tsx
│ ├── lib/
│ │ ├── api.ts # API functions
│ │ ├── query-client.ts # TanStack Query
│ │ └── utils.ts # cn() utility
│ ├── App.tsx # Main application
│ ├── index.css # Tailwind + CSS variables
│ └── main.tsx
├── components.json # shadcn config
├── tailwind.config.js # Tailwind configuration
└── package.json
```
## Next Steps
1. **Add article detail page**: Click on card to view full article
2. **Add search input**: Filter articles by title/content
3. **Add category filter**: Filter by article category
4. **Add tag badges**: Display article tags
5. **Add pagination**: Navigate through large article lists
6. **Add dark mode toggle**: Switch between light/dark themes
7. **Add article detail modal**: View article without leaving page
## Resources
- [Tailwind CSS 4 Docs](https://tailwindcss.com/docs/installation/using-postcss)
- [Shadcn/ui Documentation](https://ui.shadcn.com)
- [Radix UI](https://www.radix-ui.com/) - Underlying component library
- [Lucide Icons](https://lucide.dev/) - Icon library (installed)
- [TanStack Query Docs](https://tanstack.com/query/latest)

7
backend/.env.example Normal file
View File

@ -0,0 +1,7 @@
PORT=3000
DATABASE_PATH=./database.sqlite
NODE_ENV=development
FRONTEND_URL=http://localhost:5173
STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=your-strapi-api-token-here

4
backend/.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

98
backend/README.md Normal file
View File

@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).

35
backend/eslint.config.mjs Normal file
View File

@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);

8
backend/nest-cli.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

11942
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

80
backend/package.json Normal file
View File

@ -0,0 +1,80 @@
{
"name": "backend",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"type-check": "tsc --noEmit",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/typeorm": "^11.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sqlite3": "^5.1.7",
"typeorm": "^0.3.28"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View File

@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

28
backend/src/app.module.ts Normal file
View File

@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ArticlesModule } from './modules/articles.module';
import { StrapiModule } from './modules/strapi.module';
import { Article, Author, Category } from './modules/entities';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
TypeOrmModule.forRoot({
type: 'sqlite',
database: process.env.DATABASE_PATH ?? './database.sqlite',
entities: [Article, Author, Category],
synchronize: process.env.NODE_ENV !== 'production',
logging: process.env.NODE_ENV === 'development',
}),
ArticlesModule,
StrapiModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

View File

@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

20
backend/src/main.ts Normal file
View File

@ -0,0 +1,20 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const allowedOrigins = [
process.env.FRONTEND_URL ?? 'http://localhost:5173',
process.env.STRAPI_URL ?? 'http://localhost:1337',
];
app.enableCors({
origin: allowedOrigins,
credentials: true,
});
app.setGlobalPrefix('api/v1');
await app.listen(process.env.PORT ?? 3000);
}
void bootstrap();

View File

@ -0,0 +1,57 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
ValidationPipe,
} from '@nestjs/common';
import { ArticlesService } from './articles.service';
import {
CreateArticleDto,
UpdateArticleDto,
FindArticlesDto,
} from './articles.dto';
@Controller('articles')
export class ArticlesController {
constructor(private readonly articlesService: ArticlesService) {}
@Post()
create(@Body(new ValidationPipe({ transform: true })) dto: CreateArticleDto) {
return this.articlesService.create(dto);
}
@Get()
findAll(
@Query(new ValidationPipe({ transform: true })) dto: FindArticlesDto,
) {
return this.articlesService.findAll(dto);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.articlesService.findOne(id);
}
@Get('slug/:slug')
findBySlug(@Param('slug') slug: string) {
return this.articlesService.findBySlug(slug);
}
@Put(':id')
update(
@Param('id') id: string,
@Body(new ValidationPipe({ transform: true })) dto: UpdateArticleDto,
) {
return this.articlesService.update(id, dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.articlesService.remove(id);
}
}

View File

@ -0,0 +1,123 @@
import {
IsString,
IsOptional,
IsEnum,
IsArray,
IsUUID,
IsNumber,
} from 'class-validator';
import { ArticleStatus } from './entities';
export class CreateArticleDto {
@IsString()
title: string;
@IsString()
content: string;
@IsOptional()
@IsString()
excerpt?: string;
@IsOptional()
@IsString()
slug?: string;
@IsOptional()
@IsString()
featuredImage?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsEnum(ArticleStatus)
status?: ArticleStatus;
@IsOptional()
@IsString()
strapiId?: string;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsUUID()
categoryId?: string;
}
export class UpdateArticleDto {
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
content?: string;
@IsOptional()
@IsString()
excerpt?: string;
@IsOptional()
@IsString()
slug?: string;
@IsOptional()
@IsString()
featuredImage?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsEnum(ArticleStatus)
status?: ArticleStatus;
@IsOptional()
@IsString()
strapiId?: string;
@IsOptional()
@IsUUID()
authorId?: string;
@IsOptional()
@IsUUID()
categoryId?: string;
}
export class FindArticlesDto {
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsString()
author?: string;
@IsOptional()
@IsString()
tag?: string;
@IsOptional()
@IsEnum(ArticleStatus)
status?: ArticleStatus;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsNumber()
page?: number;
@IsOptional()
@IsNumber()
limit?: number;
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ArticlesService } from './articles.service';
import { ArticlesController } from './articles.controller';
import { Article, Author, Category } from './entities';
@Module({
imports: [TypeOrmModule.forFeature([Article, Author, Category])],
controllers: [ArticlesController],
providers: [ArticlesService],
exports: [ArticlesService],
})
export class ArticlesModule {}

View File

@ -0,0 +1,128 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Article, ArticleStatus } from './entities';
import {
CreateArticleDto,
UpdateArticleDto,
FindArticlesDto,
} from './articles.dto';
@Injectable()
export class ArticlesService {
constructor(
@InjectRepository(Article)
private readonly articleRepository: Repository<Article>,
) {}
async create(dto: CreateArticleDto): Promise<Article> {
const article = this.articleRepository.create({
...dto,
status: dto.status || ArticleStatus.DRAFT,
});
return await this.articleRepository.save(article);
}
async findAll(
dto: FindArticlesDto,
): Promise<{ data: Article[]; total: number }> {
const {
category,
author,
tag,
status = ArticleStatus.PUBLISHED,
search,
page = 1,
limit = 10,
} = dto;
const queryBuilder = this.articleRepository
.createQueryBuilder('article')
.leftJoinAndSelect('article.author', 'author')
.leftJoinAndSelect('article.category', 'category')
.where('article.status = :status', { status });
if (category) {
queryBuilder.andWhere('category.slug = :category', { category });
}
if (author) {
queryBuilder.andWhere('author.slug = :author', { author });
}
if (tag) {
queryBuilder.andWhere(':tag = ANY(article.tags)', { tag });
}
if (search) {
queryBuilder.andWhere(
'(article.title ILIKE :search OR article.content ILIKE :search)',
{ search: `%${search}%` },
);
}
const [data, total] = await queryBuilder
.orderBy('article.createdAt', 'DESC')
.skip((page - 1) * limit)
.take(limit)
.getManyAndCount();
return { data, total };
}
async findOne(id: string): Promise<Article> {
const article = await this.articleRepository.findOne({
where: { id },
relations: ['author', 'category'],
});
if (!article) {
throw new NotFoundException(`Article with ID ${id} not found`);
}
return article;
}
async findBySlug(slug: string): Promise<Article> {
const article = await this.articleRepository.findOne({
where: { slug },
relations: ['author', 'category'],
});
if (!article) {
throw new NotFoundException(`Article with slug ${slug} not found`);
}
return article;
}
async update(id: string, dto: UpdateArticleDto): Promise<Article> {
const article = await this.findOne(id);
Object.assign(article, dto);
return await this.articleRepository.save(article);
}
async remove(id: string): Promise<void> {
const article = await this.findOne(id);
await this.articleRepository.remove(article);
}
async syncFromStrapi(
strapiId: string,
data: Partial<CreateArticleDto>,
): Promise<Article> {
let article = await this.articleRepository.findOne({ where: { strapiId } });
if (!article) {
article = this.articleRepository.create({
strapiId,
...data,
status: data.status || ArticleStatus.DRAFT,
});
} else {
Object.assign(article, data);
}
return await this.articleRepository.save(article);
}
}

View File

@ -0,0 +1,129 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
export enum ArticleStatus {
DRAFT = 'draft',
PUBLISHED = 'published',
ARCHIVED = 'archived',
}
@Entity('authors')
export class Author {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column({ unique: true })
slug: string;
@Column({ nullable: true })
bio: string;
@Column({ nullable: true })
avatar: string;
@Column({ default: false })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@Entity('categories')
export class Category {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column({ unique: true })
slug: string;
@Column({ nullable: true })
description: string;
@Column({ nullable: true })
parentId: string;
@Column({ default: 0 })
order: number;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@ManyToOne(() => Category, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'parentId' })
parent: Category;
}
@Entity('articles')
export class Article {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
title: string;
@Column('text')
content: string;
@Column({ type: 'text', nullable: true })
excerpt: string;
@Column({ default: '' })
slug: string;
@Column({ default: '' })
featuredImage: string;
@Column({ type: 'text', default: '[]' })
tags: string[];
@Column({
type: 'text',
default: 'draft',
})
status: ArticleStatus;
@Column({ default: 0 })
views: number;
@Column({ nullable: true })
strapiId: string;
@Column({ nullable: true })
authorId: string;
@Column({ nullable: true })
categoryId: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@ManyToOne(() => Author, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'authorId' })
author: Author;
@ManyToOne(() => Category, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'categoryId' })
category: Category;
}

View File

@ -0,0 +1,33 @@
import { Controller, Post, Body } from '@nestjs/common';
import { StrapiService } from './strapi.service';
interface WebhookBody {
event: 'entry.create' | 'entry.update' | 'entry.delete';
model: string;
entry: {
documentId: string;
};
}
@Controller('webhooks/strapi')
export class StrapiController {
constructor(private readonly strapiService: StrapiService) {}
@Post('article')
async handleArticleWebhook(@Body() body: WebhookBody) {
const { event, model, entry } = body;
if (model !== 'article') {
return { message: 'Ignored: not an article' };
}
await this.strapiService.handleWebhook(event, entry);
return { message: 'Webhook processed successfully' };
}
@Post('sync/all')
async syncAllArticles() {
await this.strapiService.syncArticles();
return { message: 'Sync completed' };
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { StrapiService } from './strapi.service';
import { StrapiController } from './strapi.controller';
import { ArticlesModule } from './articles.module';
@Module({
imports: [HttpModule, ArticlesModule],
controllers: [StrapiController],
providers: [StrapiService],
exports: [StrapiService],
})
export class StrapiModule {}

View File

@ -0,0 +1,156 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { lastValueFrom } from 'rxjs';
import { ArticlesService } from './articles.service';
import { CreateArticleDto } from './articles.dto';
import { ArticleStatus } from './entities';
interface StrapiArticle {
id: number;
documentId: string;
title: string;
description: string;
content: string;
slug: string;
publishedAt: string | null;
createdAt: string;
updatedAt: string;
}
interface StrapiResponse<T> {
data: T;
meta: {
pagination: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
};
}
@Injectable()
export class StrapiService {
private readonly logger = new Logger(StrapiService.name);
private readonly strapiUrl: string;
private readonly strapiApiToken: string;
constructor(
private readonly configService: ConfigService,
private readonly httpService: HttpService,
private readonly articlesService: ArticlesService,
) {
this.strapiUrl =
this.configService.get<string>('STRAPI_URL') || 'http://localhost:1337';
this.strapiApiToken =
this.configService.get<string>('STRAPI_API_TOKEN') || '';
}
private getHeaders() {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.strapiApiToken) {
headers['Authorization'] = `Bearer ${this.strapiApiToken}`;
}
return headers;
}
async syncArticles(): Promise<void> {
try {
this.logger.log('Starting articles sync from Strapi...');
const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiArticle[]>>(
`${this.strapiUrl}/api/articles`,
{
headers: this.getHeaders(),
},
),
);
const strapiArticles = response.data.data;
let syncedCount = 0;
for (const strapiArticle of strapiArticles) {
const articleData: Partial<CreateArticleDto> = {
title: strapiArticle.title,
excerpt: strapiArticle.description,
content: strapiArticle.content,
slug: strapiArticle.slug,
status: strapiArticle.publishedAt
? ArticleStatus.PUBLISHED
: ArticleStatus.DRAFT,
tags: [],
};
await this.articlesService.syncFromStrapi(
strapiArticle.documentId,
articleData,
);
syncedCount++;
}
this.logger.log(
`Successfully synced ${syncedCount} articles from Strapi`,
);
} catch (error) {
this.logger.error('Failed to sync articles from Strapi', error);
throw error;
}
}
async syncSingleArticle(strapiId: string): Promise<void> {
try {
this.logger.log(`Syncing single article from Strapi: ${strapiId}`);
const response = await lastValueFrom(
this.httpService.get<StrapiResponse<StrapiArticle>>(
`${this.strapiUrl}/api/articles/${strapiId}`,
{
headers: this.getHeaders(),
},
),
);
const strapiArticle = response.data.data;
const articleData: Partial<CreateArticleDto> = {
title: strapiArticle.title,
excerpt: strapiArticle.description,
content: strapiArticle.content,
slug: strapiArticle.slug,
status: strapiArticle.publishedAt
? ArticleStatus.PUBLISHED
: ArticleStatus.DRAFT,
tags: [],
};
await this.articlesService.syncFromStrapi(
strapiArticle.documentId,
articleData,
);
this.logger.log(`Successfully synced article: ${strapiArticle.title}`);
} catch (error) {
this.logger.error(
`Failed to sync article ${strapiId} from Strapi`,
error,
);
throw error;
}
}
async handleWebhook(
event: 'entry.create' | 'entry.update' | 'entry.delete',
data: { documentId: string },
): Promise<void> {
this.logger.log(`Received webhook event: ${event}`);
if (event === 'entry.delete') {
this.logger.log(`Handling delete for document: ${data.documentId}`);
return;
}
await this.syncSingleArticle(data.documentId);
}
}

View File

@ -0,0 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});

View File

@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

25
backend/tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"noFallthroughCasesInSwitch": true
}
}

8
cms/cms/.env.example Normal file
View File

@ -0,0 +1,8 @@
HOST=0.0.0.0
PORT=1337
APP_KEYS="toBeModified1,toBeModified2"
API_TOKEN_SALT=tobemodified
ADMIN_JWT_SECRET=tobemodified
TRANSFER_TOKEN_SALT=tobemodified
JWT_SECRET=tobemodified
ENCRYPTION_KEY=tobemodified

131
cms/cms/.gitignore vendored Normal file
View File

@ -0,0 +1,131 @@
############################
# OS X
############################
.DS_Store
.AppleDouble
.LSOverride
Icon
.Spotlight-V100
.Trashes
._*
############################
# Linux
############################
*~
############################
# Windows
############################
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
*.cab
*.msi
*.msm
*.msp
############################
# Packages
############################
*.7z
*.csv
*.dat
*.dmg
*.gz
*.iso
*.jar
*.rar
*.tar
*.zip
*.com
*.class
*.dll
*.exe
*.o
*.seed
*.so
*.swo
*.swp
*.swn
*.swm
*.out
*.pid
############################
# Logs and databases
############################
.tmp
*.log
*.sql
*.sqlite
*.sqlite3
############################
# Misc.
############################
*#
ssl
.idea
nbproject
public/uploads/*
!public/uploads/.gitkeep
.tsbuildinfo
.eslintcache
############################
# Node.js
############################
lib-cov
lcov.info
pids
logs
results
node_modules
.node_history
############################
# Package managers
############################
.yarn/*
!.yarn/cache
!.yarn/unplugged
!.yarn/patches
!.yarn/releases
!.yarn/sdks
!.yarn/versions
.pnp.*
yarn-error.log
############################
# Tests
############################
coverage
############################
# Strapi
############################
.env
license.txt
exports
.strapi
dist
build
.strapi-updater.json
.strapi-cloud.json

61
cms/cms/README.md Normal file
View File

@ -0,0 +1,61 @@
# 🚀 Getting started with Strapi
Strapi comes with a full featured [Command Line Interface](https://docs.strapi.io/dev-docs/cli) (CLI) which lets you scaffold and manage your project in seconds.
### `develop`
Start your Strapi application with autoReload enabled. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-develop)
```
npm run develop
# or
yarn develop
```
### `start`
Start your Strapi application with autoReload disabled. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-start)
```
npm run start
# or
yarn start
```
### `build`
Build your admin panel. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-build)
```
npm run build
# or
yarn build
```
## ⚙️ Deployment
Strapi gives you many possible deployment options for your project including [Strapi Cloud](https://cloud.strapi.io). Browse the [deployment section of the documentation](https://docs.strapi.io/dev-docs/deployment) to find the best solution for your use case.
```
yarn strapi deploy
```
## 📚 Learn more
- [Resource center](https://strapi.io/resource-center) - Strapi resource center.
- [Strapi documentation](https://docs.strapi.io) - Official Strapi documentation.
- [Strapi tutorials](https://strapi.io/tutorials) - List of tutorials made by the core team and the community.
- [Strapi blog](https://strapi.io/blog) - Official Strapi blog containing articles made by the Strapi team and the community.
- [Changelog](https://strapi.io/changelog) - Find out about the Strapi product updates, new features and general improvements.
Feel free to check out the [Strapi GitHub repository](https://github.com/strapi/strapi). Your feedback and contributions are welcome!
## ✨ Community
- [Discord](https://discord.strapi.io) - Come chat with the Strapi community including the core team.
- [Forum](https://forum.strapi.io/) - Place to discuss, ask questions and find answers, show your Strapi project and get feedback or just talk with other Community members.
- [Awesome Strapi](https://github.com/strapi/awesome-strapi) - A curated list of awesome things related to Strapi.
---
<sub>🤫 Psst! [Strapi is hiring](https://strapi.io/careers).</sub>

20
cms/cms/config/admin.ts Normal file
View File

@ -0,0 +1,20 @@
export default ({ env }) => ({
auth: {
secret: env('ADMIN_JWT_SECRET'),
},
apiToken: {
salt: env('API_TOKEN_SALT'),
},
transfer: {
token: {
salt: env('TRANSFER_TOKEN_SALT'),
},
},
secrets: {
encryptionKey: env('ENCRYPTION_KEY'),
},
flags: {
nps: env.bool('FLAG_NPS', true),
promoteEE: env.bool('FLAG_PROMOTE_EE', true),
},
});

7
cms/cms/config/api.ts Normal file
View File

@ -0,0 +1,7 @@
export default {
rest: {
defaultLimit: 25,
maxLimit: 100,
withCount: true,
},
};

View File

@ -0,0 +1,60 @@
import path from 'path';
export default ({ env }) => {
const client = env('DATABASE_CLIENT', 'sqlite');
const connections = {
mysql: {
connection: {
host: env('DATABASE_HOST', 'localhost'),
port: env.int('DATABASE_PORT', 3306),
database: env('DATABASE_NAME', 'strapi'),
user: env('DATABASE_USERNAME', 'strapi'),
password: env('DATABASE_PASSWORD', 'strapi'),
ssl: env.bool('DATABASE_SSL', false) && {
key: env('DATABASE_SSL_KEY', undefined),
cert: env('DATABASE_SSL_CERT', undefined),
ca: env('DATABASE_SSL_CA', undefined),
capath: env('DATABASE_SSL_CAPATH', undefined),
cipher: env('DATABASE_SSL_CIPHER', undefined),
rejectUnauthorized: env.bool('DATABASE_SSL_REJECT_UNAUTHORIZED', true),
},
},
pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
},
postgres: {
connection: {
connectionString: env('DATABASE_URL'),
host: env('DATABASE_HOST', 'localhost'),
port: env.int('DATABASE_PORT', 5432),
database: env('DATABASE_NAME', 'strapi'),
user: env('DATABASE_USERNAME', 'strapi'),
password: env('DATABASE_PASSWORD', 'strapi'),
ssl: env.bool('DATABASE_SSL', false) && {
key: env('DATABASE_SSL_KEY', undefined),
cert: env('DATABASE_SSL_CERT', undefined),
ca: env('DATABASE_SSL_CA', undefined),
capath: env('DATABASE_SSL_CAPATH', undefined),
cipher: env('DATABASE_SSL_CIPHER', undefined),
rejectUnauthorized: env.bool('DATABASE_SSL_REJECT_UNAUTHORIZED', true),
},
schema: env('DATABASE_SCHEMA', 'public'),
},
pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
},
sqlite: {
connection: {
filename: path.join(__dirname, '..', '..', env('DATABASE_FILENAME', '.tmp/data.db')),
},
useNullAsDefault: true,
},
};
return {
connection: {
client,
...connections[client],
acquireConnectionTimeout: env.int('DATABASE_CONNECTION_TIMEOUT', 60000),
},
};
};

View File

@ -0,0 +1,12 @@
export default [
'strapi::logger',
'strapi::errors',
'strapi::security',
'strapi::cors',
'strapi::poweredBy',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
];

View File

@ -0,0 +1 @@
export default () => ({});

7
cms/cms/config/server.ts Normal file
View File

@ -0,0 +1,7 @@
export default ({ env }) => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
app: {
keys: env.array('APP_KEYS'),
},
});

View File

BIN
cms/cms/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 B

19850
cms/cms/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
cms/cms/package.json Normal file
View File

@ -0,0 +1,41 @@
{
"name": "cms",
"version": "0.1.0",
"private": true,
"description": "A Strapi application",
"scripts": {
"build": "strapi build",
"console": "strapi console",
"deploy": "strapi deploy",
"dev": "strapi develop",
"develop": "strapi develop",
"start": "strapi start",
"strapi": "strapi",
"upgrade": "npx @strapi/upgrade latest",
"upgrade:dry": "npx @strapi/upgrade latest --dry"
},
"dependencies": {
"@strapi/plugin-cloud": "5.33.0",
"@strapi/plugin-users-permissions": "5.33.0",
"@strapi/strapi": "5.33.0",
"better-sqlite3": "12.4.1",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-router-dom": "^6.0.0",
"styled-components": "^6.0.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"typescript": "^5"
},
"engines": {
"node": ">=20.0.0 <=24.x.x",
"npm": ">=6.0.0"
},
"strapi": {
"uuid": "74121c15-d347-4f01-badc-d66ba28b74c0",
"installId": "a70131477a93ad947c033acfccd0d7c9aacc5c05dbf488099d6edb490fd31246"
}
}

View File

@ -0,0 +1,3 @@
# To prevent search engines from seeing the site altogether, uncomment the next two lines:
# User-Agent: *
# Disallow: /

View File

@ -0,0 +1,37 @@
import type { StrapiApp } from '@strapi/strapi/admin';
export default {
config: {
locales: [
// 'ar',
// 'fr',
// 'cs',
// 'de',
// 'dk',
// 'es',
// 'he',
// 'id',
// 'it',
// 'ja',
// 'ko',
// 'ms',
// 'nl',
// 'no',
// 'pl',
// 'pt-BR',
// 'pt',
// 'ru',
// 'sk',
// 'sv',
// 'th',
// 'tr',
// 'uk',
// 'vi',
// 'zh-Hans',
// 'zh',
],
},
bootstrap(app: StrapiApp) {
console.log(app);
},
};

View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["../plugins/**/admin/src/**/*", "./"],
"exclude": ["node_modules/", "build/", "dist/", "**/*.test.ts"]
}

View File

@ -0,0 +1,12 @@
import { mergeConfig, type UserConfig } from 'vite';
export default (config: UserConfig) => {
// Important: always return the modified config
return mergeConfig(config, {
resolve: {
alias: {
'@': '/src',
},
},
});
};

0
cms/cms/src/api/.gitkeep Normal file
View File

View File

@ -0,0 +1,34 @@
{
"kind": "collectionType",
"collectionName": "articles",
"info": {
"singularName": "article",
"pluralName": "articles",
"displayName": "article"
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {},
"attributes": {
"title": {
"type": "string"
},
"content": {
"type": "richtext"
},
"media": {
"type": "media",
"multiple": true,
"allowedTypes": [
"images",
"files",
"videos",
"audios"
]
},
"author": {
"type": "string"
}
}
}

View File

@ -0,0 +1,7 @@
/**
* article controller
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::article.article');

View File

@ -0,0 +1,7 @@
/**
* article router
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::article.article');

View File

@ -0,0 +1,7 @@
/**
* article service
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::article.article');

View File

20
cms/cms/src/index.ts Normal file
View File

@ -0,0 +1,20 @@
// import type { Core } from '@strapi/strapi';
export default {
/**
* An asynchronous register function that runs before
* your application is initialized.
*
* This gives you an opportunity to extend code.
*/
register(/* { strapi }: { strapi: Core.Strapi } */) {},
/**
* An asynchronous bootstrap function that runs before
* your application gets started.
*
* This gives you an opportunity to set up your data model,
* run jobs, or perform some special logic.
*/
bootstrap(/* { strapi }: { strapi: Core.Strapi } */) {},
};

43
cms/cms/tsconfig.json Normal file
View File

@ -0,0 +1,43 @@
{
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"lib": ["ES2020"],
"target": "ES2019",
"strict": false,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"incremental": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"noEmitOnError": true,
"noImplicitThis": true,
"outDir": "dist",
"rootDir": "."
},
"include": [
// Include root files
"./",
// Include all ts files
"./**/*.ts",
// Include all js files
"./**/*.js",
// Force the JSON files in the src folder to be included
"src/**/*.json"
],
"exclude": [
"node_modules/",
"build/",
"dist/",
".cache/",
".tmp/",
// Do not include admin files in the server compilation
"src/admin/",
// Do not include test files
"**/*.test.*",
// Do not include plugins in the server compilation
"src/plugins/**"
]
}

View File

@ -0,0 +1,3 @@
/*
* The app doesn't have any components yet.
*/

View File

@ -0,0 +1,990 @@
import type { Schema, Struct } from '@strapi/strapi';
export interface AdminApiToken extends Struct.CollectionTypeSchema {
collectionName: 'strapi_api_tokens';
info: {
description: '';
displayName: 'Api Token';
name: 'Api Token';
pluralName: 'api-tokens';
singularName: 'api-token';
};
options: {
draftAndPublish: false;
};
pluginOptions: {
'content-manager': {
visible: false;
};
'content-type-builder': {
visible: false;
};
};
attributes: {
accessKey: Schema.Attribute.String &
Schema.Attribute.Required &
Schema.Attribute.SetMinMaxLength<{
minLength: 1;
}>;
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
description: Schema.Attribute.String &
Schema.Attribute.SetMinMaxLength<{
minLength: 1;
}> &
Schema.Attribute.DefaultTo<''>;
encryptedKey: Schema.Attribute.Text &
Schema.Attribute.SetMinMaxLength<{
minLength: 1;
}>;
expiresAt: Schema.Attribute.DateTime;
lastUsedAt: Schema.Attribute.DateTime;
lifespan: Schema.Attribute.BigInteger;
locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation<'oneToMany', 'admin::api-token'> &
Schema.Attribute.Private;
name: Schema.Attribute.String &
Schema.Attribute.Required &
Schema.Attribute.Unique &
Schema.Attribute.SetMinMaxLength<{
minLength: 1;
}>;
permissions: Schema.Attribute.Relation<
'oneToMany',
'admin::api-token-permission'
>;
publishedAt: Schema.Attribute.DateTime;
type: Schema.Attribute.Enumeration<['read-only', 'full-access', 'custom']> &
Schema.Attribute.Required &
Schema.Attribute.DefaultTo<'read-only'>;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
};
}
export interface AdminApiTokenPermission extends Struct.CollectionTypeSchema {
collectionName: 'strapi_api_token_permissions';
info: {
description: '';
displayName: 'API Token Permission';
name: 'API Token Permission';
pluralName: 'api-token-permissions';
singularName: 'api-token-permission';
};
options: {
draftAndPublish: false;
};
pluginOptions: {
'content-manager': {
visible: false;
};
'content-type-builder': {
visible: false;
};
};
attributes: {
action: Schema.Attribute.String &
Schema.Attribute.Required &
Schema.Attribute.SetMinMaxLength<{
minLength: 1;
}>;
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation<
'oneToMany',
'admin::api-token-permission'
> &
Schema.Attribute.Private;
publishedAt: Schema.Attribute.DateTime;
token: Schema.Attribute.Relation<'manyToOne', 'admin::api-token'>;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
};
}
export interface AdminPermission extends Struct.CollectionTypeSchema {
collectionName: 'admin_permissions';
info: {
description: '';
displayName: 'Permission';
name: 'Permission';
pluralName: 'permissions';
singularName: 'permission';
};
options: {
draftAndPublish: false;
};
pluginOptions: {
'content-manager': {
visible: false;
};
'content-type-builder': {
visible: false;
};
};
attributes: {
action: Schema.Attribute.String &
Schema.Attribute.Required &
Schema.Attribute.SetMinMaxLength<{
minLength: 1;
}>;
actionParameters: Schema.Attribute.JSON & Schema.Attribute.DefaultTo<{}>;
conditions: Schema.Attribute.JSON & Schema.Attribute.DefaultTo<[]>;
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation<'oneToMany', 'admin::permission'> &
Schema.Attribute.Private;
properties: Schema.Attribute.JSON & Schema.Attribute.DefaultTo<{}>;
publishedAt: Schema.Attribute.DateTime;
role: Schema.Attribute.Relation<'manyToOne', 'admin::role'>;
subject: Schema.Attribute.String &
Schema.Attribute.SetMinMaxLength<{
minLength: 1;
}>;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
};
}
export interface AdminRole extends Struct.CollectionTypeSchema {
collectionName: 'admin_roles';
info: {
description: '';
displayName: 'Role';
name: 'Role';
pluralName: 'roles';
singularName: 'role';
};
options: {
draftAndPublish: false;
};
pluginOptions: {
'content-manager': {
visible: false;
};
'content-type-builder': {
visible: false;
};
};
attributes: {
code: Schema.Attribute.String &
Schema.Attribute.Required &
Schema.Attribute.Unique &
Schema.Attribute.SetMinMaxLength<{
minLength: 1;
}>;
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
description: Schema.Attribute.String;
locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation<'oneToMany', 'admin::role'> &
Schema.Attribute.Private;
name: Schema.Attribute.String &
Schema.Attribute.Required &
Schema.Attribute.Unique &
Schema.Attribute.SetMinMaxLength<{
minLength: 1;
}>;
permissions: Schema.Attribute.Relation<'oneToMany', 'admin::permission'>;
publishedAt: Schema.Attribute.DateTime;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
users: Schema.Attribute.Relation<'manyToMany', 'admin::user'>;
};
}
export interface AdminSession extends Struct.CollectionTypeSchema {
collectionName: 'strapi_sessions';
info: {
description: 'Session Manager storage';
displayName: 'Session';
name: 'Session';
pluralName: 'sessions';
singularName: 'session';
};
options: {
draftAndPublish: false;
};
pluginOptions: {
'content-manager': {
visible: false;
};
'content-type-builder': {
visible: false;
};
i18n: {
localized: false;
};
};
attributes: {
absoluteExpiresAt: Schema.Attribute.DateTime & Schema.Attribute.Private;
childId: Schema.Attribute.String & Schema.Attribute.Private;
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
deviceId: Schema.Attribute.String &
Schema.Attribute.Required &
Schema.Attribute.Private;
expiresAt: Schema.Attribute.DateTime &
Schema.Attribute.Required &
Schema.Attribute.Private;
locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation<'oneToMany', 'admin::session'> &
Schema.Attribute.Private;
origin: Schema.Attribute.String &
Schema.Attribute.Required &
Schema.Attribute.Private;
publishedAt: Schema.Attribute.DateTime;
sessionId: Schema.Attribute.String &
Schema.Attribute.Required &
Schema.Attribute.Private &
Schema.Attribute.Unique;
status: Schema.Attribute.String & Schema.Attribute.Private;
type: Schema.Attribute.String & Schema.Attribute.Private;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
userId: Schema.Attribute.String &
Schema.Attribute.Required &
Schema.Attribute.Private;
};
}
export interface AdminTransferToken extends Struct.CollectionTypeSchema {
collectionName: 'strapi_transfer_tokens';
info: {
description: '';
displayName: 'Transfer Token';
name: 'Transfer Token';
pluralName: 'transfer-tokens';
singularName: 'transfer-token';
};
options: {
draftAndPublish: false;
};
pluginOptions: {
'content-manager': {
visible: false;
};
'content-type-builder': {
visible: false;
};
};
attributes: {
accessKey: Schema.Attribute.String &
Schema.Attribute.Required &
Schema.Attribute.SetMinMaxLength<{
minLength: 1;
}>;
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
description: Schema.Attribute.String &
Schema.Attribute.SetMinMaxLength<{
minLength: 1;
}> &
Schema.Attribute.DefaultTo<''>;
expiresAt: Schema.Attribute.DateTime;
lastUsedAt: Schema.Attribute.DateTime;
lifespan: Schema.Attribute.BigInteger;
locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation<
'oneToMany',
'admin::transfer-token'
> &
Schema.Attribute.Private;
name: Schema.Attribute.String &
Schema.Attribute.Required &
Schema.Attribute.Unique &
Schema.Attribute.SetMinMaxLength<{
minLength: 1;
}>;
permissions: Schema.Attribute.Relation<
'oneToMany',
'admin::transfer-token-permission'
>;
publishedAt: Schema.Attribute.DateTime;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
};
}
export interface AdminTransferTokenPermission
extends Struct.CollectionTypeSchema {
collectionName: 'strapi_transfer_token_permissions';
info: {
description: '';
displayName: 'Transfer Token Permission';
name: 'Transfer Token Permission';
pluralName: 'transfer-token-permissions';
singularName: 'transfer-token-permission';
};
options: {
draftAndPublish: false;
};
pluginOptions: {
'content-manager': {
visible: false;
};
'content-type-builder': {
visible: false;
};
};
attributes: {
action: Schema.Attribute.String &
Schema.Attribute.Required &
Schema.Attribute.SetMinMaxLength<{
minLength: 1;
}>;
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation<
'oneToMany',
'admin::transfer-token-permission'
> &
Schema.Attribute.Private;
publishedAt: Schema.Attribute.DateTime;
token: Schema.Attribute.Relation<'manyToOne', 'admin::transfer-token'>;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
};
}
export interface AdminUser extends Struct.CollectionTypeSchema {
collectionName: 'admin_users';
info: {
description: '';
displayName: 'User';
name: 'User';
pluralName: 'users';
singularName: 'user';
};
options: {
draftAndPublish: false;
};
pluginOptions: {
'content-manager': {
visible: false;
};
'content-type-builder': {
visible: false;
};
};
attributes: {
blocked: Schema.Attribute.Boolean &
Schema.Attribute.Private &
Schema.Attribute.DefaultTo<false>;
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
email: Schema.Attribute.Email &
Schema.Attribute.Required &
Schema.Attribute.Private &
Schema.Attribute.Unique &
Schema.Attribute.SetMinMaxLength<{
minLength: 6;
}>;
firstname: Schema.Attribute.String &
Schema.Attribute.SetMinMaxLength<{
minLength: 1;
}>;
isActive: Schema.Attribute.Boolean &
Schema.Attribute.Private &
Schema.Attribute.DefaultTo<false>;
lastname: Schema.Attribute.String &
Schema.Attribute.SetMinMaxLength<{
minLength: 1;
}>;
locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation<'oneToMany', 'admin::user'> &
Schema.Attribute.Private;
password: Schema.Attribute.Password &
Schema.Attribute.Private &
Schema.Attribute.SetMinMaxLength<{
minLength: 6;
}>;
preferedLanguage: Schema.Attribute.String;
publishedAt: Schema.Attribute.DateTime;
registrationToken: Schema.Attribute.String & Schema.Attribute.Private;
resetPasswordToken: Schema.Attribute.String & Schema.Attribute.Private;
roles: Schema.Attribute.Relation<'manyToMany', 'admin::role'> &
Schema.Attribute.Private;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
username: Schema.Attribute.String;
};
}
export interface ApiArticleArticle extends Struct.CollectionTypeSchema {
collectionName: 'articles';
info: {
displayName: 'article';
pluralName: 'articles';
singularName: 'article';
};
options: {
draftAndPublish: true;
};
attributes: {
author: Schema.Attribute.String;
content: Schema.Attribute.RichText;
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation<
'oneToMany',
'api::article.article'
> &
Schema.Attribute.Private;
media: Schema.Attribute.Media<
'images' | 'files' | 'videos' | 'audios',
true
>;
publishedAt: Schema.Attribute.DateTime;
title: Schema.Attribute.String;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
};
}
export interface PluginContentReleasesRelease
extends Struct.CollectionTypeSchema {
collectionName: 'strapi_releases';
info: {
displayName: 'Release';
pluralName: 'releases';
singularName: 'release';
};
options: {
draftAndPublish: false;
};
pluginOptions: {
'content-manager': {
visible: false;
};
'content-type-builder': {
visible: false;
};
};
attributes: {
actions: Schema.Attribute.Relation<
'oneToMany',
'plugin::content-releases.release-action'
>;
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation<
'oneToMany',
'plugin::content-releases.release'
> &
Schema.Attribute.Private;
name: Schema.Attribute.String & Schema.Attribute.Required;
publishedAt: Schema.Attribute.DateTime;
releasedAt: Schema.Attribute.DateTime;
scheduledAt: Schema.Attribute.DateTime;
status: Schema.Attribute.Enumeration<
['ready', 'blocked', 'failed', 'done', 'empty']
> &
Schema.Attribute.Required;
timezone: Schema.Attribute.String;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
};
}
export interface PluginContentReleasesReleaseAction
extends Struct.CollectionTypeSchema {
collectionName: 'strapi_release_actions';
info: {
displayName: 'Release Action';
pluralName: 'release-actions';
singularName: 'release-action';
};
options: {
draftAndPublish: false;
};
pluginOptions: {
'content-manager': {
visible: false;
};
'content-type-builder': {
visible: false;
};
};
attributes: {
contentType: Schema.Attribute.String & Schema.Attribute.Required;
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
entryDocumentId: Schema.Attribute.String;
isEntryValid: Schema.Attribute.Boolean;
locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation<
'oneToMany',
'plugin::content-releases.release-action'
> &
Schema.Attribute.Private;
publishedAt: Schema.Attribute.DateTime;
release: Schema.Attribute.Relation<
'manyToOne',
'plugin::content-releases.release'
>;
type: Schema.Attribute.Enumeration<['publish', 'unpublish']> &
Schema.Attribute.Required;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
};
}
export interface PluginI18NLocale extends Struct.CollectionTypeSchema {
collectionName: 'i18n_locale';
info: {
collectionName: 'locales';
description: '';
displayName: 'Locale';
pluralName: 'locales';
singularName: 'locale';
};
options: {
draftAndPublish: false;
};
pluginOptions: {
'content-manager': {
visible: false;
};
'content-type-builder': {
visible: false;
};
};
attributes: {
code: Schema.Attribute.String & Schema.Attribute.Unique;
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation<
'oneToMany',
'plugin::i18n.locale'
> &
Schema.Attribute.Private;
name: Schema.Attribute.String &
Schema.Attribute.SetMinMax<
{
max: 50;
min: 1;
},
number
>;
publishedAt: Schema.Attribute.DateTime;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
};
}
export interface PluginReviewWorkflowsWorkflow
extends Struct.CollectionTypeSchema {
collectionName: 'strapi_workflows';
info: {
description: '';
displayName: 'Workflow';
name: 'Workflow';
pluralName: 'workflows';
singularName: 'workflow';
};
options: {
draftAndPublish: false;
};
pluginOptions: {
'content-manager': {
visible: false;
};
'content-type-builder': {
visible: false;
};
};
attributes: {
contentTypes: Schema.Attribute.JSON &
Schema.Attribute.Required &
Schema.Attribute.DefaultTo<'[]'>;
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation<
'oneToMany',
'plugin::review-workflows.workflow'
> &
Schema.Attribute.Private;
name: Schema.Attribute.String &
Schema.Attribute.Required &
Schema.Attribute.Unique;
publishedAt: Schema.Attribute.DateTime;
stageRequiredToPublish: Schema.Attribute.Relation<
'oneToOne',
'plugin::review-workflows.workflow-stage'
>;
stages: Schema.Attribute.Relation<
'oneToMany',
'plugin::review-workflows.workflow-stage'
>;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
};
}
export interface PluginReviewWorkflowsWorkflowStage
extends Struct.CollectionTypeSchema {
collectionName: 'strapi_workflows_stages';
info: {
description: '';
displayName: 'Stages';
name: 'Workflow Stage';
pluralName: 'workflow-stages';
singularName: 'workflow-stage';
};
options: {
draftAndPublish: false;
version: '1.1.0';
};
pluginOptions: {
'content-manager': {
visible: false;
};
'content-type-builder': {
visible: false;
};
};
attributes: {
color: Schema.Attribute.String & Schema.Attribute.DefaultTo<'#4945FF'>;
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation<
'oneToMany',
'plugin::review-workflows.workflow-stage'
> &
Schema.Attribute.Private;
name: Schema.Attribute.String;
permissions: Schema.Attribute.Relation<'manyToMany', 'admin::permission'>;
publishedAt: Schema.Attribute.DateTime;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
workflow: Schema.Attribute.Relation<
'manyToOne',
'plugin::review-workflows.workflow'
>;
};
}
export interface PluginUploadFile extends Struct.CollectionTypeSchema {
collectionName: 'files';
info: {
description: '';
displayName: 'File';
pluralName: 'files';
singularName: 'file';
};
options: {
draftAndPublish: false;
};
pluginOptions: {
'content-manager': {
visible: false;
};
'content-type-builder': {
visible: false;
};
};
attributes: {
alternativeText: Schema.Attribute.Text;
caption: Schema.Attribute.Text;
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
ext: Schema.Attribute.String;
folder: Schema.Attribute.Relation<'manyToOne', 'plugin::upload.folder'> &
Schema.Attribute.Private;
folderPath: Schema.Attribute.String &
Schema.Attribute.Required &
Schema.Attribute.Private &
Schema.Attribute.SetMinMaxLength<{
minLength: 1;
}>;
formats: Schema.Attribute.JSON;
hash: Schema.Attribute.String & Schema.Attribute.Required;
height: Schema.Attribute.Integer;
locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation<
'oneToMany',
'plugin::upload.file'
> &
Schema.Attribute.Private;
mime: Schema.Attribute.String & Schema.Attribute.Required;
name: Schema.Attribute.String & Schema.Attribute.Required;
previewUrl: Schema.Attribute.Text;
provider: Schema.Attribute.String & Schema.Attribute.Required;
provider_metadata: Schema.Attribute.JSON;
publishedAt: Schema.Attribute.DateTime;
related: Schema.Attribute.Relation<'morphToMany'>;
size: Schema.Attribute.Decimal & Schema.Attribute.Required;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
url: Schema.Attribute.Text & Schema.Attribute.Required;
width: Schema.Attribute.Integer;
};
}
export interface PluginUploadFolder extends Struct.CollectionTypeSchema {
collectionName: 'upload_folders';
info: {
displayName: 'Folder';
pluralName: 'folders';
singularName: 'folder';
};
options: {
draftAndPublish: false;
};
pluginOptions: {
'content-manager': {
visible: false;
};
'content-type-builder': {
visible: false;
};
};
attributes: {
children: Schema.Attribute.Relation<'oneToMany', 'plugin::upload.folder'>;
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
files: Schema.Attribute.Relation<'oneToMany', 'plugin::upload.file'>;
locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation<
'oneToMany',
'plugin::upload.folder'
> &
Schema.Attribute.Private;
name: Schema.Attribute.String &
Schema.Attribute.Required &
Schema.Attribute.SetMinMaxLength<{
minLength: 1;
}>;
parent: Schema.Attribute.Relation<'manyToOne', 'plugin::upload.folder'>;
path: Schema.Attribute.String &
Schema.Attribute.Required &
Schema.Attribute.SetMinMaxLength<{
minLength: 1;
}>;
pathId: Schema.Attribute.Integer &
Schema.Attribute.Required &
Schema.Attribute.Unique;
publishedAt: Schema.Attribute.DateTime;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
};
}
export interface PluginUsersPermissionsPermission
extends Struct.CollectionTypeSchema {
collectionName: 'up_permissions';
info: {
description: '';
displayName: 'Permission';
name: 'permission';
pluralName: 'permissions';
singularName: 'permission';
};
options: {
draftAndPublish: false;
};
pluginOptions: {
'content-manager': {
visible: false;
};
'content-type-builder': {
visible: false;
};
};
attributes: {
action: Schema.Attribute.String & Schema.Attribute.Required;
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation<
'oneToMany',
'plugin::users-permissions.permission'
> &
Schema.Attribute.Private;
publishedAt: Schema.Attribute.DateTime;
role: Schema.Attribute.Relation<
'manyToOne',
'plugin::users-permissions.role'
>;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
};
}
export interface PluginUsersPermissionsRole
extends Struct.CollectionTypeSchema {
collectionName: 'up_roles';
info: {
description: '';
displayName: 'Role';
name: 'role';
pluralName: 'roles';
singularName: 'role';
};
options: {
draftAndPublish: false;
};
pluginOptions: {
'content-manager': {
visible: false;
};
'content-type-builder': {
visible: false;
};
};
attributes: {
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
description: Schema.Attribute.String;
locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation<
'oneToMany',
'plugin::users-permissions.role'
> &
Schema.Attribute.Private;
name: Schema.Attribute.String &
Schema.Attribute.Required &
Schema.Attribute.SetMinMaxLength<{
minLength: 3;
}>;
permissions: Schema.Attribute.Relation<
'oneToMany',
'plugin::users-permissions.permission'
>;
publishedAt: Schema.Attribute.DateTime;
type: Schema.Attribute.String & Schema.Attribute.Unique;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
users: Schema.Attribute.Relation<
'oneToMany',
'plugin::users-permissions.user'
>;
};
}
export interface PluginUsersPermissionsUser
extends Struct.CollectionTypeSchema {
collectionName: 'up_users';
info: {
description: '';
displayName: 'User';
name: 'user';
pluralName: 'users';
singularName: 'user';
};
options: {
draftAndPublish: false;
timestamps: true;
};
attributes: {
blocked: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo<false>;
confirmationToken: Schema.Attribute.String & Schema.Attribute.Private;
confirmed: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo<false>;
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
email: Schema.Attribute.Email &
Schema.Attribute.Required &
Schema.Attribute.SetMinMaxLength<{
minLength: 6;
}>;
locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation<
'oneToMany',
'plugin::users-permissions.user'
> &
Schema.Attribute.Private;
password: Schema.Attribute.Password &
Schema.Attribute.Private &
Schema.Attribute.SetMinMaxLength<{
minLength: 6;
}>;
provider: Schema.Attribute.String;
publishedAt: Schema.Attribute.DateTime;
resetPasswordToken: Schema.Attribute.String & Schema.Attribute.Private;
role: Schema.Attribute.Relation<
'manyToOne',
'plugin::users-permissions.role'
>;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
username: Schema.Attribute.String &
Schema.Attribute.Required &
Schema.Attribute.Unique &
Schema.Attribute.SetMinMaxLength<{
minLength: 3;
}>;
};
}
declare module '@strapi/strapi' {
export module Public {
export interface ContentTypeSchemas {
'admin::api-token': AdminApiToken;
'admin::api-token-permission': AdminApiTokenPermission;
'admin::permission': AdminPermission;
'admin::role': AdminRole;
'admin::session': AdminSession;
'admin::transfer-token': AdminTransferToken;
'admin::transfer-token-permission': AdminTransferTokenPermission;
'admin::user': AdminUser;
'api::article.article': ApiArticleArticle;
'plugin::content-releases.release': PluginContentReleasesRelease;
'plugin::content-releases.release-action': PluginContentReleasesReleaseAction;
'plugin::i18n.locale': PluginI18NLocale;
'plugin::review-workflows.workflow': PluginReviewWorkflowsWorkflow;
'plugin::review-workflows.workflow-stage': PluginReviewWorkflowsWorkflowStage;
'plugin::upload.file': PluginUploadFile;
'plugin::upload.folder': PluginUploadFolder;
'plugin::users-permissions.permission': PluginUsersPermissionsPermission;
'plugin::users-permissions.role': PluginUsersPermissionsRole;
'plugin::users-permissions.user': PluginUsersPermissionsUser;
}
}
}

1
frontend/.env.example Normal file
View File

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

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
frontend/README.md Normal file
View File

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

15
frontend/components.json Normal file
View File

@ -0,0 +1,15 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"tailwind": {
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui"
}
}

23
frontend/eslint.config.js Normal file
View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<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>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4001
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
frontend/package.json Normal file
View File

@ -0,0 +1,45 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"type-check": "tsc --noEmit",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
},
"dependencies": {
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.14",
"@tanstack/react-router": "^1.144.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"lucide-react": "^0.562.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"tailwindcss-animate": "^1.0.7",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

1
frontend/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
frontend/src/App.css Normal file
View File

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@ -0,0 +1,78 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

59
frontend/src/index.css Normal file
View File

@ -0,0 +1,59 @@
/* @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;
}
}

88
frontend/src/lib/api.ts Normal file
View File

@ -0,0 +1,88 @@
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1';
export interface Article {
id: string;
title: string;
content: string;
excerpt: string | null;
slug: string;
featuredImage: string;
tags: string[];
status: 'draft' | 'published' | 'archived';
views: number;
strapiId: string | null;
authorId: string | null;
categoryId: string | null;
author?: {
id: string;
name: string;
slug: string;
bio: string | null;
avatar: string | null;
};
category?: {
id: string;
name: string;
slug: string;
description: string | null;
};
createdAt: string;
updatedAt: string;
}
export interface ArticlesResponse {
data: Article[];
total: number;
page?: number;
limit?: number;
}
export interface FindArticlesParams {
category?: string;
author?: string;
tag?: string;
status?: 'draft' | 'published' | 'archived';
search?: string;
page?: number;
limit?: number;
}
export async function fetchArticles(params: FindArticlesParams = {}): Promise<ArticlesResponse> {
console.log('fetchArticles called with params:', params, 'API_BASE_URL:', API_BASE_URL);
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
searchParams.append(key, String(value));
}
});
const url = `${API_BASE_URL}/articles?${searchParams}`;
console.log('Fetching from:', url);
const response = await fetch(url);
console.log('Response status:', response.status, 'ok:', response.ok);
if (!response.ok) {
throw new Error('Failed to fetch articles');
}
const data = await response.json();
console.log('Response data:', data);
return data;
}
export async function fetchArticleBySlug(slug: string): Promise<Article> {
const response = await fetch(`${API_BASE_URL}/articles/slug/${slug}`);
if (!response.ok) {
throw new Error('Failed to fetch article');
}
return response.json();
}
export async function fetchArticleById(id: string): Promise<Article> {
const response = await fetch(`${API_BASE_URL}/articles/${id}`);
if (!response.ok) {
throw new Error('Failed to fetch article');
}
return response.json();
}

View File

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

15
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,15 @@
import { StrictMode } from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider } from '@tanstack/react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { router } from './routes'
const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
)

View File

@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import * as api from '../lib/api';
export function useArticles(params: api.FindArticlesParams = {}) {
return useQuery({
queryKey: ['articles', params],
queryFn: () => api.fetchArticles(params),
});
}
export function useArticle(slug: string) {
return useQuery({
queryKey: ['article', slug],
queryFn: () => api.fetchArticleBySlug(slug),
enabled: !!slug,
});
}
export function useArticleById(id: string) {
return useQuery({
queryKey: ['article', id],
queryFn: () => api.fetchArticleById(id),
enabled: !!id,
});
}

212
frontend/src/routes.tsx Normal file
View File

@ -0,0 +1,212 @@
import { createRootRoute, createRoute, createRouter, Outlet, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import * as api from './lib/api'
import './styles.css'
const rootRoute = createRootRoute({
head: () => ({
meta: [
{
title: 'Placebo.mk - Sarcastic News from Macedonia',
description: 'Latest news and articles from Macedonia with a sarcastic twist',
},
],
}),
component: () => (
<div className="min-h-screen bg-background text-foreground flex flex-col">
<header className="border-b">
<div className="container mx-auto max-w-6xl px-4 py-4">
<h1 className="text-3xl font-bold">
<Link to="/">Placebo.mk</Link>
</h1>
<nav className="flex gap-4">
<Link to="/" className="text-sm font-medium hover:underline">
Home
</Link>
<Link to="/articles" className="text-sm font-medium hover:underline">
Articles
</Link>
</nav>
</div>
</header>
<main className="flex-1 container mx-auto max-w-6xl px-4 py-8">
<Outlet />
</main>
<footer className="border-t mt-12">
<div className="container mx-auto max-w-6xl px-4 py-6 text-center text-sm text-muted-foreground">
© 2025 Placebo.mk. Sarcastic news from Macedonia.
</div>
</footer>
</div>
),
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => (
<div className="py-12 md:py-20">
<div className="max-w-4xl mx-auto text-center mb-12">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-6">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" x2="8" y1="13" y2="13" />
<line x1="16" x2="8" y1="17" y2="17" />
<line x1="10" x2="8" y1="9" y2="9" />
</svg>
</div>
<h1 className="text-4xl md:text-5xl font-bold tracking-tight mb-4">
Placebo<span className="text-primary">.mk</span>
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
Unapologetically sarcastic news and commentary on local and global affairs in Macedonia. Because sometimes the truth hurts more than fiction.
</p>
</div>
<div className="grid md:grid-cols-3 gap-6 max-w-5xl mx-auto">
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2" />
<path d="M18 14h-8" />
<path d="M15 18h-5" />
<path d="M10 6h8v4h-8V6Z" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">Latest Articles</h3>
<p className="text-muted-foreground text-sm">
Freshly brewed sarcasm on current events, politics, and everything in between.
</p>
</div>
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<path d="M12 17h.01" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">No Filter</h3>
<p className="text-muted-foreground text-sm">
We don't do nuance. We don't do diplomatic language. Just honest (and slightly mean) commentary.
</p>
</div>
<div className="p-6 rounded-xl border bg-card hover:shadow-lg transition-all duration-300 group">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">Community</h3>
<p className="text-muted-foreground text-sm">
Join thousands of readers who appreciate the finer art of Macedonian sarcasm.
</p>
</div>
</div>
<div className="mt-16 text-center">
<Link
to="/articles"
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors"
>
Browse Articles
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
</Link>
</div>
</div>
),
})
const articlesRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/articles',
component: () => {
const { data, isLoading, error } = useQuery({
queryKey: ['articles'],
queryFn: () => api.fetchArticles({ status: 'published' }),
})
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg">Loading articles...</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg text-red-500">Error loading articles</div>
</div>
)
}
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold">Articles</h1>
<p className="text-muted-foreground">Latest news and articles</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data?.data.map((article) => (
<div
key={article.id}
className="p-6 rounded-xl border bg-card hover:shadow-lg transition-shadow"
>
<h2 className="text-xl font-semibold mb-2 line-clamp-2">
{article.title}
</h2>
{article.excerpt && (
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
{article.excerpt}
</p>
)}
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{new Date(article.createdAt).toLocaleDateString('mk-MK', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
<span>{article.views} views</span>
</div>
</div>
))}
</div>
{data?.data.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground text-lg">
No articles published yet. Check back soon!
</p>
</div>
)}
</div>
)
},
})
const routeTree = rootRoute.addChildren([indexRoute, articlesRoute])
export const router = createRouter({ routeTree })
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}

78
frontend/src/styles.css Normal file
View File

@ -0,0 +1,78 @@
@import "tailwindcss";
@theme {
--color-primary: oklch(0.647 0.22 0.23);
--color-primary-foreground: oklch(0.985 0.002 0);
--color-secondary: oklch(0.97 0.002 0);
--color-secondary-foreground: oklch(0.205 0.02 266.5);
--color-muted: oklch(0.97 0 0);
--color-muted-foreground: oklch(0.556 0 0);
--color-accent: oklch(0.97 0 0);
--color-accent-foreground: oklch(0.205 0.02 266.5);
--color-destructive: oklch(0.55 0.22 25);
--color-destructive-foreground: oklch(0.985 0.002 0);
--color-border: oklch(0.9 0 0);
--color-input: oklch(0.9 0 0);
--color-ring: oklch(0.647 0.22 0.23);
--radius: 0.5rem;
--font-sans: "Inter", sans-serif;
}
: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%;
}
* {
box-sizing: border-box;
}
@layer base {
* {
border-color: hsl(var(--border));
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}
}

View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

13
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

17
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})

18
readme.md Normal file
View File

@ -0,0 +1,18 @@
## placebo.mk - дневна доза добри вести
# Description
placebo.mk is a news site in macedonia that covers current local and global affair in
sarcastic tone.
# Design principles
web site have minimalistic and clean design
# Tech stack
# frontend
tanstack
# backend
nestjs
# database
sqlite
# CMS