basic structure setup
This commit is contained in:
commit
71dfd86187
92
.gitignore
vendored
Normal file
92
.gitignore
vendored
Normal 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
151
AGENTS.md
Normal 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
219
FRONTEND_FINALIZED.md
Normal 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
348
FRONTEND_GUIDE.md
Normal 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
65
FRONTEND_READY.md
Normal 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
89
FRONTEND_WORKING.md
Normal 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
74
INTEGRATION_COMPLETE.md
Normal 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
385
ROUTER_TAILWIND_SETUP.md
Normal 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
119
SETUP_COMPLETE.md
Normal 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
188
SHADCN_SETUP.md
Normal 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
104
STRAPI_INTEGRATION.md
Normal 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
70
STRUCTURE.md
Normal 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
262
TAILWIND_SHADCN_SETUP.md
Normal 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
7
backend/.env.example
Normal 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
4
backend/.prettierrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
98
backend/README.md
Normal file
98
backend/README.md
Normal 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>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](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
35
backend/eslint.config.mjs
Normal 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
8
backend/nest-cli.json
Normal 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
11942
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
80
backend/package.json
Normal file
80
backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
22
backend/src/app.controller.spec.ts
Normal file
22
backend/src/app.controller.spec.ts
Normal 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!');
|
||||
});
|
||||
});
|
||||
});
|
||||
12
backend/src/app.controller.ts
Normal file
12
backend/src/app.controller.ts
Normal 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
28
backend/src/app.module.ts
Normal 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 {}
|
||||
8
backend/src/app.service.ts
Normal file
8
backend/src/app.service.ts
Normal 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
20
backend/src/main.ts
Normal 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();
|
||||
57
backend/src/modules/articles.controller.ts
Normal file
57
backend/src/modules/articles.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
123
backend/src/modules/articles.dto.ts
Normal file
123
backend/src/modules/articles.dto.ts
Normal 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;
|
||||
}
|
||||
13
backend/src/modules/articles.module.ts
Normal file
13
backend/src/modules/articles.module.ts
Normal 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 {}
|
||||
128
backend/src/modules/articles.service.ts
Normal file
128
backend/src/modules/articles.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
129
backend/src/modules/entities.ts
Normal file
129
backend/src/modules/entities.ts
Normal 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;
|
||||
}
|
||||
33
backend/src/modules/strapi.controller.ts
Normal file
33
backend/src/modules/strapi.controller.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
13
backend/src/modules/strapi.module.ts
Normal file
13
backend/src/modules/strapi.module.ts
Normal 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 {}
|
||||
156
backend/src/modules/strapi.service.ts
Normal file
156
backend/src/modules/strapi.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
25
backend/test/app.e2e-spec.ts
Normal file
25
backend/test/app.e2e-spec.ts
Normal 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!');
|
||||
});
|
||||
});
|
||||
9
backend/test/jest-e2e.json
Normal file
9
backend/test/jest-e2e.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
4
backend/tsconfig.build.json
Normal file
4
backend/tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
25
backend/tsconfig.json
Normal file
25
backend/tsconfig.json
Normal 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
8
cms/cms/.env.example
Normal 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
131
cms/cms/.gitignore
vendored
Normal 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
61
cms/cms/README.md
Normal 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
20
cms/cms/config/admin.ts
Normal 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
7
cms/cms/config/api.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export default {
|
||||
rest: {
|
||||
defaultLimit: 25,
|
||||
maxLimit: 100,
|
||||
withCount: true,
|
||||
},
|
||||
};
|
||||
60
cms/cms/config/database.ts
Normal file
60
cms/cms/config/database.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
};
|
||||
12
cms/cms/config/middlewares.ts
Normal file
12
cms/cms/config/middlewares.ts
Normal 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',
|
||||
];
|
||||
1
cms/cms/config/plugins.ts
Normal file
1
cms/cms/config/plugins.ts
Normal file
@ -0,0 +1 @@
|
||||
export default () => ({});
|
||||
7
cms/cms/config/server.ts
Normal file
7
cms/cms/config/server.ts
Normal 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'),
|
||||
},
|
||||
});
|
||||
0
cms/cms/database/migrations/.gitkeep
Normal file
0
cms/cms/database/migrations/.gitkeep
Normal file
BIN
cms/cms/favicon.png
Normal 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
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
41
cms/cms/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
3
cms/cms/public/robots.txt
Normal file
3
cms/cms/public/robots.txt
Normal file
@ -0,0 +1,3 @@
|
||||
# To prevent search engines from seeing the site altogether, uncomment the next two lines:
|
||||
# User-Agent: *
|
||||
# Disallow: /
|
||||
37
cms/cms/src/admin/app.example.tsx
Normal file
37
cms/cms/src/admin/app.example.tsx
Normal 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);
|
||||
},
|
||||
};
|
||||
20
cms/cms/src/admin/tsconfig.json
Normal file
20
cms/cms/src/admin/tsconfig.json
Normal 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"]
|
||||
}
|
||||
12
cms/cms/src/admin/vite.config.example.ts
Normal file
12
cms/cms/src/admin/vite.config.example.ts
Normal 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
0
cms/cms/src/api/.gitkeep
Normal file
34
cms/cms/src/api/article/content-types/article/schema.json
Normal file
34
cms/cms/src/api/article/content-types/article/schema.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
7
cms/cms/src/api/article/controllers/article.ts
Normal file
7
cms/cms/src/api/article/controllers/article.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* article controller
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreController('api::article.article');
|
||||
7
cms/cms/src/api/article/routes/article.ts
Normal file
7
cms/cms/src/api/article/routes/article.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* article router
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreRouter('api::article.article');
|
||||
7
cms/cms/src/api/article/services/article.ts
Normal file
7
cms/cms/src/api/article/services/article.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* article service
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreService('api::article.article');
|
||||
0
cms/cms/src/extensions/.gitkeep
Normal file
0
cms/cms/src/extensions/.gitkeep
Normal file
20
cms/cms/src/index.ts
Normal file
20
cms/cms/src/index.ts
Normal 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
43
cms/cms/tsconfig.json
Normal 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/**"
|
||||
]
|
||||
}
|
||||
3
cms/cms/types/generated/components.d.ts
vendored
Normal file
3
cms/cms/types/generated/components.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
/*
|
||||
* The app doesn't have any components yet.
|
||||
*/
|
||||
990
cms/cms/types/generated/contentTypes.d.ts
vendored
Normal file
990
cms/cms/types/generated/contentTypes.d.ts
vendored
Normal 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
1
frontend/.env.example
Normal file
@ -0,0 +1 @@
|
||||
VITE_API_URL=http://localhost:3000/api/v1
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal 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
73
frontend/README.md
Normal 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
15
frontend/components.json
Normal 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
23
frontend/eslint.config.js
Normal 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
13
frontend/index.html
Normal 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
4001
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
frontend/package.json
Normal file
45
frontend/package.json
Normal 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
1
frontend/public/vite.svg
Normal 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
42
frontend/src/App.css
Normal 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;
|
||||
}
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal 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 |
56
frontend/src/components/ui/button.tsx
Normal file
56
frontend/src/components/ui/button.tsx
Normal 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 }
|
||||
78
frontend/src/components/ui/card.tsx
Normal file
78
frontend/src/components/ui/card.tsx
Normal 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
59
frontend/src/index.css
Normal 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
88
frontend/src/lib/api.ts
Normal 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();
|
||||
}
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal 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
15
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
25
frontend/src/queries/articles.ts
Normal file
25
frontend/src/queries/articles.ts
Normal 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
212
frontend/src/routes.tsx
Normal 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
78
frontend/src/styles.css
Normal 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));
|
||||
}
|
||||
}
|
||||
34
frontend/tsconfig.app.json
Normal file
34
frontend/tsconfig.app.json
Normal 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
13
frontend/tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal 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
17
frontend/vite.config.ts
Normal 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
18
readme.md
Normal 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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user