Initial commit
This commit is contained in:
commit
53f35caeab
18 changed files with 5527 additions and 0 deletions
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
28
eslint.config.js
Normal file
28
eslint.config.js
Normal file
|
@ -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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
13
index.html
Normal file
13
index.html
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Creating Nostr-based Wykop Clone</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
3243
package-lock.json
generated
Normal file
3243
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
32
package.json
Normal file
32
package.json
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"name": "nostrop",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lucide-react": "^0.344.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"nostr-tools": "^2.1.5",
|
||||||
|
"react-router-dom": "^6.22.3",
|
||||||
|
"date-fns": "^3.3.1",
|
||||||
|
"zustand": "^4.5.2",
|
||||||
|
"@noble/secp256k1": "^2.0.0",
|
||||||
|
"sass": "^1.71.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.9.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"eslint": "^9.9.1",
|
||||||
|
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.11",
|
||||||
|
"globals": "^15.9.0",
|
||||||
|
"vite": "^5.4.2"
|
||||||
|
}
|
||||||
|
}
|
32
src/App.jsx
Normal file
32
src/App.jsx
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||||
|
import Header from './components/Header';
|
||||||
|
import Home from './pages/Home';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import Mikroblog from './pages/Mikroblog';
|
||||||
|
import Profile from './pages/Profile';
|
||||||
|
import AdminPanel from './pages/AdminPanel';
|
||||||
|
import Settings from './pages/Settings';
|
||||||
|
import './styles/main.scss';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<Router>
|
||||||
|
<div>
|
||||||
|
<Header />
|
||||||
|
<main className="main container">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/mikroblog" element={<Mikroblog />} />
|
||||||
|
<Route path="/profile/:pubkey" element={<Profile />} />
|
||||||
|
<Route path="/admin" element={<AdminPanel />} />
|
||||||
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
52
src/components/Header.jsx
Normal file
52
src/components/Header.jsx
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { LogIn, LogOut, Home, MessageSquare, Settings, Shield } from 'lucide-react';
|
||||||
|
import { useStore } from '../store/useStore';
|
||||||
|
|
||||||
|
function Header() {
|
||||||
|
const { publicKey, profile, logout } = useStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="header">
|
||||||
|
<div className="header__container">
|
||||||
|
<div className="header__nav">
|
||||||
|
<Link to="/" className="header__logo">
|
||||||
|
<MessageSquare />
|
||||||
|
Nostrop
|
||||||
|
</Link>
|
||||||
|
<Link to="/">
|
||||||
|
<Home size={16} />
|
||||||
|
Główna
|
||||||
|
</Link>
|
||||||
|
<Link to="/mikroblog">Mikroblog</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="header__nav">
|
||||||
|
{publicKey ? (
|
||||||
|
<>
|
||||||
|
{(profile?.role === 'admin' || profile?.role === 'moderator') && (
|
||||||
|
<Link to="/admin">
|
||||||
|
<Shield size={16} />
|
||||||
|
Panel
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Link to="/settings">
|
||||||
|
<Settings size={20} />
|
||||||
|
</Link>
|
||||||
|
<button onClick={logout} className="button">
|
||||||
|
<LogOut size={20} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Link to="/login" className="button">
|
||||||
|
<LogIn size={20} />
|
||||||
|
Zaloguj
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Header;
|
9
src/main.jsx
Normal file
9
src/main.jsx
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
262
src/pages/AdminPanel.jsx
Normal file
262
src/pages/AdminPanel.jsx
Normal file
|
@ -0,0 +1,262 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Shield, User, MessageSquare, Ban, CheckCircle, XCircle } from 'lucide-react';
|
||||||
|
import { useStore } from '../store/useStore';
|
||||||
|
import { fetchAllPosts, fetchAllUsers, updateUserRole, banUser, removePost } from '../utils/nostr';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { pl } from 'date-fns/locale';
|
||||||
|
|
||||||
|
function AdminPanel() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { publicKey, profile } = useStore();
|
||||||
|
const [activeTab, setActiveTab] = useState('users');
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
|
const [posts, setPosts] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [processingAction, setProcessingAction] = useState({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!publicKey || !profile || (profile.role !== 'admin' && profile.role !== 'moderator')) {
|
||||||
|
navigate('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, [publicKey, profile, navigate]);
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [fetchedUsers, fetchedPosts] = await Promise.all([
|
||||||
|
fetchAllUsers(),
|
||||||
|
fetchAllPosts()
|
||||||
|
]);
|
||||||
|
setUsers(fetchedUsers);
|
||||||
|
setPosts(fetchedPosts);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading admin data:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateRole(userId, newRole) {
|
||||||
|
if (processingAction[userId]) return;
|
||||||
|
|
||||||
|
setProcessingAction(prev => ({ ...prev, [userId]: true }));
|
||||||
|
try {
|
||||||
|
await updateUserRole(userId, newRole);
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating user role:', error);
|
||||||
|
} finally {
|
||||||
|
setProcessingAction(prev => ({ ...prev, [userId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBanUser(userId, isBanned) {
|
||||||
|
if (processingAction[userId]) return;
|
||||||
|
|
||||||
|
setProcessingAction(prev => ({ ...prev, [userId]: true }));
|
||||||
|
try {
|
||||||
|
await banUser(userId, isBanned);
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating user ban status:', error);
|
||||||
|
} finally {
|
||||||
|
setProcessingAction(prev => ({ ...prev, [userId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemovePost(postId) {
|
||||||
|
if (processingAction[postId]) return;
|
||||||
|
|
||||||
|
setProcessingAction(prev => ({ ...prev, [postId]: true }));
|
||||||
|
try {
|
||||||
|
await removePost(postId);
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing post:', error);
|
||||||
|
} finally {
|
||||||
|
setProcessingAction(prev => ({ ...prev, [postId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="spinner" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-panel">
|
||||||
|
<div className="card">
|
||||||
|
<div className="admin-panel__header">
|
||||||
|
<h1>
|
||||||
|
<Shield size={24} />
|
||||||
|
Panel {profile?.role === 'admin' ? 'Administratora' : 'Moderatora'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tabs">
|
||||||
|
<button
|
||||||
|
className={`tab ${activeTab === 'users' ? 'tab--active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('users')}
|
||||||
|
>
|
||||||
|
<User size={16} />
|
||||||
|
Użytkownicy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`tab ${activeTab === 'posts' ? 'tab--active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('posts')}
|
||||||
|
>
|
||||||
|
<MessageSquare size={16} />
|
||||||
|
Wpisy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'users' ? (
|
||||||
|
<div className="admin-panel__users">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Użytkownik</th>
|
||||||
|
<th>Rola</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Akcje</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map(user => (
|
||||||
|
<tr key={user.id} className={user.banned ? 'table__row--banned' : ''}>
|
||||||
|
<td>
|
||||||
|
<div className="table__user">
|
||||||
|
{user.picture ? (
|
||||||
|
<img src={user.picture} alt={user.name} />
|
||||||
|
) : (
|
||||||
|
<User size={24} />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<strong>{user.name || user.id.slice(0, 8)}</strong>
|
||||||
|
<small>{user.id}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{user.role || 'user'}</td>
|
||||||
|
<td>
|
||||||
|
{user.banned ? (
|
||||||
|
<span className="badge badge--danger">
|
||||||
|
<Ban size={14} />
|
||||||
|
Zbanowany
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge--success">
|
||||||
|
<CheckCircle size={14} />
|
||||||
|
Aktywny
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="table__actions">
|
||||||
|
{profile?.role === 'admin' && (
|
||||||
|
<select
|
||||||
|
value={user.role || 'user'}
|
||||||
|
onChange={(e) => handleUpdateRole(user.id, e.target.value)}
|
||||||
|
disabled={processingAction[user.id]}
|
||||||
|
>
|
||||||
|
<option value="user">Użytkownik</option>
|
||||||
|
<option value="moderator">Moderator</option>
|
||||||
|
<option value="admin">Administrator</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className={`button ${user.banned ? 'button--success' : 'button--danger'}`}
|
||||||
|
onClick={() => handleBanUser(user.id, !user.banned)}
|
||||||
|
disabled={processingAction[user.id]}
|
||||||
|
>
|
||||||
|
{user.banned ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle size={16} />
|
||||||
|
Odbanuj
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ban size={16} />
|
||||||
|
Zbanuj
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="admin-panel__posts">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Autor</th>
|
||||||
|
<th>Treść</th>
|
||||||
|
<th>Data</th>
|
||||||
|
<th>Akcje</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{posts.map(post => (
|
||||||
|
<tr key={post.id}>
|
||||||
|
<td>
|
||||||
|
<div className="table__user">
|
||||||
|
{post.author.picture ? (
|
||||||
|
<img src={post.author.picture} alt={post.author.name} />
|
||||||
|
) : (
|
||||||
|
<User size={24} />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<strong>{post.author.name || post.author.id.slice(0, 8)}</strong>
|
||||||
|
<small>{post.author.id}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="table__content">
|
||||||
|
<p>{post.content}</p>
|
||||||
|
<div className="table__meta">
|
||||||
|
<span>
|
||||||
|
{post.votes.up - post.votes.down} punktów
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>
|
||||||
|
{post.comments} komentarzy
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{formatDistanceToNow(post.createdAt * 1000, {
|
||||||
|
addSuffix: true,
|
||||||
|
locale: pl
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="button button--danger"
|
||||||
|
onClick={() => handleRemovePost(post.id)}
|
||||||
|
disabled={processingAction[post.id]}
|
||||||
|
>
|
||||||
|
<XCircle size={16} />
|
||||||
|
Usuń
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminPanel;
|
239
src/pages/Home.jsx
Normal file
239
src/pages/Home.jsx
Normal file
|
@ -0,0 +1,239 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { ArrowUp, ArrowDown, MessageSquare, Send, Reply } from 'lucide-react';
|
||||||
|
import { useStore } from '../store/useStore';
|
||||||
|
import { fetchPosts, publishPost, vote, fetchComments } from '../utils/nostr';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { pl } from 'date-fns/locale';
|
||||||
|
|
||||||
|
function Home() {
|
||||||
|
const [posts, setPosts] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [newPost, setNewPost] = useState('');
|
||||||
|
const [publishing, setPublishing] = useState(false);
|
||||||
|
const [votingStates, setVotingStates] = useState({});
|
||||||
|
const [expandedComments, setExpandedComments] = useState({});
|
||||||
|
const [comments, setComments] = useState({});
|
||||||
|
const [newComments, setNewComments] = useState({});
|
||||||
|
const [publishingComments, setPublishingComments] = useState({});
|
||||||
|
const { publicKey, privateKey } = useStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPosts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function loadPosts() {
|
||||||
|
try {
|
||||||
|
const fetchedPosts = await fetchPosts();
|
||||||
|
setPosts(fetchedPosts.sort((a, b) => b.createdAt - a.createdAt));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading posts:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePublishPost(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newPost.trim() || !privateKey) return;
|
||||||
|
|
||||||
|
setPublishing(true);
|
||||||
|
try {
|
||||||
|
await publishPost(newPost, privateKey);
|
||||||
|
setNewPost('');
|
||||||
|
await loadPosts();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error publishing post:', error);
|
||||||
|
} finally {
|
||||||
|
setPublishing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVote(postId, postAuthor, isUpvote) {
|
||||||
|
if (!privateKey || votingStates[postId]) return;
|
||||||
|
|
||||||
|
setVotingStates(prev => ({ ...prev, [postId]: true }));
|
||||||
|
try {
|
||||||
|
await vote(postId, postAuthor, isUpvote, privateKey);
|
||||||
|
await loadPosts();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error voting:', error);
|
||||||
|
} finally {
|
||||||
|
setVotingStates(prev => ({ ...prev, [postId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExpandComments(postId) {
|
||||||
|
if (!expandedComments[postId]) {
|
||||||
|
try {
|
||||||
|
const fetchedComments = await fetchComments(postId);
|
||||||
|
setComments(prev => ({ ...prev, [postId]: fetchedComments }));
|
||||||
|
setExpandedComments(prev => ({ ...prev, [postId]: true }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching comments:', error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setExpandedComments(prev => ({ ...prev, [postId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePublishComment(postId) {
|
||||||
|
if (!newComments[postId]?.trim() || !privateKey) return;
|
||||||
|
|
||||||
|
setPublishingComments(prev => ({ ...prev, [postId]: true }));
|
||||||
|
try {
|
||||||
|
await publishPost(newComments[postId], privateKey, postId);
|
||||||
|
setNewComments(prev => ({ ...prev, [postId]: '' }));
|
||||||
|
const fetchedComments = await fetchComments(postId);
|
||||||
|
setComments(prev => ({ ...prev, [postId]: fetchedComments }));
|
||||||
|
await loadPosts();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error publishing comment:', error);
|
||||||
|
} finally {
|
||||||
|
setPublishingComments(prev => ({ ...prev, [postId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="spinner" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{publicKey && (
|
||||||
|
<div className="card">
|
||||||
|
<form onSubmit={handlePublishPost}>
|
||||||
|
<textarea
|
||||||
|
value={newPost}
|
||||||
|
onChange={(e) => setNewPost(e.target.value)}
|
||||||
|
placeholder="Co słychać?"
|
||||||
|
className="post-input"
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={publishing || !newPost.trim()}
|
||||||
|
className="button"
|
||||||
|
>
|
||||||
|
<Send size={20} />
|
||||||
|
{publishing ? 'Publikowanie...' : 'Opublikuj'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2>Gorące dyskusje</h2>
|
||||||
|
{posts.length === 0 ? (
|
||||||
|
<p className="text-light">Brak postów do wyświetlenia</p>
|
||||||
|
) : (
|
||||||
|
posts.map((post) => (
|
||||||
|
<div key={post.id} className="post">
|
||||||
|
<div className="post__votes">
|
||||||
|
<button
|
||||||
|
className={`button ${!publicKey || votingStates[post.id] ? 'button--disabled' : ''}`}
|
||||||
|
onClick={() => handleVote(post.id, post.author, true)}
|
||||||
|
disabled={!publicKey || votingStates[post.id]}
|
||||||
|
>
|
||||||
|
<ArrowUp size={24} />
|
||||||
|
</button>
|
||||||
|
<span>{post.votes.up - post.votes.down}</span>
|
||||||
|
<button
|
||||||
|
className={`button ${!publicKey || votingStates[post.id] ? 'button--disabled' : ''}`}
|
||||||
|
onClick={() => handleVote(post.id, post.author, false)}
|
||||||
|
disabled={!publicKey || votingStates[post.id]}
|
||||||
|
>
|
||||||
|
<ArrowDown size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="post__content">
|
||||||
|
<h3>{post.content}</h3>
|
||||||
|
<div className="post__meta">
|
||||||
|
<span>przez {post.author.slice(0, 8)}...</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>
|
||||||
|
{formatDistanceToNow(post.createdAt * 1000, {
|
||||||
|
addSuffix: true,
|
||||||
|
locale: pl
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleExpandComments(post.id)}
|
||||||
|
className="button button--link"
|
||||||
|
>
|
||||||
|
<MessageSquare size={16} />
|
||||||
|
{post.comments} komentarzy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedComments[post.id] && (
|
||||||
|
<div className="comments">
|
||||||
|
{publicKey && (
|
||||||
|
<div className="comments__form">
|
||||||
|
<textarea
|
||||||
|
value={newComments[post.id] || ''}
|
||||||
|
onChange={(e) => setNewComments(prev => ({
|
||||||
|
...prev,
|
||||||
|
[post.id]: e.target.value
|
||||||
|
}))}
|
||||||
|
placeholder="Napisz komentarz..."
|
||||||
|
className="post-input"
|
||||||
|
rows="2"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePublishComment(post.id)}
|
||||||
|
disabled={publishingComments[post.id] || !newComments[post.id]?.trim()}
|
||||||
|
className="button"
|
||||||
|
>
|
||||||
|
<Reply size={16} />
|
||||||
|
{publishingComments[post.id] ? 'Wysyłanie...' : 'Odpowiedz'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{comments[post.id]?.map(comment => (
|
||||||
|
<div key={comment.id} className="comment">
|
||||||
|
<div className="comment__votes">
|
||||||
|
<button
|
||||||
|
className={`button ${!publicKey || votingStates[comment.id] ? 'button--disabled' : ''}`}
|
||||||
|
onClick={() => handleVote(comment.id, comment.author, true)}
|
||||||
|
disabled={!publicKey || votingStates[comment.id]}
|
||||||
|
>
|
||||||
|
<ArrowUp size={16} />
|
||||||
|
</button>
|
||||||
|
<span>{comment.votes.up - comment.votes.down}</span>
|
||||||
|
<button
|
||||||
|
className={`button ${!publicKey || votingStates[comment.id] ? 'button--disabled' : ''}`}
|
||||||
|
onClick={() => handleVote(comment.id, comment.author, false)}
|
||||||
|
disabled={!publicKey || votingStates[comment.id]}
|
||||||
|
>
|
||||||
|
<ArrowDown size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="comment__content">
|
||||||
|
<p>{comment.content}</p>
|
||||||
|
<div className="comment__meta">
|
||||||
|
<span>przez {comment.author.slice(0, 8)}...</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>
|
||||||
|
{formatDistanceToNow(comment.createdAt * 1000, {
|
||||||
|
addSuffix: true,
|
||||||
|
locale: pl
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Home;
|
43
src/pages/Login.jsx
Normal file
43
src/pages/Login.jsx
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { generatePrivateKey, getPublicKey } from 'nostr-tools';
|
||||||
|
import { useStore } from '../store/useStore';
|
||||||
|
|
||||||
|
function Login() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { setKeys } = useStore();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleGenerateKeys = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const privateKey = generatePrivateKey();
|
||||||
|
const publicKey = getPublicKey(privateKey);
|
||||||
|
setKeys(publicKey, privateKey);
|
||||||
|
navigate('/');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating keys:', error);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Zaloguj się</h2>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateKeys}
|
||||||
|
disabled={loading}
|
||||||
|
className="button button--full"
|
||||||
|
>
|
||||||
|
{loading ? 'Generowanie...' : 'Wygeneruj nowy klucz'}
|
||||||
|
</button>
|
||||||
|
<p className="text-light">
|
||||||
|
Twój klucz prywatny zostanie bezpiecznie zapisany w przeglądarce
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login;
|
261
src/pages/Mikroblog.jsx
Normal file
261
src/pages/Mikroblog.jsx
Normal file
|
@ -0,0 +1,261 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { ArrowUp, ArrowDown, MessageSquare, Send, Reply } from 'lucide-react';
|
||||||
|
import { useStore } from '../store/useStore';
|
||||||
|
import { fetchPosts, publishPost, vote, fetchComments } from '../utils/nostr';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { pl } from 'date-fns/locale';
|
||||||
|
|
||||||
|
function Mikroblog() {
|
||||||
|
const [posts, setPosts] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [newPost, setNewPost] = useState('');
|
||||||
|
const [publishing, setPublishing] = useState(false);
|
||||||
|
const [votingStates, setVotingStates] = useState({});
|
||||||
|
const [expandedComments, setExpandedComments] = useState({});
|
||||||
|
const [comments, setComments] = useState({});
|
||||||
|
const [newComments, setNewComments] = useState({});
|
||||||
|
const [publishingComments, setPublishingComments] = useState({});
|
||||||
|
const { publicKey, privateKey } = useStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPosts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function loadPosts() {
|
||||||
|
try {
|
||||||
|
const fetchedPosts = await fetchPosts('mikroblog');
|
||||||
|
setPosts(fetchedPosts.sort((a, b) => b.createdAt - a.createdAt));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading posts:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePublishPost(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newPost.trim() || !privateKey) return;
|
||||||
|
|
||||||
|
if (newPost.length > 280) {
|
||||||
|
alert('Wpis nie może być dłuższy niż 280 znaków!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPublishing(true);
|
||||||
|
try {
|
||||||
|
await publishPost(newPost, privateKey, null, 'mikroblog');
|
||||||
|
setNewPost('');
|
||||||
|
await loadPosts();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error publishing post:', error);
|
||||||
|
} finally {
|
||||||
|
setPublishing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVote(postId, postAuthor, isUpvote) {
|
||||||
|
if (!privateKey || votingStates[postId]) return;
|
||||||
|
|
||||||
|
setVotingStates(prev => ({ ...prev, [postId]: true }));
|
||||||
|
try {
|
||||||
|
await vote(postId, postAuthor, isUpvote, privateKey);
|
||||||
|
await loadPosts();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error voting:', error);
|
||||||
|
} finally {
|
||||||
|
setVotingStates(prev => ({ ...prev, [postId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExpandComments(postId) {
|
||||||
|
if (!expandedComments[postId]) {
|
||||||
|
try {
|
||||||
|
const fetchedComments = await fetchComments(postId);
|
||||||
|
setComments(prev => ({ ...prev, [postId]: fetchedComments }));
|
||||||
|
setExpandedComments(prev => ({ ...prev, [postId]: true }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching comments:', error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setExpandedComments(prev => ({ ...prev, [postId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePublishComment(postId) {
|
||||||
|
if (!newComments[postId]?.trim() || !privateKey) return;
|
||||||
|
|
||||||
|
if (newComments[postId].length > 280) {
|
||||||
|
alert('Komentarz nie może być dłuższy niż 280 znaków!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPublishingComments(prev => ({ ...prev, [postId]: true }));
|
||||||
|
try {
|
||||||
|
await publishPost(newComments[postId], privateKey, postId, 'mikroblog');
|
||||||
|
setNewComments(prev => ({ ...prev, [postId]: '' }));
|
||||||
|
const fetchedComments = await fetchComments(postId);
|
||||||
|
setComments(prev => ({ ...prev, [postId]: fetchedComments }));
|
||||||
|
await loadPosts();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error publishing comment:', error);
|
||||||
|
} finally {
|
||||||
|
setPublishingComments(prev => ({ ...prev, [postId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="spinner" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{publicKey && (
|
||||||
|
<div className="card">
|
||||||
|
<form onSubmit={handlePublishPost}>
|
||||||
|
<div className="post-input-wrapper">
|
||||||
|
<textarea
|
||||||
|
value={newPost}
|
||||||
|
onChange={(e) => setNewPost(e.target.value)}
|
||||||
|
placeholder="Co słychać? (max 280 znaków)"
|
||||||
|
className="post-input"
|
||||||
|
rows="3"
|
||||||
|
maxLength={280}
|
||||||
|
/>
|
||||||
|
<span className="character-count">
|
||||||
|
{newPost.length}/280
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={publishing || !newPost.trim()}
|
||||||
|
className="button"
|
||||||
|
>
|
||||||
|
<Send size={20} />
|
||||||
|
{publishing ? 'Publikowanie...' : 'Opublikuj'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2>Mikroblog</h2>
|
||||||
|
{posts.length === 0 ? (
|
||||||
|
<p className="text-light">Brak wpisów do wyświetlenia</p>
|
||||||
|
) : (
|
||||||
|
posts.map((post) => (
|
||||||
|
<div key={post.id} className="post">
|
||||||
|
<div className="post__votes">
|
||||||
|
<button
|
||||||
|
className={`button ${!publicKey || votingStates[post.id] ? 'button--disabled' : ''}`}
|
||||||
|
onClick={() => handleVote(post.id, post.author, true)}
|
||||||
|
disabled={!publicKey || votingStates[post.id]}
|
||||||
|
>
|
||||||
|
<ArrowUp size={24} />
|
||||||
|
</button>
|
||||||
|
<span>{post.votes.up - post.votes.down}</span>
|
||||||
|
<button
|
||||||
|
className={`button ${!publicKey || votingStates[post.id] ? 'button--disabled' : ''}`}
|
||||||
|
onClick={() => handleVote(post.id, post.author, false)}
|
||||||
|
disabled={!publicKey || votingStates[post.id]}
|
||||||
|
>
|
||||||
|
<ArrowDown size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="post__content">
|
||||||
|
<h3>{post.content}</h3>
|
||||||
|
<div className="post__meta">
|
||||||
|
<span>przez {post.author.slice(0, 8)}...</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>
|
||||||
|
{formatDistanceToNow(post.createdAt * 1000, {
|
||||||
|
addSuffix: true,
|
||||||
|
locale: pl
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleExpandComments(post.id)}
|
||||||
|
className="button button--link"
|
||||||
|
>
|
||||||
|
<MessageSquare size={16} />
|
||||||
|
{post.comments} komentarzy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedComments[post.id] && (
|
||||||
|
<div className="comments">
|
||||||
|
{publicKey && (
|
||||||
|
<div className="comments__form">
|
||||||
|
<div className="post-input-wrapper">
|
||||||
|
<textarea
|
||||||
|
value={newComments[post.id] || ''}
|
||||||
|
onChange={(e) => setNewComments(prev => ({
|
||||||
|
...prev,
|
||||||
|
[post.id]: e.target.value
|
||||||
|
}))}
|
||||||
|
placeholder="Napisz komentarz... (max 280 znaków)"
|
||||||
|
className="post-input"
|
||||||
|
rows="2"
|
||||||
|
maxLength={280}
|
||||||
|
/>
|
||||||
|
<span className="character-count">
|
||||||
|
{(newComments[post.id] || '').length}/280
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePublishComment(post.id)}
|
||||||
|
disabled={publishingComments[post.id] || !newComments[post.id]?.trim()}
|
||||||
|
className="button"
|
||||||
|
>
|
||||||
|
<Reply size={16} />
|
||||||
|
{publishingComments[post.id] ? 'Wysyłanie...' : 'Odpowiedz'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{comments[post.id]?.map(comment => (
|
||||||
|
<div key={comment.id} className="comment">
|
||||||
|
<div className="comment__votes">
|
||||||
|
<button
|
||||||
|
className={`button ${!publicKey || votingStates[comment.id] ? 'button--disabled' : ''}`}
|
||||||
|
onClick={() => handleVote(comment.id, comment.author, true)}
|
||||||
|
disabled={!publicKey || votingStates[comment.id]}
|
||||||
|
>
|
||||||
|
<ArrowUp size={16} />
|
||||||
|
</button>
|
||||||
|
<span>{comment.votes.up - comment.votes.down}</span>
|
||||||
|
<button
|
||||||
|
className={`button ${!publicKey || votingStates[comment.id] ? 'button--disabled' : ''}`}
|
||||||
|
onClick={() => handleVote(comment.id, comment.author, false)}
|
||||||
|
disabled={!publicKey || votingStates[comment.id]}
|
||||||
|
>
|
||||||
|
<ArrowDown size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="comment__content">
|
||||||
|
<p>{comment.content}</p>
|
||||||
|
<div className="comment__meta">
|
||||||
|
<span>przez {comment.author.slice(0, 8)}...</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>
|
||||||
|
{formatDistanceToNow(comment.createdAt * 1000, {
|
||||||
|
addSuffix: true,
|
||||||
|
locale: pl
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Mikroblog;
|
268
src/pages/Profile.jsx
Normal file
268
src/pages/Profile.jsx
Normal file
|
@ -0,0 +1,268 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import { ArrowUp, ArrowDown, MessageSquare, Send, Reply, User, Calendar } from 'lucide-react';
|
||||||
|
import { useStore } from '../store/useStore';
|
||||||
|
import { fetchUserPosts, fetchUserProfile, publishPost, vote, fetchComments } from '../utils/nostr';
|
||||||
|
import { formatDistanceToNow, format } from 'date-fns';
|
||||||
|
import { pl } from 'date-fns/locale';
|
||||||
|
|
||||||
|
function Profile() {
|
||||||
|
const { pubkey } = useParams();
|
||||||
|
const [profile, setProfile] = useState(null);
|
||||||
|
const [posts, setPosts] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [votingStates, setVotingStates] = useState({});
|
||||||
|
const [expandedComments, setExpandedComments] = useState({});
|
||||||
|
const [comments, setComments] = useState({});
|
||||||
|
const [newComments, setNewComments] = useState({});
|
||||||
|
const [publishingComments, setPublishingComments] = useState({});
|
||||||
|
const { publicKey, privateKey } = useStore();
|
||||||
|
const [activeTab, setActiveTab] = useState('all');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadProfileData();
|
||||||
|
}, [pubkey]);
|
||||||
|
|
||||||
|
async function loadProfileData() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [userProfile, userPosts] = await Promise.all([
|
||||||
|
fetchUserProfile(pubkey),
|
||||||
|
fetchUserPosts(pubkey)
|
||||||
|
]);
|
||||||
|
setProfile(userProfile);
|
||||||
|
setPosts(userPosts.sort((a, b) => b.createdAt - a.createdAt));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading profile data:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVote(postId, postAuthor, isUpvote) {
|
||||||
|
if (!privateKey || votingStates[postId]) return;
|
||||||
|
|
||||||
|
setVotingStates(prev => ({ ...prev, [postId]: true }));
|
||||||
|
try {
|
||||||
|
await vote(postId, postAuthor, isUpvote, privateKey);
|
||||||
|
await loadProfileData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error voting:', error);
|
||||||
|
} finally {
|
||||||
|
setVotingStates(prev => ({ ...prev, [postId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExpandComments(postId) {
|
||||||
|
if (!expandedComments[postId]) {
|
||||||
|
try {
|
||||||
|
const fetchedComments = await fetchComments(postId);
|
||||||
|
setComments(prev => ({ ...prev, [postId]: fetchedComments }));
|
||||||
|
setExpandedComments(prev => ({ ...prev, [postId]: true }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching comments:', error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setExpandedComments(prev => ({ ...prev, [postId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePublishComment(postId) {
|
||||||
|
if (!newComments[postId]?.trim() || !privateKey) return;
|
||||||
|
|
||||||
|
if (newComments[postId].length > 280) {
|
||||||
|
alert('Komentarz nie może być dłuższy niż 280 znaków!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPublishingComments(prev => ({ ...prev, [postId]: true }));
|
||||||
|
try {
|
||||||
|
await publishPost(newComments[postId], privateKey, postId);
|
||||||
|
setNewComments(prev => ({ ...prev, [postId]: '' }));
|
||||||
|
const fetchedComments = await fetchComments(postId);
|
||||||
|
setComments(prev => ({ ...prev, [postId]: fetchedComments }));
|
||||||
|
await loadProfileData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error publishing comment:', error);
|
||||||
|
} finally {
|
||||||
|
setPublishingComments(prev => ({ ...prev, [postId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredPosts = posts.filter(post => {
|
||||||
|
if (activeTab === 'all') return true;
|
||||||
|
if (activeTab === 'mikroblog') return post.tags.includes('mikroblog');
|
||||||
|
if (activeTab === 'main') return !post.tags.includes('mikroblog');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="spinner" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="card profile">
|
||||||
|
<div className="profile__header">
|
||||||
|
<div className="profile__avatar">
|
||||||
|
{profile?.picture ? (
|
||||||
|
<img src={profile.picture} alt={profile.name || 'Avatar'} />
|
||||||
|
) : (
|
||||||
|
<User size={64} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="profile__info">
|
||||||
|
<h1>{profile?.name || pubkey.slice(0, 8)}</h1>
|
||||||
|
{profile?.about && <p className="profile__bio">{profile.about}</p>}
|
||||||
|
<div className="profile__meta">
|
||||||
|
<span>
|
||||||
|
<Calendar size={16} />
|
||||||
|
Dołączył(a): {format(profile?.created_at || Date.now(), 'MMMM yyyy', { locale: pl })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="tabs">
|
||||||
|
<button
|
||||||
|
className={`tab ${activeTab === 'all' ? 'tab--active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('all')}
|
||||||
|
>
|
||||||
|
Wszystkie wpisy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`tab ${activeTab === 'mikroblog' ? 'tab--active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('mikroblog')}
|
||||||
|
>
|
||||||
|
Mikroblog
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`tab ${activeTab === 'main' ? 'tab--active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('main')}
|
||||||
|
>
|
||||||
|
Główna
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredPosts.length === 0 ? (
|
||||||
|
<p className="text-light">Brak wpisów do wyświetlenia</p>
|
||||||
|
) : (
|
||||||
|
filteredPosts.map((post) => (
|
||||||
|
<div key={post.id} className="post">
|
||||||
|
<div className="post__votes">
|
||||||
|
<button
|
||||||
|
className={`button ${!publicKey || votingStates[post.id] ? 'button--disabled' : ''}`}
|
||||||
|
onClick={() => handleVote(post.id, post.author, true)}
|
||||||
|
disabled={!publicKey || votingStates[post.id]}
|
||||||
|
>
|
||||||
|
<ArrowUp size={24} />
|
||||||
|
</button>
|
||||||
|
<span>{post.votes.up - post.votes.down}</span>
|
||||||
|
<button
|
||||||
|
className={`button ${!publicKey || votingStates[post.id] ? 'button--disabled' : ''}`}
|
||||||
|
onClick={() => handleVote(post.id, post.author, false)}
|
||||||
|
disabled={!publicKey || votingStates[post.id]}
|
||||||
|
>
|
||||||
|
<ArrowDown size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="post__content">
|
||||||
|
<h3>{post.content}</h3>
|
||||||
|
<div className="post__meta">
|
||||||
|
<span>
|
||||||
|
{formatDistanceToNow(post.createdAt * 1000, {
|
||||||
|
addSuffix: true,
|
||||||
|
locale: pl
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleExpandComments(post.id)}
|
||||||
|
className="button button--link"
|
||||||
|
>
|
||||||
|
<MessageSquare size={16} />
|
||||||
|
{post.comments} komentarzy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedComments[post.id] && (
|
||||||
|
<div className="comments">
|
||||||
|
{publicKey && (
|
||||||
|
<div className="comments__form">
|
||||||
|
<div className="post-input-wrapper">
|
||||||
|
<textarea
|
||||||
|
value={newComments[post.id] || ''}
|
||||||
|
onChange={(e) => setNewComments(prev => ({
|
||||||
|
...prev,
|
||||||
|
[post.id]: e.target.value
|
||||||
|
}))}
|
||||||
|
placeholder="Napisz komentarz... (max 280 znaków)"
|
||||||
|
className="post-input"
|
||||||
|
rows="2"
|
||||||
|
maxLength={280}
|
||||||
|
/>
|
||||||
|
<span className="character-count">
|
||||||
|
{(newComments[post.id] || '').length}/280
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePublishComment(post.id)}
|
||||||
|
disabled={publishingComments[post.id] || !newComments[post.id]?.trim()}
|
||||||
|
className="button"
|
||||||
|
>
|
||||||
|
<Reply size={16} />
|
||||||
|
{publishingComments[post.id] ? 'Wysyłanie...' : 'Odpowiedz'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{comments[post.id]?.map(comment => (
|
||||||
|
<div key={comment.id} className="comment">
|
||||||
|
<div className="comment__votes">
|
||||||
|
<button
|
||||||
|
className={`button ${!publicKey || votingStates[comment.id] ? 'button--disabled' : ''}`}
|
||||||
|
onClick={() => handleVote(comment.id, comment.author, true)}
|
||||||
|
disabled={!publicKey || votingStates[comment.id]}
|
||||||
|
>
|
||||||
|
<ArrowUp size={16} />
|
||||||
|
</button>
|
||||||
|
<span>{comment.votes.up - comment.votes.down}</span>
|
||||||
|
<button
|
||||||
|
className={`button ${!publicKey || votingStates[comment.id] ? 'button--disabled' : ''}`}
|
||||||
|
onClick={() => handleVote(comment.id, comment.author, false)}
|
||||||
|
disabled={!publicKey || votingStates[comment.id]}
|
||||||
|
>
|
||||||
|
<ArrowDown size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="comment__content">
|
||||||
|
<p>{comment.content}</p>
|
||||||
|
<div className="comment__meta">
|
||||||
|
<Link to={`/profile/${comment.author}`}>
|
||||||
|
{comment.author.slice(0, 8)}...
|
||||||
|
</Link>
|
||||||
|
<span>•</span>
|
||||||
|
<span>
|
||||||
|
{formatDistanceToNow(comment.createdAt * 1000, {
|
||||||
|
addSuffix: true,
|
||||||
|
locale: pl
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Profile;
|
10
src/store/useStore.js
Normal file
10
src/store/useStore.js
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
export const useStore = create((set) => ({
|
||||||
|
publicKey: null,
|
||||||
|
privateKey: null,
|
||||||
|
profile: null,
|
||||||
|
setKeys: (publicKey, privateKey) => set({ publicKey, privateKey }),
|
||||||
|
setProfile: (profile) => set({ profile }),
|
||||||
|
logout: () => set({ publicKey: null, privateKey: null, profile: null }),
|
||||||
|
}));
|
561
src/styles/main.scss
Normal file
561
src/styles/main.scss
Normal file
|
@ -0,0 +1,561 @@
|
||||||
|
// Variables
|
||||||
|
$primary-color: #7e22ce; // purple-700
|
||||||
|
$primary-hover: #6b21a8; // purple-800
|
||||||
|
$text-color: #111827; // gray-900
|
||||||
|
$text-light: #6b7280; // gray-500
|
||||||
|
$bg-color: #f3f4f6; // gray-100
|
||||||
|
$white: #ffffff;
|
||||||
|
$border-color: #e5e7eb; // gray-200
|
||||||
|
$danger-color: #ef4444; // red-500
|
||||||
|
$danger-hover: #dc2626; // red-600
|
||||||
|
$success-color: #22c55e; // green-500
|
||||||
|
$success-hover: #16a34a; // green-600
|
||||||
|
|
||||||
|
// Mixins
|
||||||
|
@mixin flex-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: $bg-color;
|
||||||
|
color: $text-color;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||||
|
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layout
|
||||||
|
.container {
|
||||||
|
@include container;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header
|
||||||
|
.header {
|
||||||
|
background-color: $primary-color;
|
||||||
|
color: $white;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
&__container {
|
||||||
|
@include container;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__logo {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: $white;
|
||||||
|
text-decoration: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: $white;
|
||||||
|
text-decoration: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main content
|
||||||
|
.main {
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card
|
||||||
|
.card {
|
||||||
|
background-color: $white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile
|
||||||
|
.profile {
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__avatar {
|
||||||
|
width: 128px;
|
||||||
|
height: 128px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: $bg-color;
|
||||||
|
@include flex-center;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: $text-light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__bio {
|
||||||
|
color: $text-color;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
color: $text-light;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin Panel
|
||||||
|
.admin-panel {
|
||||||
|
&__header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-bottom: 2px solid $border-color;
|
||||||
|
color: $text-light;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-bottom: 1px solid $border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
&--banned {
|
||||||
|
background-color: rgba($danger-color, 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: $text-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: $text-light;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
p {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
color: $text-light;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
select {
|
||||||
|
padding: 0.375rem;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background-color: $white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Badge
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&--success {
|
||||||
|
background-color: rgba($success-color, 0.1);
|
||||||
|
color: $success-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--danger {
|
||||||
|
background-color: rgba($danger-color, 0.1);
|
||||||
|
color: $danger-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tabs
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border-bottom: 1px solid $border-color;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: $text-light;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $primary-color;
|
||||||
|
background-color: rgba($primary-color, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
color: $primary-color;
|
||||||
|
background-color: rgba($primary-color, 0.1);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post input
|
||||||
|
.post-input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-count {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: $text-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: $primary-color;
|
||||||
|
box-shadow: 0 0 0 2px rgba($primary-color, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post
|
||||||
|
.post {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
border-bottom: 1px solid $border-color;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__votes {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 0.25rem;
|
||||||
|
min-width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
color: $text-light;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comments
|
||||||
|
.comments {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-left: 1rem;
|
||||||
|
border-left: 2px solid $border-color;
|
||||||
|
|
||||||
|
&__form {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
|
||||||
|
&__votes {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 0.125rem;
|
||||||
|
min-width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
color: $text-light;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: $primary-color;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
.button {
|
||||||
|
background-color: $primary-color;
|
||||||
|
color: $white;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background-color: $primary-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled,
|
||||||
|
&--disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--full {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--link {
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: none;
|
||||||
|
color: $primary-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--danger {
|
||||||
|
background-color: $danger-color;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background-color: $danger-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--success {
|
||||||
|
background-color: $success-color;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background-color: $success-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading spinner
|
||||||
|
.spinner {
|
||||||
|
@include flex-center;
|
||||||
|
height: 16rem;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border: 2px solid $border-color;
|
||||||
|
border-top-color: $primary-color;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text utilities
|
||||||
|
.text-light {
|
||||||
|
color: $text-light;
|
||||||
|
}
|
439
src/utils/nostr.js
Normal file
439
src/utils/nostr.js
Normal file
|
@ -0,0 +1,439 @@
|
||||||
|
import { SimplePool, getEventHash, signEvent } from 'nostr-tools';
|
||||||
|
|
||||||
|
const RELAYS = [
|
||||||
|
'wss://relay.damus.io',
|
||||||
|
'wss://relay.nostr.band',
|
||||||
|
'wss://nos.lol',
|
||||||
|
'wss://relay.snort.social'
|
||||||
|
];
|
||||||
|
|
||||||
|
const pool = new SimplePool();
|
||||||
|
|
||||||
|
export async function publishPost(content, privateKey, replyTo = null, section = 'main') {
|
||||||
|
try {
|
||||||
|
const event = {
|
||||||
|
kind: 1,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['t', 'nostrop'],
|
||||||
|
['t', section]
|
||||||
|
],
|
||||||
|
content: content,
|
||||||
|
pubkey: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (replyTo) {
|
||||||
|
event.tags.push(['e', replyTo]);
|
||||||
|
}
|
||||||
|
|
||||||
|
event.id = getEventHash(event);
|
||||||
|
event.sig = await signEvent(event, privateKey);
|
||||||
|
|
||||||
|
const pubs = pool.publish(RELAYS, event);
|
||||||
|
await Promise.all(pubs);
|
||||||
|
|
||||||
|
return event;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error publishing post:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function vote(postId, postAuthor, isUpvote, privateKey) {
|
||||||
|
try {
|
||||||
|
const event = {
|
||||||
|
kind: isUpvote ? 7 : 8,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['e', postId],
|
||||||
|
['p', postAuthor],
|
||||||
|
['t', 'nostrop']
|
||||||
|
],
|
||||||
|
content: isUpvote ? '+' : '-',
|
||||||
|
pubkey: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
event.id = getEventHash(event);
|
||||||
|
event.sig = await signEvent(event, privateKey);
|
||||||
|
|
||||||
|
const pubs = pool.publish(RELAYS, event);
|
||||||
|
await Promise.all(pubs);
|
||||||
|
|
||||||
|
return event;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error voting:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVotes(postIds) {
|
||||||
|
try {
|
||||||
|
const votes = await pool.list(RELAYS, [
|
||||||
|
{
|
||||||
|
kinds: [7, 8],
|
||||||
|
'#e': postIds,
|
||||||
|
'#t': ['nostrop']
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const voteCounts = {};
|
||||||
|
postIds.forEach(id => {
|
||||||
|
voteCounts[id] = { up: 0, down: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
votes.forEach(vote => {
|
||||||
|
const postId = vote.tags.find(tag => tag[0] === 'e')?.[1];
|
||||||
|
if (postId && voteCounts[postId]) {
|
||||||
|
if (vote.kind === 7) voteCounts[postId].up++;
|
||||||
|
if (vote.kind === 8) voteCounts[postId].down++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return voteCounts;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching votes:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchComments(postId) {
|
||||||
|
try {
|
||||||
|
const events = await pool.list(RELAYS, [
|
||||||
|
{
|
||||||
|
kinds: [1],
|
||||||
|
'#e': [postId],
|
||||||
|
'#t': ['nostrop']
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const comments = events.map(event => ({
|
||||||
|
id: event.id,
|
||||||
|
content: event.content,
|
||||||
|
author: event.pubkey,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
votes: { up: 0, down: 0 }
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (comments.length > 0) {
|
||||||
|
const votes = await fetchVotes(comments.map(comment => comment.id));
|
||||||
|
comments.forEach(comment => {
|
||||||
|
comment.votes = votes[comment.id] || { up: 0, down: 0 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return comments.sort((a, b) => b.createdAt - a.createdAt);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching comments:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPosts(section = 'main') {
|
||||||
|
try {
|
||||||
|
const events = await pool.list(RELAYS, [
|
||||||
|
{
|
||||||
|
kinds: [1],
|
||||||
|
limit: 100,
|
||||||
|
'#t': ['nostrop', section]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const posts = events.filter(event =>
|
||||||
|
!event.tags.some(tag => tag[0] === 'e')
|
||||||
|
).map(event => ({
|
||||||
|
id: event.id,
|
||||||
|
content: event.content,
|
||||||
|
author: event.pubkey,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
votes: { up: 0, down: 0 },
|
||||||
|
comments: 0,
|
||||||
|
tags: event.tags.map(tag => tag[1])
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (posts.length > 0) {
|
||||||
|
const votes = await fetchVotes(posts.map(post => post.id));
|
||||||
|
const comments = await pool.list(RELAYS, [
|
||||||
|
{
|
||||||
|
kinds: [1],
|
||||||
|
'#e': posts.map(p => p.id),
|
||||||
|
'#t': ['nostrop']
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
posts.forEach(post => {
|
||||||
|
post.votes = votes[post.id] || { up: 0, down: 0 };
|
||||||
|
post.comments = comments.filter(c =>
|
||||||
|
c.tags.some(t => t[0] === 'e' && t[1] === post.id)
|
||||||
|
).length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return posts;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching posts:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUserPosts(pubkey) {
|
||||||
|
try {
|
||||||
|
const events = await pool.list(RELAYS, [
|
||||||
|
{
|
||||||
|
kinds: [1],
|
||||||
|
authors: [pubkey],
|
||||||
|
'#t': ['nostrop']
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const posts = events.filter(event =>
|
||||||
|
!event.tags.some(tag => tag[0] === 'e')
|
||||||
|
).map(event => ({
|
||||||
|
id: event.id,
|
||||||
|
content: event.content,
|
||||||
|
author: event.pubkey,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
votes: { up: 0, down: 0 },
|
||||||
|
comments: 0,
|
||||||
|
tags: event.tags.map(tag => tag[1])
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (posts.length > 0) {
|
||||||
|
const votes = await fetchVotes(posts.map(post => post.id));
|
||||||
|
const comments = await pool.list(RELAYS, [
|
||||||
|
{
|
||||||
|
kinds: [1],
|
||||||
|
'#e': posts.map(p => p.id),
|
||||||
|
'#t': ['nostrop']
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
posts.forEach(post => {
|
||||||
|
post.votes = votes[post.id] || { up: 0, down: 0 };
|
||||||
|
post.comments = comments.filter(c =>
|
||||||
|
c.tags.some(t => t[0] === 'e' && t[1] === post.id)
|
||||||
|
).length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return posts;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user posts:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUserProfile(pubkey) {
|
||||||
|
try {
|
||||||
|
const events = await pool.list(RELAYS, [
|
||||||
|
{
|
||||||
|
kinds: [0],
|
||||||
|
authors: [pubkey]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
return {
|
||||||
|
created_at: Date.now() / 1000
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileEvent = events[0];
|
||||||
|
const profile = JSON.parse(profileEvent.content);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...profile,
|
||||||
|
created_at: profileEvent.created_at
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user profile:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAllUsers() {
|
||||||
|
try {
|
||||||
|
const events = await pool.list(RELAYS, [
|
||||||
|
{
|
||||||
|
kinds: [0],
|
||||||
|
'#t': ['nostrop']
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const users = events.map(event => {
|
||||||
|
const profile = JSON.parse(event.content);
|
||||||
|
return {
|
||||||
|
id: event.pubkey,
|
||||||
|
...profile,
|
||||||
|
created_at: event.created_at
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const roleEvents = await pool.list(RELAYS, [
|
||||||
|
{
|
||||||
|
kinds: [30000],
|
||||||
|
'#t': ['nostrop-role']
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const banEvents = await pool.list(RELAYS, [
|
||||||
|
{
|
||||||
|
kinds: [30000],
|
||||||
|
'#t': ['nostrop-ban']
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
users.forEach(user => {
|
||||||
|
const roleEvent = roleEvents.find(e =>
|
||||||
|
e.tags.some(t => t[0] === 'p' && t[1] === user.id)
|
||||||
|
);
|
||||||
|
const banEvent = banEvents.find(e =>
|
||||||
|
e.tags.some(t => t[0] === 'p' && t[1] === user.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
user.role = roleEvent?.content || 'user';
|
||||||
|
user.banned = banEvent?.content === 'true';
|
||||||
|
});
|
||||||
|
|
||||||
|
return users;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching users:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAllPosts() {
|
||||||
|
try {
|
||||||
|
const events = await pool.list(RELAYS, [
|
||||||
|
{
|
||||||
|
kinds: [1],
|
||||||
|
'#t': ['nostrop']
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const posts = events.filter(event =>
|
||||||
|
!event.tags.some(tag => tag[0] === 'e')
|
||||||
|
).map(event => ({
|
||||||
|
id: event.id,
|
||||||
|
content: event.content,
|
||||||
|
author: event.pubkey,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
votes: { up: 0, down: 0 },
|
||||||
|
comments: 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (posts.length > 0) {
|
||||||
|
const votes = await fetchVotes(posts.map(post => post.id));
|
||||||
|
const comments = await pool.list(RELAYS, [
|
||||||
|
{
|
||||||
|
kinds: [1],
|
||||||
|
'#e': posts.map(p => p.id),
|
||||||
|
'#t': ['nostrop']
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const authors = [...new Set(posts.map(p => p.author))];
|
||||||
|
const profiles = await Promise.all(
|
||||||
|
authors.map(author => fetchUserProfile(author))
|
||||||
|
);
|
||||||
|
|
||||||
|
const authorProfiles = authors.reduce((acc, author, index) => {
|
||||||
|
acc[author] = profiles[index];
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
posts.forEach(post => {
|
||||||
|
post.votes = votes[post.id] || { up: 0, down: 0 };
|
||||||
|
post.comments = comments.filter(c =>
|
||||||
|
c.tags.some(t => t[0] === 'e' && t[1] === post.id)
|
||||||
|
).length;
|
||||||
|
post.author = {
|
||||||
|
id: post.author,
|
||||||
|
...authorProfiles[post.author]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return posts.sort((a, b) => b.createdAt - a.createdAt);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching posts:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserRole(userId, role) {
|
||||||
|
try {
|
||||||
|
const event = {
|
||||||
|
kind: 30000,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['t', 'nostrop-role'],
|
||||||
|
['p', userId]
|
||||||
|
],
|
||||||
|
content: role,
|
||||||
|
pubkey: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
event.id = getEventHash(event);
|
||||||
|
event.sig = await signEvent(event, privateKey);
|
||||||
|
|
||||||
|
const pubs = pool.publish(RELAYS, event);
|
||||||
|
await Promise.all(pubs);
|
||||||
|
|
||||||
|
return event;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating user role:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function banUser(userId, isBanned) {
|
||||||
|
try {
|
||||||
|
const event = {
|
||||||
|
kind: 30000,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['t', 'nostrop-ban'],
|
||||||
|
['p', userId]
|
||||||
|
],
|
||||||
|
content: isBanned.toString(),
|
||||||
|
pubkey: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
event.id = getEventHash(event);
|
||||||
|
event.sig = await signEvent(event, privateKey);
|
||||||
|
|
||||||
|
const pubs = pool.publish(RELAYS, event);
|
||||||
|
await Promise.all(pubs);
|
||||||
|
|
||||||
|
return event;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating user ban status:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removePost(postId) {
|
||||||
|
try {
|
||||||
|
const event = {
|
||||||
|
kind: 30000,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['t', 'nostrop-remove'],
|
||||||
|
['e', postId]
|
||||||
|
],
|
||||||
|
content: 'removed',
|
||||||
|
pubkey: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
event.id = getEventHash(event);
|
||||||
|
event.sig = await signEvent(event, privateKey);
|
||||||
|
|
||||||
|
const pubs = pool.publish(RELAYS, event);
|
||||||
|
await Promise.all(pubs);
|
||||||
|
|
||||||
|
return event;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing post:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
10
vite.config.ts
Normal file
10
vite.config.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ['lucide-react'],
|
||||||
|
},
|
||||||
|
});
|
Loading…
Add table
Add a link
Reference in a new issue