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