feat: We Talk — podcast communautaire PWA
Some checks failed
Build & Deploy / build-and-deploy (push) Failing after 16s

This commit is contained in:
ordinarthur 2026-04-12 11:45:29 +02:00
commit 503e658f03
51 changed files with 10990 additions and 0 deletions

11
.claude/launch.json Normal file
View File

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "wetalk-dev",
"runtimeExecutable": "./node_modules/.bin/vite",
"runtimeArgs": ["--port", "5173"],
"port": 5173
}
]
}

View File

@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(npm install:*)",
"WebFetch(domain:archive.org)",
"WebSearch"
]
}
}

5
.dockerignore Normal file
View File

@ -0,0 +1,5 @@
node_modules
dist
.git
.env
.env.local

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key

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

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [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
View File

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

18
index.html Normal file
View 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
View 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
View File

@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: wetalk

13
k8s/service.yml Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

41
package.json Normal file
View 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
View 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
View 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>
)
}

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

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

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

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

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

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

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

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

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

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

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

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

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
tsconfig.node.json Normal file
View 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
View 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',
},
},
})