Merge pull request #788 from plebbit/development

Development
This commit is contained in:
Tom
2026-01-08 13:57:40 +01:00
committed by GitHub
53 changed files with 4467 additions and 1181 deletions

5
.gitignore vendored
View File

@@ -163,4 +163,7 @@ yarn-error.log*
.playwright-mcp
# YoYo AI version control directory
.yoyo/
.yoyo/
.specstory/
.cursorindexingignore

813
docs/react-guide.md Normal file
View File

@@ -0,0 +1,813 @@
# React guide
A lot of AI-generated React code is heavily biased toward using useState and useEffect, often in verbose or naive ways. Thats because these hooks are:
1. Explicit, making them easy for LLMs to pattern-match.
2. Safe defaults, requiring less architectural decision-making.
3. Well-documented, so the models training data is oversaturated with examples.
But modern, well-architected React apps rarely rely on raw useState/useEffect beyond simple UI state or lifecycle quirks. Heres a breakdown of what serious devs use instead—and why this is the real moat for human engineers.
Alternatives to useState and useEffect
1. State Machines / Statecharts (e.g. Zustand)
- Why: Replaces ad-hoc local state management with deterministic, declarative logic.
- Advantage: Less bugs, easier to test, clearer state transitions.
- AI gap: LLMs struggle to model state transitions declaratively. LLM Agent must overcome this.
2. React Query / SWR for Data Fetching
- Why: Avoids useEffect-based data fetching and loading/error state juggling.
- Advantage: Caching, stale-while-revalidate, retries, pagination—all handled out of the box.
- AI gap: AI often uses useEffect + fetch and forgets cleanup, race conditions, or caching.
3. Global State with Zustand, Jotai, Recoil, or Redux Toolkit
- Why: Local useState doesnt scale for shared or persistent state.
- Advantage: Better performance, devtools support, persistence.
- AI gap: Defaults to prop-drilling or bloated context APIs.
4. Custom Hooks and Composition
- Why: Reuse logic cleanly without bloating components or copy-pasting useEffect.
- Advantage: Separation of concerns, encapsulation.
- AI gap: Often fails to factor logic out, keeping everything in one component.
## Why This Matters
AI can mimic patterns, but it cant:
- Architect a system for long-term maintainability.
- Predict runtime performance tradeoffs.
- Refactor spaghetti effects into custom hooks or state machines.
- Decide when state should be colocated vs globalized.
- Avoid footguns like stale closures, unnecessary re-renders, or race conditions.
In short: AI is great at writing code, but bad at engineering software.
Takeaway
- Use fewer useEffect and useState hooks.
- Learn to design systems, not just code components.
- Reach for declarative, composable patterns.
- Understand caching, reactivity, and scalability beyond local state.
Heres a side-by-side breakdown of common AI-generated React patterns (bad) vs what experienced engineers write (good) across 4 key areas: state management, data fetching, side effects, and logic reuse.
## 1. State Management
❌ Bad (AI-style useState)
```typescript
function TodoApp() {
const [todos, setTodos] = useState([]);
const [input, setInput] = useState('');
const addTodo = () => {
setTodos([...todos, { text: input }]);
setInput('');
};
return (
<>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button onClick={addTodo}>Add</button>
<ul>
{todos.map((todo, i) => <li key={i}>{todo.text}</li>)}
</ul>
</>
);
}
```
✅ Good (Zustand store, state extracted)
```typescript
import { create } from 'zustand';
const useTodoStore = create((set) => ({
todos: [],
input: '',
addTodo: () => set((state) => ({
todos: [...state.todos, { text: state.input }],
input: '',
})),
setInput: (val) => set({ input: val }),
}));
function TodoApp() {
const { todos, input, setInput, addTodo } = useTodoStore();
return (
<>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button onClick={addTodo}>Add</button>
<ul>
{todos.map((todo, i) => <li key={i}>{todo.text}</li>)}
</ul>
</>
);
}
```
## 2. Data Fetching
❌ Bad (AI-style useEffect)
```typescript
function UserList() {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(setUsers);
}, []);
return (
<ul>
{users.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
);
}
```
✅ Good (React Query)
```typescript
import { useQuery } from '@tanstack/react-query';
function UserList() {
const { data: users = [], isLoading } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(res => res.json()),
});
if (isLoading) return <div>Loading...</div>;
return (
<ul>
{users.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
);
}
```
## 3. Side Effects
❌ Bad (naive effect with race condition)
```typescript
function Search({ term }) {
const [results, setResults] = useState([]);
useEffect(() => {
fetch(`/api/search?q=${term}`)
.then(res => res.json())
.then(setResults);
}, [term]);
return <div>{results.length} results</div>;
}
```
✅ Good (abort previous fetch with AbortController)
```typescript
function Search({ term }) {
const [results, setResults] = useState([]);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${term}`, { signal: controller.signal })
.then(res => res.json())
.then(setResults)
.catch((e) => {
if (e.name !== 'AbortError') console.error(e);
});
return () => controller.abort();
}, [term]);
return <div>{results.length} results</div>;
}
```
Better yet? Dont use useEffect at all. Use React Query with term as a key.
## 4. Logic Reuse
❌ Bad (repeated logic inline)
```typescript
function ComponentA() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []);
return <div>{count}</div>;
}
function ComponentB() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []);
return <div>{count}</div>;
}
```
✅ Good (custom hook)
```typescript
function useCounter(intervalMs = 1000) {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), intervalMs);
return () => clearInterval(id);
}, [intervalMs]);
return count;
}
function ComponentA() {
const count = useCounter();
return <div>{count}</div>;
}
function ComponentB() {
const count = useCounter();
return <div>{count}</div>;
}
```
## 5. State Machines with Zustand
❌ Bad (AI-style complex useState with boolean flags)
```typescript
function OrderProcess() {
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [cart, setCart] = useState([]);
const [paymentDetails, setPaymentDetails] = useState(null);
const [orderPlaced, setOrderPlaced] = useState(false);
const addToCart = (item) => {
setCart([...cart, item]);
};
const submitPayment = async (details) => {
setIsLoading(true);
setIsError(false);
try {
await processPayment(details);
setPaymentDetails(details);
setIsSuccess(true);
setIsLoading(false);
} catch (error) {
setIsError(true);
setIsLoading(false);
}
};
const placeOrder = async () => {
setIsLoading(true);
setIsError(false);
try {
await submitOrder(cart, paymentDetails);
setOrderPlaced(true);
setIsSuccess(true);
setIsLoading(false);
} catch (error) {
setIsError(true);
setIsLoading(false);
}
};
return (
<div>
{/* Complex conditional rendering based on multiple state variables */}
{isLoading && <LoadingSpinner />}
{isError && <ErrorMessage />}
{!isLoading && !isError && !orderPlaced && (
<>
<CartItems items={cart} onAddItem={addToCart} />
<PaymentForm onSubmit={submitPayment} />
{paymentDetails && <OrderButton onClick={placeOrder} />}
</>
)}
{orderPlaced && <OrderConfirmation />}
</div>
);
}
```
✅ Good (Zustand state machine)
```typescript
import { create } from 'zustand';
// Create a Zustand store with state machine pattern
const useOrderStore = create((set, get) => ({
// State machine's current state
state: 'browsing',
// Context/data
cart: [],
paymentDetails: null,
error: null,
// Actions that transition between states
addToCart: (item) => set((state) => ({
cart: [...state.cart, item]
})),
goToCheckout: () => set({ state: 'paymentEntry' }),
goBack: () => {
const { state: currentState } = get();
if (currentState === 'paymentEntry') {
set({ state: 'browsing' });
} else if (currentState === 'confirmOrder') {
set({ state: 'paymentEntry' });
}
},
submitPayment: async (details) => {
set({ state: 'processingPayment', error: null });
try {
const result = await processPayment(details);
set({
state: 'confirmOrder',
paymentDetails: result
});
} catch (error) {
set({
state: 'paymentEntry',
error: error.message
});
}
},
placeOrder: async () => {
const { cart, paymentDetails } = get();
set({ state: 'processingOrder', error: null });
try {
await submitOrder(cart, paymentDetails);
set({ state: 'orderComplete' });
} catch (error) {
set({
state: 'confirmOrder',
error: error.message
});
}
}
}));
function OrderProcess() {
const {
state,
cart,
paymentDetails,
error,
addToCart,
goToCheckout,
goBack,
submitPayment,
placeOrder
} = useOrderStore();
// Render UI based on current state
return (
<div>
{error && <ErrorMessage message={error} />}
{state === 'browsing' && (
<CartItems
items={cart}
onAddItem={addToCart}
onCheckout={goToCheckout}
/>
)}
{state === 'paymentEntry' && (
<PaymentForm
onSubmit={submitPayment}
onBack={goBack}
/>
)}
{state === 'processingPayment' && (
<LoadingSpinner message="Processing payment..." />
)}
{state === 'confirmOrder' && (
<OrderSummary
cart={cart}
paymentDetails={paymentDetails}
onConfirm={placeOrder}
onBack={goBack}
/>
)}
{state === 'processingOrder' && (
<LoadingSpinner message="Placing your order..." />
)}
{state === 'orderComplete' && (
<OrderConfirmation orderDetails={{ cart, paymentDetails }} />
)}
</div>
);
}
```
### Key Benefits of the Zustand State Machine Approach:
1. **Explicit states**: The system can only be in one well-defined state at a time
2. **Centralized logic**: All state transitions are defined in one place
3. **Predictable transitions**: State changes follow clear patterns
4. **Simplified UI logic**: Components just render based on the current state
5. **Async handling**: Asynchronous operations are contained within the state transitions
6. **Shared state**: Any component can access the store without prop drilling
7. **Improved debugging**: The current state is always clear and predictable
8. **No impossible states**: Unlike boolean flags that could create invalid combinations
9. **Simpler API**: Compared to XState, Zustand offers a more approachable API for many developers
10. **Smaller bundle size**: Zustand is significantly smaller than XState
This approach gives most of the benefits of formal state machines while maintaining the simplicity and familiarity of React state management. It's a pragmatic middle ground that's often easier to adopt in existing projects.
## 6. Concurrent UI Techniques
❌ Bad (Blocking UI updates without concurrency):
```jsx
// Bad example: Synchronously fetching data in useEffect can cause UI jank.
function SearchResults({ query }) {
const [results, setResults] = React.useState([]);
React.useEffect(() => {
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => setResults(data));
}, [query]);
return (
<div>
{results.map(item => <p key={item.id}>{item.title}</p>)}
</div>
);
}
```
✅ Good (Using useTransition for non-blocking updates):
```jsx
// Good example: Leveraging useTransition to mark state updates as low-priority.
function SearchResults({ query }) {
const [results, setResults] = React.useState([]);
const [isPending, startTransition] = React.useTransition();
React.useEffect(() => {
startTransition(() => {
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => setResults(data));
});
}, [query, startTransition]);
return (
<div>
{isPending && <div>Loading...</div>}
{results.map(item => <p key={item.id}>{item.title}</p>)}
</div>
);
}
```
## 7. Routing and Navigation (Not applicable with Next.js apps)
❌ Bad (Manual routing without a dedicated library):
```jsx
// Bad example: Handling routing manually using state and window history.
function App() {
const [route, setRoute] = React.useState(window.location.pathname);
React.useEffect(() => {
const onPopState = () => setRoute(window.location.pathname);
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, []);
return (
<div>
{route === '/' && <Home />}
{route === '/about' && <About />}
<button onClick={() => {
window.history.pushState({}, '', '/about');
setRoute('/about');
}}>
About
</button>
</div>
);
}
```
✅ Good (Using React Router v6 with lazy-loaded routes):
```jsx
// Good example: Utilizing React Router to manage routes and lazy load components.
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
import React, { lazy, Suspense } from 'react';
const Home = lazy(() => import('./Home'));
const About = lazy(() => import('./About'));
function App() {
return (
<BrowserRouter>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
</nav>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
```
## 8. Error Handling
❌ Bad (No error boundary; crashes on errors):
```jsx
// Bad example: A component that throws errors without an error boundary.
function MyComponent({ data }) {
if (!data) {
throw new Error("Data not found");
}
return <div>{data}</div>;
}
function App() {
return <MyComponent data={null} />;
}
```
✅ Good (Wrapping components with an Error Boundary):
```jsx
// Good example: Using an ErrorBoundary component to catch and handle errors gracefully.
import React from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.error("Error caught in boundary:", error, errorInfo);
}
render() {
if (this.state.hasError) {
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
function MyComponent({ data }) {
if (!data) {
throw new Error("Data not found");
}
return <div>{data}</div>;
}
function App() {
return (
<ErrorBoundary>
<MyComponent data={null} />
</ErrorBoundary>
);
}
```
## 9. Form Management
❌ Bad (Manual state handling with no validation):
```jsx
// Bad example: Uncontrolled form without validation; error handling is omitted.
function ContactForm() {
const [formData, setFormData] = React.useState({ name: '', email: '' });
const handleSubmit = (e) => {
e.preventDefault();
console.log(formData);
};
return (
<form onSubmit={handleSubmit}>
<input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Name"
/>
<input
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="Email"
/>
<button type="submit">Submit</button>
</form>
);
}
```
✅ Good (Using React Hook Form with Zod for schema validation):
```jsx
import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// 1. Define your schema with Zod
const contactSchema = z.object({
name: z.string().nonempty('Name is required'),
email: z
.string()
.nonempty('Email is required')
.email('Invalid email'),
});
// 2. Infer the TypeScript type of your form data
type ContactFormData = z.infer<typeof contactSchema>;
export function ContactForm() {
// 3. Hook up RHF with the zodResolver
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
});
const onSubmit = (data: ContactFormData) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input
{...register('name')}
placeholder="Name"
aria-invalid={!!errors.name}
/>
{errors.name && <p role="alert">{errors.name.message}</p>}
</div>
<div>
<input
{...register('email')}
placeholder="Email"
aria-invalid={!!errors.email}
/>
{errors.email && <p role="alert">{errors.email.message}</p>}
</div>
<button type="submit">Submit</button>
</form>
);
}
```
## 10. Code Splitting and Lazy Loading
❌ Bad (Eagerly importing heavy components, increasing the initial bundle size):
```jsx
// Bad example: Directly importing a large component causing a heavier bundle.
import HeavyComponent from './HeavyComponent';
function App() {
return (
<div>
<HeavyComponent />
</div>
);
}
```
✅ Good (Lazy loading heavy components with React.lazy and Suspense):
```jsx
// Good example: Dynamically importing components to reduce initial load time.
import React, { lazy, Suspense } from 'react';
const HeavyComponent = lazy(() => import('./HeavyComponent'));
function App() {
return (
<Suspense fallback={<div>Loading Component...</div>}>
<HeavyComponent />
</Suspense>
);
}
```
## 11. Testing Strategies
❌ Bad (Testing implementation details, resulting in brittle tests):
```jsx
// Bad example: Relying on internal DOM structure which may change.
import { render } from '@testing-library/react';
import MyComponent from './MyComponent';
test('MyComponent renders data', () => {
const { container } = render(<MyComponent />);
expect(container.querySelector('.data')).toBeDefined();
});
```
✅ Good (Focusing on user-centric behaviors using React Testing Library):
```jsx
// Good example: Testing component behavior by simulating user interactions.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import MyComponent from './MyComponent';
test('MyComponent displays loaded data after user action', async () => {
render(<MyComponent />);
// Check for an initial loading state
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
// Simulate user action that triggers data loading
const button = screen.getByRole('button', { name: /load data/i });
userEvent.click(button);
// Wait and assert that data is displayed
expect(await screen.findByText(/Data loaded/i)).toBeInTheDocument();
});
```
## 12. Accessibility & Theming
❌ Bad (Non-semantic elements and missing ARIA labels):
```jsx
// Bad example: Using a div as a clickable element without semantics or accessibility.
function ThemedButton() {
return <div onClick={() => console.log('Clicked!')}>Click me</div>;
}
```
✅ Good (Semantic button with ARIA attributes and theming via CSS variables):
```jsx
// Good example: Accessible button component that utilizes theming.
function ThemedButton() {
return (
<button
aria-label="Click me"
onClick={() => console.log('Clicked!')}
style={{ padding: '8px 16px', backgroundColor: 'var(--primary-color)', color: '#fff' }}
>
Click me
</button>
);
}
/* CSS (in a separate file or styled-components):
:root {
--primary-color: #007bff;
}
*/
```
# Summary Table
| Concern | Bad AI Pattern | Solid Engineering Practice |
|---------|---------------|----------------------------|
| **State Management** | `useState` everywhere, propdrilling | Central store (Zustand / Redux Toolkit) or colocated custom hooks |
| **Data Fetching** | `useEffect`+`fetch`, manual loading/error juggling | React Query / SWR with caching, retries, stalewhilerevalidate |
| **Side Effects** | Effects without cleanup → race conditions, leaks | AbortController or query library handles cancellation & retries |
| **Logic Reuse** | Copypasted hooks and state in every component | Extract crosscutting logic into custom hooks |
| **Workflow / State Machine** | Booleanflag soup, impossible states | Deterministic statechart in a single store (Zustand / XState) |
| **Concurrent UI Updates** | Blocking synchronous work inside effects | `useTransition` / `startTransition` for lowpriority updates |
| **Routing & Navigation** | DIY history manipulation, inline route state | React Router v6 (or Next.js builtins) with lazyloaded routes |
| **Error Handling** | No error boundary → fullapp crashes | Toplevel `ErrorBoundary` plus logging in `componentDidCatch` |
| **Form Management** | Manual state, zero validation or feedback | React Hook Form + Yup/Zod schema for declarative validation |
| **Code Splitting & Lazy Loading** | Eager imports ballooning bundle size | `React.lazy` + `Suspense` for ondemand loading |
| **Testing Strategy** | Brittle tests keyed to DOM structure | Usercentric tests with React Testing Library & jestdom |
| **Accessibility & Theming** | Nonsemantic elements, missing ARIA, inline colors | Semantic HTML, ARIA labels, CSS variables / theme provider |

View File

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,7 @@
"@capacitor/status-bar": "7.0.1",
"@capawesome/capacitor-android-edge-to-edge-support": "7.2.2",
"@floating-ui/react": "0.26.1",
"@plebbit/plebbit-react-hooks": "https://github.com/plebbit/plebbit-react-hooks.git#eecac4503b7099b2dde22505869f2a70d280e14f",
"@plebbit/plebbit-react-hooks": "https://github.com/plebbit/plebbit-react-hooks.git#d17fa5d9b50f6e489f11d67f7e652c69be33cc6d",
"@testing-library/jest-dom": "5.14.1",
"@testing-library/react": "13.0.0",
"@testing-library/user-event": "13.2.1",
@@ -41,10 +41,11 @@
"memoizee": "0.4.15",
"node-fetch": "2",
"prettier": "3.0.3",
"react": "19.1.0",
"react": "19.1.2",
"react-ace": "14.0.1",
"react-dom": "19.1.0",
"react-dom": "19.1.2",
"react-dropzone": "14.3.8",
"react-grab": "^0.0.18",
"react-i18next": "13.2.2",
"react-markdown": "8.0.6",
"react-router-dom": "6.16.0",

View File

@@ -328,8 +328,8 @@
"no": "لا",
"are_you_sure": "هل أنت متأكد؟",
"a_short_title": "عنوان قصير لمجتمعك",
"anti_spam_challenges": "تحديات مكافحة البريد العشوائي",
"anti_spam_challenges_subtitle": "اختر تحدي أو أكثر لمنع الرسائل غير المرغوب فيها",
"challenges": "التحديات",
"challenges_subtitle": "اختر تحدي أو أكثر لمنع الرسائل غير المرغوب فيها",
"add_a_challenge": "إضافة تحدي",
"communities_you_moderate": "المجتمعات التي تشرف عليها",
"blur_media": "تمويه الوسائط المعلمة كـ NSFW/18+",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "أنت لست مشتركًا ولا مشرفًا في أي مجتمع.",
"more_posts_last_year": "{{count}} منشورات في {{currentTimeFilterName}}: <1>عرض المزيد من المنشورات من العام الماضي</1>",
"editor_fallback_warning": "فشل تحميل المحرر المتقدم، يتم استخدام محرر نصوص أساسي كبديل."
}
}

View File

@@ -328,8 +328,8 @@
"no": "না",
"are_you_sure": "আপনি কি নিশ্চিত?",
"a_short_title": "আপনার সম্প্রদায়ের জন্য একটি সংক্ষিপ্ত শিরোনাম",
"anti_spam_challenges": "অ্যান্টি-স্প্যাম চ্যালেঞ্জ",
"anti_spam_challenges_subtitle": "স্প্যাম প্রতিরোধ করতে এক বা একাধিক চ্যালেঞ্জ নির্বাচন করুন",
"challenges": "চ্যালেঞ্জ",
"challenges_subtitle": "স্প্যাম প্রতিরোধ করতে এক বা একাধিক চ্যালেঞ্জ নির্বাচন করুন",
"add_a_challenge": "একটি চ্যালেঞ্জ যোগ করুন",
"communities_you_moderate": "আপনি যে কমিউনিটিগুলি পরিচালনা করেন",
"blur_media": "NSFW/18+ হিসেবে চিহ্নিত মিডিয়া ব্লার করুন",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "আপনি কোনও কমিউনিটির সদস্য বা মডারেটর নন।",
"more_posts_last_year": "{{count}} পোস্ট {{currentTimeFilterName}} এ: <1>গত বছরের আরও পোস্ট দেখান</1>",
"editor_fallback_warning": "উন্নত সম্পাদক লোড করতে ব্যর্থ হয়েছে, বিকল্প হিসেবে মৌলিক টেক্সট সম্পাদক ব্যবহার করা হচ্ছে।"
}
}

View File

@@ -328,8 +328,8 @@
"no": "ne",
"are_you_sure": "Jste si jistí?",
"a_short_title": "Krátký název pro vaši komunitu",
"anti_spam_challenges": "Anti-spam výzvy",
"anti_spam_challenges_subtitle": "Vyberte jednu nebo více výzev k prevenci spamu",
"challenges": "Výzvy",
"challenges_subtitle": "Vyberte jednu nebo více výzev k prevenci spamu",
"add_a_challenge": "Přidat výzvu",
"communities_you_moderate": "Komunity, které moderujete",
"blur_media": "Zamázat média označená jako NSFW/18+",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "Nejste odběratelem ani moderátorem žádné komunity.",
"more_posts_last_year": "{{count}} příspěvků v {{currentTimeFilterName}}: <1>zobrazit více příspěvků z minulého roku</1>",
"editor_fallback_warning": "Pokročilý editor se nepodařilo načíst, jako záložní je použit základní textový editor."
}
}

View File

@@ -328,8 +328,8 @@
"no": "nej",
"are_you_sure": "Er du sikker?",
"a_short_title": "En kort titel til dit fællesskab",
"anti_spam_challenges": "Anti-spam udfordringer",
"anti_spam_challenges_subtitle": "Vælg en eller flere udfordringer for at forhindre spam",
"challenges": "Udfordringer",
"challenges_subtitle": "Vælg en eller flere udfordringer for at forhindre spam",
"add_a_challenge": "Tilføj en udfordring",
"communities_you_moderate": "De samfund, du modererer",
"blur_media": "Slør medier markeret som NSFW/18+",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "Du er hverken abonnent eller moderator i nogen fællesskab.",
"more_posts_last_year": "{{count}} indlæg sidste {{currentTimeFilterName}}: <1>vis flere indlæg fra sidste år</1>",
"editor_fallback_warning": "Avanceret editor kunne ikke indlæses, bruger grundlæggende teksteditor som fallback."
}
}

View File

@@ -328,8 +328,8 @@
"no": "nein",
"are_you_sure": "Bist du sicher?",
"a_short_title": "Ein kurzer Titel für deine Gemeinschaft",
"anti_spam_challenges": "Anti-Spam-Herausforderungen",
"anti_spam_challenges_subtitle": "Wählen Sie eine oder mehrere Herausforderungen zur Vermeidung von Spam",
"challenges": "Herausforderungen",
"challenges_subtitle": "Wählen Sie eine oder mehrere Herausforderungen zur Vermeidung von Spam",
"add_a_challenge": "Herausforderung hinzufügen",
"communities_you_moderate": "Gemeinschaften, die du moderierst",
"blur_media": "Unschärfe Medien, die als NSFW/18+ markiert sind",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "Sie sind weder Abonnent noch Moderator einer Community.",
"more_posts_last_year": "{{count}} Beiträge im letzten {{currentTimeFilterName}}: <1>zeige mehr Beiträge vom letzten Jahr</1>",
"editor_fallback_warning": "Erweiterter Editor konnte nicht geladen werden, grundlegender Texteditor wird als Ersatz verwendet."
}
}

View File

@@ -328,8 +328,8 @@
"no": "όχι",
"are_you_sure": "Είσαι σίγουρος;",
"a_short_title": "Ένας σύντομος τίτλος για την κοινότητά σας",
"anti_spam_challenges": "Προκλήσεις κατά του spam",
"anti_spam_challenges_subtitle": "Επιλέξτε μία ή περισσότερες προκλήσεις για την αποτροπή των spam",
"challenges": "Προκλήσεις",
"challenges_subtitle": "Επιλέξτε μία ή περισσότερες προκλήσεις για την αποτροπή των spam",
"add_a_challenge": "Προσθήκη πρόκλησης",
"communities_you_moderate": "Κοινότητες που moderates",
"blur_media": "Θολώστε τα μέσα που είναι επισημασμένα ως NSFW/18+",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "Δεν είστε συνδρομητής ούτε διαχειριστής σε καμία κοινότητα.",
"more_posts_last_year": "{{count}} αναρτήσεις στο {{currentTimeFilterName}}: <1>δείτε περισσότερες αναρτήσεις από το περασμένο έτος</1>",
"editor_fallback_warning": "Ο προηγμένος επεξεργαστής απέτυχε να φορτωθεί, χρησιμοποιείται βασικός επεξεργαστής κειμένου ως εναλλακτική λύση."
}
}

View File

@@ -328,8 +328,8 @@
"no": "no",
"are_you_sure": "Are you sure?",
"a_short_title": "A short title for your community",
"anti_spam_challenges": "Anti-spam challenges",
"anti_spam_challenges_subtitle": "Choose one or more challenges to prevent spam",
"challenges": "Challenges",
"challenges_subtitle": "Choose one or more challenges to prevent spam",
"add_a_challenge": "Add a challenge",
"communities_you_moderate": "Communities you moderate",
"blur_media": "Blur media marked as NSFW/18+",
@@ -448,4 +448,4 @@
"moderator_of": "moderator of",
"not_subscriber_nor_moderator": "You are not a subscriber nor a moderator of any community.",
"more_posts_last_year": "{{count}} posts last {{currentTimeFilterName}}: <1>show more posts from last year</1>"
}
}

View File

@@ -328,8 +328,8 @@
"no": "no",
"are_you_sure": "¿Estás seguro?",
"a_short_title": "Un título corto para tu comunidad",
"anti_spam_challenges": "Desafíos anti-spam",
"anti_spam_challenges_subtitle": "Elige uno o más desafíos para prevenir el spam",
"challenges": "Desafíos",
"challenges_subtitle": "Elige uno o más desafíos para prevenir el spam",
"add_a_challenge": "Añadir un desafío",
"communities_you_moderate": "Comunidades que moderas",
"blur_media": "Difuminar los medios marcados como NSFW/18+",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "No eres suscriptor ni moderador de ninguna comunidad.",
"more_posts_last_year": "{{count}} publicaciones en {{currentTimeFilterName}}: <1>mostrar más publicaciones del año pasado</1>",
"editor_fallback_warning": "No se pudo cargar el editor avanzado, se está usando el editor de texto básico como alternativa."
}
}

View File

@@ -328,8 +328,8 @@
"no": "نه",
"are_you_sure": "آیا مطمئن هستید؟",
"a_short_title": "یک عنوان کوتاه برای جامعه شما",
"anti_spam_challenges": "چالش‌های ضد هرزنامه",
"anti_spam_challenges_subtitle": "یک یا چند چالش برای جلوگیری از هرزنامه انتخاب کنید",
"challenges": "چالش‌ها",
"challenges_subtitle": "یک یا چند چالش برای جلوگیری از هرزنامه انتخاب کنید",
"add_a_challenge": "یک چالش اضافه کنید",
"communities_you_moderate": "انجمن‌هایی که شما مدیریت می‌کنید",
"blur_media": "پوشاندن رسانه‌های علامت‌گذاری شده به عنوان NSFW/18+",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "شما نه عضو مشترک هستید و نه مدیر هیچ جامعه‌ای.",
"more_posts_last_year": "{{count}} پست در {{currentTimeFilterName}}: <1>نمایش پست‌های بیشتر از سال گذشته</1>",
"editor_fallback_warning": "ویرایشگر پیشرفته بارگیری نشد، از ویرایشگر متن ساده به عنوان جایگزین استفاده می‌شود."
}
}

View File

@@ -328,8 +328,8 @@
"no": "ei",
"are_you_sure": "Oletko varma?",
"a_short_title": "Lyhyt otsikko yhteisöllesi",
"anti_spam_challenges": "Vasta-roskapostin haasteet",
"anti_spam_challenges_subtitle": "Valitse yksi tai useampi haaste estämään roskapostia",
"challenges": "Haasteet",
"challenges_subtitle": "Valitse yksi tai useampi haaste estämään roskapostia",
"add_a_challenge": "Lisää haaste",
"communities_you_moderate": "Yhteisöt, joita moderoinnit",
"blur_media": "Sumenna media, jotka on merkitty NSFW/18+",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "Et ole tilaaja etkä moderaattori missään yhteisössä.",
"more_posts_last_year": "{{count}} julkaisua viime {{currentTimeFilterName}}: <1>näytä lisää julkaisuja viime vuodelta</1>",
"editor_fallback_warning": "edistynyt editori epäonnistui latautumaan, perus tekstieditori käytössä varatoimintona."
}
}

View File

@@ -328,8 +328,8 @@
"no": "hindi",
"are_you_sure": "Sigurado ka ba?",
"a_short_title": "Isang maikling pamagat para sa iyong komunidad",
"anti_spam_challenges": "Mga anti-spam na hamon",
"anti_spam_challenges_subtitle": "Pumili ng isa o higit pang mga hamon upang maiwasan ang spam",
"challenges": "Mga Hamon",
"challenges_subtitle": "Pumili ng isa o higit pang mga hamon upang maiwasan ang spam",
"add_a_challenge": "Magdagdag ng hamon",
"communities_you_moderate": "Mga komunidad na iyong mino-moderate",
"blur_media": "I-blur ang media na minarkahan bilang NSFW/18+",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "Hindi ka subscriber ni moderator ng anumang komunidad.",
"more_posts_last_year": "{{count}} post sa huling {{currentTimeFilterName}}: <1>ipakita ang mas maraming post mula sa nakaraang taon</1>",
"editor_fallback_warning": "Nabigong mag-load ang advanced editor, gamit ang basic text editor bilang fallback."
}
}

View File

@@ -328,8 +328,8 @@
"no": "non",
"are_you_sure": "Êtes-vous sûr?",
"a_short_title": "Un titre court pour votre communauté",
"anti_spam_challenges": "Défis anti-spam",
"anti_spam_challenges_subtitle": "Choisissez un ou plusieurs défis pour prévenir le spam",
"challenges": "Défis",
"challenges_subtitle": "Choisissez un ou plusieurs défis pour prévenir le spam",
"add_a_challenge": "Ajouter un défi",
"communities_you_moderate": "Communautés que vous modérez",
"blur_media": "Flouter les médias marqués comme NSFW/18+",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "Vous n'êtes ni abonné ni modérateur d'aucune communauté.",
"more_posts_last_year": "{{count}} publications lors du dernier {{currentTimeFilterName}} : <1>afficher plus de publications de l'année dernière</1>",
"editor_fallback_warning": "Le éditeur avancé n'a pas pu se charger, utilisation de l'éditeur de texte basique en remplacement."
}
}

View File

@@ -328,8 +328,8 @@
"no": "לא",
"are_you_sure": "האם אתה בטוח?",
"a_short_title": "כותרת קצרה עבור הקהילה שלך",
"anti_spam_challenges": "אתגרים נגד ספאם",
"anti_spam_challenges_subtitle": "בחר אתגר אחד או יותר כדי למנוע דואר זבל",
"challenges": "אתגרים",
"challenges_subtitle": "בחר אתגר אחד או יותר כדי למנוע דואר זבל",
"add_a_challenge": "הוסף אתגר",
"communities_you_moderate": "קהילות שאתה מנהל",
"blur_media": "הטשטש מדיה שסומנה כ-NSFW/18+",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "אתה לא מנוי ולא מפקח על שום קהילה.",
"more_posts_last_year": "{{count}} פוסטים ב{{currentTimeFilterName}} האחרון: <1>הצג עוד פוסטים מהשנה שעברה</1>",
"editor_fallback_warning": "העורך המתקדם נכשל בטעינה, משתמשים בעורך טקסט בסיסי כפתרון חלופי."
}
}

View File

@@ -328,8 +328,8 @@
"no": "नहीं",
"are_you_sure": "क्या आप सुनिश्चित हैं?",
"a_short_title": "आपके समुदाय के लिए एक छोटा शीर्षक",
"anti_spam_challenges": "एंटी-स्पैम चुनौतियाँ",
"anti_spam_challenges_subtitle": "स्पैम को रोकने के लिए एक या अधिक चुनौतियाँ चुनें",
"challenges": "चुनौतियाँ",
"challenges_subtitle": "स्पैम को रोकने के लिए एक या अधिक चुनौतियाँ चुनें",
"add_a_challenge": "एक चुनौती जोड़ें",
"communities_you_moderate": "आप जो समुदाय मॉडरेट करते हैं",
"blur_media": "NSFW/18+ के रूप में चिह्नित मीडिया को ब्लर करें",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "आप किसी भी समुदाय के सदस्य या मॉडरेटर नहीं हैं।",
"more_posts_last_year": "{{count}} पोस्ट पिछले {{currentTimeFilterName}} में: <1>पिछले साल के और पोस्ट दिखाएं</1>",
"editor_fallback_warning": "उन्नत संपादक लोड करने में विफल, बैकअप के रूप में मूल पाठ संपादक का उपयोग कर रहे हैं।"
}
}

View File

@@ -328,8 +328,8 @@
"no": "nem",
"are_you_sure": "Biztos vagy benne?",
"a_short_title": "Egy rövid cím a közösségednek",
"anti_spam_challenges": "Anti-spam kihívások",
"anti_spam_challenges_subtitle": "Válasszon egy vagy több kihívást a spam megelőzésére",
"challenges": "Kihívások",
"challenges_subtitle": "Válasszon egy vagy több kihívást a spam megelőzésére",
"add_a_challenge": "Hozzáad egy kihívást",
"communities_you_moderate": "Közösségek, amelyeket moderálsz",
"blur_media": "Elhomályosítja az NSFW/18+ jelöléssel ellátott médiát",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "Nem vagy előfizető vagy moderátor egyetlen közösségben sem.",
"more_posts_last_year": "{{count}} bejegyzés a múlt {{currentTimeFilterName}}: <1>több bejegyzés mutatása a múlt évből</1>",
"editor_fallback_warning": "A fejlett szerkesztő betöltése sikertelen, alap szövegszerkesztő használata tartalék megoldásként."
}
}

View File

@@ -328,8 +328,8 @@
"no": "tidak",
"are_you_sure": "Apakah Anda yakin?",
"a_short_title": "Judul singkat untuk komunitas Anda",
"anti_spam_challenges": "Tantangan anti-spam",
"anti_spam_challenges_subtitle": "Pilih satu atau lebih tantangan untuk mencegah spam",
"challenges": "Tantangan",
"challenges_subtitle": "Pilih satu atau lebih tantangan untuk mencegah spam",
"add_a_challenge": "Tambahkan tantangan",
"communities_you_moderate": "Komunitas yang Anda moderasi",
"blur_media": "Blur media yang ditandai sebagai NSFW/18+",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "Anda bukan pelanggan maupun moderator komunitas manapun.",
"more_posts_last_year": "{{count}} postingan pada {{currentTimeFilterName}} terakhir: <1>tampilkan lebih banyak postingan dari tahun lalu</1>",
"editor_fallback_warning": "editor lanjutan gagal dimuat, menggunakan editor teks dasar sebagai fallback."
}
}

View File

@@ -328,8 +328,8 @@
"no": "no",
"are_you_sure": "Sei sicuro?",
"a_short_title": "Un titolo breve per la tua comunità",
"anti_spam_challenges": "challenge anti-spam",
"anti_spam_challenges_subtitle": "Scegli una o più challenge per prevenire lo spam",
"challenges": "Sfide",
"challenges_subtitle": "Scegli una o più challenge per prevenire lo spam",
"add_a_challenge": "Aggiungi un challenge",
"communities_you_moderate": "Comunità che moderi",
"blur_media": "Offusca media contrassegnati come NSFW/18+",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "Non sei né iscritto né moderatore di nessuna community.",
"more_posts_last_year": "{{count}} post nell'ultimo {{currentTimeFilterName}}: <1>mostra più post dell'anno scorso</1>",
"editor_fallback_warning": "il editor avanzato non è riuscito a caricare, utilizzo dell'editor di testo di base come fallback."
}
}

View File

@@ -328,8 +328,8 @@
"no": "いいえ",
"are_you_sure": "本当にそうですか?",
"a_short_title": "あなたのコミュニティのための短いタイトル",
"anti_spam_challenges": "アンチスパムチャレンジ",
"anti_spam_challenges_subtitle": "スパムを防ぐために1つ以上のチャレンジを選択してください",
"challenges": "チャレンジ",
"challenges_subtitle": "スパムを防ぐために1つ以上のチャレンジを選択してください",
"add_a_challenge": "チャレンジを追加",
"communities_you_moderate": "あなたが管理するコミュニティ",
"blur_media": "NSFW/18+としてマークされたメディアをぼかす",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "あなたはどのコミュニティの購読者でもモデレーターでもありません。",
"more_posts_last_year": "{{count}} 件の投稿(直近の{{currentTimeFilterName}}: <1>昨年の投稿をもっと見る</1>",
"editor_fallback_warning": "高度なエディタの読み込みに失敗したため、基本的なテキストエディタを代替として使用しています。"
}
}

View File

@@ -328,8 +328,8 @@
"no": "아니요",
"are_you_sure": "정말 확실합니까?",
"a_short_title": "귀하의 커뮤니티를 위한 짧은 제목",
"anti_spam_challenges": "스팸 방지 챌린지",
"anti_spam_challenges_subtitle": "스팸을 방지하기 위해 하나 이상의 챌린지를 선택하세요",
"challenges": "도전",
"challenges_subtitle": "스팸을 방지하기 위해 하나 이상의 챌린지를 선택하세요",
"add_a_challenge": "도전 추가",
"communities_you_moderate": "당신이 관리하는 커뮤니티",
"blur_media": "NSFW/18+로 표시된 미디어 흐리게 처리",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "당신은 어떤 커뮤니티의 구독자도 아니고 중재자도 아닙니다.",
"more_posts_last_year": "{{count}} 개 게시물 지난 {{currentTimeFilterName}}: <1>작년 게시물 더 보기</1>",
"editor_fallback_warning": "고급 편집기 로드에 실패하여 기본 텍스트 편집기를 대체로 사용합니다."
}
}

View File

@@ -328,8 +328,8 @@
"no": "नाही",
"are_you_sure": "तुम्हाला खात्री आहे का?",
"a_short_title": "तुमच्या समुदायासाठी एक छोटा शीर्षक",
"anti_spam_challenges": "ॲंटी-स्पॅम चॅलेंजेस",
"anti_spam_challenges_subtitle": "स्पॅम रोखण्यासाठी एक किंवा अधिक आव्हाने निवडा",
"challenges": "आव्हाने",
"challenges_subtitle": "स्पॅम रोखण्यासाठी एक किंवा अधिक आव्हाने निवडा",
"add_a_challenge": "एक आव्हान जोडा",
"communities_you_moderate": "तुम्ही ज्या समुदायाचे व्यवस्थापन करता",
"blur_media": "NSFW/18+ म्हणून चिन्हांकित केलेली मीडिया धूसर करा",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "तुम कोणत्याही समुदायाचे सदस्य किंवा मॉडरेटर नाही.",
"more_posts_last_year": "{{count}} पोस्ट मागील {{currentTimeFilterName}}: <1>मागील वर्षीचे अधिक पोस्ट दाखवा</1>",
"editor_fallback_warning": "प्रगत संपादक लोड करण्यात अयशस्वी, फallback म्हणून मूलभूत टेक्स्ट संपादक वापरत आहे."
}
}

View File

@@ -328,8 +328,8 @@
"no": "nee",
"are_you_sure": "Weet je het zeker?",
"a_short_title": "Een korte titel voor je gemeenschap",
"anti_spam_challenges": "Anti-spam uitdagingen",
"anti_spam_challenges_subtitle": "Kies een of meer uitdagingen om spam te voorkomen",
"challenges": "Uitdagingen",
"challenges_subtitle": "Kies een of meer uitdagingen om spam te voorkomen",
"add_a_challenge": "Voeg een uitdaging toe",
"communities_you_moderate": "Gemeenschappen die je modereert",
"blur_media": "Vervagen media gemarkeerd als NSFW/18+",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "Je bent geen abonnee of moderator van een gemeenschap.",
"more_posts_last_year": "{{count}} berichten afgelopen {{currentTimeFilterName}}: <1>toon meer berichten van vorig jaar</1>",
"editor_fallback_warning": "Geavanceerde editor kon niet worden geladen, basisteksteditor wordt als fallback gebruikt."
}
}

View File

@@ -328,8 +328,8 @@
"no": "nei",
"are_you_sure": "Er du sikker?",
"a_short_title": "En kort tittel for ditt fellesskap",
"anti_spam_challenges": "Anti-spam utfordringer",
"anti_spam_challenges_subtitle": "Velg en eller flere utfordringer for å hindre spam",
"challenges": "Utfordringer",
"challenges_subtitle": "Velg en eller flere utfordringer for å hindre spam",
"add_a_challenge": "Legg til en utfordring",
"communities_you_moderate": "Samfunnene du modererer",
"blur_media": "Uskarpe media merket som NSFW/18+",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "Du er verken abonnent eller moderator i noe fellesskap.",
"more_posts_last_year": "{{count}} innlegg siste {{currentTimeFilterName}}: <1>vis flere innlegg fra i fjor</1>",
"editor_fallback_warning": "Avansert editor lastet ikke inn, bruker grunnleggende teksteditor som reserve."
}
}

View File

@@ -328,8 +328,8 @@
"no": "nie",
"are_you_sure": "Jesteś pewny?",
"a_short_title": "Krótki tytuł dla twojej społeczności",
"anti_spam_challenges": "Wyzwania przeciw spamowi",
"anti_spam_challenges_subtitle": "Wybierz jedno lub więcej wyzwań, aby zapobiec spamowi",
"challenges": "Wyzwania",
"challenges_subtitle": "Wybierz jedno lub więcej wyzwań, aby zapobiec spamowi",
"add_a_challenge": "Dodaj wyzwanie",
"communities_you_moderate": "Społeczności, którymi moderujesz",
"blur_media": "Rozmyj media oznaczone jako NSFW/18+",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "Nie jesteś subskrybentem ani moderatorem żadnej społeczności.",
"more_posts_last_year": "{{count}} postów w ostatnim {{currentTimeFilterName}}: <1>pokaż więcej postów z zeszłego roku</1>",
"editor_fallback_warning": "Zaawansowany edytor nie załadował się, używany jest podstawowy edytor tekstu jako zastępstwo."
}
}

View File

@@ -328,8 +328,8 @@
"no": "não",
"are_you_sure": "Tem certeza?",
"a_short_title": "Um título curto para sua comunidade",
"anti_spam_challenges": "Desafios anti-spam",
"anti_spam_challenges_subtitle": "Escolha um ou mais desafios para evitar spam",
"challenges": "Desafios",
"challenges_subtitle": "Escolha um ou mais desafios para evitar spam",
"add_a_challenge": "Adicionar um desafio",
"communities_you_moderate": "Comunidades que você modera",
"blur_media": "Desfoque mídias marcadas como NSFW/18+",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "Você não é assinante nem moderador de nenhuma comunidade.",
"more_posts_last_year": "{{count}} posts no último {{currentTimeFilterName}}: <1>mostrar mais posts do ano passado</1>",
"editor_fallback_warning": "editor avançado falhou ao carregar, usando editor de texto básico como fallback."
}
}

View File

@@ -328,8 +328,8 @@
"no": "nu",
"are_you_sure": "Ești sigur?",
"a_short_title": "Un titlu scurt pentru comunitatea ta",
"anti_spam_challenges": "Provocări anti-spam",
"anti_spam_challenges_subtitle": "Alegeți unul sau mai multe provocări pentru a preveni spamul",
"challenges": "Provocări",
"challenges_subtitle": "Alegeți unul sau mai multe provocări pentru a preveni spamul",
"add_a_challenge": "Adăugați o provocare",
"communities_you_moderate": "Comunități pe care le moderezi",
"blur_media": "Estompați media marcate ca NSFW/18+",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "Nu ești abonat și nici moderator al vreunei comunități.",
"more_posts_last_year": "{{count}} postări în ultimul {{currentTimeFilterName}}: <1>arată mai multe postări din anul trecut</1>",
"editor_fallback_warning": "editorul avansat nu a reușit să se încarce, se folosește editorul de text simplu ca soluție de rezervă."
}
}

View File

@@ -328,8 +328,8 @@
"no": "нет",
"are_you_sure": "Вы уверены?",
"a_short_title": "Короткое название для вашего сообщества",
"anti_spam_challenges": "Антиспам вызовы",
"anti_spam_challenges_subtitle": "Выберите одну или несколько задач для предотвращения спама",
"challenges": "Вызовы",
"challenges_subtitle": "Выберите одну или несколько задач для предотвращения спама",
"add_a_challenge": "Добавить задачу",
"communities_you_moderate": "Сообщества, которые вы модерируете",
"blur_media": "Размыть медиа, помеченные как NSFW/18+",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "Вы не являетесь подписчиком или модератором ни одного сообщества.",
"more_posts_last_year": "{{count}} постов за последний {{currentTimeFilterName}}: <1>показать больше постов за прошлый год</1>",
"editor_fallback_warning": "Не удалось загрузить продвинутый редактор, используется базовый текстовый редактор в качестве запасного варианта."
}
}

View File

@@ -328,8 +328,8 @@
"no": "jo",
"are_you_sure": "Jeni të sigurt?",
"a_short_title": "Një titull i shkurtër për komunitetin tuaj",
"anti_spam_challenges": "challenget anti-spam",
"anti_spam_challenges_subtitle": "Zgjidhni një ose më shumë challenge për të parandaluar spam",
"challenges": "Sfidat",
"challenges_subtitle": "Zgjidhni një ose më shumë challenge për të parandaluar spam",
"add_a_challenge": "Shtoni një sfidë",
"communities_you_moderate": "Komunitetet që moderoni",
"blur_media": "Bluroni mediat e shënuara si NSFW/18+",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "Nuk jeni as abonues as moderator i ndonjë komuniteti.",
"more_posts_last_year": "{{count}} postime në {{currentTimeFilterName}} e fundit: <1>shfaq më shumë postime nga viti i kaluar</1>",
"editor_fallback_warning": "Redaktori i avancuar dështoi të ngarkohet, përdoret redaktori bazë i tekstit si zëvendësim."
}
}

View File

@@ -328,8 +328,8 @@
"no": "nej",
"are_you_sure": "Är du säker?",
"a_short_title": "En kort titel för ditt samhälle",
"anti_spam_challenges": "Anti-spam-utmaningar",
"anti_spam_challenges_subtitle": "Välj en eller flera utmaningar för att förhindra spam",
"challenges": "Utmaningar",
"challenges_subtitle": "Välj en eller flera utmaningar för att förhindra spam",
"add_a_challenge": "Lägg till en utmaning",
"communities_you_moderate": "Gemenskaper du modererar",
"blur_media": "Sudda ut media markerade som NSFW/18+",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "Du är varken prenumerant eller moderator i någon community.",
"more_posts_last_year": "{{count}} inlägg senaste {{currentTimeFilterName}}: <1>visa fler inlägg från förra året</1>",
"editor_fallback_warning": "Avancerad redigerare kunde inte laddas, använder enkel textredigerare som fallback."
}
}

View File

@@ -328,8 +328,8 @@
"no": "కాదు",
"are_you_sure": "మీకు నమ్మకమా?",
"a_short_title": "మీ సముదాయం కోసం ఒక చిన్న శీర్షిక",
"anti_spam_challenges": "ఏంటి-స్పామ్ సవాళ్ు",
"anti_spam_challenges_subtitle": "స్పామ్ ను నివారించడానికి ఒకటి లేదా ఎక్కువ సవాళ్లను ఎంచుకోండి",
"challenges": "సవాళ్ు",
"challenges_subtitle": "స్పామ్ ను నివారించడానికి ఒకటి లేదా ఎక్కువ సవాళ్లను ఎంచుకోండి",
"add_a_challenge": "ఒక సవాలు జోడించండి",
"communities_you_moderate": "మీరు నిర్వహించే కమ్యూనిటీలు",
"blur_media": "NSFW/18+ గా గుర్తించిన మీడియాను బ్లర్ చేయండి",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "మీరు ఏ కమ్యూనిటీలో సభ్యుడూ, మోడరేటర్ కూడా కాదు.",
"more_posts_last_year": "{{count}} పోస్ట్లు గత {{currentTimeFilterName}}: <1>గత సంవత్సరం నుండి మరిన్ని పోస్ట్లు చూపించు</1>",
"editor_fallback_warning": "అడ్వాన్స్ ఎడిటర్ లోడ్ అవ్వడం విఫలమైంది, బేసిక్ టెక్స్ట్ ఎడిటర్ fallback గా ఉపయోగిస్తున్నారు."
}
}

View File

@@ -328,8 +328,8 @@
"no": "ไม่ใช่",
"are_you_sure": "คุณมั่นใจไหม?",
"a_short_title": "ชื่อเรื่องสั้นๆ สำหรับชุมชนของคุณ",
"anti_spam_challenges": "ความท้าทายต่อต้านสแปม",
"anti_spam_challenges_subtitle": "เลือกความท้าทายหนึ่งหรือหลายรายการเพื่อป้องกันสแปม",
"challenges": "ความท้าทาย",
"challenges_subtitle": "เลือกความท้าทายหนึ่งหรือหลายรายการเพื่อป้องกันสแปม",
"add_a_challenge": "เพิ่มความท้าทาย",
"communities_you_moderate": "ชุมชนที่คุณเป็นผู้ดูแล",
"blur_media": "เบลอเนื้อหาที่ถูกทำเครื่องหมายว่า NSFW/18+",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "คุณไม่ใช่ผู้สมัครสมาชิกและไม่ใช่ผู้ดูแลชุมชนใดๆ",
"more_posts_last_year": "{{count}} โพสต์ใน{{currentTimeFilterName}} ล่าสุด: <1>แสดงโพสต์เพิ่มเติมจากปีที่แล้ว</1>",
"editor_fallback_warning": "ตัวแก้ไขขั้นสูงโหลดไม่สำเร็จ ใช้ตัวแก้ไขข้อความพื้นฐานเป็นทางเลือกสำรอง"
}
}

View File

@@ -328,8 +328,8 @@
"no": "hayır",
"are_you_sure": "Emin misin?",
"a_short_title": "Topluluğunuz için kısa bir başlık",
"anti_spam_challenges": "Anti-spam zorlukları",
"anti_spam_challenges_subtitle": "Spam önlemek için bir veya daha fazla zorluk seçin",
"challenges": "Zorluklar",
"challenges_subtitle": "Spam önlemek için bir veya daha fazla zorluk seçin",
"add_a_challenge": "Bir zorluk ekle",
"communities_you_moderate": "Moderatörlük yaptığınız topluluklar",
"blur_media": "NSFW/18+ olarak işaretlenmiş medyayı bulanıklaştırın",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "Hiçbir topluluğun abonesi veya moderatörü değilsiniz.",
"more_posts_last_year": "{{count}} gönderi son {{currentTimeFilterName}}: <1>geçen yıldan daha fazla gönderi göster</1>",
"editor_fallback_warning": "gelişmiş editör yüklenemedi, yedek olarak temel metin editörü kullanılıyor."
}
}

View File

@@ -328,8 +328,8 @@
"no": "ні",
"are_you_sure": "Ви впевнені?",
"a_short_title": "Короткий заголовок для вашої спільноти",
"anti_spam_challenges": "Антиспам виклики",
"anti_spam_challenges_subtitle": "Виберіть одне або кілька завдань для запобігання спаму",
"challenges": "Виклики",
"challenges_subtitle": "Виберіть одне або кілька завдань для запобігання спаму",
"add_a_challenge": "Додати завдання",
"communities_you_moderate": "Спільноти, які ви модеруєте",
"blur_media": "Розмити медіа, позначені як NSFW/18+",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "Ви не є підписником або модератором жодної спільноти.",
"more_posts_last_year": "{{count}} постів за останній {{currentTimeFilterName}}: <1>показати більше постів з минулого року</1>",
"editor_fallback_warning": "Розширений редактор не вдалося завантажити, використовується базовий текстовий редактор як запасний варіант."
}
}

View File

@@ -328,8 +328,8 @@
"no": "نہیں",
"are_you_sure": "کیا آپ کو یقین ہے؟",
"a_short_title": "آپ کی کمیونٹی کے لیے ایک مختصر عنوان",
"anti_spam_challenges": "اینٹی اسپام چیلنجز",
"anti_spam_challenges_subtitle": "اسپام کو روکنے کے لیے ایک یا زیادہ چیلنجز منتخب کریں",
"challenges": "چیلنجز",
"challenges_subtitle": "اسپام کو روکنے کے لیے ایک یا زیادہ چیلنجز منتخب کریں",
"add_a_challenge": "ایک چیلنج شامل کریں",
"communities_you_moderate": "کمیونٹیز جو آپ ماڈریٹ کرتے ہیں",
"blur_media": "NSFW/18+ کے طور پر نشان زد میڈیا کو دھندلا کریں",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "آپ کسی کمیونٹی کے سبسکرائبر یا ماڈریٹر نہیں ہیں۔",
"more_posts_last_year": "{{count}} پوسٹس پچھلے {{currentTimeFilterName}} میں: <1>پچھلے سال کی مزید پوسٹس دکھائیں</1>",
"editor_fallback_warning": "ایڈوانس ایڈیٹر لوڈ کرنے میں ناکام، بیک اپ کے طور پر بنیادی ٹیکسٹ ایڈیٹر استعمال کر رہے ہیں۔"
}
}

View File

@@ -328,8 +328,8 @@
"no": "không",
"are_you_sure": "Bạn chắc chắn chứ?",
"a_short_title": "Một tiêu đề ngắn cho cộng đồng của bạn",
"anti_spam_challenges": "Thử thách chống spam",
"anti_spam_challenges_subtitle": "Chọn một hoặc nhiều thử thách để ngừng spam",
"challenges": "Thử thách",
"challenges_subtitle": "Chọn một hoặc nhiều thử thách để ngừng spam",
"add_a_challenge": "Thêm một thử thách",
"communities_you_moderate": "Cộng đồng bạn quản lý",
"blur_media": "Mờ phương tiện được đánh dấu là NSFW/18+",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "Bạn không phải là người đăng ký cũng không phải là người điều hành của bất kỳ cộng đồng nào.",
"more_posts_last_year": "{{count}} bài viết trong {{currentTimeFilterName}} vừa qua: <1>hiển thị thêm bài viết từ năm ngoái</1>",
"editor_fallback_warning": "trình soạn thảo nâng cao không tải được, đang sử dụng trình soạn thảo văn bản cơ bản làm phương án dự phòng."
}
}

View File

@@ -328,8 +328,8 @@
"no": "不是",
"are_you_sure": "你确定吗?",
"a_short_title": "为您的社区提供一个简短的标题",
"anti_spam_challenges": "反垃圾邮件挑战",
"anti_spam_challenges_subtitle": "选择一个或多个挑战来防止垃圾邮件",
"challenges": "挑战",
"challenges_subtitle": "选择一个或多个挑战来防止垃圾邮件",
"add_a_challenge": "添加一个挑战",
"communities_you_moderate": "您管理的社区",
"blur_media": "模糊标记为NSFW/18+的媒体",
@@ -448,4 +448,4 @@
"not_subscriber_nor_moderator": "您既不是任何社区的订阅者,也不是管理员。",
"more_posts_last_year": "{{count}} 篇帖子在过去的 {{currentTimeFilterName}}<1>显示去年的更多帖子</1>",
"editor_fallback_warning": "高级编辑器加载失败,使用基础文本编辑器作为备用。"
}
}

View File

@@ -0,0 +1,265 @@
#!/usr/bin/env node
/*
Translation updater CLI
Usage examples:
- Copy value of a key from en to all languages (dry run):
node scripts/update-translations.js --key 5chan_description --from en --dry
- Set a specific value for all languages (including en):
node scripts/update-translations.js --key no_global_rules_info --value "Your value" --include-en --write
- Use a mapping file with per-language values (JSON object of { langCode: value, ... }):
node scripts/update-translations.js --key my_key --map ./path/to/map.json --write
- Delete a key from all languages (dry run):
node scripts/update-translations.js --key obsolete_key --delete --dry
- Delete a key from all languages (actually delete):
node scripts/update-translations.js --key obsolete_key --delete --write
Flags:
--key <name> Required. The translation key to update/delete.
--delete Delete the key from all languages instead of updating.
--from <lang> Source language to read value from if --value/--map are not provided (default: en).
--value <string> Literal value to set for targets (use with caution, no auto-translate).
--map <file> JSON file mapping of { langCode: string } to set per language.
--only <langs> Comma-separated list of language codes to update (e.g. "es,fr,it").
--exclude <langs> Comma-separated list of language codes to skip.
--include-en Include the source language (e.g. en) in updates.
--dry|--dry-run Show changes but do not write files (default).
--write Actually write the files.
*/
import fs from 'fs/promises'
import path from 'path'
function parseArgs(argv) {
const out = { flags: new Set() }
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
const next = i + 1 < argv.length ? argv[i + 1] : undefined
if (arg.startsWith('--')) {
if (['--dry', '--dry-run', '--write', '--include-en', '--delete'].includes(arg)) {
out.flags.add(arg)
continue
}
const key = arg
let value
if (next && !next.startsWith('--')) {
value = next
i++
}
out[key] = value ?? ''
}
}
return out
}
function usage(exitCode = 1, msg) {
if (msg) console.error(msg)
console.error(
'Usage: node scripts/update-translations.js --key <name> [--delete] [--from <lang>] [--value <string> | --map <file>] [--only <langs>] [--exclude <langs>] [--include-en] [--dry|--write]'
)
process.exit(exitCode)
}
async function fileExists(p) {
try {
await fs.access(p)
return true
} catch {
return false
}
}
function parseCsv(val) {
if (!val) return new Set()
return new Set(
val
.split(',')
.map((s) => s.trim())
.filter(Boolean)
)
}
async function loadJson(filePath) {
const text = await fs.readFile(filePath, 'utf8')
try {
return JSON.parse(text)
} catch (err) {
throw new Error(`Failed to parse JSON: ${filePath}: ${err.message}`)
}
}
async function writeJson(filePath, data) {
const json = JSON.stringify(data, null, 2) + '\n'
await fs.writeFile(filePath, json, 'utf8')
}
async function handleDelete(key, translationsRoot, only, exclude, dryRun, write) {
const dirents = await fs.readdir(translationsRoot, { withFileTypes: true })
const langs = dirents.filter((d) => d.isDirectory()).map((d) => d.name)
const planned = []
for (const lang of langs) {
if (only.size && !only.has(lang)) continue
if (exclude.size && exclude.has(lang)) continue
const filePath = path.join(translationsRoot, lang, 'default.json')
if (!(await fileExists(filePath))) continue
const json = await loadJson(filePath)
if (!Object.prototype.hasOwnProperty.call(json, key)) {
// Key does not exist in this language, skip
continue
}
const prevVal = json[key]
planned.push({ lang, filePath, prevVal, json })
}
if (!planned.length) {
console.log('No changes planned.')
return
}
for (const change of planned) {
const { lang, filePath, prevVal } = change
const prevPreview = typeof prevVal === 'undefined' ? '<missing>' : String(prevVal).substring(0, 60)
console.log(`[${lang}] ${key}:`)
console.log(` - deleted: ${prevPreview}${String(prevVal).length > 60 ? '...' : ''}`)
if (!dryRun) {
delete change.json[key]
await writeJson(filePath, change.json)
}
}
if (dryRun) {
console.log(`\nDry run complete. ${planned.length} file(s) would change. Re-run with --write to apply.`)
} else if (write) {
console.log(`\nWrote ${planned.length} file(s).`)
}
}
async function handleUpdate(key, translationsRoot, fromLang, includeEn, only, exclude, literalValue, mapFile, map, dryRun, write) {
let sourceValue = undefined
if (!literalValue && !map) {
// default to --from
const srcPath = path.join(translationsRoot, fromLang, 'default.json')
if (!(await fileExists(srcPath))) usage(1, `Source file not found for --from ${fromLang}: ${srcPath}`)
const srcJson = await loadJson(srcPath)
sourceValue = srcJson[key]
if (typeof sourceValue === 'undefined') usage(1, `Key "${key}" not found in source language ${fromLang}`)
}
const dirents = await fs.readdir(translationsRoot, { withFileTypes: true })
const langs = dirents.filter((d) => d.isDirectory()).map((d) => d.name)
const planned = []
for (const lang of langs) {
if (!includeEn && lang === fromLang) continue
if (only.size && !only.has(lang)) continue
if (exclude.size && exclude.has(lang)) continue
const filePath = path.join(translationsRoot, lang, 'default.json')
if (!(await fileExists(filePath))) continue
const json = await loadJson(filePath)
let nextVal
if (map && Object.prototype.hasOwnProperty.call(map, lang)) {
nextVal = map[lang]
} else if (typeof literalValue !== 'undefined') {
nextVal = literalValue
} else {
nextVal = sourceValue
}
if (typeof nextVal === 'undefined') {
// No value for this lang in the chosen mode; skip
continue
}
const prevVal = json[key]
if (prevVal === nextVal) continue
planned.push({ lang, filePath, prevVal, nextVal, json })
}
if (!planned.length) {
console.log('No changes planned.')
return
}
for (const change of planned) {
const { lang, filePath, prevVal, nextVal } = change
const prevPreview = typeof prevVal === 'undefined' ? '<missing>' : String(prevVal).substring(0, 60)
const nextPreview = String(nextVal).substring(0, 60)
console.log(`[${lang}] ${key}:`)
console.log(` - from: ${prevPreview}${String(prevVal).length > 60 ? '...' : ''}`)
console.log(` + to : ${nextPreview}${String(nextVal).length > 60 ? '...' : ''}`)
if (!dryRun) {
change.json[key] = nextVal
await writeJson(filePath, change.json)
}
}
if (dryRun) {
console.log(`\nDry run complete. ${planned.length} file(s) would change. Re-run with --write to apply.`)
} else if (write) {
console.log(`\nWrote ${planned.length} file(s).`)
}
}
async function main() {
const argv = process.argv.slice(2)
const args = parseArgs(argv)
const key = args['--key']
if (!key) usage(1, 'Missing required --key')
const translationsRoot = path.join(process.cwd(), 'public', 'translations')
if (!(await fileExists(translationsRoot))) {
usage(1, `Translations directory not found: ${translationsRoot}`)
}
const isDelete = args.flags.has('--delete')
const fromLang = args['--from'] || 'en'
const includeEn = args.flags.has('--include-en')
const dryRun = args.flags.has('--dry') || args.flags.has('--dry-run') || !args.flags.has('--write')
const write = args.flags.has('--write')
const only = parseCsv(args['--only'])
const exclude = parseCsv(args['--exclude'])
if (isDelete) {
// Delete mode
await handleDelete(key, translationsRoot, only, exclude, dryRun, write)
} else {
// Update mode
let literalValue = args['--value']
let mapFile = args['--map']
let map = undefined
if (mapFile) {
const absMap = path.isAbsolute(mapFile) ? mapFile : path.join(process.cwd(), mapFile)
if (!(await fileExists(absMap))) usage(1, `--map not found: ${absMap}`)
map = await loadJson(absMap)
if (typeof map !== 'object' || map === null) usage(1, '--map must be a JSON object of { lang: value }')
}
await handleUpdate(key, translationsRoot, fromLang, includeEn, only, exclude, literalValue, mapFile, map, dryRun, write)
}
}
main().catch((err) => {
console.error(err)
process.exit(1)
})

View File

@@ -38,7 +38,7 @@ const AuthorModeratingList = ({ accountSubplebbits, authorSubplebbits, isAuthor
<ul className={`${styles.modListContent} ${styles.modsList}`}>
{subplebbitAddresses.map((address, index) => (
<li key={index}>
<Link to={`/p/${address}`}>p/{Plebbit.getShortAddress(address)}</Link>
<Link to={`/p/${address}`}>p/{Plebbit.getShortAddress({ address })}</Link>
</li>
))}
</ul>

View File

@@ -332,14 +332,16 @@ const HeaderTitle = ({ title, pendingPostSubplebbitAddress }: { title: string; p
const subplebbitTitle = (
<Link to={`/p/${isInPendingPostView ? pendingPostSubplebbitAddress : subplebbitAddress}`}>
{title ||
(subplebbitAddress && Plebbit.getShortAddress(subplebbitAddress)) ||
(pendingPostSubplebbitAddress && Plebbit.getShortAddress(pendingPostSubplebbitAddress))}
(subplebbitAddress && Plebbit.getShortAddress({ address: subplebbitAddress })) ||
(pendingPostSubplebbitAddress && Plebbit.getShortAddress({ address: pendingPostSubplebbitAddress }))}
</Link>
);
const domainTitle = <Link to={`/domain/${params.domain}`}>{params.domain}</Link>;
const submitTitle = <span className={styles.submitTitle}>{t('submit')}</span>;
const profileTitle = <Link to='/profile'>{account?.author?.shortAddress}</Link>;
const authorTitle = <Link to={`/u/${params.authorAddress}/c/${params.commentCid}`}>{params.authorAddress && Plebbit.getShortAddress(params.authorAddress)}</Link>;
const authorTitle = (
<Link to={`/u/${params.authorAddress}/c/${params.commentCid}`}>{params.authorAddress && Plebbit.getShortAddress({ address: params.authorAddress })}</Link>
);
if (isBroadlyNsfwSubplebbit && !hasUnhiddenAnyNsfwCommunity) {
return <span>{t('over_18')}</span>;

View File

@@ -38,7 +38,7 @@ const BlockSubplebbitButton = ({ subplebbitAddress }: HideMenuProps) => {
return (
<div className={styles.menuItem} onClick={blocked ? unblock : block}>
{blocked ? `${t('unblock')}` : `${t('block')}`} p/{subplebbitAddress && Plebbit.getShortAddress(subplebbitAddress)}
{blocked ? `${t('unblock')}` : `${t('block')}`} p/{subplebbitAddress && Plebbit.getShortAddress({ address: subplebbitAddress })}
</div>
);
};

View File

@@ -260,7 +260,9 @@ const Post = ({ index, post = {} }: PostProps) => {
{hostname ? (
<Link to={`/domain/${hostname}`}>{hostname.length > 25 ? hostname.slice(0, 25) + '...' : hostname}</Link>
) : (
<Link to={`/p/${subplebbitAddress}`}>self.{subplebbit?.shortAddress || (subplebbitAddress && Plebbit.getShortAddress(subplebbitAddress))}</Link>
<Link to={`/p/${subplebbitAddress}`}>
self.{subplebbit?.shortAddress || (subplebbitAddress && Plebbit.getShortAddress({ address: subplebbitAddress }))}
</Link>
)}
)
</span>
@@ -307,7 +309,7 @@ const Post = ({ index, post = {} }: PostProps) => {
</span>
)}
<Link className={`${styles.subplebbit} ${subscribed && hasClickedSubscribe ? styles.greenSubplebbitAddress : ''}`} to={`/p/${subplebbitAddress}`}>
p/{subplebbit?.shortAddress || (subplebbitAddress && Plebbit.getShortAddress(subplebbitAddress))}
p/{subplebbit?.shortAddress || (subplebbitAddress && Plebbit.getShortAddress({ address: subplebbitAddress }))}
</Link>
</span>
</>

View File

@@ -282,7 +282,7 @@ const InboxShowParentButton = ({ parentCid }: { parentCid: string | undefined })
const InboxParentInfo = ({ address, cid, markedAsRead, parentCid, postCid, shortAddress, subplebbitAddress, timestamp }: ParentLinkProps) => {
const { t } = useTranslation();
const shortSubplebbitAddress = subplebbitAddress ? (subplebbitAddress.includes('.') ? subplebbitAddress : Plebbit.getShortAddress(subplebbitAddress)) : '';
const shortSubplebbitAddress = subplebbitAddress ? (subplebbitAddress.includes('.') ? subplebbitAddress : Plebbit.getShortAddress({ address: subplebbitAddress })) : '';
return (
<>

View File

@@ -265,7 +265,7 @@ const SearchBar = ({ isFocused = false, onExpandoChange }: SearchBarProps) => {
onTouchEnd={() => handleCommunitySelect(address)}
onMouseEnter={() => setActiveDropdownIndex(index)}
>
{Plebbit.getShortAddress(address)}
{Plebbit.getShortAddress({ address })}
</li>
))}
</ul>

View File

@@ -55,7 +55,7 @@ const ModeratorsList = ({ roles }: { roles: Record<string, Role> }) => {
<ul className={`${styles.listContent} ${styles.modsList}`}>
{rolesList.map(({ address }, index) => (
<li key={index} onClick={() => window.alert('Direct profile links are not supported yet.')}>
u/{Plebbit.getShortAddress(address)}
u/{Plebbit.getShortAddress({ address })}
</li>
))}
{/* TODO: https://github.com/plebbit/seedit/issues/274
@@ -196,7 +196,7 @@ const Sidebar = ({ comment, isSubCreatedButNotYetPublished, settings, subplebbit
const pendingPost = useAccountComment({ commentIndex: params?.accountCommentIndex as any });
const subplebbitCreator = findSubplebbitCreator(roles);
const creatorAddress = subplebbitCreator === 'anonymous' ? 'anonymous' : `${Plebbit.getShortAddress(subplebbitCreator)}`;
const creatorAddress = subplebbitCreator === 'anonymous' ? 'anonymous' : `${Plebbit.getShortAddress({ address: subplebbitCreator })}`;
const submitRoute =
isInHomeView || isInHomeAboutView || isInAllView || isInModView || isInDomainView
? '/submit'

View File

@@ -42,7 +42,7 @@ const CommunitiesDropdown = () => {
<div className={`${styles.dropChoices} ${styles.subsDropChoices} ${subsDropdownClass}`} ref={subsdropdownItemsRef}>
{reversedSubscriptions?.map((subscription: string, index: number) => (
<Link key={index} to={`/p/${subscription}`} className={styles.dropdownItem}>
{Plebbit.getShortAddress(subscription)}
{Plebbit.getShortAddress({ address: subscription })}
</Link>
))}
<Link to='/communities/subscriber' className={`${styles.dropdownItem} ${styles.myCommunitiesItemButtonDotted}`}>
@@ -331,7 +331,7 @@ const TopBar = memo(() => {
)}
{subscriptions?.length > 0 && <span className={styles.separator}> | </span>}
{reversedSubscriptions?.map((subscription: string, index: number) => {
const shortAddress = Plebbit.getShortAddress(subscription);
const shortAddress = Plebbit.getShortAddress({ address: subscription });
const displayAddress = shortAddress.includes('.eth') ? shortAddress.slice(0, -4) : shortAddress.includes('.sol') ? shortAddress.slice(0, -4) : shortAddress;
return (
<li key={index}>
@@ -345,7 +345,7 @@ const TopBar = memo(() => {
{!hideDefaultCommunities && filteredSubplebbitAddresses?.length > 0 && <span className={styles.separator}> | </span>}
{!hideDefaultCommunities &&
filteredSubplebbitAddresses?.map((address, index) => {
const shortAddress = Plebbit.getShortAddress(address);
const shortAddress = Plebbit.getShortAddress({ address });
const displayAddress = shortAddress.includes('.eth')
? shortAddress.slice(0, -4)
: shortAddress.includes('.sol')

View File

@@ -13,6 +13,7 @@ import { registerSW } from 'virtual:pwa-register';
if (window.location.hostname.startsWith('p2p.')) {
(window as any).defaultPlebbitOptions = {
libp2pJsClientsOptions: [{ key: 'libp2pjs' }],
httpsRoutersOptions: ['https://peers.pleb.bot', 'https://peers.forumindex.com'],
};
}

View File

@@ -405,7 +405,7 @@ const SubplebbitAddressField = () => {
setPublishPostStore({ subplebbitAddress: subscription });
}}
>
{Plebbit.getShortAddress(subscription)}
{Plebbit.getShortAddress({ address: subscription })}
</span>
))}
</div>
@@ -471,7 +471,7 @@ const SubmitPage = () => {
const selectedSubplebbitData = useSubplebbit({ subplebbitAddress });
const { rules, title: subplebbitTitle } = selectedSubplebbitData;
const shortAddress = subplebbitAddress && Plebbit.getShortAddress(subplebbitAddress);
const shortAddress = subplebbitAddress && Plebbit.getShortAddress({ address: subplebbitAddress });
const { isOffline, offlineTitle } = useIsSubplebbitOffline(selectedSubplebbitData);
const { index, publishComment } = usePublishComment(publishCommentOptions);

View File

@@ -498,8 +498,8 @@ const Challenges = ({
return (
<div className={`${styles.box} ${isReadOnly && !challenges ? styles.hidden : styles.visible}`}>
<div className={styles.boxTitle}>{t('anti_spam_challenges')}</div>
<div className={styles.boxSubtitle}>{t('anti_spam_challenges_subtitle')}</div>
<div className={styles.boxTitle}>{t('challenges')}</div>
<div className={styles.boxSubtitle}>{t('challenges_subtitle')}</div>
<div className={styles.boxInput}>
{!isReadOnly && (
<button className={styles.addButton} onClick={handleAddChallenge} disabled={isReadOnly}>

View File

@@ -5,6 +5,7 @@ import { nodePolyfills } from 'vite-plugin-node-polyfills';
import eslint from 'vite-plugin-eslint';
import { VitePWA } from 'vite-plugin-pwa';
import reactScan from '@react-scan/vite-plugin-react-scan';
import { reactGrab } from 'react-grab/plugins/vite';
const isProduction = process.env.NODE_ENV === 'production';
const isDevelopment = process.env.NODE_ENV === 'development';
@@ -25,6 +26,8 @@ export default defineConfig({
showToolbar: true,
playSound: true,
}),
// Only include React Grab in development mode - never in production builds
(isDevelopment || (!isProduction && process.env.NODE_ENV !== 'production')) && reactGrab(),
!isProduction && eslint({
lintOnStart: true,
overrideConfigFile: './.eslintrc.cjs',
@@ -201,7 +204,9 @@ export default defineConfig({
'buffer',
'process',
'stream-browserify',
'isomorphic-fetch'
'isomorphic-fetch',
'workbox-core',
'workbox-precaching'
],
},
esbuild: {

2555
yarn.lock
View File

File diff suppressed because it is too large Load Diff