Initial commit

This commit is contained in:
Sebastian Korotkiewicz 2025-04-10 10:36:02 +02:00
commit 53f35caeab
18 changed files with 5527 additions and 0 deletions

24
.gitignore vendored Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

32
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

10
vite.config.ts Normal file
View 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'],
},
});