mirror of
https://github.com/plebbit/seedit.git
synced 2026-02-05 03:21:04 -05:00
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
813
docs/react-guide.md
Normal 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. That’s 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 model’s 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. Here’s 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 doesn’t 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 can’t:
|
||||
- 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.
|
||||
|
||||
Here’s 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? Don’t 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, prop‑drilling | Central store (Zustand / Redux Toolkit) or colocated custom hooks |
|
||||
| **Data Fetching** | `useEffect` + `fetch`, manual loading/error juggling | React Query / SWR with caching, retries, stale‑while‑revalidate |
|
||||
| **Side Effects** | Effects without cleanup → race conditions, leaks | AbortController or query library handles cancellation & retries |
|
||||
| **Logic Reuse** | Copy‑pasted hooks and state in every component | Extract cross‑cutting logic into custom hooks |
|
||||
| **Workflow / State Machine** | Boolean‑flag soup, impossible states | Deterministic statechart in a single store (Zustand / XState) |
|
||||
| **Concurrent UI Updates** | Blocking synchronous work inside effects | `useTransition` / `startTransition` for low‑priority updates |
|
||||
| **Routing & Navigation** | DIY history manipulation, inline route state | React Router v6 (or Next.js built‑ins) with lazy‑loaded routes |
|
||||
| **Error Handling** | No error boundary → full‑app crashes | Top‑level `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 on‑demand loading |
|
||||
| **Testing Strategy** | Brittle tests keyed to DOM structure | User‑centric tests with React Testing Library & jest‑dom |
|
||||
| **Accessibility & Theming** | Non‑semantic elements, missing ARIA, inline colors | Semantic HTML, ARIA labels, CSS variables / theme provider |
|
||||
1745
docs/you-might-not-need-an-effect.md
Normal file
1745
docs/you-might-not-need-an-effect.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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": "فشل تحميل المحرر المتقدم، يتم استخدام محرر نصوص أساسي كبديل."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "উন্নত সম্পাদক লোড করতে ব্যর্থ হয়েছে, বিকল্প হিসেবে মৌলিক টেক্সট সম্পাদক ব্যবহার করা হচ্ছে।"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Ο προηγμένος επεξεργαστής απέτυχε να φορτωθεί, χρησιμοποιείται βασικός επεξεργαστής κειμένου ως εναλλακτική λύση."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "ویرایشگر پیشرفته بارگیری نشد، از ویرایشگر متن ساده به عنوان جایگزین استفاده میشود."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "העורך המתקדם נכשל בטעינה, משתמשים בעורך טקסט בסיסי כפתרון חלופי."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "उन्नत संपादक लोड करने में विफल, बैकअप के रूप में मूल पाठ संपादक का उपयोग कर रहे हैं।"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "高度なエディタの読み込みに失敗したため、基本的なテキストエディタを代替として使用しています。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "고급 편집기 로드에 실패하여 기본 텍스트 편집기를 대체로 사용합니다."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 म्हणून मूलभूत टेक्स्ट संपादक वापरत आहे."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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ă."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Не удалось загрузить продвинутый редактор, используется базовый текстовый редактор в качестве запасного варианта."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 గా ఉపయోగిస్తున్నారు."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "ตัวแก้ไขขั้นสูงโหลดไม่สำเร็จ ใช้ตัวแก้ไขข้อความพื้นฐานเป็นทางเลือกสำรอง"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Розширений редактор не вдалося завантажити, використовується базовий текстовий редактор як запасний варіант."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "ایڈوانس ایڈیٹر لوڈ کرنے میں ناکام، بیک اپ کے طور پر بنیادی ٹیکسٹ ایڈیٹر استعمال کر رہے ہیں۔"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "高级编辑器加载失败,使用基础文本编辑器作为备用。"
|
||||
}
|
||||
}
|
||||
|
||||
265
scripts/update-translations.js
Normal file
265
scripts/update-translations.js
Normal 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)
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -265,7 +265,7 @@ const SearchBar = ({ isFocused = false, onExpandoChange }: SearchBarProps) => {
|
||||
onTouchEnd={() => handleCommunitySelect(address)}
|
||||
onMouseEnter={() => setActiveDropdownIndex(index)}
|
||||
>
|
||||
{Plebbit.getShortAddress(address)}
|
||||
{Plebbit.getShortAddress({ address })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user