commit a61674147052d6f1604990f17cf4750a30c877bf Author: GarandPLG Date: Tue Sep 10 21:30:40 2024 +0200 Pierwszy commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2a20c49 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.vscode/* +node_modules +dist diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a17a986 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# Build stage +FROM oven/bun:latest as build + +WORKDIR /app + +COPY package.json bun.lockb ./ +RUN bun install + +COPY . . +RUN bun run build + +# Production stage +FROM nginx:alpine + +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0353b38 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# Reddit-Like-App + +Reddit-Like-App to aplikacja webowa inspirowana Redditem, stworzona przy użyciu React, TypeScript oraz szeregu nowoczesnych narzędzi takich jak Vite, Bun, TailwindCSS, Jotai, Docker, Nginx oraz PocketBase (BaaS). Użytkownicy mogą rejestrować konta, dodawać posty oraz głosować na nie w czasie rzeczywistym. Posty są dynamicznie sortowane według liczby głosów, a zmiany są automatycznie aktualizowane dzięki funkcjom real-time PocketBase. + +## Technologie +- **Frontend**: React, Vite, TypeScript, TailwindCSS, PostCSS, Jotai +- **Backend**: PocketBase (Backend as a Service) +- **Serwowanie aplikacji**: Nginx, Docker +- **Inne**: Obsługa realtime za pomocą PocketBase + +## Funkcje +- Tworzenie kont użytkowników +- Dodawanie postów +- Głosowanie na posty (dodawanie/odejmowanie punktów) +- Dynamiczne sortowanie postów według liczby głosów +- Aktualizacje w czasie rzeczywistym dzięki PocketBase + +## Wymagania +- Bun +- Docker (jeśli chcesz uruchomić aplikację w kontenerze) +- PocketBase (BaaS) - uruchomiony lokalnie lub zdalnie diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..325e239 Binary files /dev/null and b/bun.lockb differ diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..f628212 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,5 @@ +services: + app: + build: . + ports: + - "3333:80" \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..092408a --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,28 @@ +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' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/index.html b/index.html new file mode 100644 index 0000000..803b0a4 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + Reddit-Like-App + + +
+ + + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..333bc49 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,8 @@ +server { + listen 80; + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..b5c28de --- /dev/null +++ b/package.json @@ -0,0 +1,37 @@ +{ + "name": "reddit-like-app", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "build-tsc": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@types/react-router-dom": "^5.3.3", + "autoprefixer": "^10.4.20", + "jotai": "^2.9.3", + "pocketbase": "^0.21.5", + "postcss": "^8.4.45", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2", + "tailwindcss": "^3.4.10" + }, + "devDependencies": { + "@eslint/js": "^9.9.0", + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react-swc": "^3.5.0", + "eslint": "^9.9.0", + "eslint-plugin-react-hooks": "^5.1.0-rc.0", + "eslint-plugin-react-refresh": "^0.4.9", + "globals": "^15.9.0", + "typescript": "^5.6.2", + "typescript-eslint": "^8.0.1", + "vite": "^5.4.1" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/public/garand-ico.svg b/public/garand-ico.svg new file mode 100644 index 0000000..aa57bc5 --- /dev/null +++ b/public/garand-ico.svg @@ -0,0 +1,182 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..2c5021b --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; +import { Provider } from 'jotai'; +import Header from './components/Header'; +import Home from './pages/Home'; +import Login from './pages/Login'; +import Register from './pages/Register'; +import ForgotPassword from './pages/ForgotPassword'; +import Profile from './pages/Profile'; +import CreatePost from './pages/CreatePost'; + +const App: React.FC = () => { + return ( + + +
+
+
+ + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+
+
+ ); +}; + +export default App; + +// Rdzenny komponent aplikacji. Tutaj używany jest nagłowek +// oraz definiowane są adresy url oraz +// jaki komponent się pod nimi znajduje. \ No newline at end of file diff --git a/src/atoms/authAtom.ts b/src/atoms/authAtom.ts new file mode 100644 index 0000000..6365960 --- /dev/null +++ b/src/atoms/authAtom.ts @@ -0,0 +1,15 @@ +import { atom } from 'jotai'; +import { pb } from '../lib/pocketbase'; + +export const authAtom = atom(pb.authStore.isValid); + +export const updateAuthAtom = atom( + (get) => get(authAtom), + (_, set) => { + set(authAtom, pb.authStore.isValid); + } +); + +// Utworzenie store przy użyciu Jotai +// do przechowywania danych o zalogowanym +// użytkowniku między komponentami. diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..27f017c --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { useAtom } from 'jotai'; +import { authAtom, updateAuthAtom } from '../atoms/authAtom'; +import { pb } from '../lib/pocketbase'; + +const Header: React.FC = () => { + const [isAuth] = useAtom(authAtom); + const [, updateAuth] = useAtom(updateAuthAtom); + + const handleLogout = () => { + pb.authStore.clear(); + updateAuth(); + }; + + return ( +
+
+ Reddit-Like-App + Kod źródłowy + +
+
+ ); +}; + +export default Header; + +// komponent odpowiedzialny za nagłówek strony diff --git a/src/components/Post.tsx b/src/components/Post.tsx new file mode 100644 index 0000000..b6b10de --- /dev/null +++ b/src/components/Post.tsx @@ -0,0 +1,103 @@ +import React, { useState, useEffect } from 'react'; +import { useAtom } from 'jotai'; +import { authAtom } from '../atoms/authAtom'; +import { pb } from '../lib/pocketbase'; + +interface PostProps { + post: { + id: string; + title: string; + content: string; + user: string; + votes: number; + created: string; + }; + onVoteChange?: (postId: string, newVotes: number) => void; +} + +const Post: React.FC = ({ post, onVoteChange }) => { + const [isAuth] = useAtom(authAtom); + const [username, setUsername] = useState(''); + const [votes, setVotes] = useState(post.votes); + + useEffect(() => { + const fetchUsername = async () => { + try { + const user = await pb.collection("users").getOne(post.user); + setUsername(user.username); + } catch (error) { + console.error('Błąd przy pobieraniu nazwy użytkownika:', error); + setUsername('Nieznany użytkownik'); + } + }; + fetchUsername(); + }, [post.user]); + + useEffect(() => { + const subscribeToPost = async () => { + try { + pb.realtime.subscribe(`posts/${post.id}`, (e) => { + if (e.action === 'update' && e.record.id === post.id) { + setVotes(e.record.votes); + } + }); + } catch (error) { + console.error('Błąd przy renderowaniu głosów in real time:', error); + } + }; + + subscribeToPost(); + + return () => { + pb.realtime.unsubscribe(`posts/${post.id}`); + }; + }, [post.id]); + + const handleVote = async (type: 'up' | 'down') => { + if (!isAuth) { + alert('Musisz się zalogować, by móc głosować.'); + return; + } + + const newVotes = type === 'up' ? votes + 1 : votes - 1; + setVotes(newVotes); + + try { + await pb.collection('posts').update(post.id, { votes: newVotes }); + if (onVoteChange) { + onVoteChange(post.id, newVotes); + } + } catch (error) { + console.error('Błąd przy głosowaniu:', error); + setVotes(votes); + alert('Nie udało się dodać głosu. Spróbuj jeszcze raz.'); + } + }; + + return ( +
+
+ + + {votes} + + + + | + + + {username} + + + | + + {new Date(post.created).toLocaleString()} +
+

{post.title}

+

{post.content}

+
+)}; + +export default Post; + +// Komponent odpowiedzialny za renderowanie pojedynczego posta. diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/src/lib/pocketbase.ts b/src/lib/pocketbase.ts new file mode 100644 index 0000000..33a3731 --- /dev/null +++ b/src/lib/pocketbase.ts @@ -0,0 +1,7 @@ +import PocketBase from 'pocketbase'; + +export const pb = new PocketBase('https://pb.garandplg.com').autoCancellation(false); + +// Inicjacja połączenia z moją instancją pocketbase +// oraz wyeksportowanie tego połączenia +// by było łatwo dostępne w projekcie. diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..6f4ac9b --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './App.tsx' +import './index.css' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/src/pages/CreatePost.tsx b/src/pages/CreatePost.tsx new file mode 100644 index 0000000..e221911 --- /dev/null +++ b/src/pages/CreatePost.tsx @@ -0,0 +1,60 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { pb } from '../lib/pocketbase'; + +const CreatePost: React.FC = () => { + const [title, setTitle] = useState(''); + const [content, setContent] = useState(''); + const navigate = useNavigate(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await pb.collection('posts').create({ + title, + content, + user: pb.authStore.model!.id, + votes: 0, + }); + navigate('/'); + } catch (error) { + console.error('Błąd przy tworzeniu posta:', error); + alert('Nie udało się stworzyć posta'); + } + }; + + return ( +
+

Stwórz post

+
+
+ + setTitle(e.target.value)} + required + className="w-full px-3 py-2 border rounded" + /> +
+
+ + +
+ +
+
+ ); +}; + +export default CreatePost; + +// Formularz dodawania posta. diff --git a/src/pages/ForgotPassword.tsx b/src/pages/ForgotPassword.tsx new file mode 100644 index 0000000..dd5b767 --- /dev/null +++ b/src/pages/ForgotPassword.tsx @@ -0,0 +1,43 @@ +import React, { useState } from 'react'; +import { pb } from '../lib/pocketbase'; + +const ForgotPassword: React.FC = () => { + const [email, setEmail] = useState(''); + const [message, setMessage] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await pb.collection('users').requestPasswordReset(email); + setMessage('Mail z resetem hasła został wysłany.'); + } catch (error) { + console.error('Błąd z resetem hasła:', error); + setMessage('Nie udało się wysłać maila z resetem hasła.'); + } + }; + + return ( +
+

Zapomniałem hasła

+
+
+ + setEmail(e.target.value)} + required + className="w-full px-3 py-2 border rounded" + /> +
+ +
+ {message &&

{message}

} +
+ ); +}; + +export default ForgotPassword; + +// Formularz przypominania hasła. diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx new file mode 100644 index 0000000..a68e2e9 --- /dev/null +++ b/src/pages/Home.tsx @@ -0,0 +1,69 @@ +import React, { useState, useEffect } from 'react'; +import { pb } from '../lib/pocketbase'; +import Post from '../components/Post'; + +interface PostType { + id: string; + title: string; + content: string; + user: string; + votes: number; + created: string; +} + +const Home: React.FC = () => { + const [posts, setPosts] = useState([]); + + useEffect(() => { + const fetchPosts = async () => { + try { + const records = await pb.collection('posts').getFullList({ + sort: '-votes', + expand: 'user', + }); + setPosts(records); + } catch (error) { + console.error('Błąd przy pobieraniu postów:', error); + } + }; + + fetchPosts(); + + pb.realtime.subscribe('posts', (e) => { + const updatedRecord = e.record as PostType; + + if (e.action === 'create') { + setPosts((prevPosts) => [updatedRecord, ...prevPosts].sort((a, b) => b.votes - a.votes)); + } else if (e.action === 'update') { + setPosts((prevPosts) => + [...prevPosts.map((post) => + post.id === updatedRecord.id ? updatedRecord : post + )].sort((a, b) => b.votes - a.votes) + ); + } else if (e.action === 'delete') { + setPosts((prevPosts) => + prevPosts.filter((post) => post.id !== updatedRecord.id) + ); + } + }); + + return () => { + pb.realtime.unsubscribe(); + }; + }, []); + + return ( +
+

Najwyżej oceniane posty

+
+ {posts.map((post) => ( + + ))} +
+
+ ); +}; + +export default Home; + +// Strona głowna, tutaj wyświetlają się posty. diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx new file mode 100644 index 0000000..2a846ab --- /dev/null +++ b/src/pages/Login.tsx @@ -0,0 +1,62 @@ +import React, { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { pb } from '../lib/pocketbase'; +import { useAtom } from 'jotai'; +import { updateAuthAtom } from '../atoms/authAtom'; + +const Login: React.FC = () => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [, updateAuth] = useAtom(updateAuthAtom); + const navigate = useNavigate(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await pb.collection('users').authWithPassword(email, password); + updateAuth(); + navigate('/'); + } catch (error) { + console.error('Błąd logowania:', error); + alert('Nie udało się zalogować'); + } + }; + + return ( +
+

Login

+
+
+ + setEmail(e.target.value)} + required + className="w-full px-3 py-2 border rounded" + /> +
+
+ + setPassword(e.target.value)} + required + className="w-full px-3 py-2 border rounded" + /> +
+ +
+
+ Zapomniałeś hasła? +
+
+ ); +}; + +export default Login; + +// Formularz logowania się. diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx new file mode 100644 index 0000000..757566e --- /dev/null +++ b/src/pages/Profile.tsx @@ -0,0 +1,99 @@ +import React, { useState, useEffect } from 'react'; +import { pb } from '../lib/pocketbase'; +import { useAtom } from 'jotai'; +import { updateAuthAtom } from '../atoms/authAtom'; + +const Profile: React.FC = () => { + const [email, setEmail] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [, updateAuth] = useAtom(updateAuthAtom); + + useEffect(() => { + if (pb.authStore.model) { + setEmail(pb.authStore.model.email); + } + }, []); + + const handleUpdateProfile = async (e: React.FormEvent) => { + e.preventDefault(); + if (newPassword && newPassword !== confirmPassword) { + alert("Hasła nie są takie same"); + return; + } + + try { + const data: Record = { email }; + if (newPassword) { + data.password = newPassword; + data.passwordConfirm = confirmPassword; + } + + await pb.collection('users').update(pb.authStore.model!.id, data); + alert('Profile updated successfully'); + updateAuth(); + } catch (error) { + console.error('Profile update error:', error); + alert('Failed to update profile'); + } + }; + + const handleDeleteAccount = async () => { + if (window.confirm('Jesteś pewien, że chcesz usunąć konto? Tej akcji nie da się cofnąć.')) { + try { + await pb.collection('users').delete(pb.authStore.model!.id); + pb.authStore.clear(); + updateAuth(); + alert('Konto poprawnie usunięte'); + } catch (error) { + console.error('Błąd z usuwaniem konta:', error); + alert('Nie udało się usunąc konta.'); + } + } + }; + + return ( +
+

Profile

+
+
+ + setEmail(e.target.value)} + required + className="w-full px-3 py-2 border rounded" + /> +
+
+ + setNewPassword(e.target.value)} + className="w-full px-3 py-2 border rounded" + /> +
+
+ + setConfirmPassword(e.target.value)} + className="w-full px-3 py-2 border rounded" + /> +
+ +
+ +
+ ); +}; + +export default Profile; + +// Formularz zmieniania profilu użytkownika diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx new file mode 100644 index 0000000..3533f51 --- /dev/null +++ b/src/pages/Register.tsx @@ -0,0 +1,97 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { pb } from '../lib/pocketbase'; +import { useAtom } from 'jotai'; +import { updateAuthAtom } from '../atoms/authAtom'; + +const Register: React.FC = () => { + const [email, setEmail] = useState(''); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [passwordConfirm, setPasswordConfirm] = useState(''); + const [, updateAuth] = useAtom(updateAuthAtom); + const navigate = useNavigate(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (password !== passwordConfirm) { + alert("Hasła nie są takie same!"); + return; + } + try { + const createdUser = await pb.collection('users').create({ + email, + username, + password, + passwordConfirm, + }); + + if (createdUser) { + await pb.collection('users').requestVerification(email); + alert('Udana rejestracja. Sprawdź swój adres email aby aktywować konto.'); + } + + navigate('/login'); + } catch (error) { + console.error('Błąd rejestracji:', error); + alert('Nieudana rejestracja'); + } + }; + + return ( +
+

Zarejestruj się

+
+
+ + setEmail(e.target.value)} + required + className="w-full px-3 py-2 border rounded" + /> +
+
+ + setUsername(e.target.value)} + required + className="w-full px-3 py-2 border rounded" + /> +
+
+ + setPassword(e.target.value)} + required + className="w-full px-3 py-2 border rounded" + /> +
+
+ + setPasswordConfirm(e.target.value)} + required + className="w-full px-3 py-2 border rounded" + /> +
+ +
+
+ ); +}; + +export default Register; + +// Formularz rejestracji. diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..89a305e --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} \ No newline at end of file diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..f0a2350 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..0d3d714 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..0dd4c08 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react-swc' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + build: { + outDir: 'dist', + } +})