feat: We Talk — podcast communautaire PWA
Some checks failed
Build & Deploy / build-and-deploy (push) Failing after 16s
Some checks failed
Build & Deploy / build-and-deploy (push) Failing after 16s
This commit is contained in:
commit
503e658f03
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "wetalk-dev",
|
||||
"runtimeExecutable": "./node_modules/.bin/vite",
|
||||
"runtimeArgs": ["--port", "5173"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
9
.claude/settings.local.json
Normal file
9
.claude/settings.local.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm install:*)",
|
||||
"WebFetch(domain:archive.org)",
|
||||
"WebSearch"
|
||||
]
|
||||
}
|
||||
}
|
||||
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.env
|
||||
.env.local
|
||||
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
VITE_SUPABASE_URL=https://your-project.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=your-anon-key
|
||||
63
.gitea/workflows/deploy.yml
Normal file
63
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,63 @@
|
||||
name: Build & Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
REGISTRY: git.arthurbarre.fr
|
||||
IMAGE: ordinarthur/wetalk
|
||||
NAMESPACE: wetalk
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ordinarthur
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }}
|
||||
build-args: |
|
||||
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
||||
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
|
||||
- name: Deploy to K3s
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG }}" | base64 -d > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
|
||||
# Create namespace if needed
|
||||
kubectl apply -f k8s/namespace.yml
|
||||
|
||||
# Create registry secret if needed
|
||||
kubectl -n $NAMESPACE create secret docker-registry gitea-registry \
|
||||
--docker-server=$REGISTRY \
|
||||
--docker-username=ordinarthur \
|
||||
--docker-password=${{ secrets.REGISTRY_PASSWORD }} \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
# Apply manifests
|
||||
kubectl apply -f k8s/service.yml
|
||||
kubectl apply -f k8s/deployment.yml
|
||||
|
||||
# Force rollout with new image
|
||||
kubectl -n $NAMESPACE set image deployment/wetalk \
|
||||
wetalk=$REGISTRY/$IMAGE:${{ github.sha }}
|
||||
|
||||
# Wait for rollout
|
||||
kubectl -n $NAMESPACE rollout status deployment/wetalk --timeout=120s
|
||||
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
@ -0,0 +1,15 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
ARG VITE_SUPABASE_URL
|
||||
ARG VITE_SUPABASE_ANON_KEY
|
||||
RUN ./node_modules/.bin/tsc -b && ./node_modules/.bin/vite build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD wget -q --spider http://localhost/ || exit 1
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
73
README.md
Normal file
73
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 [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## 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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
eslint.config.js
Normal file
23
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,
|
||||
},
|
||||
},
|
||||
])
|
||||
18
index.html
Normal file
18
index.html
Normal file
@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#4A90D9" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Nunito:wght@600;700;800&display=swap" rel="stylesheet" />
|
||||
<title>We Talk</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
41
k8s/deployment.yml
Normal file
41
k8s/deployment.yml
Normal file
@ -0,0 +1,41 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: wetalk
|
||||
namespace: wetalk
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: wetalk
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: wetalk
|
||||
spec:
|
||||
containers:
|
||||
- name: wetalk
|
||||
image: git.arthurbarre.fr/ordinarthur/wetalk:latest
|
||||
ports:
|
||||
- containerPort: 80
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 80
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 80
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
requests:
|
||||
memory: "32Mi"
|
||||
cpu: "10m"
|
||||
limits:
|
||||
memory: "64Mi"
|
||||
cpu: "100m"
|
||||
imagePullSecrets:
|
||||
- name: gitea-registry
|
||||
4
k8s/namespace.yml
Normal file
4
k8s/namespace.yml
Normal file
@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: wetalk
|
||||
13
k8s/service.yml
Normal file
13
k8s/service.yml
Normal file
@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: wetalk
|
||||
namespace: wetalk
|
||||
spec:
|
||||
type: NodePort
|
||||
selector:
|
||||
app: wetalk
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
||||
nodePort: 30094
|
||||
19
k8s/traefik-dynamic.yml
Normal file
19
k8s/traefik-dynamic.yml
Normal file
@ -0,0 +1,19 @@
|
||||
# Copy this to /etc/traefik/dynamic/wetalk.yml on gateway (10.10.10.2)
|
||||
http:
|
||||
routers:
|
||||
wetalk:
|
||||
rule: "Host(`we-talk.arthurbarre.fr`)"
|
||||
entryPoints:
|
||||
- websecure
|
||||
service: wetalk
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
middlewares:
|
||||
- security-headers
|
||||
- rate-limit
|
||||
|
||||
services:
|
||||
wetalk:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://10.10.10.5:30094"
|
||||
17
nginx.conf
Normal file
17
nginx.conf
Normal file
@ -0,0 +1,17 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
|
||||
}
|
||||
7887
package-lock.json
generated
Normal file
7887
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
package.json
Normal file
41
package.json
Normal file
@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "podcast-us",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stripe/stripe-js": "^9.1.0",
|
||||
"@supabase/supabase-js": "^2.103.0",
|
||||
"@tanstack/react-query": "^5.99.0",
|
||||
"@tanstack/react-query-devtools": "^5.99.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"stripe": "^22.0.1",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.0",
|
||||
"vite": "^8.0.4",
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
}
|
||||
}
|
||||
7
public/favicon.svg
Normal file
7
public/favicon.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<circle cx="32" cy="32" r="30" fill="#4A90D9"/>
|
||||
<path d="M24 20c0-4.4 3.6-8 8-8s8 3.6 8 8v12c0 4.4-3.6 8-8 8s-8-3.6-8-8V20z" fill="white" opacity="0.9"/>
|
||||
<path d="M20 30v2c0 6.6 5.4 12 12 12s12-5.4 12-12v-2" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round"/>
|
||||
<line x1="32" y1="44" x2="32" y2="50" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<line x1="26" y1="50" x2="38" y2="50" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 551 B |
46
src/App.tsx
Normal file
46
src/App.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Layout } from '@/components/layout/Layout'
|
||||
import { Home } from '@/pages/Home'
|
||||
import { Explore } from '@/pages/Explore'
|
||||
import { Auth } from '@/pages/Auth'
|
||||
import { Upload } from '@/pages/Upload'
|
||||
import { PodcastDetail } from '@/pages/PodcastDetail'
|
||||
import { Profile } from '@/pages/Profile'
|
||||
import { Favorites } from '@/pages/Favorites'
|
||||
import { Settings } from '@/pages/Settings'
|
||||
|
||||
export default function App() {
|
||||
const { setUser, fetchProfile } = useAuthStore()
|
||||
|
||||
useEffect(() => {
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
setUser(session?.user ?? null)
|
||||
if (session?.user) fetchProfile()
|
||||
})
|
||||
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setUser(session?.user ?? null)
|
||||
if (session?.user) fetchProfile()
|
||||
})
|
||||
|
||||
return () => subscription.unsubscribe()
|
||||
}, [setUser, fetchProfile])
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route index element={<Home />} />
|
||||
<Route path="explore" element={<Explore />} />
|
||||
<Route path="auth" element={<Auth />} />
|
||||
<Route path="upload" element={<Upload />} />
|
||||
<Route path="podcast/:id" element={<PodcastDetail />} />
|
||||
<Route path="profile/:username" element={<Profile />} />
|
||||
<Route path="favorites" element={<Favorites />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
24
src/components/layout/Layout.tsx
Normal file
24
src/components/layout/Layout.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { Navbar } from './Navbar'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { PlayerBar } from './PlayerBar'
|
||||
import { MobileNav } from './MobileNav'
|
||||
import { usePlayerStore } from '@/stores/player'
|
||||
|
||||
export function Layout() {
|
||||
const current = usePlayerStore((s) => s.current)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<div className="flex flex-1">
|
||||
<Sidebar />
|
||||
<main className="flex-1 max-w-6xl mx-auto w-full px-4 py-6 pb-24 lg:pb-6" style={{ paddingBottom: current ? '7rem' : undefined }}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
<PlayerBar />
|
||||
<MobileNav />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
53
src/components/layout/MobileNav.tsx
Normal file
53
src/components/layout/MobileNav.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { Home, Compass, Upload, Heart, User } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usePlayerStore } from '@/stores/player'
|
||||
|
||||
export function MobileNav() {
|
||||
const { user, profile } = useAuthStore()
|
||||
const current = usePlayerStore((s) => s.current)
|
||||
|
||||
const links = [
|
||||
{ to: '/', icon: Home, label: 'Accueil' },
|
||||
{ to: '/explore', icon: Compass, label: 'Explorer' },
|
||||
...(user
|
||||
? [
|
||||
{ to: '/upload', icon: Upload, label: 'Publier' },
|
||||
{ to: '/favorites', icon: Heart, label: 'Favoris' },
|
||||
{ to: `/profile/${profile?.username || user.id}`, icon: User, label: 'Profil' },
|
||||
]
|
||||
: [{ to: '/auth', icon: User, label: 'Connexion' }]),
|
||||
]
|
||||
|
||||
return (
|
||||
<nav className={cn(
|
||||
'lg:hidden fixed left-0 right-0 z-40 glass border-t border-border/60',
|
||||
current ? 'bottom-[4.75rem]' : 'bottom-0',
|
||||
)}>
|
||||
<div className="flex items-center justify-around h-[3.5rem] px-2">
|
||||
{links.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex flex-col items-center gap-0.5 text-[10px] font-semibold transition-all duration-150 px-3 py-1 rounded-xl',
|
||||
isActive ? 'text-primary' : 'text-text-secondary',
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<div className={cn('p-1 rounded-xl transition-colors', isActive && 'bg-primary-soft')}>
|
||||
<Icon size={19} strokeWidth={isActive ? 2.5 : 2} />
|
||||
</div>
|
||||
<span>{label}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
58
src/components/layout/Navbar.tsx
Normal file
58
src/components/layout/Navbar.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Search, Upload, LogIn } from 'lucide-react'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Avatar } from '@/components/ui/Avatar'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
|
||||
export function Navbar() {
|
||||
const { user, profile } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 glass border-b border-border/60">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 h-[4.25rem] flex items-center justify-between gap-4">
|
||||
<Link to="/" className="flex items-center gap-2.5 shrink-0 group">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-primary to-[#7B6AEF] rounded-2xl flex items-center justify-center shadow-[0_2px_12px_rgba(91,76,219,0.3)] group-hover:shadow-[0_2px_20px_rgba(91,76,219,0.45)] transition-shadow">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" className="text-white">
|
||||
<path d="M12 2C9.24 2 7 4.24 7 7v5c0 2.76 2.24 5 5 5s5-2.24 5-5V7c0-2.76-2.24-5-5-5z" fill="currentColor" opacity="0.9"/>
|
||||
<path d="M5 10v2c0 3.87 3.13 7 7 7s7-3.13 7-7v-2" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<path d="M12 19v3M9 22h6" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-heading font-extrabold text-[1.15rem] text-text hidden sm:block tracking-tight">
|
||||
we<span className="gradient-text">talk</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex-1 max-w-sm">
|
||||
<button
|
||||
onClick={() => navigate('/explore')}
|
||||
className="w-full flex items-center gap-2.5 px-4 py-2.5 rounded-2xl bg-surface-warm/60 border border-border text-text-secondary text-sm hover:border-primary/40 hover:bg-surface transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
<Search size={15} className="shrink-0" />
|
||||
<span className="text-[13px]">Rechercher...</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center gap-2">
|
||||
{user ? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/upload')}>
|
||||
<Upload size={15} />
|
||||
<span className="hidden sm:inline">Publier</span>
|
||||
</Button>
|
||||
<button onClick={() => navigate(`/profile/${profile?.username || user.id}`)} className="cursor-pointer">
|
||||
<Avatar src={profile?.avatar_url} name={profile?.username || 'U'} size="sm" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="sm" onClick={() => navigate('/auth')}>
|
||||
<LogIn size={15} />
|
||||
Connexion
|
||||
</Button>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
87
src/components/layout/PlayerBar.tsx
Normal file
87
src/components/layout/PlayerBar.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import { Play, Pause, Volume2, VolumeX, SkipBack, SkipForward } from 'lucide-react'
|
||||
import { usePlayerStore } from '@/stores/player'
|
||||
import { formatDuration } from '@/lib/utils'
|
||||
import { Avatar } from '@/components/ui/Avatar'
|
||||
|
||||
export function PlayerBar() {
|
||||
const { current, isPlaying, progress, duration, volume, toggle, seek, setVolume } = usePlayerStore()
|
||||
|
||||
if (!current) return null
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 glass border-t border-border/60 shadow-[0_-4px_30px_rgba(30,27,51,0.08)]">
|
||||
{/* Progress bar */}
|
||||
<div className="relative h-[3px] bg-border-light cursor-pointer group" onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const pct = (e.clientX - rect.left) / rect.width
|
||||
seek(pct * duration)
|
||||
}}>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-gradient-to-r from-primary to-[#7B6AEF] transition-all rounded-full"
|
||||
style={{ width: `${duration ? (progress / duration) * 100 : 0}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-primary shadow-[0_0_0_3px_rgba(91,76,219,0.2)] opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
style={{ left: `${duration ? (progress / duration) * 100 : 0}%`, marginLeft: '-6px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 h-[4.25rem] flex items-center gap-4">
|
||||
{/* Track info */}
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="relative shrink-0">
|
||||
{current.cover_url ? (
|
||||
<img src={current.cover_url} alt="" className="w-11 h-11 rounded-xl object-cover shadow-organic-sm" />
|
||||
) : (
|
||||
<Avatar name={current.title} size="md" className="!rounded-xl" />
|
||||
)}
|
||||
{isPlaying && (
|
||||
<div className="absolute -bottom-0.5 -right-0.5 flex items-end gap-[2px] h-3 p-[2px] bg-surface rounded-md">
|
||||
<div className="wave-bar" /><div className="wave-bar" /><div className="wave-bar" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-semibold truncate">{current.title}</p>
|
||||
<p className="text-[11px] text-text-secondary truncate">{current.creator?.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<button className="text-text-secondary hover:text-primary transition-colors cursor-pointer p-1" onClick={() => seek(Math.max(0, progress - 15))}>
|
||||
<SkipBack size={17} />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="w-11 h-11 rounded-full bg-gradient-to-br from-primary to-[#7B6AEF] text-white flex items-center justify-center hover:shadow-[0_2px_20px_rgba(91,76,219,0.4)] transition-all active:scale-95 cursor-pointer"
|
||||
>
|
||||
{isPlaying ? <Pause size={17} /> : <Play size={17} className="ml-0.5" />}
|
||||
</button>
|
||||
<button className="text-text-secondary hover:text-primary transition-colors cursor-pointer p-1" onClick={() => seek(Math.min(duration, progress + 15))}>
|
||||
<SkipForward size={17} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Volume & time */}
|
||||
<div className="hidden sm:flex items-center gap-3 flex-1 justify-end">
|
||||
<span className="text-[11px] text-text-secondary tabular-nums font-medium">
|
||||
{formatDuration(progress)} / {formatDuration(duration)}
|
||||
</span>
|
||||
<button onClick={() => setVolume(volume === 0 ? 0.8 : 0)} className="text-text-secondary hover:text-primary transition-colors cursor-pointer">
|
||||
{volume === 0 ? <VolumeX size={15} /> : <Volume2 size={15} />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
||||
className="w-20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
89
src/components/layout/Sidebar.tsx
Normal file
89
src/components/layout/Sidebar.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { Home, Compass, Upload, Heart, User, Settings } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const publicLinks = [
|
||||
{ to: '/', icon: Home, label: 'Accueil' },
|
||||
{ to: '/explore', icon: Compass, label: 'Explorer' },
|
||||
]
|
||||
|
||||
const authLinks = [
|
||||
{ to: '/upload', icon: Upload, label: 'Publier' },
|
||||
{ to: '/favorites', icon: Heart, label: 'Favoris' },
|
||||
{ to: '/settings', icon: Settings, label: 'Paramètres' },
|
||||
]
|
||||
|
||||
export function Sidebar() {
|
||||
const { user, profile } = useAuthStore()
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:flex flex-col w-[13.5rem] shrink-0 bg-surface-warm/40 p-4 pt-5 gap-1">
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{publicLinks.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 px-3.5 py-2.5 rounded-2xl text-[13px] font-semibold transition-all duration-150',
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-primary-soft to-primary-soft/50 text-primary shadow-organic-sm'
|
||||
: 'text-text-secondary hover:text-text hover:bg-surface',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon size={17} strokeWidth={2.2} />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{user && (
|
||||
<>
|
||||
<div className="h-px bg-border/60 my-3 mx-2" />
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{authLinks.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 px-3.5 py-2.5 rounded-2xl text-[13px] font-semibold transition-all duration-150',
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-primary-soft to-primary-soft/50 text-primary shadow-organic-sm'
|
||||
: 'text-text-secondary hover:text-text hover:bg-surface',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon size={17} strokeWidth={2.2} />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
<NavLink
|
||||
to={`/profile/${profile?.username || user.id}`}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 px-3.5 py-2.5 rounded-2xl text-[13px] font-semibold transition-all duration-150',
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-primary-soft to-primary-soft/50 text-primary shadow-organic-sm'
|
||||
: 'text-text-secondary hover:text-text hover:bg-surface',
|
||||
)
|
||||
}
|
||||
>
|
||||
<User size={17} strokeWidth={2.2} />
|
||||
Mon profil
|
||||
</NavLink>
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-auto pt-6 px-2">
|
||||
<div className="rounded-2xl bg-gradient-to-br from-primary/[0.07] to-accent/[0.05] p-4 border border-primary/10">
|
||||
<p className="text-[11px] font-bold text-primary uppercase tracking-wider mb-1">We Talk Pro</p>
|
||||
<p className="text-[12px] text-text-secondary leading-relaxed">Ecoutes illimitees pour seulement 1 euro/mois.</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
119
src/components/podcast/PodcastCard.tsx
Normal file
119
src/components/podcast/PodcastCard.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import { Play, Pause, Heart, MessageCircle, Clock } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { Podcast } from '@/types'
|
||||
import { formatDuration, timeAgo } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Avatar } from '@/components/ui/Avatar'
|
||||
import { usePlayerStore } from '@/stores/player'
|
||||
|
||||
const coverGradients = [
|
||||
'from-primary/20 via-primary/5 to-accent/10',
|
||||
'from-accent/15 via-sun/5 to-mint/10',
|
||||
'from-mint/15 via-primary/5 to-sun/10',
|
||||
'from-sun/15 via-accent/5 to-primary/10',
|
||||
]
|
||||
|
||||
function titleToGradient(title: string) {
|
||||
let hash = 0
|
||||
for (let i = 0; i < title.length; i++) hash = title.charCodeAt(i) + ((hash << 5) - hash)
|
||||
return coverGradients[Math.abs(hash) % coverGradients.length]
|
||||
}
|
||||
|
||||
interface PodcastCardProps {
|
||||
podcast: Podcast
|
||||
}
|
||||
|
||||
export function PodcastCard({ podcast }: PodcastCardProps) {
|
||||
const play = usePlayerStore((s) => s.play)
|
||||
const currentId = usePlayerStore((s) => s.current?.id)
|
||||
const isPlaying = usePlayerStore((s) => s.isPlaying)
|
||||
const isActive = currentId === podcast.id
|
||||
|
||||
return (
|
||||
<article className="group lift bg-surface rounded-3xl shadow-organic-sm hover:shadow-organic overflow-hidden">
|
||||
{/* Cover */}
|
||||
<div className="relative aspect-[4/3]">
|
||||
{podcast.cover_url ? (
|
||||
<img src={podcast.cover_url} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className={`w-full h-full flex items-center justify-center bg-gradient-to-br ${titleToGradient(podcast.title)}`}>
|
||||
<div className="relative">
|
||||
<span className="text-5xl font-heading font-extrabold text-primary/15 select-none">
|
||||
{podcast.title[0]?.toUpperCase()}
|
||||
</span>
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-30">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" className="text-primary">
|
||||
<path d="M12 2C9.24 2 7 4.24 7 7v5c0 2.76 2.24 5 5 5s5-2.24 5-5V7c0-2.76-2.24-5-5-5z" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Play overlay */}
|
||||
<button
|
||||
onClick={() => play(podcast)}
|
||||
className="absolute inset-0 flex items-end justify-between p-3 bg-gradient-to-t from-black/40 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-all duration-300 cursor-pointer"
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-full bg-white/95 shadow-lg flex items-center justify-center transition-all duration-300 ${isActive && isPlaying ? 'scale-100' : 'scale-90 group-hover:scale-100'}`}>
|
||||
{isActive && isPlaying
|
||||
? <Pause size={16} className="text-primary" fill="currentColor" />
|
||||
: <Play size={16} className="text-primary ml-0.5" fill="currentColor" />
|
||||
}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Duration pill */}
|
||||
<div className="absolute top-2.5 right-2.5 glass rounded-full px-2.5 py-1 flex items-center gap-1 text-[10px] font-bold text-text shadow-sm">
|
||||
<Clock size={9} />
|
||||
{formatDuration(podcast.duration_seconds)}
|
||||
</div>
|
||||
|
||||
{/* Active indicator */}
|
||||
{isActive && (
|
||||
<div className="absolute bottom-2.5 left-3 flex items-end gap-[2px] h-4">
|
||||
<div className="wave-bar" /><div className="wave-bar" /><div className="wave-bar" /><div className="wave-bar" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-3.5 pt-3">
|
||||
<Link to={`/podcast/${podcast.id}`} className="block">
|
||||
<h3 className="font-heading font-bold text-[13px] leading-snug line-clamp-2 hover:text-primary transition-colors">
|
||||
{podcast.title}
|
||||
</h3>
|
||||
</Link>
|
||||
|
||||
{podcast.creator && (
|
||||
<Link to={`/profile/${podcast.creator.username}`} className="flex items-center gap-1.5 mt-2">
|
||||
<Avatar src={podcast.creator.avatar_url} name={podcast.creator.username} size="sm" className="!w-5 !h-5 !text-[8px] !ring-1" />
|
||||
<span className="text-[11px] text-text-secondary font-medium hover:text-primary transition-colors">{podcast.creator.username}</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{podcast.tags && podcast.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{podcast.tags.slice(0, 2).map((tag, i) => (
|
||||
<Badge key={tag.id} variant={(['primary', 'mint', 'sun', 'accent'] as const)[i % 4]} className="!text-[9px] !py-0 !px-2">
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-2.5 pt-2.5 border-t border-border-light text-text-secondary text-[11px] font-medium">
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart size={11} />
|
||||
{podcast.likes_count || 0}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageCircle size={11} />
|
||||
{podcast.comments_count || 0}
|
||||
</span>
|
||||
<span className="ml-auto opacity-70">{timeAgo(podcast.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
60
src/components/ui/Avatar.tsx
Normal file
60
src/components/ui/Avatar.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AvatarProps {
|
||||
src?: string | null
|
||||
name: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
className?: string
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
sm: 'w-8 h-8 text-xs',
|
||||
md: 'w-10 h-10 text-sm',
|
||||
lg: 'w-16 h-16 text-xl',
|
||||
}
|
||||
|
||||
const gradients = [
|
||||
'from-primary to-[#7B6AEF]',
|
||||
'from-accent to-sun',
|
||||
'from-mint to-primary',
|
||||
'from-sun to-accent',
|
||||
'from-[#7B6AEF] to-mint',
|
||||
]
|
||||
|
||||
function nameToGradient(name: string) {
|
||||
let hash = 0
|
||||
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash)
|
||||
return gradients[Math.abs(hash) % gradients.length]
|
||||
}
|
||||
|
||||
export function Avatar({ src, name, size = 'md', className }: AvatarProps) {
|
||||
const initials = name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
|
||||
if (src) {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={name}
|
||||
className={cn('rounded-full object-cover ring-2 ring-surface', sizes[size], className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-full bg-gradient-to-br text-white font-bold flex items-center justify-center ring-2 ring-surface',
|
||||
nameToGradient(name),
|
||||
sizes[size],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
src/components/ui/Badge.tsx
Normal file
33
src/components/ui/Badge.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode
|
||||
variant?: 'default' | 'primary' | 'accent' | 'mint' | 'sun'
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const variants = {
|
||||
default: 'bg-surface-warm text-text-secondary border border-border-light',
|
||||
primary: 'bg-primary-soft text-primary',
|
||||
accent: 'bg-accent-soft text-accent',
|
||||
mint: 'bg-mint-soft text-mint',
|
||||
sun: 'bg-sun-soft text-sun',
|
||||
}
|
||||
|
||||
export function Badge({ children, variant = 'default', className, onClick }: BadgeProps) {
|
||||
const Tag = onClick ? 'button' : 'span'
|
||||
return (
|
||||
<Tag
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'pill inline-flex items-center font-semibold transition-all duration-150',
|
||||
variants[variant],
|
||||
onClick && 'cursor-pointer hover:scale-105 active:scale-95',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
37
src/components/ui/Button.tsx
Normal file
37
src/components/ui/Button.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ButtonHTMLAttributes } from 'react'
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'ghost' | 'accent'
|
||||
type Size = 'sm' | 'md' | 'lg'
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant
|
||||
size?: Size
|
||||
}
|
||||
|
||||
const variants: Record<Variant, string> = {
|
||||
primary: 'bg-gradient-to-r from-primary to-[#7B6AEF] text-white hover:shadow-[0_4px_20px_rgba(91,76,219,0.35)] active:scale-[0.97]',
|
||||
secondary: 'bg-surface text-text border border-border hover:border-primary/30 hover:bg-primary-soft/40 active:scale-[0.97]',
|
||||
ghost: 'text-text-secondary hover:text-primary hover:bg-primary-soft/40',
|
||||
accent: 'bg-gradient-to-r from-accent to-[#F07B5A] text-white hover:shadow-[0_4px_20px_rgba(232,96,76,0.35)] active:scale-[0.97]',
|
||||
}
|
||||
|
||||
const sizes: Record<Size, string> = {
|
||||
sm: 'px-3.5 py-1.5 text-[13px]',
|
||||
md: 'px-5 py-2.5 text-sm',
|
||||
lg: 'px-7 py-3 text-[15px]',
|
||||
}
|
||||
|
||||
export function Button({ variant = 'primary', size = 'md', className, ...props }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-2 rounded-full font-semibold transition-all duration-200 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed select-none',
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
29
src/components/ui/Input.tsx
Normal file
29
src/components/ui/Input.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { InputHTMLAttributes } from 'react'
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function Input({ label, error, className, id, ...props }: InputProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{label && (
|
||||
<label htmlFor={id} className="text-[13px] font-semibold text-text tracking-wide">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
id={id}
|
||||
className={cn(
|
||||
'rounded-2xl border-2 border-border bg-surface-warm/50 px-4 py-3 text-sm text-text placeholder:text-text-secondary/40 outline-none transition-all duration-200 focus:border-primary focus:bg-surface focus:shadow-[0_0_0_4px_rgba(91,76,219,0.08)]',
|
||||
error && 'border-accent focus:border-accent focus:shadow-[0_0_0_4px_rgba(232,96,76,0.08)]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="text-xs text-accent font-medium">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
src/components/ui/Modal.tsx
Normal file
34
src/components/ui/Modal.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function Modal({ open, onClose, title, children }: ModalProps) {
|
||||
useEffect(() => {
|
||||
if (open) document.body.style.overflow = 'hidden'
|
||||
else document.body.style.overflow = ''
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative bg-surface rounded-2xl shadow-xl w-full max-w-md mx-4 p-6 animate-in fade-in zoom-in-95">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
{title && <h3 className="text-lg font-bold font-heading">{title}</h3>}
|
||||
<button onClick={onClose} className="p-1 rounded-lg hover:bg-border-light transition-colors text-text-secondary">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
150
src/index.css
Normal file
150
src/index.css
Normal file
@ -0,0 +1,150 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
/* Warm ivory base with deep indigo personality */
|
||||
--color-bg: #FBF9F4;
|
||||
--color-surface: #FFFFFF;
|
||||
--color-surface-warm: #F5F0E8;
|
||||
--color-primary: #5B4CDB;
|
||||
--color-primary-hover: #4A3CC7;
|
||||
--color-primary-soft: #EDE9FF;
|
||||
--color-accent: #E8604C;
|
||||
--color-accent-hover: #D44F3B;
|
||||
--color-accent-soft: #FFF0ED;
|
||||
--color-mint: #3BBFA0;
|
||||
--color-mint-soft: #E6F9F3;
|
||||
--color-sun: #F5B731;
|
||||
--color-sun-soft: #FFF8E6;
|
||||
--color-text: #1E1B33;
|
||||
--color-text-secondary: #7C7893;
|
||||
--color-border: #E8E4DC;
|
||||
--color-border-light: #F2EFE8;
|
||||
|
||||
--font-heading: "Nunito", sans-serif;
|
||||
--font-body: "Inter", sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
/* Noise texture overlay for depth */
|
||||
.noise-bg {
|
||||
position: relative;
|
||||
}
|
||||
.noise-bg::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.03;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Glass card effect */
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||
}
|
||||
|
||||
/* Soft organic shadow */
|
||||
.shadow-organic {
|
||||
box-shadow:
|
||||
0 2px 8px rgba(91, 76, 219, 0.06),
|
||||
0 12px 32px rgba(30, 27, 51, 0.08);
|
||||
}
|
||||
|
||||
.shadow-organic-sm {
|
||||
box-shadow:
|
||||
0 1px 4px rgba(91, 76, 219, 0.04),
|
||||
0 4px 12px rgba(30, 27, 51, 0.05);
|
||||
}
|
||||
|
||||
/* Hover lift */
|
||||
.lift {
|
||||
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.25s ease;
|
||||
}
|
||||
.lift:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
/* Gradient text */
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Audio range input */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--color-border);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(91, 76, 219, 0.2);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.25);
|
||||
box-shadow: 0 0 0 5px rgba(91, 76, 219, 0.15);
|
||||
}
|
||||
|
||||
/* Waveform bars animation for active player */
|
||||
@keyframes wave {
|
||||
0%, 100% { height: 8px; }
|
||||
50% { height: 20px; }
|
||||
}
|
||||
.wave-bar {
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-primary);
|
||||
animation: wave 0.8s ease-in-out infinite;
|
||||
}
|
||||
.wave-bar:nth-child(2) { animation-delay: 0.15s; }
|
||||
.wave-bar:nth-child(3) { animation-delay: 0.3s; }
|
||||
.wave-bar:nth-child(4) { animation-delay: 0.45s; }
|
||||
|
||||
/* Pill shape for tags */
|
||||
.pill {
|
||||
border-radius: 999px;
|
||||
padding: 4px 14px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Skeleton shimmer */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--color-border-light) 25%, var(--color-surface-warm) 50%, var(--color-border-light) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
6
src/lib/supabase.ts
Normal file
6
src/lib/supabase.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || 'https://placeholder.supabase.co'
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY || 'placeholder'
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
|
||||
29
src/lib/utils.ts
Normal file
29
src/lib/utils.ts
Normal file
@ -0,0 +1,29 @@
|
||||
export function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function timeAgo(date: string): string {
|
||||
const now = new Date()
|
||||
const past = new Date(date)
|
||||
const diff = Math.floor((now.getTime() - past.getTime()) / 1000)
|
||||
|
||||
if (diff < 60) return 'à l\'instant'
|
||||
if (diff < 3600) return `il y a ${Math.floor(diff / 60)}min`
|
||||
if (diff < 86400) return `il y a ${Math.floor(diff / 3600)}h`
|
||||
if (diff < 2592000) return `il y a ${Math.floor(diff / 86400)}j`
|
||||
return past.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
export function cn(...classes: (string | false | undefined | null)[]): string {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
export function durationLabel(seconds: number): string {
|
||||
if (seconds < 600) return 'Court'
|
||||
if (seconds < 1800) return 'Moyen'
|
||||
return 'Long'
|
||||
}
|
||||
20
src/main.tsx
Normal file
20
src/main.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: 1000 * 60 * 5 } },
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
133
src/pages/Auth.tsx
Normal file
133
src/pages/Auth.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Mic } from 'lucide-react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
|
||||
type Mode = 'login' | 'signup'
|
||||
|
||||
export function Auth() {
|
||||
const [mode, setMode] = useState<Mode>('login')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
if (mode === 'signup') {
|
||||
const { error: signUpErr } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: { data: { username } },
|
||||
})
|
||||
if (signUpErr) {
|
||||
setError(signUpErr.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
navigate('/')
|
||||
} else {
|
||||
const { error: signInErr } = await supabase.auth.signInWithPassword({ email, password })
|
||||
if (signInErr) {
|
||||
setError(signInErr.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
navigate('/')
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
async function handleGoogle() {
|
||||
await supabase.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
options: { redirectTo: window.location.origin },
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-14 h-14 bg-primary rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Mic size={24} className="text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-heading font-bold">
|
||||
{mode === 'login' ? 'Bon retour !' : 'Créer un compte'}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-sm mt-1">
|
||||
{mode === 'login' ? 'Connectez-vous pour continuer' : 'Rejoignez la communauté We Talk'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button variant="secondary" className="w-full mb-4" onClick={handleGoogle}>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" />
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
||||
</svg>
|
||||
Continuer avec Google
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3 my-4">
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
<span className="text-xs text-text-secondary">ou par email</span>
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
{mode === 'signup' && (
|
||||
<Input
|
||||
id="username"
|
||||
label="Pseudo"
|
||||
placeholder="votre_pseudo"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
id="email"
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="vous@exemple.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
id="password"
|
||||
label="Mot de passe"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
error={error}
|
||||
/>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? 'Chargement...' : mode === 'login' ? 'Se connecter' : 'Créer mon compte'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-text-secondary mt-4">
|
||||
{mode === 'login' ? 'Pas encore de compte ?' : 'Déjà un compte ?'}{' '}
|
||||
<button
|
||||
onClick={() => { setMode(mode === 'login' ? 'signup' : 'login'); setError('') }}
|
||||
className="text-primary hover:underline cursor-pointer font-medium"
|
||||
>
|
||||
{mode === 'login' ? 'Créer un compte' : 'Se connecter'}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
178
src/pages/Explore.tsx
Normal file
178
src/pages/Explore.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { Search, SlidersHorizontal, X } from 'lucide-react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import type { Podcast, Tag } from '@/types'
|
||||
import { PodcastCard } from '@/components/podcast/PodcastCard'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
|
||||
type DurationFilter = 'all' | 'short' | 'medium' | 'long'
|
||||
type SortBy = 'recent' | 'trending' | 'duration'
|
||||
|
||||
const durationRanges: Record<DurationFilter, [number, number]> = {
|
||||
all: [0, Infinity],
|
||||
short: [0, 600],
|
||||
medium: [600, 1800],
|
||||
long: [1800, Infinity],
|
||||
}
|
||||
|
||||
export function Explore() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [podcasts, setPodcasts] = useState<Podcast[]>([])
|
||||
const [tags, setTags] = useState<Tag[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
|
||||
const query = searchParams.get('q') || ''
|
||||
const sortBy = (searchParams.get('sort') as SortBy) || 'recent'
|
||||
const selectedTags = searchParams.get('tags')?.split(',').filter(Boolean) || []
|
||||
const durationFilter = (searchParams.get('duration') as DurationFilter) || 'all'
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const [podcastsRes, tagsRes] = await Promise.all([
|
||||
supabase
|
||||
.from('podcasts')
|
||||
.select('*, creator:profiles(*), tags:podcast_tags(tag:tags(*))')
|
||||
.order(sortBy === 'trending' ? 'plays_count' : sortBy === 'duration' ? 'duration_seconds' : 'created_at', { ascending: false })
|
||||
.limit(50),
|
||||
supabase.from('tags').select('*').order('name'),
|
||||
])
|
||||
|
||||
if (podcastsRes.data) {
|
||||
setPodcasts(podcastsRes.data.map((p: any) => ({
|
||||
...p,
|
||||
tags: p.tags?.map((t: any) => t.tag).filter(Boolean) || [],
|
||||
})))
|
||||
}
|
||||
if (tagsRes.data) setTags(tagsRes.data)
|
||||
setLoading(false)
|
||||
}
|
||||
load()
|
||||
}, [sortBy])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let result = podcasts
|
||||
|
||||
if (query) {
|
||||
const q = query.toLowerCase()
|
||||
result = result.filter(
|
||||
(p) => p.title.toLowerCase().includes(q) || p.description.toLowerCase().includes(q),
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedTags.length > 0) {
|
||||
result = result.filter((p) =>
|
||||
p.tags?.some((t) => selectedTags.includes(t.slug)),
|
||||
)
|
||||
}
|
||||
|
||||
const [min, max] = durationRanges[durationFilter]
|
||||
result = result.filter((p) => p.duration_seconds >= min && p.duration_seconds < max)
|
||||
|
||||
return result
|
||||
}, [podcasts, query, selectedTags, durationFilter])
|
||||
|
||||
function updateParam(key: string, value: string) {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
if (value) params.set(key, value)
|
||||
else params.delete(key)
|
||||
setSearchParams(params)
|
||||
}
|
||||
|
||||
function toggleTag(slug: string) {
|
||||
const current = selectedTags.includes(slug)
|
||||
? selectedTags.filter((t) => t !== slug)
|
||||
: [...selectedTags, slug]
|
||||
updateParam('tags', current.join(','))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher par titre, description..."
|
||||
value={query}
|
||||
onChange={(e) => updateParam('q', e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-border bg-surface text-sm outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-colors"
|
||||
/>
|
||||
{query && (
|
||||
<button onClick={() => updateParam('q', '')} className="absolute right-3 top-1/2 -translate-y-1/2 text-text-secondary hover:text-text cursor-pointer">
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`p-2.5 rounded-xl border transition-colors cursor-pointer ${showFilters ? 'border-primary bg-primary/10 text-primary' : 'border-border text-text-secondary hover:text-text'}`}
|
||||
>
|
||||
<SlidersHorizontal size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showFilters && (
|
||||
<div className="bg-surface rounded-xl border border-border p-4 space-y-4">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-secondary uppercase tracking-wider mb-2">Trier par</h4>
|
||||
<div className="flex gap-2">
|
||||
{([['recent', 'Récents'], ['trending', 'Populaires'], ['duration', 'Durée']] as const).map(([val, label]) => (
|
||||
<Badge key={val} variant={sortBy === val ? 'primary' : 'default'} onClick={() => updateParam('sort', val)}>
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-secondary uppercase tracking-wider mb-2">Durée</h4>
|
||||
<div className="flex gap-2">
|
||||
{([['all', 'Tous'], ['short', '< 10min'], ['medium', '10-30min'], ['long', '> 30min']] as const).map(([val, label]) => (
|
||||
<Badge key={val} variant={durationFilter === val ? 'primary' : 'default'} onClick={() => updateParam('duration', val === 'all' ? '' : val)}>
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-secondary uppercase tracking-wider mb-2">Tags</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
<Badge
|
||||
key={tag.id}
|
||||
variant={selectedTags.includes(tag.slug) ? 'primary' : 'default'}
|
||||
onClick={() => toggleTag(tag.slug)}
|
||||
>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="bg-border-light rounded-2xl aspect-square animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-text-secondary">Aucun podcast trouvé.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{filtered.map((p) => (
|
||||
<PodcastCard key={p.id} podcast={p} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
64
src/pages/Favorites.tsx
Normal file
64
src/pages/Favorites.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Heart } from 'lucide-react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { Podcast } from '@/types'
|
||||
import { PodcastCard } from '@/components/podcast/PodcastCard'
|
||||
|
||||
export function Favorites() {
|
||||
const { user } = useAuthStore()
|
||||
const [podcasts, setPodcasts] = useState<Podcast[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return
|
||||
async function load() {
|
||||
const { data } = await supabase
|
||||
.from('likes')
|
||||
.select('podcast:podcasts(*, creator:profiles(*), tags:podcast_tags(tag:tags(*)))')
|
||||
.eq('user_id', user!.id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (data) {
|
||||
setPodcasts(
|
||||
data
|
||||
.map((d: any) => d.podcast)
|
||||
.filter(Boolean)
|
||||
.map((p: any) => ({
|
||||
...p,
|
||||
tags: p.tags?.map((t: any) => t.tag).filter(Boolean) || [],
|
||||
})),
|
||||
)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
load()
|
||||
}, [user])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-heading font-bold mb-6 flex items-center gap-2">
|
||||
<Heart size={22} />
|
||||
Favoris
|
||||
</h1>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="bg-border-light rounded-2xl aspect-square animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : podcasts.length === 0 ? (
|
||||
<p className="text-text-secondary text-sm text-center py-16">
|
||||
Vous n'avez pas encore de favoris. Explorez et likez des podcasts !
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{podcasts.map((p) => (
|
||||
<PodcastCard key={p.id} podcast={p} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
165
src/pages/Home.tsx
Normal file
165
src/pages/Home.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { TrendingUp, Clock, ArrowRight, Headphones, Users, Mic } from 'lucide-react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import type { Podcast } from '@/types'
|
||||
import { PodcastCard } from '@/components/podcast/PodcastCard'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
|
||||
export function Home() {
|
||||
const [trending, setTrending] = useState<Podcast[]>([])
|
||||
const [recent, setRecent] = useState<Podcast[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const [trendingRes, recentRes] = await Promise.all([
|
||||
supabase
|
||||
.from('podcasts')
|
||||
.select('*, creator:profiles(*), tags:podcast_tags(tag:tags(*))')
|
||||
.order('plays_count', { ascending: false })
|
||||
.limit(8),
|
||||
supabase
|
||||
.from('podcasts')
|
||||
.select('*, creator:profiles(*), tags:podcast_tags(tag:tags(*))')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(8),
|
||||
])
|
||||
|
||||
if (trendingRes.data) setTrending(normalizePodcasts(trendingRes.data))
|
||||
if (recentRes.data) setRecent(normalizePodcasts(recentRes.data))
|
||||
setLoading(false)
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-14">
|
||||
{/* Hero */}
|
||||
<section className="relative overflow-hidden rounded-[2rem] bg-gradient-to-br from-primary/[0.06] via-surface to-accent/[0.04] border border-border/50 noise-bg">
|
||||
{/* Decorative blobs */}
|
||||
<div className="absolute -top-20 -right-20 w-64 h-64 bg-primary/[0.07] rounded-full blur-3xl" />
|
||||
<div className="absolute -bottom-16 -left-16 w-48 h-48 bg-accent/[0.06] rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-mint/[0.03] rounded-full blur-3xl" />
|
||||
|
||||
<div className="relative z-10 px-6 sm:px-10 py-14 sm:py-20 text-center">
|
||||
{/* Floating mic icon */}
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-[1.25rem] bg-gradient-to-br from-primary to-[#7B6AEF] shadow-[0_8px_30px_rgba(91,76,219,0.3)] mb-6 rotate-[-6deg]">
|
||||
<Mic size={28} className="text-white rotate-[6deg]" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-[2.5rem] sm:text-[3.5rem] font-heading font-extrabold tracking-tight leading-[1.1] max-w-2xl mx-auto">
|
||||
Votre voix compte,{' '}
|
||||
<span className="gradient-text">partagez-la.</span>
|
||||
</h1>
|
||||
<p className="text-text-secondary mt-4 text-base sm:text-lg max-w-md mx-auto leading-relaxed">
|
||||
La plateforme de podcast ou tout le monde peut creer, ecouter et decouvrir des voix authentiques.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-center gap-3 mt-8">
|
||||
<Button size="lg" onClick={() => navigate('/explore')}>
|
||||
Decouvrir
|
||||
<ArrowRight size={17} />
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" onClick={() => navigate('/auth')}>
|
||||
Creer un compte
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center justify-center gap-8 sm:gap-12 mt-10 pt-8 border-t border-border/40">
|
||||
{[
|
||||
{ icon: Headphones, label: 'Ecoutes', value: '2.4k' },
|
||||
{ icon: Users, label: 'Createurs', value: '128' },
|
||||
{ icon: Mic, label: 'Podcasts', value: '340' },
|
||||
].map(({ icon: Icon, label, value }) => (
|
||||
<div key={label} className="text-center">
|
||||
<div className="flex items-center justify-center gap-1.5 text-primary mb-0.5">
|
||||
<Icon size={14} strokeWidth={2.5} />
|
||||
<span className="font-heading font-extrabold text-xl">{value}</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-text-secondary font-medium uppercase tracking-wider">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PodcastSection
|
||||
icon={<TrendingUp size={20} strokeWidth={2.5} />}
|
||||
title="Tendances"
|
||||
subtitle="Les plus ecoutees cette semaine"
|
||||
podcasts={trending}
|
||||
loading={loading}
|
||||
link="/explore?sort=trending"
|
||||
/>
|
||||
|
||||
<PodcastSection
|
||||
icon={<Clock size={20} strokeWidth={2.5} />}
|
||||
title="Fraichement publiees"
|
||||
subtitle="Les dernieres voix a decouvrir"
|
||||
podcasts={recent}
|
||||
loading={loading}
|
||||
link="/explore?sort=recent"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PodcastSection({ icon, title, subtitle, podcasts, loading, link }: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
subtitle: string
|
||||
podcasts: Podcast[]
|
||||
loading: boolean
|
||||
link: string
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<div className="flex items-end justify-between mb-5">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-xl font-heading font-extrabold">
|
||||
{icon}
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-[13px] text-text-secondary mt-0.5">{subtitle}</p>
|
||||
</div>
|
||||
<Link to={link} className="text-[13px] text-primary hover:text-primary-hover font-semibold flex items-center gap-1 transition-colors">
|
||||
Voir tout <ArrowRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-5">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="rounded-3xl aspect-[4/5] skeleton" />
|
||||
))}
|
||||
</div>
|
||||
) : podcasts.length === 0 ? (
|
||||
<div className="text-center py-12 rounded-3xl bg-surface-warm/50 border border-border/50">
|
||||
<div className="w-12 h-12 rounded-2xl bg-primary-soft flex items-center justify-center mx-auto mb-3">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" className="text-primary">
|
||||
<path d="M12 2C9.24 2 7 4.24 7 7v5c0 2.76 2.24 5 5 5s5-2.24 5-5V7c0-2.76-2.24-5-5-5z" fill="currentColor" opacity="0.3"/>
|
||||
<path d="M5 10v2c0 3.87 3.13 7 7 7s7-3.13 7-7v-2" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm font-medium">Aucun podcast pour le moment</p>
|
||||
<p className="text-text-secondary/60 text-xs mt-1">Soyez le premier a partager votre voix !</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-5">
|
||||
{podcasts.map((p) => (
|
||||
<PodcastCard key={p.id} podcast={p} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePodcasts(data: any[]): Podcast[] {
|
||||
return data.map((p) => ({
|
||||
...p,
|
||||
tags: p.tags?.map((t: any) => t.tag).filter(Boolean) || [],
|
||||
}))
|
||||
}
|
||||
219
src/pages/PodcastDetail.tsx
Normal file
219
src/pages/PodcastDetail.tsx
Normal file
@ -0,0 +1,219 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { Play, Pause, Heart, MessageCircle, Clock, Share2 } from 'lucide-react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usePlayerStore } from '@/stores/player'
|
||||
import type { Podcast, Comment } from '@/types'
|
||||
import { formatDuration, timeAgo } from '@/lib/utils'
|
||||
import { Avatar } from '@/components/ui/Avatar'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
|
||||
export function PodcastDetail() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const { user } = useAuthStore()
|
||||
const { play, toggle, current, isPlaying } = usePlayerStore()
|
||||
const [podcast, setPodcast] = useState<Podcast | null>(null)
|
||||
const [comments, setComments] = useState<Comment[]>([])
|
||||
const [newComment, setNewComment] = useState('')
|
||||
const [isLiked, setIsLiked] = useState(false)
|
||||
const [likesCount, setLikesCount] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const isActive = current?.id === id
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
async function load() {
|
||||
const { data: p } = await supabase
|
||||
.from('podcasts')
|
||||
.select('*, creator:profiles(*), tags:podcast_tags(tag:tags(*))')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
|
||||
if (p) {
|
||||
const podcast = { ...p, tags: p.tags?.map((t: any) => t.tag).filter(Boolean) || [] }
|
||||
setPodcast(podcast)
|
||||
setLikesCount(p.likes_count || 0)
|
||||
}
|
||||
|
||||
const { data: commentsData } = await supabase
|
||||
.from('comments')
|
||||
.select('*, user:profiles(*)')
|
||||
.eq('podcast_id', id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (commentsData) setComments(commentsData)
|
||||
|
||||
if (user) {
|
||||
const { data: like } = await supabase
|
||||
.from('likes')
|
||||
.select('*')
|
||||
.eq('podcast_id', id)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
setIsLiked(!!like)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
load()
|
||||
}, [id, user])
|
||||
|
||||
async function handleLike() {
|
||||
if (!user || !id) return
|
||||
if (isLiked) {
|
||||
await supabase.from('likes').delete().eq('podcast_id', id).eq('user_id', user.id)
|
||||
setIsLiked(false)
|
||||
setLikesCount((c) => c - 1)
|
||||
} else {
|
||||
await supabase.from('likes').insert({ podcast_id: id, user_id: user.id })
|
||||
setIsLiked(true)
|
||||
setLikesCount((c) => c + 1)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleComment(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!user || !id || !newComment.trim()) return
|
||||
|
||||
const { data } = await supabase
|
||||
.from('comments')
|
||||
.insert({ podcast_id: id, user_id: user.id, content: newComment.trim() })
|
||||
.select('*, user:profiles(*)')
|
||||
.single()
|
||||
|
||||
if (data) {
|
||||
setComments([data, ...comments])
|
||||
setNewComment('')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<div className="h-64 bg-border-light rounded-2xl animate-pulse" />
|
||||
<div className="h-8 w-2/3 bg-border-light rounded-lg animate-pulse" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!podcast) {
|
||||
return <div className="text-center py-16 text-text-secondary">Podcast introuvable.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-8">
|
||||
<div className="flex flex-col sm:flex-row gap-6">
|
||||
<div className="w-full sm:w-48 shrink-0">
|
||||
{podcast.cover_url ? (
|
||||
<img src={podcast.cover_url} alt="" className="w-full aspect-square rounded-2xl object-cover shadow-md" />
|
||||
) : (
|
||||
<div className="w-full aspect-square rounded-2xl bg-gradient-to-br from-primary/15 to-primary/5 flex items-center justify-center">
|
||||
<span className="text-6xl font-heading font-bold text-primary/25">{podcast.title[0]?.toUpperCase()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-2xl font-heading font-bold">{podcast.title}</h1>
|
||||
|
||||
{podcast.creator && (
|
||||
<Link to={`/profile/${podcast.creator.username}`} className="flex items-center gap-2 mt-3">
|
||||
<Avatar src={podcast.creator.avatar_url} name={podcast.creator.username} size="sm" />
|
||||
<span className="text-sm font-medium hover:text-primary transition-colors">{podcast.creator.username}</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 mt-4 text-sm text-text-secondary">
|
||||
<span className="flex items-center gap-1"><Clock size={14} />{formatDuration(podcast.duration_seconds)}</span>
|
||||
<span>{timeAgo(podcast.created_at)}</span>
|
||||
<span>{podcast.plays_count} écoutes</span>
|
||||
</div>
|
||||
|
||||
{podcast.tags && podcast.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-3">
|
||||
{podcast.tags.map((tag) => (
|
||||
<Badge key={tag.id} variant="primary">{tag.name}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mt-5">
|
||||
<Button
|
||||
onClick={() => isActive ? toggle() : play(podcast)}
|
||||
size="lg"
|
||||
>
|
||||
{isActive && isPlaying ? <Pause size={18} /> : <Play size={18} className="ml-0.5" />}
|
||||
{isActive && isPlaying ? 'Pause' : 'Écouter'}
|
||||
</Button>
|
||||
<Button
|
||||
variant={isLiked ? 'accent' : 'secondary'}
|
||||
onClick={handleLike}
|
||||
disabled={!user}
|
||||
>
|
||||
<Heart size={16} fill={isLiked ? 'currentColor' : 'none'} />
|
||||
{likesCount}
|
||||
</Button>
|
||||
<Button variant="ghost">
|
||||
<Share2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{podcast.description && (
|
||||
<div>
|
||||
<h2 className="text-lg font-heading font-bold mb-2">Description</h2>
|
||||
<p className="text-text-secondary text-sm leading-relaxed whitespace-pre-line">{podcast.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg font-heading font-bold mb-4 flex items-center gap-2">
|
||||
<MessageCircle size={18} />
|
||||
Commentaires ({comments.length})
|
||||
</h2>
|
||||
|
||||
{user && (
|
||||
<form onSubmit={handleComment} className="flex gap-3 mb-6">
|
||||
<Avatar src={null} name={user.email || 'U'} size="sm" className="mt-1 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<textarea
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder="Ajouter un commentaire..."
|
||||
rows={2}
|
||||
className="w-full rounded-xl border border-border bg-surface px-4 py-2.5 text-sm outline-none resize-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-colors"
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<Button size="sm" type="submit" disabled={!newComment.trim()}>
|
||||
Commenter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{comments.map((comment) => (
|
||||
<div key={comment.id} className="flex gap-3">
|
||||
<Avatar src={comment.user?.avatar_url} name={comment.user?.username || 'U'} size="sm" className="mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{comment.user?.username}</span>
|
||||
<span className="text-xs text-text-secondary">{timeAgo(comment.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary mt-0.5">{comment.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{comments.length === 0 && (
|
||||
<p className="text-sm text-text-secondary text-center py-6">Aucun commentaire pour le moment.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
147
src/pages/Profile.tsx
Normal file
147
src/pages/Profile.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Users, Headphones } from 'lucide-react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { Profile as ProfileType, Podcast } from '@/types'
|
||||
import { Avatar } from '@/components/ui/Avatar'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { PodcastCard } from '@/components/podcast/PodcastCard'
|
||||
|
||||
export function Profile() {
|
||||
const { username } = useParams<{ username: string }>()
|
||||
const { user } = useAuthStore()
|
||||
const [profile, setProfile] = useState<ProfileType | null>(null)
|
||||
const [podcasts, setPodcasts] = useState<Podcast[]>([])
|
||||
const [isFollowing, setIsFollowing] = useState(false)
|
||||
const [followersCount, setFollowersCount] = useState(0)
|
||||
const [followingCount, setFollowingCount] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const isOwn = user && profile && user.id === profile.id
|
||||
|
||||
useEffect(() => {
|
||||
if (!username) return
|
||||
async function load() {
|
||||
const { data: profileData } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('username', username)
|
||||
.single()
|
||||
|
||||
if (!profileData) { setLoading(false); return }
|
||||
setProfile(profileData)
|
||||
|
||||
const [podcastsRes, followersRes, followingRes] = await Promise.all([
|
||||
supabase
|
||||
.from('podcasts')
|
||||
.select('*, creator:profiles(*), tags:podcast_tags(tag:tags(*))')
|
||||
.eq('creator_id', profileData.id)
|
||||
.order('created_at', { ascending: false }),
|
||||
supabase.from('follows').select('*', { count: 'exact', head: true }).eq('following_id', profileData.id),
|
||||
supabase.from('follows').select('*', { count: 'exact', head: true }).eq('follower_id', profileData.id),
|
||||
])
|
||||
|
||||
if (podcastsRes.data) {
|
||||
setPodcasts(podcastsRes.data.map((p: any) => ({
|
||||
...p,
|
||||
tags: p.tags?.map((t: any) => t.tag).filter(Boolean) || [],
|
||||
})))
|
||||
}
|
||||
setFollowersCount(followersRes.count || 0)
|
||||
setFollowingCount(followingRes.count || 0)
|
||||
|
||||
if (user) {
|
||||
const { data: follow } = await supabase
|
||||
.from('follows')
|
||||
.select('*')
|
||||
.eq('follower_id', user.id)
|
||||
.eq('following_id', profileData.id)
|
||||
.maybeSingle()
|
||||
setIsFollowing(!!follow)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
load()
|
||||
}, [username, user])
|
||||
|
||||
async function handleFollow() {
|
||||
if (!user || !profile) return
|
||||
if (isFollowing) {
|
||||
await supabase.from('follows').delete().eq('follower_id', user.id).eq('following_id', profile.id)
|
||||
setIsFollowing(false)
|
||||
setFollowersCount((c) => c - 1)
|
||||
} else {
|
||||
await supabase.from('follows').insert({ follower_id: user.id, following_id: profile.id })
|
||||
setIsFollowing(true)
|
||||
setFollowersCount((c) => c + 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="w-20 h-20 rounded-full bg-border-light animate-pulse" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="h-6 w-40 bg-border-light rounded animate-pulse" />
|
||||
<div className="h-4 w-60 bg-border-light rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return <div className="text-center py-16 text-text-secondary">Utilisateur introuvable.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-8">
|
||||
<div className="flex flex-col sm:flex-row items-start gap-6">
|
||||
<Avatar src={profile.avatar_url} name={profile.username} size="lg" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-2xl font-heading font-bold">{profile.username}</h1>
|
||||
{profile.is_premium && (
|
||||
<span className="bg-primary/10 text-primary text-xs font-semibold px-2.5 py-0.5 rounded-full">PRO</span>
|
||||
)}
|
||||
</div>
|
||||
{profile.bio && <p className="text-text-secondary text-sm mt-1">{profile.bio}</p>}
|
||||
|
||||
<div className="flex items-center gap-5 mt-3 text-sm text-text-secondary">
|
||||
<span className="flex items-center gap-1"><Headphones size={14} />{podcasts.length} podcasts</span>
|
||||
<span className="flex items-center gap-1"><Users size={14} />{followersCount} abonnés</span>
|
||||
<span>{followingCount} abonnements</span>
|
||||
</div>
|
||||
|
||||
{!isOwn && user && (
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant={isFollowing ? 'secondary' : 'primary'}
|
||||
size="sm"
|
||||
onClick={handleFollow}
|
||||
>
|
||||
{isFollowing ? 'Abonné' : 'S\'abonner'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg font-heading font-bold mb-4">Podcasts</h2>
|
||||
{podcasts.length === 0 ? (
|
||||
<p className="text-text-secondary text-sm text-center py-8">Aucun podcast publié.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
{podcasts.map((p) => (
|
||||
<PodcastCard key={p.id} podcast={p} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
107
src/pages/Settings.tsx
Normal file
107
src/pages/Settings.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Settings as SettingsIcon, LogOut, CreditCard, Crown } from 'lucide-react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Avatar } from '@/components/ui/Avatar'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
|
||||
export function Settings() {
|
||||
const { user, profile, signOut, fetchProfile } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const [username, setUsername] = useState(profile?.username || '')
|
||||
const [bio, setBio] = useState(profile?.bio || '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
if (!user) {
|
||||
navigate('/auth')
|
||||
return null
|
||||
}
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
await supabase
|
||||
.from('profiles')
|
||||
.update({ username, bio })
|
||||
.eq('id', user!.id)
|
||||
await fetchProfile()
|
||||
setSaving(false)
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto space-y-8">
|
||||
<h1 className="text-2xl font-heading font-bold flex items-center gap-2">
|
||||
<SettingsIcon size={22} />
|
||||
Paramètres
|
||||
</h1>
|
||||
|
||||
<form onSubmit={handleSave} className="bg-surface rounded-2xl border border-border p-6 space-y-4">
|
||||
<div className="flex items-center gap-4 mb-2">
|
||||
<Avatar src={profile?.avatar_url} name={username || 'U'} size="lg" />
|
||||
<div>
|
||||
<p className="font-heading font-bold">{username}</p>
|
||||
<p className="text-sm text-text-secondary">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input id="username" label="Pseudo" value={username} onChange={(e) => setUsername(e.target.value)} required />
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="bio" className="text-sm font-medium text-text">Bio</label>
|
||||
<textarea
|
||||
id="bio"
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
placeholder="Parlez de vous..."
|
||||
rows={3}
|
||||
className="rounded-xl border border-border bg-surface px-4 py-2.5 text-sm text-text placeholder:text-text-secondary/50 outline-none resize-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saved ? 'Sauvegardé !' : saving ? 'Sauvegarde...' : 'Sauvegarder'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="bg-surface rounded-2xl border border-border p-6">
|
||||
<h2 className="font-heading font-bold mb-3 flex items-center gap-2">
|
||||
<Crown size={18} />
|
||||
Abonnement
|
||||
</h2>
|
||||
{profile?.is_premium ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-text-secondary">
|
||||
Vous êtes abonné <span className="text-primary font-medium">We Talk Pro</span> — écoutes illimitées.
|
||||
</p>
|
||||
<Button variant="secondary" size="sm">
|
||||
<CreditCard size={14} />
|
||||
Gérer l'abonnement
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-text-secondary">
|
||||
Écoutes gratuites restantes : <span className="font-semibold text-text">{10 - (profile?.free_listens_count || 0)}/10</span>
|
||||
</p>
|
||||
<Button size="sm">
|
||||
<Crown size={14} />
|
||||
Passer Pro — 1€/mois
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" className="text-accent" onClick={() => { signOut(); navigate('/') }}>
|
||||
<LogOut size={16} />
|
||||
Se déconnecter
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
201
src/pages/Upload.tsx
Normal file
201
src/pages/Upload.tsx
Normal file
@ -0,0 +1,201 @@
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Upload as UploadIcon, Music, X, Image } from 'lucide-react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
|
||||
export function Upload() {
|
||||
const { user } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [audioFile, setAudioFile] = useState<File | null>(null)
|
||||
const [coverFile, setCoverFile] = useState<File | null>(null)
|
||||
const [coverPreview, setCoverPreview] = useState<string | null>(null)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const audioInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleAudioSelect = useCallback((file: File) => {
|
||||
if (!file.type.startsWith('audio/')) {
|
||||
setError('Veuillez sélectionner un fichier audio.')
|
||||
return
|
||||
}
|
||||
setAudioFile(file)
|
||||
setError('')
|
||||
|
||||
const audio = new Audio()
|
||||
audio.src = URL.createObjectURL(file)
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
setDuration(Math.floor(audio.duration))
|
||||
URL.revokeObjectURL(audio.src)
|
||||
})
|
||||
|
||||
if (!title) setTitle(file.name.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' '))
|
||||
}, [title])
|
||||
|
||||
function handleCoverSelect(file: File) {
|
||||
if (!file.type.startsWith('image/')) return
|
||||
setCoverFile(file)
|
||||
setCoverPreview(URL.createObjectURL(file))
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!user || !audioFile) return
|
||||
setUploading(true)
|
||||
setError('')
|
||||
|
||||
const timestamp = Date.now()
|
||||
const audioPath = `${user.id}/${timestamp}-${audioFile.name}`
|
||||
|
||||
const { error: audioErr } = await supabase.storage
|
||||
.from('podcasts')
|
||||
.upload(audioPath, audioFile)
|
||||
|
||||
if (audioErr) {
|
||||
setError('Erreur lors de l\'upload audio: ' + audioErr.message)
|
||||
setUploading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const { data: audioUrl } = supabase.storage.from('podcasts').getPublicUrl(audioPath)
|
||||
|
||||
let cover_url: string | null = null
|
||||
if (coverFile) {
|
||||
const coverPath = `${user.id}/${timestamp}-cover-${coverFile.name}`
|
||||
const { error: coverErr } = await supabase.storage
|
||||
.from('covers')
|
||||
.upload(coverPath, coverFile)
|
||||
if (!coverErr) {
|
||||
const { data } = supabase.storage.from('covers').getPublicUrl(coverPath)
|
||||
cover_url = data.publicUrl
|
||||
}
|
||||
}
|
||||
|
||||
const { data: podcast, error: insertErr } = await supabase
|
||||
.from('podcasts')
|
||||
.insert({
|
||||
creator_id: user.id,
|
||||
title,
|
||||
description,
|
||||
audio_url: audioUrl.publicUrl,
|
||||
duration_seconds: duration,
|
||||
cover_url,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (insertErr) {
|
||||
setError('Erreur: ' + insertErr.message)
|
||||
setUploading(false)
|
||||
return
|
||||
}
|
||||
|
||||
navigate(`/podcast/${podcast.id}`)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-text-secondary mb-4">Connectez-vous pour publier un podcast.</p>
|
||||
<Button onClick={() => navigate('/auth')}>Se connecter</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-2xl font-heading font-bold mb-6">Publier un podcast</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{!audioFile ? (
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-2xl p-12 text-center transition-colors cursor-pointer ${dragActive ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/30'}`}
|
||||
onClick={() => audioInputRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragActive(true) }}
|
||||
onDragLeave={() => setDragActive(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
setDragActive(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) handleAudioSelect(file)
|
||||
}}
|
||||
>
|
||||
<UploadIcon size={40} className="mx-auto text-text-secondary mb-3" />
|
||||
<p className="font-medium">Glissez votre fichier audio ici</p>
|
||||
<p className="text-sm text-text-secondary mt-1">ou cliquez pour sélectionner (MP3, WAV, M4A)</p>
|
||||
<input
|
||||
ref={audioInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
className="hidden"
|
||||
onChange={(e) => e.target.files?.[0] && handleAudioSelect(e.target.files[0])}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 p-4 bg-primary/5 rounded-xl border border-primary/20">
|
||||
<Music size={20} className="text-primary shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{audioFile.name}</p>
|
||||
<p className="text-xs text-text-secondary">
|
||||
{(audioFile.size / 1024 / 1024).toFixed(1)} Mo
|
||||
{duration > 0 && ` — ${Math.floor(duration / 60)}min ${duration % 60}s`}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => { setAudioFile(null); setDuration(0) }} className="text-text-secondary hover:text-text cursor-pointer">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-6">
|
||||
<div className="space-y-4">
|
||||
<Input id="title" label="Titre" value={title} onChange={(e) => setTitle(e.target.value)} required placeholder="Le titre de votre podcast" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="desc" className="text-sm font-medium text-text">Description</label>
|
||||
<textarea
|
||||
id="desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="De quoi parle votre podcast ?"
|
||||
rows={4}
|
||||
className="rounded-xl border border-border bg-surface px-4 py-2.5 text-sm text-text placeholder:text-text-secondary/50 outline-none resize-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<label className="text-sm font-medium text-text">Couverture</label>
|
||||
<div
|
||||
className="w-32 h-32 rounded-xl border-2 border-dashed border-border hover:border-primary/30 flex items-center justify-center cursor-pointer overflow-hidden transition-colors"
|
||||
onClick={() => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'image/*'
|
||||
input.onchange = () => input.files?.[0] && handleCoverSelect(input.files[0])
|
||||
input.click()
|
||||
}}
|
||||
>
|
||||
{coverPreview ? (
|
||||
<img src={coverPreview} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Image size={24} className="text-text-secondary" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-accent">{error}</p>}
|
||||
|
||||
<Button type="submit" size="lg" className="w-full" disabled={!audioFile || !title || uploading}>
|
||||
{uploading ? 'Publication en cours...' : 'Publier le podcast'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
src/stores/auth.ts
Normal file
42
src/stores/auth.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { create } from 'zustand'
|
||||
import type { User } from '@supabase/supabase-js'
|
||||
import type { Profile } from '@/types'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
|
||||
interface AuthState {
|
||||
user: User | null
|
||||
profile: Profile | null
|
||||
loading: boolean
|
||||
setUser: (user: User | null) => void
|
||||
setProfile: (profile: Profile | null) => void
|
||||
fetchProfile: () => Promise<void>
|
||||
signOut: () => Promise<void>
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
user: null,
|
||||
profile: null,
|
||||
loading: true,
|
||||
|
||||
setUser: (user) => set({ user, loading: false }),
|
||||
|
||||
setProfile: (profile) => set({ profile }),
|
||||
|
||||
fetchProfile: async () => {
|
||||
const { user } = get()
|
||||
if (!user) return set({ profile: null })
|
||||
|
||||
const { data } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
set({ profile: data })
|
||||
},
|
||||
|
||||
signOut: async () => {
|
||||
await supabase.auth.signOut()
|
||||
set({ user: null, profile: null })
|
||||
},
|
||||
}))
|
||||
91
src/stores/player.ts
Normal file
91
src/stores/player.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { create } from 'zustand'
|
||||
import type { Podcast } from '@/types'
|
||||
|
||||
interface PlayerState {
|
||||
current: Podcast | null
|
||||
isPlaying: boolean
|
||||
progress: number
|
||||
duration: number
|
||||
volume: number
|
||||
audio: HTMLAudioElement | null
|
||||
|
||||
play: (podcast: Podcast) => void
|
||||
toggle: () => void
|
||||
pause: () => void
|
||||
seek: (time: number) => void
|
||||
setVolume: (vol: number) => void
|
||||
setProgress: (progress: number) => void
|
||||
setDuration: (duration: number) => void
|
||||
}
|
||||
|
||||
export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
current: null,
|
||||
isPlaying: false,
|
||||
progress: 0,
|
||||
duration: 0,
|
||||
volume: 0.8,
|
||||
audio: null,
|
||||
|
||||
play: (podcast) => {
|
||||
const { audio, current } = get()
|
||||
|
||||
if (current?.id === podcast.id && audio) {
|
||||
audio.play()
|
||||
set({ isPlaying: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (audio) {
|
||||
audio.pause()
|
||||
audio.removeAttribute('src')
|
||||
}
|
||||
|
||||
const newAudio = new Audio(podcast.audio_url)
|
||||
newAudio.volume = get().volume
|
||||
|
||||
newAudio.addEventListener('timeupdate', () => {
|
||||
set({ progress: newAudio.currentTime })
|
||||
})
|
||||
newAudio.addEventListener('loadedmetadata', () => {
|
||||
set({ duration: newAudio.duration })
|
||||
})
|
||||
newAudio.addEventListener('ended', () => {
|
||||
set({ isPlaying: false, progress: 0 })
|
||||
})
|
||||
|
||||
newAudio.play()
|
||||
set({ audio: newAudio, current: podcast, isPlaying: true, progress: 0 })
|
||||
},
|
||||
|
||||
toggle: () => {
|
||||
const { audio, isPlaying } = get()
|
||||
if (!audio) return
|
||||
if (isPlaying) {
|
||||
audio.pause()
|
||||
} else {
|
||||
audio.play()
|
||||
}
|
||||
set({ isPlaying: !isPlaying })
|
||||
},
|
||||
|
||||
pause: () => {
|
||||
get().audio?.pause()
|
||||
set({ isPlaying: false })
|
||||
},
|
||||
|
||||
seek: (time) => {
|
||||
const { audio } = get()
|
||||
if (!audio) return
|
||||
audio.currentTime = time
|
||||
set({ progress: time })
|
||||
},
|
||||
|
||||
setVolume: (vol) => {
|
||||
const { audio } = get()
|
||||
if (audio) audio.volume = vol
|
||||
set({ volume: vol })
|
||||
},
|
||||
|
||||
setProgress: (progress) => set({ progress }),
|
||||
setDuration: (duration) => set({ duration }),
|
||||
}))
|
||||
52
src/types/index.ts
Normal file
52
src/types/index.ts
Normal file
@ -0,0 +1,52 @@
|
||||
export interface Profile {
|
||||
id: string
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
bio: string | null
|
||||
is_premium: boolean
|
||||
stripe_customer_id: string | null
|
||||
free_listens_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Podcast {
|
||||
id: string
|
||||
creator_id: string
|
||||
title: string
|
||||
description: string
|
||||
audio_url: string
|
||||
duration_seconds: number
|
||||
cover_url: string | null
|
||||
plays_count: number
|
||||
created_at: string
|
||||
creator?: Profile
|
||||
tags?: Tag[]
|
||||
likes_count?: number
|
||||
comments_count?: number
|
||||
is_liked?: boolean
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string
|
||||
user_id: string
|
||||
podcast_id: string
|
||||
content: string
|
||||
created_at: string
|
||||
user?: Profile
|
||||
}
|
||||
|
||||
export interface Like {
|
||||
user_id: string
|
||||
podcast_id: string
|
||||
}
|
||||
|
||||
export interface Follow {
|
||||
follower_id: string
|
||||
following_id: string
|
||||
}
|
||||
148
supabase/migrations/001_initial_schema.sql
Normal file
148
supabase/migrations/001_initial_schema.sql
Normal file
@ -0,0 +1,148 @@
|
||||
-- Profiles (extends auth.users)
|
||||
create table public.profiles (
|
||||
id uuid references auth.users on delete cascade primary key,
|
||||
username text unique not null,
|
||||
avatar_url text,
|
||||
bio text,
|
||||
is_premium boolean default false,
|
||||
stripe_customer_id text,
|
||||
free_listens_count integer default 0,
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
|
||||
alter table public.profiles enable row level security;
|
||||
|
||||
create policy "Profiles are viewable by everyone" on public.profiles
|
||||
for select using (true);
|
||||
|
||||
create policy "Users can update own profile" on public.profiles
|
||||
for update using (auth.uid() = id);
|
||||
|
||||
-- Auto-create profile on signup
|
||||
create or replace function public.handle_new_user()
|
||||
returns trigger as $$
|
||||
begin
|
||||
insert into public.profiles (id, username)
|
||||
values (new.id, coalesce(new.raw_user_meta_data->>'username', split_part(new.email, '@', 1)));
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql security definer;
|
||||
|
||||
create trigger on_auth_user_created
|
||||
after insert on auth.users
|
||||
for each row execute function public.handle_new_user();
|
||||
|
||||
-- Podcasts
|
||||
create table public.podcasts (
|
||||
id uuid default gen_random_uuid() primary key,
|
||||
creator_id uuid references public.profiles(id) on delete cascade not null,
|
||||
title text not null,
|
||||
description text default '',
|
||||
audio_url text not null,
|
||||
duration_seconds integer default 0,
|
||||
cover_url text,
|
||||
plays_count integer default 0,
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
|
||||
alter table public.podcasts enable row level security;
|
||||
|
||||
create policy "Podcasts are viewable by everyone" on public.podcasts
|
||||
for select using (true);
|
||||
|
||||
create policy "Users can insert own podcasts" on public.podcasts
|
||||
for insert with check (auth.uid() = creator_id);
|
||||
|
||||
create policy "Users can update own podcasts" on public.podcasts
|
||||
for update using (auth.uid() = creator_id);
|
||||
|
||||
create policy "Users can delete own podcasts" on public.podcasts
|
||||
for delete using (auth.uid() = creator_id);
|
||||
|
||||
-- Tags
|
||||
create table public.tags (
|
||||
id uuid default gen_random_uuid() primary key,
|
||||
name text unique not null,
|
||||
slug text unique not null
|
||||
);
|
||||
|
||||
alter table public.tags enable row level security;
|
||||
create policy "Tags are viewable by everyone" on public.tags for select using (true);
|
||||
|
||||
create table public.podcast_tags (
|
||||
podcast_id uuid references public.podcasts(id) on delete cascade,
|
||||
tag_id uuid references public.tags(id) on delete cascade,
|
||||
primary key (podcast_id, tag_id)
|
||||
);
|
||||
|
||||
alter table public.podcast_tags enable row level security;
|
||||
create policy "Podcast tags are viewable by everyone" on public.podcast_tags for select using (true);
|
||||
create policy "Creators can manage podcast tags" on public.podcast_tags
|
||||
for all using (
|
||||
exists (select 1 from public.podcasts where id = podcast_id and creator_id = auth.uid())
|
||||
);
|
||||
|
||||
-- Likes
|
||||
create table public.likes (
|
||||
user_id uuid references public.profiles(id) on delete cascade,
|
||||
podcast_id uuid references public.podcasts(id) on delete cascade,
|
||||
created_at timestamptz default now(),
|
||||
primary key (user_id, podcast_id)
|
||||
);
|
||||
|
||||
alter table public.likes enable row level security;
|
||||
create policy "Likes are viewable by everyone" on public.likes for select using (true);
|
||||
create policy "Users can manage own likes" on public.likes
|
||||
for all using (auth.uid() = user_id);
|
||||
|
||||
-- Comments
|
||||
create table public.comments (
|
||||
id uuid default gen_random_uuid() primary key,
|
||||
user_id uuid references public.profiles(id) on delete cascade not null,
|
||||
podcast_id uuid references public.podcasts(id) on delete cascade not null,
|
||||
content text not null,
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
|
||||
alter table public.comments enable row level security;
|
||||
create policy "Comments are viewable by everyone" on public.comments for select using (true);
|
||||
create policy "Users can insert own comments" on public.comments
|
||||
for insert with check (auth.uid() = user_id);
|
||||
create policy "Users can delete own comments" on public.comments
|
||||
for delete using (auth.uid() = user_id);
|
||||
|
||||
-- Follows
|
||||
create table public.follows (
|
||||
follower_id uuid references public.profiles(id) on delete cascade,
|
||||
following_id uuid references public.profiles(id) on delete cascade,
|
||||
created_at timestamptz default now(),
|
||||
primary key (follower_id, following_id)
|
||||
);
|
||||
|
||||
alter table public.follows enable row level security;
|
||||
create policy "Follows are viewable by everyone" on public.follows for select using (true);
|
||||
create policy "Users can manage own follows" on public.follows
|
||||
for all using (auth.uid() = follower_id);
|
||||
|
||||
-- Listen history (for freemium tracking)
|
||||
create table public.listen_history (
|
||||
id uuid default gen_random_uuid() primary key,
|
||||
user_id uuid references public.profiles(id) on delete cascade not null,
|
||||
podcast_id uuid references public.podcasts(id) on delete cascade not null,
|
||||
listened_at timestamptz default now()
|
||||
);
|
||||
|
||||
alter table public.listen_history enable row level security;
|
||||
create policy "Users can view own history" on public.listen_history
|
||||
for select using (auth.uid() = user_id);
|
||||
create policy "Users can insert own history" on public.listen_history
|
||||
for insert with check (auth.uid() = user_id);
|
||||
|
||||
-- Indexes
|
||||
create index idx_podcasts_creator on public.podcasts(creator_id);
|
||||
create index idx_podcasts_created on public.podcasts(created_at desc);
|
||||
create index idx_podcasts_plays on public.podcasts(plays_count desc);
|
||||
create index idx_comments_podcast on public.comments(podcast_id);
|
||||
create index idx_likes_podcast on public.likes(podcast_id);
|
||||
create index idx_follows_following on public.follows(following_id);
|
||||
create index idx_listen_history_user on public.listen_history(user_id);
|
||||
204
supabase/seed.sql
Normal file
204
supabase/seed.sql
Normal file
@ -0,0 +1,204 @@
|
||||
-- ==============================================
|
||||
-- We Talk — Seed Data
|
||||
-- Podcasts libres de droit (Creative Commons)
|
||||
-- Source: Hacker Public Radio (archive.org)
|
||||
-- ==============================================
|
||||
|
||||
-- 1. Create demo users (fake auth entries then profiles)
|
||||
-- NOTE: Run this AFTER the migration 001_initial_schema.sql
|
||||
|
||||
-- Insert profiles directly (these won't have auth.users entries,
|
||||
-- but work for display purposes)
|
||||
INSERT INTO public.profiles (id, username, avatar_url, bio, is_premium, free_listens_count) VALUES
|
||||
('a1000000-0000-0000-0000-000000000001', 'ahuka', NULL, 'Passionné de technologie et de logiciels libres. Contributeur Hacker Public Radio.', true, 0),
|
||||
('a1000000-0000-0000-0000-000000000002', 'sgoti', NULL, 'Podcaster et bricoleur informatique. J''aime parler de tout et de rien.', false, 3),
|
||||
('a1000000-0000-0000-0000-000000000003', 'klaatu', NULL, 'Explorateur numerique. Voyage, technologie et culture libre.', true, 0),
|
||||
('a1000000-0000-0000-0000-000000000004', 'windigo', NULL, 'Administrateur systeme le jour, podcaster la nuit.', false, 7),
|
||||
('a1000000-0000-0000-0000-000000000005', 'corydoctorow', NULL, 'Auteur, journaliste, activiste. Electronic Frontier Foundation.', true, 0)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 2. Create tags
|
||||
INSERT INTO public.tags (id, name, slug) VALUES
|
||||
('b1000000-0000-0000-0000-000000000001', 'Technologie', 'technologie'),
|
||||
('b1000000-0000-0000-0000-000000000002', 'Open Source', 'open-source'),
|
||||
('b1000000-0000-0000-0000-000000000003', 'Jeux Video', 'jeux-video'),
|
||||
('b1000000-0000-0000-0000-000000000004', 'Tutoriel', 'tutoriel'),
|
||||
('b1000000-0000-0000-0000-000000000005', 'Culture', 'culture'),
|
||||
('b1000000-0000-0000-0000-000000000006', 'Voyage', 'voyage'),
|
||||
('b1000000-0000-0000-0000-000000000007', 'Productivite', 'productivite'),
|
||||
('b1000000-0000-0000-0000-000000000008', 'Vie Quotidienne', 'vie-quotidienne'),
|
||||
('b1000000-0000-0000-0000-000000000009', 'Droit Numerique', 'droit-numerique'),
|
||||
('b1000000-0000-0000-0000-000000000010', 'Windows', 'windows'),
|
||||
('b1000000-0000-0000-0000-000000000011', 'Linux', 'linux'),
|
||||
('b1000000-0000-0000-0000-000000000012', 'Podcasting', 'podcasting')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 3. Insert podcasts (audio from archive.org, Creative Commons)
|
||||
INSERT INTO public.podcasts (id, creator_id, title, description, audio_url, duration_seconds, cover_url, plays_count, created_at) VALUES
|
||||
(
|
||||
'c1000000-0000-0000-0000-000000000001',
|
||||
'a1000000-0000-0000-0000-000000000001',
|
||||
'Creative Commons Search Engine',
|
||||
'Decouvrez Openverse, le nouveau moteur de recherche pour le contenu Creative Commons. Une evolution de l''ancien CC Search developpe par Creative Commons, repris par WordPress, et qui continue d''evoluer.',
|
||||
'https://archive.org/download/hpr3977/hpr3977.mp3',
|
||||
358,
|
||||
NULL,
|
||||
247,
|
||||
NOW() - INTERVAL '2 days'
|
||||
),
|
||||
(
|
||||
'c1000000-0000-0000-0000-000000000002',
|
||||
'a1000000-0000-0000-0000-000000000003',
|
||||
'Playing Alpha Centauri, Part 1',
|
||||
'Premiere partie de nos conseils pour jouer a Alpha Centauri. On commence par regarder comment approcher ce jeu de strategie classique et les bases pour bien demarrer.',
|
||||
'https://archive.org/download/hpr3970/hpr3970.mp3',
|
||||
1102,
|
||||
NULL,
|
||||
183,
|
||||
NOW() - INTERVAL '5 days'
|
||||
),
|
||||
(
|
||||
'c1000000-0000-0000-0000-000000000003',
|
||||
'a1000000-0000-0000-0000-000000000002',
|
||||
'Comment se faire des amis sur Internet',
|
||||
'Sgoti et Mugs discutent avec des amis de la facon de creer des liens authentiques en ligne. Un echange decontracte sur les relations humaines a l''ere du numerique.',
|
||||
'https://archive.org/download/hpr3971/hpr3971.mp3',
|
||||
2245,
|
||||
NULL,
|
||||
412,
|
||||
NOW() - INTERVAL '3 days'
|
||||
),
|
||||
(
|
||||
'c1000000-0000-0000-0000-000000000004',
|
||||
'a1000000-0000-0000-0000-000000000002',
|
||||
'Filtrage Thunderbird : garder une boite mail propre',
|
||||
'Sgoti explique comment filtrer efficacement votre boite de reception Thunderbird pour rester organise et productif au quotidien.',
|
||||
'https://archive.org/download/hpr3972/hpr3972.mp3',
|
||||
743,
|
||||
NULL,
|
||||
156,
|
||||
NOW() - INTERVAL '7 days'
|
||||
),
|
||||
(
|
||||
'c1000000-0000-0000-0000-000000000005',
|
||||
'a1000000-0000-0000-0000-000000000004',
|
||||
'Creer un preset d''equalisation pour vos podcasts',
|
||||
'Une methode pour creer un traitement audio reproductible pour vos episodes de podcast. Apprenez a standardiser la qualite sonore de vos productions.',
|
||||
'https://archive.org/download/hpr3973/hpr3973.mp3',
|
||||
1019,
|
||||
NULL,
|
||||
298,
|
||||
NOW() - INTERVAL '4 days'
|
||||
),
|
||||
(
|
||||
'c1000000-0000-0000-0000-000000000006',
|
||||
'a1000000-0000-0000-0000-000000000004',
|
||||
'Decouverte de USBimager',
|
||||
'Pourquoi vous devriez utiliser USBimager. Introduction a cet outil pratique pour ecrire des images sur des peripheriques de stockage en toute simplicite.',
|
||||
'https://archive.org/download/hpr3974/hpr3974.mp3',
|
||||
1019,
|
||||
NULL,
|
||||
89,
|
||||
NOW() - INTERVAL '10 days'
|
||||
),
|
||||
(
|
||||
'c1000000-0000-0000-0000-000000000007',
|
||||
'a1000000-0000-0000-0000-000000000003',
|
||||
'Mesa Verde : journal de voyage Colorado',
|
||||
'Notre recit d''une journee de visite a Mesa Verde, Colorado. Nous etions la pour un mariage et avons profite pour explorer ce site remarquable.',
|
||||
'https://archive.org/download/hpr3975/hpr3975.mp3',
|
||||
730,
|
||||
NULL,
|
||||
321,
|
||||
NOW() - INTERVAL '1 day'
|
||||
),
|
||||
(
|
||||
'c1000000-0000-0000-0000-000000000008',
|
||||
'a1000000-0000-0000-0000-000000000004',
|
||||
'L''evolution de l''outil Capture d''ecran Windows',
|
||||
'KD retrace l''histoire de l''evolution des outils de capture d''ecran sous Windows, du Print Screen au Snipping Tool moderne.',
|
||||
'https://archive.org/download/hpr3976/hpr3976.mp3',
|
||||
429,
|
||||
NULL,
|
||||
134,
|
||||
NOW() - INTERVAL '6 days'
|
||||
),
|
||||
(
|
||||
'c1000000-0000-0000-0000-000000000009',
|
||||
'a1000000-0000-0000-0000-000000000005',
|
||||
'Les trolls du droit d''auteur Creative Commons',
|
||||
'Discussion sur Pixsy, un nouveau type de troll du droit d''auteur qui cible les utilisateurs de Creative Commons. Lecture et analyse d''un article de fond sur ce sujet preoccupant.',
|
||||
'https://archive.org/download/Cory_Doctorow_Podcast_412/Cory_Doctorow_Podcast_412_-_A_Bug_in_Early_Creative_Commons_Licenses.mp3',
|
||||
2441,
|
||||
NULL,
|
||||
567,
|
||||
NOW() - INTERVAL '8 days'
|
||||
);
|
||||
|
||||
-- 4. Associate tags to podcasts
|
||||
INSERT INTO public.podcast_tags (podcast_id, tag_id) VALUES
|
||||
-- Creative Commons Search Engine
|
||||
('c1000000-0000-0000-0000-000000000001', 'b1000000-0000-0000-0000-000000000002'), -- Open Source
|
||||
('c1000000-0000-0000-0000-000000000001', 'b1000000-0000-0000-0000-000000000001'), -- Technologie
|
||||
-- Alpha Centauri
|
||||
('c1000000-0000-0000-0000-000000000002', 'b1000000-0000-0000-0000-000000000003'), -- Jeux Video
|
||||
('c1000000-0000-0000-0000-000000000002', 'b1000000-0000-0000-0000-000000000004'), -- Tutoriel
|
||||
-- Comment se faire des amis
|
||||
('c1000000-0000-0000-0000-000000000003', 'b1000000-0000-0000-0000-000000000008'), -- Vie Quotidienne
|
||||
('c1000000-0000-0000-0000-000000000003', 'b1000000-0000-0000-0000-000000000005'), -- Culture
|
||||
-- Thunderbird
|
||||
('c1000000-0000-0000-0000-000000000004', 'b1000000-0000-0000-0000-000000000007'), -- Productivite
|
||||
('c1000000-0000-0000-0000-000000000004', 'b1000000-0000-0000-0000-000000000001'), -- Technologie
|
||||
-- Equalisation podcast
|
||||
('c1000000-0000-0000-0000-000000000005', 'b1000000-0000-0000-0000-000000000012'), -- Podcasting
|
||||
('c1000000-0000-0000-0000-000000000005', 'b1000000-0000-0000-0000-000000000004'), -- Tutoriel
|
||||
-- USBimager
|
||||
('c1000000-0000-0000-0000-000000000006', 'b1000000-0000-0000-0000-000000000011'), -- Linux
|
||||
('c1000000-0000-0000-0000-000000000006', 'b1000000-0000-0000-0000-000000000002'), -- Open Source
|
||||
-- Mesa Verde
|
||||
('c1000000-0000-0000-0000-000000000007', 'b1000000-0000-0000-0000-000000000006'), -- Voyage
|
||||
('c1000000-0000-0000-0000-000000000007', 'b1000000-0000-0000-0000-000000000005'), -- Culture
|
||||
-- Snipping Tool
|
||||
('c1000000-0000-0000-0000-000000000008', 'b1000000-0000-0000-0000-000000000010'), -- Windows
|
||||
('c1000000-0000-0000-0000-000000000008', 'b1000000-0000-0000-0000-000000000001'), -- Technologie
|
||||
-- Cory Doctorow - Copyright trolls
|
||||
('c1000000-0000-0000-0000-000000000009', 'b1000000-0000-0000-0000-000000000009'), -- Droit Numerique
|
||||
('c1000000-0000-0000-0000-000000000009', 'b1000000-0000-0000-0000-000000000002'); -- Open Source
|
||||
|
||||
-- 5. Add some likes
|
||||
INSERT INTO public.likes (user_id, podcast_id) VALUES
|
||||
('a1000000-0000-0000-0000-000000000001', 'c1000000-0000-0000-0000-000000000003'),
|
||||
('a1000000-0000-0000-0000-000000000001', 'c1000000-0000-0000-0000-000000000009'),
|
||||
('a1000000-0000-0000-0000-000000000002', 'c1000000-0000-0000-0000-000000000001'),
|
||||
('a1000000-0000-0000-0000-000000000002', 'c1000000-0000-0000-0000-000000000007'),
|
||||
('a1000000-0000-0000-0000-000000000002', 'c1000000-0000-0000-0000-000000000009'),
|
||||
('a1000000-0000-0000-0000-000000000003', 'c1000000-0000-0000-0000-000000000005'),
|
||||
('a1000000-0000-0000-0000-000000000003', 'c1000000-0000-0000-0000-000000000003'),
|
||||
('a1000000-0000-0000-0000-000000000004', 'c1000000-0000-0000-0000-000000000001'),
|
||||
('a1000000-0000-0000-0000-000000000004', 'c1000000-0000-0000-0000-000000000003'),
|
||||
('a1000000-0000-0000-0000-000000000004', 'c1000000-0000-0000-0000-000000000007'),
|
||||
('a1000000-0000-0000-0000-000000000005', 'c1000000-0000-0000-0000-000000000003'),
|
||||
('a1000000-0000-0000-0000-000000000005', 'c1000000-0000-0000-0000-000000000005'),
|
||||
('a1000000-0000-0000-0000-000000000005', 'c1000000-0000-0000-0000-000000000007');
|
||||
|
||||
-- 6. Add some comments
|
||||
INSERT INTO public.comments (id, user_id, podcast_id, content, created_at) VALUES
|
||||
('d1000000-0000-0000-0000-000000000001', 'a1000000-0000-0000-0000-000000000002', 'c1000000-0000-0000-0000-000000000001', 'Super episode ! Je ne connaissais pas Openverse, merci pour la decouverte.', NOW() - INTERVAL '1 day'),
|
||||
('d1000000-0000-0000-0000-000000000002', 'a1000000-0000-0000-0000-000000000003', 'c1000000-0000-0000-0000-000000000001', 'Tres utile pour trouver du contenu libre. Je recommande aussi ccMixter pour la musique.', NOW() - INTERVAL '12 hours'),
|
||||
('d1000000-0000-0000-0000-000000000003', 'a1000000-0000-0000-0000-000000000001', 'c1000000-0000-0000-0000-000000000003', 'J''adore ce format de discussion decontractee. On s''y retrouve tous un peu !', NOW() - INTERVAL '2 days'),
|
||||
('d1000000-0000-0000-0000-000000000004', 'a1000000-0000-0000-0000-000000000004', 'c1000000-0000-0000-0000-000000000003', 'Tellement vrai ce qui est dit sur les communautes en ligne. Bel episode.', NOW() - INTERVAL '2 days'),
|
||||
('d1000000-0000-0000-0000-000000000005', 'a1000000-0000-0000-0000-000000000005', 'c1000000-0000-0000-0000-000000000009', 'Ce sujet me tient a coeur. Les trolls du copyright sont un vrai probleme pour le libre.', NOW() - INTERVAL '7 days'),
|
||||
('d1000000-0000-0000-0000-000000000006', 'a1000000-0000-0000-0000-000000000001', 'c1000000-0000-0000-0000-000000000005', 'Exactement ce qu''il me fallait pour ameliorer le son de mon podcast. Merci !', NOW() - INTERVAL '3 days'),
|
||||
('d1000000-0000-0000-0000-000000000007', 'a1000000-0000-0000-0000-000000000002', 'c1000000-0000-0000-0000-000000000007', 'Mesa Verde a l''air magnifique. Ca donne envie de voyager !', NOW() - INTERVAL '20 hours'),
|
||||
('d1000000-0000-0000-0000-000000000008', 'a1000000-0000-0000-0000-000000000003', 'c1000000-0000-0000-0000-000000000002', 'Alpha Centauri, quel classique ! J''attends la partie 2 avec impatience.', NOW() - INTERVAL '4 days');
|
||||
|
||||
-- 7. Add some follows
|
||||
INSERT INTO public.follows (follower_id, following_id) VALUES
|
||||
('a1000000-0000-0000-0000-000000000001', 'a1000000-0000-0000-0000-000000000005'),
|
||||
('a1000000-0000-0000-0000-000000000002', 'a1000000-0000-0000-0000-000000000001'),
|
||||
('a1000000-0000-0000-0000-000000000002', 'a1000000-0000-0000-0000-000000000003'),
|
||||
('a1000000-0000-0000-0000-000000000003', 'a1000000-0000-0000-0000-000000000002'),
|
||||
('a1000000-0000-0000-0000-000000000003', 'a1000000-0000-0000-0000-000000000005'),
|
||||
('a1000000-0000-0000-0000-000000000004', 'a1000000-0000-0000-0000-000000000001'),
|
||||
('a1000000-0000-0000-0000-000000000004', 'a1000000-0000-0000-0000-000000000002'),
|
||||
('a1000000-0000-0000-0000-000000000005', 'a1000000-0000-0000-0000-000000000003');
|
||||
27
tsconfig.app.json
Normal file
27
tsconfig.app.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "esnext",
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["src/*"] },
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
tsconfig.node.json
Normal file
24
tsconfig.node.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"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 */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
34
vite.config.ts
Normal file
34
vite.config.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.svg', 'icons/*.png'],
|
||||
manifest: {
|
||||
name: 'We Talk',
|
||||
short_name: 'WeTalk',
|
||||
description: 'Podcast communautaire — écoutez, créez, partagez.',
|
||||
theme_color: '#4A90D9',
|
||||
background_color: '#FAFAF8',
|
||||
display: 'standalone',
|
||||
start_url: '/',
|
||||
icons: [
|
||||
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': '/src',
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user