Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
11
apps/web/src/lib/amplify.ts
Normal file
11
apps/web/src/lib/amplify.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import type { Platform } from "./platforms";
|
||||
|
||||
export interface NativeAmplifyAction { kind: "repost" | "quote" | "share"; label: string; }
|
||||
|
||||
export function nativeAmplifyActions(platform: Platform): NativeAmplifyAction[] {
|
||||
return [
|
||||
...(platform.capabilities.amplifyRepost ? [{ kind: "repost" as const, label: platform.amplifyLabels.repost ?? "Repost" }] : []),
|
||||
...(platform.capabilities.amplifyQuote ? [{ kind: "quote" as const, label: platform.amplifyLabels.quote ?? "Quote" }] : []),
|
||||
...(platform.capabilities.amplifyShare ? [{ kind: "share" as const, label: platform.amplifyLabels.share ?? "Share" }] : []),
|
||||
];
|
||||
}
|
||||
188
apps/web/src/lib/api-client.ts
Normal file
188
apps/web/src/lib/api-client.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import { getSessionToken, setSessionToken } from "./session-token";
|
||||
|
||||
/**
|
||||
* The single path from the web app to the API. Every call goes through
|
||||
* `apiFetch` so auth, error shape, and the offline signal stay uniform.
|
||||
*/
|
||||
|
||||
export const API_URL = (process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000").replace(/\/$/, "");
|
||||
|
||||
/** The API answered with a non-2xx status. */
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
message: string,
|
||||
readonly body: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
/** The API could not be reached at all (server down, DNS, CORS). Screens fall back to sample data. */
|
||||
export class ApiUnreachableError extends Error {
|
||||
constructor(override readonly cause: unknown) {
|
||||
super("API unreachable");
|
||||
this.name = "ApiUnreachableError";
|
||||
}
|
||||
}
|
||||
|
||||
type QueryValue = string | number | undefined | null;
|
||||
|
||||
export interface ApiRequestOptions {
|
||||
method?: "GET" | "POST" | "PATCH" | "DELETE";
|
||||
body?: unknown;
|
||||
query?: Record<string, QueryValue>;
|
||||
signal?: AbortSignal;
|
||||
/** Default true. Auth endpoints turn this off so a bad password shows an error instead of navigating away. */
|
||||
redirectOn401?: boolean;
|
||||
}
|
||||
|
||||
export const ONBOARDING_PATH = "/onboarding";
|
||||
let reissuePromise: Promise<string> | null = null;
|
||||
|
||||
function buildUrl(path: string, query: Record<string, QueryValue> | undefined): string {
|
||||
const url = new URL(`${API_URL}${path.startsWith("/") ? path : `/${path}`}`);
|
||||
for (const [key, value] of Object.entries(query ?? {})) {
|
||||
if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function messageFrom(body: unknown, fallback: string): string {
|
||||
if (typeof body === "object" && body !== null && "message" in body) {
|
||||
const message = (body as { message: unknown }).message;
|
||||
if (typeof message === "string") return message;
|
||||
if (Array.isArray(message)) return message.filter((m): m is string => typeof m === "string").join("; ");
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Multipart bytes with browser upload progress; shares the API's auth/error rules. */
|
||||
export async function apiUploadFile<T>(path: string, file: File, onProgress: (percent: number) => void): Promise<T> {
|
||||
const send = async (retried: boolean): Promise<T> => {
|
||||
const token = getSessionToken();
|
||||
const response = await new Promise<{ status: number; text: string }>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", buildUrl(path, undefined));
|
||||
xhr.withCredentials = true;
|
||||
xhr.timeout = 120_000;
|
||||
if (token !== null) xhr.setRequestHeader("Authorization", `Bearer ${token}`);
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) onProgress(Math.floor((event.loaded / event.total) * 100));
|
||||
};
|
||||
xhr.upload.onload = () => onProgress(100);
|
||||
xhr.onload = () => resolve({ status: xhr.status, text: xhr.responseText });
|
||||
xhr.onerror = () => reject(new ApiUnreachableError(new Error("Upload network error")));
|
||||
xhr.ontimeout = () => reject(new Error("Upload timed out; check the video library before retrying."));
|
||||
xhr.onabort = () => reject(new Error("Upload cancelled"));
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
xhr.send(form);
|
||||
});
|
||||
let body: unknown = null;
|
||||
if (response.text !== "") {
|
||||
try { body = JSON.parse(response.text) as unknown; } catch { body = response.text; }
|
||||
}
|
||||
if (response.status >= 200 && response.status < 300) return body as T;
|
||||
if (response.status === 401 && !retried && token !== null && messageFrom(body, "").includes("authorization epoch stale")) {
|
||||
let reissued = false;
|
||||
try {
|
||||
await reissueSession();
|
||||
reissued = true;
|
||||
} catch { /* Ordinary 401 handling below. */ }
|
||||
if (reissued) { onProgress(0); return send(true); }
|
||||
}
|
||||
if (response.status === 401) redirectToSession();
|
||||
throw new ApiError(response.status, messageFrom(body, `HTTP ${response.status}`), body);
|
||||
};
|
||||
return send(false);
|
||||
}
|
||||
|
||||
export function reissueSession(): Promise<string> {
|
||||
if (reissuePromise === null) {
|
||||
reissuePromise = apiFetch<{ token: string }>("/auth/reissue", { method: "POST", redirectOn401: false })
|
||||
.then((result) => {
|
||||
setSessionToken(result.token);
|
||||
return result.token;
|
||||
}).finally(() => { reissuePromise = null; });
|
||||
}
|
||||
return reissuePromise;
|
||||
}
|
||||
|
||||
function redirectToSession(): void {
|
||||
setSessionToken(null);
|
||||
if (typeof window !== "undefined" && window.location.pathname !== ONBOARDING_PATH) {
|
||||
window.location.assign(`${ONBOARDING_PATH}?reason=session`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
|
||||
return fetchWithEpochRetry<T>(path, options, false);
|
||||
}
|
||||
|
||||
async function fetchWithEpochRetry<T>(path: string, options: ApiRequestOptions, retried: boolean): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
const token = getSessionToken();
|
||||
if (token !== null) headers.Authorization = `Bearer ${token}`;
|
||||
if (options.body !== undefined) headers["Content-Type"] = "application/json";
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(buildUrl(path, options.query), {
|
||||
method: options.method ?? "GET",
|
||||
credentials: "include",
|
||||
headers,
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
signal: options.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") throw err;
|
||||
throw new ApiUnreachableError(err);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
let body: unknown = null;
|
||||
if (text !== "") {
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
body = text;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && !retried && token !== null && path !== "/auth/reissue" &&
|
||||
messageFrom(body, "").includes("authorization epoch stale")) {
|
||||
let reissued = false;
|
||||
try {
|
||||
await reissueSession();
|
||||
reissued = true;
|
||||
} catch {
|
||||
// A revoked or expired session follows the ordinary 401 path below.
|
||||
}
|
||||
if (reissued) return fetchWithEpochRetry<T>(path, options, true);
|
||||
}
|
||||
if (response.status === 401 && (options.redirectOn401 ?? true)) {
|
||||
redirectToSession();
|
||||
}
|
||||
throw new ApiError(response.status, messageFrom(body, `HTTP ${response.status}`), body);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string, query?: Record<string, QueryValue>, signal?: AbortSignal): Promise<T> =>
|
||||
apiFetch<T>(path, { query, signal }),
|
||||
post: <T>(path: string, body?: unknown, options: Omit<ApiRequestOptions, "method" | "body"> = {}): Promise<T> =>
|
||||
apiFetch<T>(path, { ...options, method: "POST", body: body ?? {} }),
|
||||
patch: <T>(path: string, body: unknown): Promise<T> => apiFetch<T>(path, { method: "PATCH", body }),
|
||||
del: <T>(path: string): Promise<T> => apiFetch<T>(path, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
export function errorMessage(err: unknown): string {
|
||||
if (err instanceof ApiUnreachableError) return "API unreachable — check that the API server is running.";
|
||||
if (err instanceof ApiError) return err.message;
|
||||
if (err instanceof Error) return err.message;
|
||||
return "Unexpected error";
|
||||
}
|
||||
140
apps/web/src/lib/api-types.ts
Normal file
140
apps/web/src/lib/api-types.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
/** Response shapes of apps/api — kept in sync by hand with the controllers. */
|
||||
|
||||
export interface MeResponse {
|
||||
user: { id: string; username: string; email: string | null; displayName: string };
|
||||
workspace: { id: string; name: string; slug: string };
|
||||
role: string;
|
||||
permissions: string[];
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
token: string;
|
||||
me: MeResponse;
|
||||
}
|
||||
|
||||
export interface ConnectionSummary {
|
||||
id: string;
|
||||
provider: string;
|
||||
channelId: string;
|
||||
fields: Record<string, unknown>;
|
||||
health: "healthy" | "needs_credentials" | "unavailable";
|
||||
capabilities?: string[];
|
||||
credentialRotatedAt: string | null;
|
||||
recentPublishes: string[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type MemberStatus = "active" | "invited";
|
||||
|
||||
export interface Member {
|
||||
id: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
displayName: string;
|
||||
role: string;
|
||||
status: MemberStatus;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Membership mutations bump the workspace authz epoch; `token` is the caller's re-issued session. */
|
||||
export interface MemberMutationResult {
|
||||
member: Member | null;
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
/** Public, unauthenticated preview of a workspace invite link — GET /invites/:token/info. */
|
||||
export interface InviteInfo {
|
||||
workspaceName: string;
|
||||
role: string;
|
||||
/** ISO timestamp; the link is dead after this. */
|
||||
expiresAt: string;
|
||||
/** null = unlimited uses. */
|
||||
maxUses: number | null;
|
||||
usedCount: number;
|
||||
}
|
||||
|
||||
/** Claiming installs a fresh workspace session — same envelope as POST /auth/login. */
|
||||
export type InviteClaimResult = AuthResult;
|
||||
|
||||
/** Anonymous claimant credentials; ignored when the request carries a valid session. */
|
||||
export interface InviteClaimInput {
|
||||
username: string;
|
||||
password: string;
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export type InviteLinkStatus = "active" | "expired" | "revoked" | "exhausted";
|
||||
|
||||
/** Admin view of a workspace invite link. `token`/`url` are present only on the 201 create response. */
|
||||
export interface InviteLink {
|
||||
id: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
maxUses: number | null;
|
||||
useCount: number;
|
||||
revokedAt: string | null;
|
||||
status: InviteLinkStatus;
|
||||
createdByUserId: string;
|
||||
token?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface InviteLinkCreateInput {
|
||||
role: string;
|
||||
expiresInHours: number;
|
||||
maxUses?: number;
|
||||
}
|
||||
|
||||
|
||||
export interface AuditEntryDto {
|
||||
id: string;
|
||||
seq: string;
|
||||
at: string;
|
||||
actorUserId: string | null;
|
||||
actorEmail: string | null;
|
||||
actorRole: string | null;
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetId: string;
|
||||
diff: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface AuditPage {
|
||||
entries: AuditEntryDto[];
|
||||
nextCursor: string | null;
|
||||
facets: { actions: string[]; actors: Array<{ id: string | null; email: string | null }> };
|
||||
}
|
||||
|
||||
export type DraftState = "editing" | "in_review" | "approved" | "rejected" | "scheduled" | "published";
|
||||
export type Visibility = "public" | "unlisted" | "private" | "followers" | "provider_default";
|
||||
|
||||
export interface DraftPost {
|
||||
text: string;
|
||||
mediaAssetIds: string[];
|
||||
visibility: Visibility;
|
||||
scheduledAt?: string;
|
||||
providerMetadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DraftRecord {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
authorUserId: string;
|
||||
post: DraftPost;
|
||||
state: DraftState;
|
||||
reviewerUserId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ScheduledPublicationRecord {
|
||||
id: string;
|
||||
draftId: string;
|
||||
connectionId: string;
|
||||
publishAt: string;
|
||||
state: "pending" | "in_flight" | "done" | "failed";
|
||||
}
|
||||
168
apps/web/src/lib/auth-store.ts
Normal file
168
apps/web/src/lib/auth-store.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, createElement, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { api, apiFetch, ApiError, ApiUnreachableError } from "./api-client";
|
||||
import type { AuthResult, InviteClaimInput, InviteClaimResult, MeResponse } from "./api-types";
|
||||
import { getSessionToken, onSessionTokenChange, setSessionToken } from "./session-token";
|
||||
|
||||
/**
|
||||
* - loading: verifying a stored token / probing the API
|
||||
* - authenticated: token verified via GET /auth/me
|
||||
* - anonymous: API reachable, no valid session → the gate sends you to /onboarding
|
||||
* - offline: API unreachable → dev convenience: screens render sample data
|
||||
*/
|
||||
export type AuthStatus = "loading" | "authenticated" | "anonymous" | "offline";
|
||||
|
||||
export interface RegisterInput {
|
||||
workspaceName: string;
|
||||
username: string;
|
||||
password: string;
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface AuthContextValue {
|
||||
session: MeResponse | null;
|
||||
status: AuthStatus;
|
||||
isLoading: boolean;
|
||||
offline: boolean;
|
||||
login: (username: string, password: string) => Promise<MeResponse>;
|
||||
register: (input: RegisterInput) => Promise<MeResponse>;
|
||||
/** Claim an invite link with the current session; installs the returned workspace token. */
|
||||
claimInvite: (inviteToken: string) => Promise<MeResponse>;
|
||||
/** Register-or-authenticate inline, then claim; installs the returned workspace token. */
|
||||
claimInviteWithCredentials: (inviteToken: string, input: InviteClaimInput) => Promise<MeResponse>;
|
||||
logout: () => Promise<void>;
|
||||
/** Install a re-issued token (membership mutations bump the authz epoch and return one). */
|
||||
adoptToken: (token: string | null) => void;
|
||||
can: (permission: string) => boolean;
|
||||
/** Re-run session verification (e.g. after the API comes back). */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
async function probeReachable(): Promise<boolean> {
|
||||
try {
|
||||
await apiFetch<unknown>("/healthz", { redirectOn401: false });
|
||||
return true;
|
||||
} catch (err) {
|
||||
return !(err instanceof ApiUnreachableError);
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }): React.ReactElement {
|
||||
const [session, setSession] = useState<MeResponse | null>(null);
|
||||
const [status, setStatus] = useState<AuthStatus>("loading");
|
||||
|
||||
const verify = useCallback(async (): Promise<void> => {
|
||||
const token = getSessionToken();
|
||||
if (token === null) {
|
||||
setSession(null);
|
||||
setStatus((await probeReachable()) ? "anonymous" : "offline");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const me = await apiFetch<MeResponse>("/auth/me", { redirectOn401: false });
|
||||
setSession(me);
|
||||
setStatus("authenticated");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiUnreachableError) {
|
||||
setStatus("offline");
|
||||
} else if (err instanceof ApiError && err.status === 401) {
|
||||
setSessionToken(null);
|
||||
setSession(null);
|
||||
setStatus("anonymous");
|
||||
} else {
|
||||
setStatus("offline");
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void verify();
|
||||
}, [verify]);
|
||||
|
||||
// The API client clears the token on 401; mirror that into React state.
|
||||
useEffect(
|
||||
() =>
|
||||
onSessionTokenChange((token) => {
|
||||
if (token === null) {
|
||||
setSession(null);
|
||||
setStatus((s) => (s === "offline" ? s : "anonymous"));
|
||||
}
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const accept = useCallback((result: AuthResult): MeResponse => {
|
||||
setSessionToken(result.token);
|
||||
setSession(result.me);
|
||||
setStatus("authenticated");
|
||||
return result.me;
|
||||
}, []);
|
||||
|
||||
const login = useCallback(
|
||||
async (username: string, password: string) =>
|
||||
accept(await api.post<AuthResult>("/auth/login", { username, password }, { redirectOn401: false })),
|
||||
[accept],
|
||||
);
|
||||
|
||||
const register = useCallback(
|
||||
async (input: RegisterInput) => accept(await api.post<AuthResult>("/auth/register", input, { redirectOn401: false })),
|
||||
[accept],
|
||||
);
|
||||
|
||||
// Both claim paths return an AuthResult issued in the invite's workspace, so `accept`
|
||||
// installs token + session + status atomically, exactly like login/register.
|
||||
const claimInvite = useCallback(
|
||||
async (inviteToken: string) =>
|
||||
accept(await api.post<InviteClaimResult>(`/invites/${encodeURIComponent(inviteToken)}/claim`, {}, { redirectOn401: false })),
|
||||
[accept],
|
||||
);
|
||||
|
||||
const claimInviteWithCredentials = useCallback(
|
||||
async (inviteToken: string, input: InviteClaimInput) =>
|
||||
accept(await api.post<InviteClaimResult>(`/invites/${encodeURIComponent(inviteToken)}/claim`, input, { redirectOn401: false })),
|
||||
[accept],
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await api.post<unknown>("/auth/logout", {}, { redirectOn401: false }).catch(() => undefined);
|
||||
setSessionToken(null);
|
||||
setSession(null);
|
||||
setStatus("anonymous");
|
||||
}, []);
|
||||
|
||||
const adoptToken = useCallback((token: string | null) => {
|
||||
if (token !== null) setSessionToken(token);
|
||||
}, []);
|
||||
|
||||
const can = useCallback((permission: string) => session?.permissions.includes(permission) ?? false, [session]);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
session,
|
||||
status,
|
||||
isLoading: status === "loading",
|
||||
offline: status === "offline",
|
||||
login,
|
||||
register,
|
||||
claimInvite,
|
||||
claimInviteWithCredentials,
|
||||
logout,
|
||||
adoptToken,
|
||||
can,
|
||||
refresh: verify,
|
||||
}),
|
||||
[session, status, login, register, claimInvite, claimInviteWithCredentials, logout, adoptToken, can, verify],
|
||||
);
|
||||
|
||||
return createElement(AuthContext.Provider, { value }, children);
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (ctx === null) throw new Error("useAuth must be used inside <AuthProvider>");
|
||||
return ctx;
|
||||
}
|
||||
22
apps/web/src/lib/connections-client.ts
Normal file
22
apps/web/src/lib/connections-client.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { setSessionToken } from "./session-token";
|
||||
import { api } from "./api-client";
|
||||
|
||||
export interface ConnectProvider {
|
||||
id: string;
|
||||
displayName: string;
|
||||
kind: "oauth2-redirect" | "per-instance-oauth2" | "token";
|
||||
enabled: boolean;
|
||||
note?: string;
|
||||
instanceRequired: boolean;
|
||||
targetField?: string;
|
||||
capabilities: string[];
|
||||
}
|
||||
|
||||
export async function linkBotToken(token: string, channelId: string, connectionId?: string): Promise<{ connected: true }> {
|
||||
const result = await api.post<{ connected: true; token: string }>("/connections/telegram/bot-token", { token, channelId: channelId || undefined, connectionId });
|
||||
setSessionToken(result.token);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Shared provider boundary for the bot archive view. */
|
||||
export const isBotConnection = (connection: { provider: string }): boolean => connection.provider === "telegram";
|
||||
173
apps/web/src/lib/fixtures.ts
Normal file
173
apps/web/src/lib/fixtures.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
export interface FeedItem {
|
||||
id: number;
|
||||
platform: string;
|
||||
author: string;
|
||||
handle: string;
|
||||
text: string;
|
||||
ageMinutes: number;
|
||||
likes: number;
|
||||
reposts: number;
|
||||
replies: number;
|
||||
hasMedia: boolean;
|
||||
}
|
||||
|
||||
const AUTHORS: ReadonlyArray<readonly [string, string]> = [
|
||||
["Ada Lovelace", "@ada"],
|
||||
["Grace Hopper", "@amazing.grace"],
|
||||
["Alan Turing", "@turingtest"],
|
||||
["Katherine Johnson", "@kjohnson"],
|
||||
["Edsger Dijkstra", "@goesto"],
|
||||
["Barbara Liskov", "@abl"],
|
||||
["Donald Knuth", "@taocp"],
|
||||
["Radia Perlman", "@spanning"],
|
||||
["Linus Torvalds", "@torvalds"],
|
||||
["Margaret Hamilton", "@apollo"],
|
||||
];
|
||||
|
||||
const TEXTS = [
|
||||
"Shipped the tenant isolation suite today — RLS catches what code review misses.",
|
||||
"Hot take: your audit log should be hash-chained from day one, not after the breach.",
|
||||
"The connector manifest pattern is paying off: new platform, zero core changes.",
|
||||
"Reminder: capability-driven UI means no grayed-out lies. If the API can't do it, the button isn't there.",
|
||||
"Spent the morning fighting a trigger that referenced OLD on INSERT. COALESCE(NEW, OLD), friends.",
|
||||
"Our streaming budget math finally balances. Per-host token buckets for the win.",
|
||||
"Quote-posting this because the thread below is exactly why we deferred X.",
|
||||
"The compositor held 8 hours without dropping a frame. Soak test verdict: green.",
|
||||
"Anyone else maintaining three different rate-limit spreadsheets? There has to be a better way.",
|
||||
"FFmpeg filter graphs are write-only code unless you diagram them first. Lesson learned.",
|
||||
"Just watched the auto-director fail over mid-stream and nobody in chat noticed. Perfect.",
|
||||
"OAuth tokens in the vault, passwords never touched. The only sane architecture.",
|
||||
"Drafts → approvals → schedule → publish. The pipeline is boring and I love it.",
|
||||
"New rule: every migration ships with its rollback tested, not just written.",
|
||||
"The mock connectors now drive the entire E2E suite offline. Deterministic tests or bust.",
|
||||
"Every ping of the source monitor is adaptive now. The instances stop noticing us.",
|
||||
];
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed;
|
||||
return () => {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
export function generateFeed(platform: string, count: number, seed: number): FeedItem[] {
|
||||
const rand = mulberry32(seed);
|
||||
const items: FeedItem[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const pair = AUTHORS[Math.floor(rand() * AUTHORS.length)] ?? ["unknown", "@unknown"];
|
||||
const [author, handle] = pair;
|
||||
items.push({
|
||||
id: i,
|
||||
platform,
|
||||
author,
|
||||
handle,
|
||||
text: TEXTS[Math.floor(rand() * TEXTS.length)] ?? "",
|
||||
ageMinutes: Math.floor(rand() * 1440),
|
||||
likes: Math.floor(rand() * 500),
|
||||
reposts: Math.floor(rand() * 120),
|
||||
replies: Math.floor(rand() * 60),
|
||||
hasMedia: rand() > 0.75,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function ageLabel(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
if (minutes < 1440) return `${Math.floor(minutes / 60)}h`;
|
||||
return `${Math.floor(minutes / 1440)}d`;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: number;
|
||||
platform: string;
|
||||
participant: string;
|
||||
handle: string;
|
||||
preview: string;
|
||||
unread: number;
|
||||
assignee: string | null;
|
||||
lastMinutes: number;
|
||||
}
|
||||
|
||||
export const CONVERSATIONS: Conversation[] = [
|
||||
{ id: 1, platform: "mastodon", participant: "Ada Lovelace", handle: "@ada", preview: "Is the boost button capability-gated per column?", unread: 2, assignee: "GH", lastMinutes: 4 },
|
||||
{ id: 2, platform: "instagram", participant: "Grace Hopper", handle: "@amazing.grace", preview: "Story scheduling would be huge for us", unread: 0, assignee: null, lastMinutes: 22 },
|
||||
{ id: 3, platform: "facebook", participant: "Alan Turing", handle: "@turingtest", preview: "The page token expires tomorrow — can we rotate?", unread: 1, assignee: "MC", lastMinutes: 65 },
|
||||
{ id: 4, platform: "mastodon", participant: "Katherine Johnson", handle: "@kjohnson", preview: "Trajectories confirmed. Launch window 0400.", unread: 0, assignee: null, lastMinutes: 190 },
|
||||
];
|
||||
|
||||
export interface ThreadMessage {
|
||||
id: number;
|
||||
from: string;
|
||||
mine: boolean;
|
||||
text: string;
|
||||
minutes: number;
|
||||
}
|
||||
|
||||
export const THREADS: Record<number, ThreadMessage[]> = {
|
||||
1: [
|
||||
{ id: 1, from: "@ada", mine: false, text: "Is the boost button capability-gated per column?", minutes: 30 },
|
||||
{ id: 2, from: "you", mine: true, text: "Yes — the column renders only what its platform manifest declares.", minutes: 25 },
|
||||
{ id: 3, from: "@ada", mine: false, text: "Beautiful. No grayed-out lies?", minutes: 4 },
|
||||
],
|
||||
2: [
|
||||
{ id: 1, from: "@amazing.grace", mine: false, text: "Story scheduling would be huge for us", minutes: 22 },
|
||||
],
|
||||
3: [
|
||||
{ id: 1, from: "@turingtest", mine: false, text: "The page token expires tomorrow — can we rotate?", minutes: 65 },
|
||||
],
|
||||
4: [
|
||||
{ id: 1, from: "@kjohnson", mine: false, text: "Trajectories confirmed. Launch window 0400.", minutes: 190 },
|
||||
],
|
||||
};
|
||||
|
||||
export const ROLES = [
|
||||
"Owner", "Admin", "Publisher", "Editor", "Approver", "Analyst", "Moderator", "VideoPublisher", "StreamOperator", "BroadcastOperator",
|
||||
];
|
||||
|
||||
export interface Member {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
status: "active" | "invited";
|
||||
}
|
||||
|
||||
export const MEMBERS: Member[] = [
|
||||
{ id: 1, name: "Marcus Chen", email: "marcus@studio.io", role: "Owner", status: "active" },
|
||||
{ id: 2, name: "Priya Sharma", email: "priya@studio.io", role: "Publisher", status: "active" },
|
||||
{ id: 3, name: "Yuki Tanaka", email: "yuki@studio.io", role: "BroadcastOperator", status: "active" },
|
||||
{ id: 4, name: "Sofia Rossi", email: "sofia@studio.io", role: "Approver", status: "invited" },
|
||||
];
|
||||
|
||||
export const ASSETS = [
|
||||
{ id: 1, name: "logo-dark.svg", group: "Brand", locked: false },
|
||||
{ id: 2, name: "launch-banner.png", group: "Launch", locked: false },
|
||||
{ id: 3, name: "q4-roadmap.pdf", group: "Internal", locked: true },
|
||||
{ id: 4, name: "team-photo.jpg", group: "Brand", locked: false },
|
||||
{ id: 5, name: "keynote-clip.mp4", group: "Launch", locked: true },
|
||||
{ id: 6, name: "press-kit.zip", group: "PR", locked: false },
|
||||
];
|
||||
|
||||
export interface AuditEntry {
|
||||
id: number;
|
||||
actor: string;
|
||||
action: string;
|
||||
targetType: string;
|
||||
target: string;
|
||||
minutes: number;
|
||||
diff: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export const AUDIT_ENTRIES: AuditEntry[] = [
|
||||
{ id: 1, actor: "marcus@studio.io", action: "connection.created", targetType: "connection", target: "mastodon/@studio", minutes: 12, diff: { provider: "mastodon", scopes: ["read", "write"] } },
|
||||
{ id: 2, actor: "priya@studio.io", action: "publication.scheduled", targetType: "publication", target: "draft-8821", minutes: 34, diff: { scheduledAt: "2026-10-07T09:00Z", targets: 3 } },
|
||||
{ id: 3, actor: "yuki@studio.io", action: "membership.role_changed", targetType: "membership", target: "sofia@studio.io", minutes: 58, diff: { role: ["Editor", "Approver"] } },
|
||||
{ id: 4, actor: "system", action: "credential.rotated", targetType: "credential", target: "cred-117", minutes: 90, diff: { keyVersion: [1, 2], accessToken: "[REDACTED]" } },
|
||||
{ id: 5, actor: "marcus@studio.io", action: "publication.published", targetType: "publication", target: "pub-3390", minutes: 140, diff: { remoteUrl: "https://fosstodon.org/@studio/1099", streamKey: "[REDACTED]" } },
|
||||
{ id: 6, actor: "sofia@studio.io", action: "approval.granted", targetType: "draft", target: "draft-8819", minutes: 210, diff: null },
|
||||
];
|
||||
14
apps/web/src/lib/gestures.ts
Normal file
14
apps/web/src/lib/gestures.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export type SwipeIntent = "pending" | "horizontal" | "vertical";
|
||||
|
||||
/** Lock a gesture only after it has moved far enough to distinguish scrolling. */
|
||||
export function swipeIntent(dx: number, dy: number, threshold = 12): SwipeIntent {
|
||||
if (Math.hypot(dx, dy) < threshold) return "pending";
|
||||
return Math.abs(dx) > Math.abs(dy) * 1.5 ? "horizontal" : "vertical";
|
||||
}
|
||||
|
||||
export function swipeResult(dx: number, velocity: number, direction: "left" | "right", width = 204): "closed" | "open" | "full" {
|
||||
const travel = direction === "left" ? -dx : dx;
|
||||
if (travel <= 0) return "closed";
|
||||
if (travel >= width * 0.82 || (travel > width * 0.45 && velocity > 0.75)) return "full";
|
||||
return travel >= width * 0.25 ? "open" : "closed";
|
||||
}
|
||||
122
apps/web/src/lib/platforms.ts
Normal file
122
apps/web/src/lib/platforms.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
export interface PlatformCapabilities {
|
||||
publishText: boolean;
|
||||
requiresMediaForPublish?: boolean;
|
||||
amplifyRepost: boolean;
|
||||
amplifyQuote: boolean;
|
||||
amplifyShare: boolean;
|
||||
feed: boolean;
|
||||
directMessages: boolean;
|
||||
comments: boolean;
|
||||
}
|
||||
|
||||
export interface Platform {
|
||||
id: string;
|
||||
displayName: string;
|
||||
charLimit: number;
|
||||
capabilities: PlatformCapabilities;
|
||||
amplifyLabels: { repost?: string; quote?: string; share?: string };
|
||||
}
|
||||
|
||||
export const PLATFORMS: Record<string, Platform> = {
|
||||
mastodon: {
|
||||
id: "mastodon",
|
||||
displayName: "Mastodon",
|
||||
charLimit: 500,
|
||||
capabilities: { publishText: true, amplifyRepost: true, amplifyQuote: true, amplifyShare: false, feed: true, directMessages: true, comments: true },
|
||||
amplifyLabels: { repost: "Boost", quote: "Quote" },
|
||||
},
|
||||
telegram: {
|
||||
id: "telegram",
|
||||
displayName: "Telegram",
|
||||
charLimit: 4096,
|
||||
capabilities: { publishText: true, amplifyRepost: false, amplifyQuote: false, amplifyShare: true, feed: true, directMessages: false, comments: false },
|
||||
amplifyLabels: { share: "Forward" },
|
||||
},
|
||||
limited: {
|
||||
id: "limited",
|
||||
displayName: "Limited API",
|
||||
charLimit: 2200,
|
||||
capabilities: { publishText: true, amplifyRepost: false, amplifyQuote: false, amplifyShare: false, feed: true, directMessages: false, comments: true },
|
||||
amplifyLabels: {},
|
||||
},
|
||||
facebook: {
|
||||
id: "facebook",
|
||||
displayName: "Facebook",
|
||||
charLimit: 63206,
|
||||
capabilities: { publishText: true, amplifyRepost: false, amplifyQuote: false, amplifyShare: true, feed: true, directMessages: true, comments: true },
|
||||
amplifyLabels: { share: "Share link" },
|
||||
},
|
||||
instagram: {
|
||||
id: "instagram",
|
||||
displayName: "Instagram",
|
||||
charLimit: 2200,
|
||||
capabilities: { publishText: true, requiresMediaForPublish: true, amplifyRepost: false, amplifyQuote: false, amplifyShare: false, feed: true, directMessages: true, comments: true },
|
||||
amplifyLabels: {},
|
||||
},
|
||||
};
|
||||
|
||||
export const CONNECTED_ACCOUNTS = [
|
||||
{ platform: "mastodon", handle: "@studio@fosstodon.org", status: "healthy" as const },
|
||||
{ platform: "telegram", handle: "@studiochannel", status: "healthy" as const },
|
||||
{ platform: "limited", handle: "@studio", status: "healthy" as const },
|
||||
{ platform: "facebook", handle: "Studio Page", status: "expiring" as const },
|
||||
];
|
||||
|
||||
export function getPlatform(id: string): Platform {
|
||||
const platform = PLATFORMS[id];
|
||||
if (platform === undefined) throw new Error(`unknown platform: ${id}`);
|
||||
return platform;
|
||||
}
|
||||
|
||||
export interface DocumentedRate {
|
||||
dailyPostCap?: number;
|
||||
requestsPerSecond?: number;
|
||||
commentsPerHour?: number;
|
||||
tierNote?: string;
|
||||
}
|
||||
|
||||
export const DOCUMENTED_RATES: Record<string, DocumentedRate> = {
|
||||
instagram: { dailyPostCap: 25, requestsPerSecond: 0.5, commentsPerHour: 200 },
|
||||
threads: { dailyPostCap: 250, requestsPerSecond: 0.5 },
|
||||
mastodon: { requestsPerSecond: 0.5, dailyPostCap: 300 },
|
||||
telegram: { requestsPerSecond: 1 },
|
||||
facebook: { requestsPerSecond: 2 },
|
||||
bluesky: { requestsPerSecond: 1 },
|
||||
limited: {},
|
||||
};
|
||||
|
||||
export interface ConnectionCapacity {
|
||||
handle: string;
|
||||
platform: string;
|
||||
resolvedCap?: number;
|
||||
used: number;
|
||||
recentPublishes: string[];
|
||||
tierNote?: string;
|
||||
overrideCap?: number;
|
||||
}
|
||||
|
||||
const HOUR = 3600_000;
|
||||
const now = Date.now();
|
||||
const ago = (h: number): string => new Date(now - h * HOUR).toISOString();
|
||||
|
||||
export const CONNECTION_CAPACITIES: ConnectionCapacity[] = [
|
||||
{ handle: "@studio@fosstodon.org", platform: "mastodon", resolvedCap: 300, used: 42, recentPublishes: Array.from({ length: 42 }, (_, i) => ago(0.5 + i * 0.4)) },
|
||||
{ handle: "@studiochannel", platform: "telegram", resolvedCap: undefined, used: 15, recentPublishes: Array.from({ length: 15 }, (_, i) => ago(1 + i * 0.8)) },
|
||||
{ handle: "Studio News", platform: "instagram", resolvedCap: 100, used: 100, recentPublishes: Array.from({ length: 100 }, (_, i) => ago(0.2 + i * 0.22)), tierNote: "news-publisher advanced access", overrideCap: 100 },
|
||||
{ handle: "Studio Page", platform: "facebook", resolvedCap: undefined, used: 8, recentPublishes: Array.from({ length: 8 }, (_, i) => ago(2 + i)) },
|
||||
];
|
||||
|
||||
export interface ColumnDef {
|
||||
key: string;
|
||||
title: string;
|
||||
platformId: string;
|
||||
staleness: string;
|
||||
}
|
||||
|
||||
export const MOCK_COLUMN_DEFS: ColumnDef[] = [
|
||||
{ key: "home", title: "Home", platformId: "mastodon", staleness: "live" },
|
||||
{ key: "mentions", title: "Mentions", platformId: "telegram", staleness: "live" },
|
||||
{ key: "search", title: "Saved: product", platformId: "limited", staleness: "12s old" },
|
||||
];
|
||||
|
||||
export const MOCK_TARGETS = ["mastodon", "telegram", "facebook", "instagram"];
|
||||
16
apps/web/src/lib/saved-searches.ts
Normal file
16
apps/web/src/lib/saved-searches.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export const SAVED_SEARCHES_KEY = "tmbh.saved-searches";
|
||||
export const DEFAULT_SAVED_SEARCHES = ["product"];
|
||||
|
||||
export function readSavedSearches(): string[] {
|
||||
if (typeof window === "undefined") return DEFAULT_SAVED_SEARCHES;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SAVED_SEARCHES_KEY);
|
||||
const parsed: unknown = raw === null ? DEFAULT_SAVED_SEARCHES : JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === "string") : DEFAULT_SAVED_SEARCHES;
|
||||
} catch { return DEFAULT_SAVED_SEARCHES; }
|
||||
}
|
||||
|
||||
export function saveSearches(values: string[]): void {
|
||||
window.localStorage.setItem(SAVED_SEARCHES_KEY, JSON.stringify([...new Set(values.map((value) => value.trim()).filter(Boolean))]));
|
||||
window.dispatchEvent(new Event("saved-searches-change"));
|
||||
}
|
||||
31
apps/web/src/lib/session-token.ts
Normal file
31
apps/web/src/lib/session-token.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Session token holder: memory first, mirrored to localStorage so a reload
|
||||
* keeps the session. Listeners let the auth store react when the API client
|
||||
* clears a token (401) or a mutation hands back a re-issued one.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "tmbh.session";
|
||||
|
||||
let memory: string | null | undefined;
|
||||
const listeners = new Set<(token: string | null) => void>();
|
||||
|
||||
export function getSessionToken(): string | null {
|
||||
if (memory !== undefined) return memory;
|
||||
if (typeof window === "undefined") return null;
|
||||
memory = window.localStorage.getItem(STORAGE_KEY);
|
||||
return memory;
|
||||
}
|
||||
|
||||
export function setSessionToken(token: string | null): void {
|
||||
memory = token;
|
||||
if (typeof window !== "undefined") {
|
||||
if (token === null) window.localStorage.removeItem(STORAGE_KEY);
|
||||
else window.localStorage.setItem(STORAGE_KEY, token);
|
||||
}
|
||||
for (const listener of listeners) listener(token);
|
||||
}
|
||||
|
||||
export function onSessionTokenChange(listener: (token: string | null) => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
49
apps/web/src/lib/streams-data.ts
Normal file
49
apps/web/src/lib/streams-data.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import type { FeedItem } from "./fixtures";
|
||||
import { getPlatform, PLATFORMS, type Platform } from "./platforms";
|
||||
import type { ColumnDef } from "../components/streams/column";
|
||||
|
||||
export interface StreamItem extends FeedItem {
|
||||
connectionId?: string;
|
||||
remoteId?: string;
|
||||
feedId?: string;
|
||||
mediaUrls?: string[];
|
||||
sourceLabel?: string;
|
||||
livePlatform?: Platform;
|
||||
}
|
||||
export interface FeedsResponse {
|
||||
items: Array<{ id: string; remoteId: string; connectionId: string; provider: string; authorHandle: string; text: string; mediaUrls: string[]; createdAt: string; metrics: Record<string, number> }>;
|
||||
nextCursor: string | null;
|
||||
meta: {
|
||||
configuredFeeds: number;
|
||||
connections: Array<{ id: string; provider: string; handle: string; displayName: string; capabilities: string[]; fetchedAt: string | null; status: "ready" | "pending" }>;
|
||||
excludedConnections: Array<{ id: string; provider: string; reason: string }>;
|
||||
};
|
||||
}
|
||||
export const streamItemKey = (item: StreamItem): string => `${item.connectionId ?? item.platform}:${item.feedId ?? item.remoteId ?? item.id}`;
|
||||
export const streamPlatform = (item: StreamItem): Platform => item.livePlatform ?? getPlatform(item.platform);
|
||||
export function liveColumns(response: FeedsResponse): ColumnDef[] {
|
||||
return response.meta.connections.map((connection) => {
|
||||
const enabled = (key: string): boolean => connection.capabilities.includes(key);
|
||||
const known = PLATFORMS[connection.provider];
|
||||
const platform: Platform = {
|
||||
id: connection.provider, displayName: known?.displayName ?? connection.provider,
|
||||
charLimit: known?.charLimit ?? 500, amplifyLabels: known?.amplifyLabels ?? { repost: "Repost", quote: "Quote" },
|
||||
capabilities: { publishText: enabled("publishText"), amplifyRepost: enabled("amplifyRepost"), amplifyQuote: enabled("amplifyQuote"), amplifyShare: enabled("amplifyShare"), feed: true, directMessages: enabled("directMessages"), comments: enabled("comments") },
|
||||
};
|
||||
const items: StreamItem[] = response.items.filter((item) => item.connectionId === connection.id).map((item) => ({
|
||||
// Existing mobile cards use numeric IDs; selection and React keys use the
|
||||
// actual (connection, feed item) identity instead of this presentation ID.
|
||||
id: Array.from(`${connection.id}:${item.id}`).reduce((hash, char) => (hash * 31 + char.charCodeAt(0)) >>> 0, 0),
|
||||
platform: connection.provider, author: item.authorHandle, handle: item.authorHandle,
|
||||
text: item.text, ageMinutes: Math.max(0, (Date.now() - Date.parse(item.createdAt)) / 60_000),
|
||||
likes: item.metrics.likes ?? item.metrics.favourites ?? 0, reposts: item.metrics.reposts ?? item.metrics.reblogs ?? 0,
|
||||
replies: item.metrics.replies ?? 0, hasMedia: item.mediaUrls.length > 0,
|
||||
connectionId: connection.id, remoteId: item.remoteId, feedId: item.id, mediaUrls: item.mediaUrls,
|
||||
sourceLabel: `${connection.displayName} (${connection.handle})`, livePlatform: platform,
|
||||
}));
|
||||
return {
|
||||
key: connection.id, title: `${connection.displayName} · ${connection.handle}`, platformId: connection.provider,
|
||||
platform, staleness: connection.fetchedAt ? `Updated ${new Date(connection.fetchedAt).toLocaleTimeString()}` : "Waiting for feed", items,
|
||||
};
|
||||
});
|
||||
}
|
||||
68
apps/web/src/lib/use-api.ts
Normal file
68
apps/web/src/lib/use-api.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api, ApiUnreachableError, errorMessage } from "./api-client";
|
||||
import { useAuth } from "./auth-store";
|
||||
|
||||
export type DataSource = "loading" | "live" | "sample";
|
||||
|
||||
export interface ApiResource<T> {
|
||||
data: T;
|
||||
source: DataSource;
|
||||
error: string | null;
|
||||
reload: () => void;
|
||||
setData: (update: (current: T) => T) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET a resource once the session is verified. When the API is unreachable
|
||||
* the screen gets `sample` (its fixture data) so local UI work never blocks
|
||||
* on a running backend — pages show an "offline — sample data" indicator.
|
||||
*/
|
||||
export function useApiResource<Live, View>(
|
||||
path: string,
|
||||
toView: (live: Live) => View,
|
||||
sample: View,
|
||||
query?: Record<string, string | number | undefined>,
|
||||
): ApiResource<View> {
|
||||
const { status } = useAuth();
|
||||
const [data, setDataState] = useState<View>(sample);
|
||||
const [source, setSource] = useState<DataSource>("loading");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [nonce, setNonce] = useState(0);
|
||||
const queryKey = JSON.stringify(query ?? {});
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "loading") return;
|
||||
if (status !== "authenticated") {
|
||||
setDataState(sample);
|
||||
setSource("sample");
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const parsedQuery = JSON.parse(queryKey) as Record<string, string | number | undefined>;
|
||||
api
|
||||
.get<Live>(path, parsedQuery, controller.signal)
|
||||
.then((live) => {
|
||||
setDataState(toView(live));
|
||||
setSource("live");
|
||||
setError(null);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (err instanceof ApiUnreachableError) {
|
||||
setDataState(sample);
|
||||
setSource("sample");
|
||||
return;
|
||||
}
|
||||
setError(errorMessage(err));
|
||||
setSource("live");
|
||||
});
|
||||
return () => controller.abort();
|
||||
// `toView` / `sample` must be stable (module-level); keying on them would refetch every render.
|
||||
}, [status, path, queryKey, nonce]);
|
||||
|
||||
const reload = useCallback(() => setNonce((n) => n + 1), []);
|
||||
const setData = useCallback((update: (current: View) => View) => setDataState(update), []);
|
||||
return { data, source, error, reload, setData };
|
||||
}
|
||||
90
apps/web/src/lib/video-fixtures.ts
Normal file
90
apps/web/src/lib/video-fixtures.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import type { VideoLibraryEntryDto } from "./video-types";
|
||||
|
||||
/**
|
||||
* Offline sample library for the video screen (useApiResource conventions: when
|
||||
* the API is unreachable the screen renders these read-only). Lives under
|
||||
* src/lib so provider literals stay inside the mock-registry boundary.
|
||||
*/
|
||||
|
||||
export const SAMPLE_VIDEO_ENTRIES: VideoLibraryEntryDto[] = [
|
||||
{
|
||||
videoAsset: {
|
||||
id: "1f0a2b3c-4d5e-6f70-8192-a3b4c5d6e7f8",
|
||||
workspaceId: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
|
||||
storageKey: "sha256/9f2c7ab41d06e5b38c0f4a71d295e6b83c10d47a9e52f68b14c3d2e1f0a9b8c7.mp4",
|
||||
mimeType: "video/mp4",
|
||||
sizeBytes: 851_738_112,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
durationMs: 362_400,
|
||||
frameRate: 29.97,
|
||||
codec: "h264",
|
||||
audioCodec: "aac",
|
||||
},
|
||||
processingState: "complete",
|
||||
publications: [
|
||||
{
|
||||
connectionId: "3c4d5e6f-7081-9293-a4b5-c6d7e8f90a1b",
|
||||
remoteId: "981",
|
||||
platform: "peertube",
|
||||
publishedAt: "2026-10-05T09:12:00.000Z",
|
||||
},
|
||||
{
|
||||
connectionId: "4d5e6f70-8192-93a4-b5c6-d7e8f90a1b2c",
|
||||
remoteId: "dQw4w9WgXcQ",
|
||||
platform: "youtube",
|
||||
publishedAt: "2026-10-04T18:40:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
videoAsset: {
|
||||
id: "2a1b3c4d-5e6f-7081-9293-b4c5d6e7f8a9",
|
||||
workspaceId: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
|
||||
storageKey: "sha256/1c8d3e9f2a6b4d08e7c5a19f3b6d2e8c4a7f1b9e3d6c2a8f4e1b7d3c9a5e2f0.webm",
|
||||
mimeType: "video/webm",
|
||||
sizeBytes: 218_103_808,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
durationMs: 95_200,
|
||||
frameRate: 30,
|
||||
codec: "vp9",
|
||||
audioCodec: "opus",
|
||||
},
|
||||
processingState: "transcoding",
|
||||
publications: [],
|
||||
},
|
||||
{
|
||||
videoAsset: {
|
||||
id: "3b2c4d5e-6f70-8192-93a4-c5d6e7f8a9b0",
|
||||
workspaceId: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
|
||||
storageKey: "sha256/6e1f0a2b3c4d5e6f70819293a4b5c6d7e8f90a1b2c3d4e5f6071829384a5b.mov",
|
||||
mimeType: "video/quicktime",
|
||||
sizeBytes: 41_943_040,
|
||||
},
|
||||
processingState: "probing",
|
||||
publications: [],
|
||||
},
|
||||
{
|
||||
videoAsset: {
|
||||
id: "4c3d5e6f-7081-9293-a4b5-d6e7f8a9b0c1",
|
||||
workspaceId: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
|
||||
storageKey: "sha256/2d9e4a1f7c3b5d08e6a19f4b7d2e8c3a5f1b9e6d3c2a8f4e0b7d3c9a5e2f1a8.mp4",
|
||||
mimeType: "video/mp4",
|
||||
sizeBytes: 12_582_912,
|
||||
},
|
||||
processingState: "queued",
|
||||
publications: [],
|
||||
},
|
||||
{
|
||||
videoAsset: {
|
||||
id: "5d4e6f70-8192-93a4-b5c6-e7f8a9b0c1d2",
|
||||
workspaceId: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
|
||||
storageKey: "sha256/8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8.mp4",
|
||||
mimeType: "video/mp4",
|
||||
sizeBytes: 68_157_440,
|
||||
},
|
||||
processingState: "failed",
|
||||
publications: [],
|
||||
},
|
||||
];
|
||||
57
apps/web/src/lib/video-types.ts
Normal file
57
apps/web/src/lib/video-types.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* Response shapes of the /video routes — kept in sync by hand with
|
||||
* apps/api VideoController/VideoService (same standing as api-types.ts;
|
||||
* declared here because api-client.ts is owned by another worker right now).
|
||||
*/
|
||||
|
||||
/** video_processing_jobs.state — the processing pipeline a video walks. */
|
||||
export type VideoProcessingState = "queued" | "probing" | "transcoding" | "complete" | "failed";
|
||||
|
||||
export interface VideoAssetDto {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
storageKey: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
durationMs?: number;
|
||||
frameRate?: number;
|
||||
codec?: string;
|
||||
audioCodec?: string;
|
||||
thumbnailKey?: string;
|
||||
}
|
||||
|
||||
export interface VideoProcessingJobDto {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
videoAssetId: string;
|
||||
state: VideoProcessingState;
|
||||
progressPercent?: number;
|
||||
outputKey?: string;
|
||||
error?: string;
|
||||
processingNote?: string;
|
||||
}
|
||||
|
||||
export interface VideoPublicationDto {
|
||||
connectionId: string;
|
||||
remoteId: string;
|
||||
platform: string;
|
||||
publishedAt: string;
|
||||
}
|
||||
|
||||
export interface VideoLibraryEntryDto {
|
||||
videoAsset: VideoAssetDto;
|
||||
processingState: VideoProcessingState;
|
||||
publications: VideoPublicationDto[];
|
||||
}
|
||||
|
||||
export interface VideoLibraryPageDto {
|
||||
items: VideoLibraryEntryDto[];
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
export interface VideoStatusDto {
|
||||
videoAsset: VideoAssetDto;
|
||||
processingJob: VideoProcessingJobDto;
|
||||
}
|
||||
886
apps/web/src/lib/wiki-content.ts
Normal file
886
apps/web/src/lib/wiki-content.ts
Normal file
|
|
@ -0,0 +1,886 @@
|
|||
/**
|
||||
* Bilingual (EN/ES) wiki content for the in-app documentation section.
|
||||
*
|
||||
* Every manual is written for a non-technical activist: plain language, no
|
||||
* unexplained jargon, and concrete "click this, then that" steps. Spanish is
|
||||
* written in the register activist collectives actually use (tú form, assembly
|
||||
* vocabulary), not machine translation.
|
||||
*
|
||||
* Body text conventions understood by the wiki renderer:
|
||||
* - paragraphs are separated by a blank line ("\n\n")
|
||||
* - lines starting with "- " render as a bullet list
|
||||
* - lines starting with "1. " (any number) render as an ordered list
|
||||
* - **text** renders as bold — use it for on-screen button and field labels
|
||||
*/
|
||||
|
||||
export interface LocalizedText {
|
||||
readonly en: string;
|
||||
readonly es: string;
|
||||
}
|
||||
|
||||
export interface WikiSection {
|
||||
readonly heading: LocalizedText;
|
||||
readonly body: LocalizedText;
|
||||
}
|
||||
|
||||
export interface WikiPage {
|
||||
readonly slug: string;
|
||||
readonly title: LocalizedText;
|
||||
readonly blurb: LocalizedText;
|
||||
readonly sections: readonly WikiSection[];
|
||||
}
|
||||
|
||||
export const WIKI_PAGES: readonly WikiPage[] = [
|
||||
{
|
||||
slug: "getting-started",
|
||||
title: { en: "Getting started", es: "Primeros pasos" },
|
||||
blurb: {
|
||||
en: "Create your account, join your collective's workspace, and learn what each role can do.",
|
||||
es: "Crea tu cuenta, únete al espacio de trabajo de tu colectivo y aprende qué puede hacer cada rol.",
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
heading: { en: "What TMBH is", es: "Qué es TMBH" },
|
||||
body: {
|
||||
en: "TMBH is a broadcast newsroom your collective runs itself: one place to write, review, schedule and publish to many platforms at once. It also watches live feeds, bridges your chats, runs votes, and keeps an archive that survives takedowns. You do not need to install anything — everything happens in your browser at app.occupymedia.cc.\n\nThis wiki explains every feature step by step. If a word is new to you, it is explained the first time it appears.",
|
||||
es: "TMBH es una sala de redacción que tu colectivo administra por su cuenta: un solo lugar para escribir, revisar, programar y publicar en muchas plataformas a la vez. También vigila fuentes en vivo, une tus chats, hace votaciones y mantiene un archivo que sobrevive a los borradones. No hay que instalar nada: todo ocurre en tu navegador, en app.occupymedia.cc.\n\nEsta wiki explica cada función paso a paso. Si una palabra es nueva, se explica la primera vez que aparece.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Creating your account", es: "Crear tu cuenta" },
|
||||
body: {
|
||||
en: "Open app.occupymedia.cc. The first screen shows **Sign in** and **Create account** — choose **Create account**.\n\n1. Pick a username (no spaces, no @) and a display name others in the collective will recognise.\n2. Enter your email so invites and recovery can reach you.\n3. Choose a password of at least 10 characters. A long phrase you can remember is stronger than a short messy one.\n4. Press **Create account**. You land in your new, empty workspace as its **Owner**.",
|
||||
es: "Abre app.occupymedia.cc. La primera pantalla muestra **Sign in** y **Create account** — elige **Create account**.\n\n1. Elige un nombre de usuario (sin espacios y sin @) y un nombre visible que el colectivo reconozca.\n2. Escribe tu correo para que te lleguen invitaciones y recuperación.\n3. Elige una contraseña de al menos 10 caracteres. Una frase larga que recuerdes es más fuerte que una corta y enredada.\n4. Pulsa **Create account**. Entrarás en tu espacio de trabajo nuevo y vacío como su **Owner** (propietario).",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Joining an existing workspace", es: "Unirte a un espacio de trabajo existente" },
|
||||
body: {
|
||||
en: "Most people do not create a workspace — they join their collective's. Ask a team admin for an **invite link** and open it in your browser.\n\nThe link shows the workspace name, the role you will get, and how many times the link can still be used. Press **Join** and you are in; the link stops working once it hits its use limit or its expiry date. If the link has expired, ask for a fresh one — that expiry is a safety feature, not a bug.",
|
||||
es: "La mayoría no crea un espacio de trabajo: se une al de su colectivo. Pide a quien administra el equipo un **enlace de invitación** y ábrelo en tu navegador.\n\nEl enlace muestra el nombre del espacio, el rol que recibirás y cuántos usos le quedan. Pulsa **Join** y ya estarás dentro; el enlace deja de funcionar al agotar sus usos o al llegar a su fecha de caducidad. Si el enlace caducó, pide uno nuevo: esa caducidad es una medida de seguridad, no un fallo.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Signing in and out", es: "Iniciar y cerrar sesión" },
|
||||
body: {
|
||||
en: "To sign in later, open the app, enter your username and password, and press **Sign in**. Your session is remembered on that device until you sign out.\n\nOn a shared or borrowed computer always sign out when you finish: open **More** (bottom right on mobile, or the sidebar) and press **Sign out**. On your own laptop or phone, staying signed in is fine and saves time.",
|
||||
es: "Para entrar más tarde, abre la app, escribe tu usuario y contraseña y pulsa **Sign in**. La sesión queda recordada en ese dispositivo hasta que la cierres.\n\nEn un equipo compartido o prestado, cierra siempre la sesión al terminar: abre **More** (abajo a la derecha en el móvil, o en la barra lateral) y pulsa **Sign out**. En tu propio portátil o móvil puedes quedarte con la sesión abierta y ahorrar tiempo.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Understanding roles", es: "Entender los roles" },
|
||||
body: {
|
||||
en: "A role is the set of things a person is allowed to do. Roles keep the newsroom honest: no single person can publish, moderate and audit at the same time without anyone noticing.\n\n- **Owner** — runs the workspace, can do everything, including deleting it.\n- **Admin** — manages members, connections and settings.\n- **Editor** — writes and edits drafts, requests approval.\n- **Approver** — votes on drafts in review; can block with a reason.\n- **Publisher** — dispatches approved posts and manages schedules.\n- **Moderator** — handles the bridged chats: deletes, times out, resolves flags.\n- **BroadcastOperator** — drives the live Control Room and stream destinations.\n- **Viewer** — read-only: watches streams, calendar and archive, changes nothing.\n\nOne person can hold more than one role in small collectives, but splitting writing from approving is the cheapest safety you can buy. See the Team management manual for how to assign them.",
|
||||
es: "Un rol es el conjunto de cosas que una persona puede hacer. Los roles mantienen la redacción honesta: nadie puede publicar, moderar y auditar a la vez sin que se note.\n\n- **Owner** — administra el espacio, puede hacer todo, incluso borrarlo.\n- **Admin** — gestiona miembros, conexiones y ajustes.\n- **Editor** — escribe y edita borradores, pide aprobación.\n- **Approver** — vota los borradores en revisión; puede bloquear con un motivo.\n- **Publisher** — despacha las publicaciones aprobadas y gestiona la programación.\n- **Moderator** — cuida los chats unidos: borra, expulsa temporalmente, resuelve avisos.\n- **BroadcastOperator** — maneja la sala de control en vivo y los destinos de transmisión.\n- **Viewer** — solo lectura: mira streams, calendario y archivo, no cambia nada.\n\nEn colectivos pequeños una persona puede tener más de un rol, pero separar quien escribe de quien aprueba es la seguridad más barata que existe. Mira el manual de Gestión del equipo para asignarlos.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Finding your way around", es: "Orientarte en la app" },
|
||||
body: {
|
||||
en: "On a laptop the sidebar on the left lists every section: **Streams**, **Search**, **Inbox**, **Calendar**, **Video**, **Analytics**, **Connections**, **Control Room**, **Team**, **Audit** and more. On a phone the same sections live in the bottom bar and under **More**.\n\nThe fastest way anywhere is the keyboard: press **⌘K** (or **Ctrl-K**) to open the command palette and type where you want to go. This wiki is always reachable from **More → Help & docs**.",
|
||||
es: "En un portátil, la barra lateral izquierda lista todas las secciones: **Streams**, **Search**, **Inbox**, **Calendar**, **Video**, **Analytics**, **Connections**, **Control Room**, **Team**, **Audit** y más. En el móvil las mismas secciones están en la barra inferior y dentro de **More**.\n\nLa forma más rápida de moverse es el teclado: pulsa **⌘K** (o **Ctrl-K**) para abrir la paleta de comandos y escribe a dónde quieres ir. Esta wiki siempre está en **More → Ayuda y documentación**.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "composing-posts",
|
||||
title: { en: "Composing posts", es: "Redactar publicaciones" },
|
||||
blurb: {
|
||||
en: "Write a draft, pick the platforms it goes to, preview each version, and send it for group review.",
|
||||
es: "Escribe un borrador, elige a qué plataformas va, previsualiza cada versión y envíalo a revisión del grupo.",
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
heading: { en: "Where composing happens", es: "Dónde se redacta" },
|
||||
body: {
|
||||
en: "Open **Compose** from the sidebar, the bottom bar, or by pressing **⌘K** and typing compose. The same editor opens from the pencil button on the streams wall, so you can write while watching the feeds.\n\nEverything you write starts as a **draft**. A draft is private to the workspace and cannot reach the outside world until the group approves it — that is the whole point of the pipeline.",
|
||||
es: "Abre **Compose** desde la barra lateral, la barra inferior, o pulsando **⌘K** y escribiendo compose. El mismo editor se abre desde el botón de lápiz en el muro de streams, así que puedes escribir mientras miras las fuentes.\n\nTodo lo que escribes empieza como **borrador**. Un borrador es privado del espacio de trabajo y no puede salir al exterior hasta que el grupo lo apruebe: esa es la gracia de todo el circuito.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Writing the draft", es: "Escribir el borrador" },
|
||||
body: {
|
||||
en: "Type your text in the main editor box. Links, hashtags and line breaks are kept as you write them. If the text runs long, the editor shows a character counter per platform so you know what still fits.\n\nA few habits that save rework later: put the call to action in the first line (platforms truncate), write out dates and places instead of \"tomorrow\", and avoid thread-dependent meaning, because each platform gets one post.",
|
||||
es: "Escribe tu texto en el cuadro principal del editor. Los enlaces, hashtags y saltos de línea se conservan tal cual. Si el texto se alarga, el editor muestra un contador de caracteres por plataforma para que sepas qué cabe todavía.\n\nAlgunos hábitos que ahorran retrabajo: pon la llamada a la acción en la primera línea (las plataformas cortan el texto), escribe fechas y lugares completos en vez de \"mañana\", y evita sentidos que dependan de un hilo, porque cada plataforma recibe una sola publicación.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Choosing target platforms", es: "Elegir las plataformas destino" },
|
||||
body: {
|
||||
en: "Below the editor, tick every platform this post should reach: Mastodon, Telegram, Bluesky, PeerTube, Facebook, Instagram, Threads, LinkedIn, Reddit, Twitch, Discord or Slack. Only the accounts your collective has connected appear as choices — see the Connections manual if one is missing.\n\nPick several at once; that is the fan-out. One approval covers all the boxes you tick, and each platform gets its own copy sized to its rules.",
|
||||
es: "Debajo del editor, marca todas las plataformas a las que debe llegar esta publicación: Mastodon, Telegram, Bluesky, PeerTube, Facebook, Instagram, Threads, LinkedIn, Reddit, Twitch, Discord o Slack. Solo aparecen como opciones las cuentas que tu colectivo conectó; si falta alguna, mira el manual de Conexiones.\n\nMarca varias a la vez: eso es la difusión múltiple. Una sola aprobación cubre todas las casillas que marques, y cada plataforma recibe su propia copia ajustada a sus reglas.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Previews: seeing what each platform gets", es: "Previsualizaciones: ver qué recibe cada plataforma" },
|
||||
body: {
|
||||
en: "The **Previews** panel renders your draft exactly as each ticked platform will show it, including truncation and media layout. Check it before submitting — a post that looks fine for one network can look broken on another.\n\nIf the preview shows a warning (for example, a platform that requires media), fix the draft or untick that platform rather than hoping for the best.",
|
||||
es: "El panel **Previews** muestra tu borrador exactamente como lo presentará cada plataforma marcada, incluidos los cortes y la disposición de los medios. Revísalo antes de enviar: una publicación que se ve bien en una red puede verse rota en otra.\n\nSi la vista previa muestra un aviso (por ejemplo, una plataforma que exige medios), corrige el borrador o desmarca esa plataforma en vez de cruzar los dedos.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Visibility and media", es: "Visibilidad y medios" },
|
||||
body: {
|
||||
en: "Use **Visibility** to set who can see the post where the platform supports it — public, followers-only, or unlisted. Followers-only is the right choice for internal callouts you do not want screenshotted to fascists.\n\nAttach photos or video with the media button before you submit; attached media travels with the draft through review and archive. Video you upload here is transcoded automatically — see the Video upload manual.",
|
||||
es: "Usa **Visibility** para definir quién puede ver la publicación donde la plataforma lo permita: pública, solo seguidores o no listada. \"Solo seguidores\" es la opción correcta para convocatorias internas que no quieres que capten por captura de pantalla.\n\nAdjunta fotos o video con el botón de medios antes de enviar; los medios acompañan al borrador durante la revisión y quedan en el archivo. El video que subas aquí se transcodifica solo: mira el manual de Subida de video.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Saving a draft to finish later", es: "Guardar un borrador para terminarlo después" },
|
||||
body: {
|
||||
en: "Press **Save draft** at any moment. Nothing is sent anywhere, and the draft waits for you under **Recent drafts** on the same page. Reopen it by tapping its title and keep writing.\n\nDrafts also serve as a parking lot during actions: sketch the text while it is fresh, then finish and submit once the assembly has decided the line.",
|
||||
es: "Pulsa **Save draft** en cualquier momento. No se envía nada a ninguna parte y el borrador queda esperando en **Recent drafts**, en la misma página. Vuélvelo a abrir tocando su título y sigue escribiendo.\n\nLos borradores también sirven de estacionamiento durante las acciones: esboza el texto mientras esté fresco y termínalo y envíalo cuando la asamblea haya decidido la línea.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Submitting for review", es: "Enviar a revisión" },
|
||||
body: {
|
||||
en: "When the draft is ready, press **Request approval**. The draft moves to state **in review** and appears in the queue of everyone who can approve (Approvers, and Editors depending on your setup). You cannot publish it yourself from here — that separation is deliberate.\n\nIf you also set **Date and time** before requesting approval, the post is scheduled the moment it is approved, and you can skip the scheduling step. Full details are in the Approval workflow and Scheduling posts manuals.",
|
||||
es: "Cuando el borrador esté listo, pulsa **Request approval**. El borrador pasa al estado **in review** y aparece en la cola de quien pueda aprobar (Approver y, según la configuración, Editor). Desde aquí no puedes publicarlo tú: esa separación es deliberada.\n\nSi además fijaste **Date and time** antes de pedir aprobación, la publicación queda programada en cuanto se apruebe y te saltas el paso de programación. Los detalles están en los manuales de Flujo de aprobación y de Programar publicaciones.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "approval-workflow",
|
||||
title: { en: "Approval workflow", es: "Flujo de aprobación" },
|
||||
blurb: {
|
||||
en: "How group review works: the review queue, quorum rules, voting from chat, blocking with reason codes, and the pre-dispatch gate.",
|
||||
es: "Cómo funciona la revisión en grupo: la cola de revisión, las reglas de quórum, votar desde el chat, bloquear con códigos de motivo y la compuerta previa al despacho.",
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
heading: { en: "Why group approval exists", es: "Por qué existe la aprobación en grupo" },
|
||||
body: {
|
||||
en: "Anything published in the collective's name is a risk the whole collective carries: legal heat, doxxing, burn of a hard-won account. Group approval spreads that decision across several people instead of leaving it to whoever typed fastest.\n\nThe pipeline is strict on purpose: draft → review → approved → scheduled → dispatched → receipt. A post cannot skip a stage, and every stage leaves a mark in the audit log.",
|
||||
es: "Todo lo que se publica en nombre del colectivo es un riesgo que carga todo el colectivo: presión legal, doxxing, quemar una cuenta costosa de conseguir. La aprobación en grupo reparte esa decisión entre varias personas en vez de dejarla en manos de quien escribió más rápido.\n\nEl circuito es estricto a propósito: borrador → revisión → aprobado → programado → despachado → acuse. Una publicación no puede saltarse una etapa, y cada etapa deja marca en el registro de auditoría.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Where reviews wait", es: "Dónde esperan las revisiones" },
|
||||
body: {
|
||||
en: "Open **Inbox** and switch to the **Approvals** segment (or press **Approvals** directly in the sidebar). Every draft in state **in review** is listed there, newest first, with its author, target platforms and any deadline.\n\nThe badge on the Inbox tab counts drafts waiting in review right now, so nobody can claim they did not see it.",
|
||||
es: "Abre **Inbox** y cambia al segmento **Approvals** (o pulsa **Approvals** directamente en la barra lateral). Ahí se listan todos los borradores en estado **in review**, los más nuevos primero, con su autor, plataformas destino y fecha límite si la hay.\n\nEl contador en la pestaña del Inbox indica cuántos borradores esperan revisión ahora mismo, así que nadie puede decir que no lo vio.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Reviewing a draft (the E1 gate)", es: "Revisar un borrador (la compuerta E1)" },
|
||||
body: {
|
||||
en: "Open a draft from the queue. You see the full text, the per-platform previews and the history of votes so far. Read it as an outsider would: is it true, is it safe, does it name anyone who did not consent?\n\nThen choose **Approve** to add your yes, or **Block** to stop it. Approval here is the E1 gate — the first checkpoint a draft must pass on its way out of the building.",
|
||||
es: "Abre un borrador desde la cola. Verás el texto completo, las vistas previas por plataforma y el historial de votos hasta ahora. Léelo como lo leería alguien de afuera: ¿es cierto, es seguro, nombra a alguien que no consintió?\n\nLuego elige **Approve** para sumar tu sí, o **Block** para frenarlo. La aprobación aquí es la compuerta E1: el primer control que un borrador debe pasar antes de salir del edificio.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Quorum rules, in plain words", es: "Las reglas de quórum, en palabras simples" },
|
||||
body: {
|
||||
en: "A quorum is the number of votes needed before the draft counts as approved. Your collective configures it, typically as x out of y: 3 of 5 approvers, or half of the editors, and so on.\n\nThe queue always shows the gap to quorum — how many more yes votes are needed. Until that gap closes, the draft simply waits; it is never approved by silence. If the collective set a mixed rule (for example, 1 of 2 admins AND 2 of 5 participants), both arms must be satisfied.",
|
||||
es: "Un quórum es la cantidad de votos necesaria para que el borrador cuente como aprobado. Tu colectivo lo configura, normalmente como x de y: 3 de 5 aprobadores, o la mitad de quienes editan, etc.\n\nLa cola siempre muestra lo que falta para el quórum: cuántos sí más se necesitan. Hasta cerrar esa brecha, el borrador simplemente espera; nunca se aprueba por silencio. Si el colectivo fijó una regla mixta (por ejemplo, 1 de 2 admins Y 2 de 5 participantes), deben cumplirse los dos brazos.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Blocking and reason codes", es: "Bloquear y códigos de motivo" },
|
||||
body: {
|
||||
en: "**Block** stops the draft immediately, no matter how many approvals it had. A block is not a veto war: you must attach a **reason code**, a short machine-readable tag saying why — for example unsafe-claim, legal-risk, wrong-target, needs-source, tone.\n\nCodes matter because they are counted. After a month you can see whether blocks cluster on \"needs-source\" and fix the writing process, instead of arguing about personalities. The blocked item sits in the blocked queue with the gap to quorum spelled out, so the author knows exactly what to change and resubmit.",
|
||||
es: "**Block** frena el borrador de inmediato, sin importar cuántas aprobaciones tuviera. Un bloqueo no es una guerra de vetos: debes adjuntar un **código de motivo**, una etiqueta corta y legible por máquinas que diga por qué — por ejemplo afirmación-insegura, riesgo-legal, destino-equivocado, falta-fuente, tono.\n\nLos códigos importan porque se cuentan. Tras un mes puedes ver si los bloqueos se concentran en \"falta-fuente\" y arreglar el proceso de escritura, en vez de discutir personalidades. El ítem bloqueado queda en la cola de bloqueados con la brecha hasta el quórum a la vista, así quien escribió sabe exactamente qué cambiar y reenviar.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Voting from chat", es: "Votar desde el chat" },
|
||||
body: {
|
||||
en: "You do not have to be at a laptop. The collective can vote from the bridged chats — Telegram, Twitch or Discord — with a short command under the draft the bot posts. The web and the chat see the same tally, because it is the same vote.\n\nThis is the difference between deciding in the street and deciding in the meeting: people who are kettled can still approve the statement. See the Chat bridge manual for how the overlay works.",
|
||||
es: "No hace falta estar frente a un portátil. El colectivo puede votar desde los chats unidos — Telegram, Twitch o Discord — con un comando corto bajo el borrador que publica el bot. La web y el chat ven el mismo recuento, porque es la misma votación.\n\nEsta es la diferencia entre decidir en la calle y decidir en la reunión: quien está rodeado por la policía puede igualmente aprobar el comunicado. Mira el manual de Puente de chat para entender la superposición.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "The pre-dispatch gate (E3)", es: "La compuerta previa al despacho (E3)" },
|
||||
body: {
|
||||
en: "Approval is not the last look. At the moment of dispatch there is a second, smaller gate (E3) that re-checks the post against the platforms it is about to hit — token still valid, media ready, schedule still in the future.\n\nIf anything fails the gate, the dispatch is fenced: nothing half-published goes out, and the failure is recorded with a reason instead of silently retrying into the void.",
|
||||
es: "La aprobación no es la última mirada. En el momento del despacho hay una segunda compuerta, más chica (E3), que vuelve a revisar la publicación contra las plataformas que va a tocar: token vigente, medios listos, programación todavía en el futuro.\n\nSi algo no pasa la compuerta, el despacho queda cercado: no sale nada publicado a medias, y el fallo queda registrado con su motivo en vez de reintentarse en silencio hacia la nada.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "scheduling-posts",
|
||||
title: { en: "Scheduling posts", es: "Programar publicaciones" },
|
||||
blurb: {
|
||||
en: "Publish at the right hour in any timezone, read the calendar, trust the daylight-saving handling, and cancel safely.",
|
||||
es: "Publica a la hora correcta en cualquier zona horaria, lee el calendario, confía en el manejo del horario de verano y cancela con seguridad.",
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
heading: { en: "Why schedule instead of posting now", es: "Por qué programar en vez de publicar ya" },
|
||||
body: {
|
||||
en: "Timing decides reach. A callout posted at 3 a.m. local time dies quietly; the same text at 8 a.m. rides the morning scroll. Scheduling also lets the group approve tonight what goes out before tomorrow's march, without anyone waking up early to press a button.\n\nScheduling in TMBH is timezone-correct and daylight-saving-proof, so an action at a fixed local hour stays at that local hour all year.",
|
||||
es: "El momento decide el alcance. Una convocatoria publicada a las 3 de la madrugada muere en silencio; el mismo texto a las 8 cabalga el scroll matinal. Programar también permite que el grupo apruebe esta noche lo que saldrá antes de la marcha de mañana, sin que nadie madrugue a apretar un botón.\n\nLa programación en TMBH respeta zonas horarias y el cambio de horario, así que una acción a una hora local fija se mantiene en esa hora local todo el año.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Setting date and time", es: "Fijar fecha y hora" },
|
||||
body: {
|
||||
en: "In **Compose**, fill **Date and time** under the editor. The time you enter is interpreted in your own timezone and shown back to you with the timezone spelled out — check that suffix before saving, especially if you coordinate across continents.\n\nThen press **Schedule** (or **Request approval** with the time already set, so the post schedules itself once approved). The draft leaves your hands and appears on the calendar.",
|
||||
es: "En **Compose**, completa **Date and time** debajo del editor. La hora que escribas se interpreta en tu propia zona horaria y se te muestra de vuelta con la zona explícita: revisa ese sufijo antes de guardar, sobre todo si coordinas entre continentes.\n\nLuego pulsa **Schedule** (o **Request approval** con la hora ya fijada, para que la publicación se programe sola al aprobarse). El borrador sale de tus manos y aparece en el calendario.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Reading the calendar", es: "Leer el calendario" },
|
||||
body: {
|
||||
en: "Open **Calendar** to see every scheduled publication on a timeline: what goes out, to which platforms, and when in your local time. Scheduled items are fenced, which means the text is frozen — editing it would need a new approval round.\n\nUse the calendar as the newsroom's shared clock: before proposing a new time, look for collisions with actions, streams or other posts in the same window.",
|
||||
es: "Abre **Calendar** para ver todas las publicaciones programadas en una línea de tiempo: qué sale, a qué plataformas y cuándo en tu hora local. Los ítems programados están cercados, lo que significa que el texto queda congelado; editarlo exigiría una nueva ronda de aprobación.\n\nUsa el calendario como el reloj compartido de la redacción: antes de proponer una hora nueva, mira choques con acciones, transmisiones u otras publicaciones en la misma ventana.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Daylight saving, handled", es: "El horario de verano, resuelto" },
|
||||
body: {
|
||||
en: "Clocks change twice a year and they change on different dates in different countries. TMBH stores each schedule as an exact instant, computed from the timezone rules in force on that date — not as \"9 a.m.\" floating free.\n\nIn practice: a post scheduled for 9 a.m. Madrid time lands at 9 a.m. Madrid time, whether Spain is on winter or summer clock, and colleagues in Buenos Aires see the correct local equivalent. You never need to reschedule around a clock change.",
|
||||
es: "Los relojes cambian dos veces al año y cambian en fechas distintas según el país. TMBH guarda cada programación como un instante exacto, calculado con las reglas de zona horaria vigentes en esa fecha, no como un \"9:00\" flotando en el aire.\n\nEn la práctica: una publicación programada para las 9:00 de Madrid sale a las 9:00 de Madrid, esté España en horario de invierno o de verano, y quien esté en Buenos Aires ve el equivalente local correcto. Nunca hay que reprogramar por un cambio de hora.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Cancelling or changing a scheduled post", es: "Cancelar o cambiar una publicación programada" },
|
||||
body: {
|
||||
en: "Open the item on the calendar and press **Cancel**. You will be asked **Cancel this scheduled publication?** — confirm with the cancel button or back out with **Keep it**. Cancelling is safe at any point before dispatch, and the cancellation is written to the audit log with who did it.\n\nTo change the text rather than the time, cancel and compose a new draft: the old one stays archived, so you can copy from it. To move only the time, cancel and reschedule the same approved content if your role allows it.",
|
||||
es: "Abre el ítem en el calendario y pulsa **Cancel**. Te preguntarán **Cancel this scheduled publication?** — confirma con el botón de cancelar o vuelve atrás con **Keep it**. Cancelar es seguro en cualquier momento antes del despacho, y la cancelación queda escrita en el registro de auditoría con quién la hizo.\n\nPara cambiar el texto y no la hora, cancela y redacta un borrador nuevo: el viejo queda archivado y puedes copiar de él. Para mover solo la hora, cancela y reprograma el mismo contenido aprobado si tu rol lo permite.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "What happens at dispatch time", es: "Qué pasa en el momento del despacho" },
|
||||
body: {
|
||||
en: "When the moment arrives, the dispatcher fans the post out to every target platform through the pre-dispatch gate. Each platform gets its own attempt, its own retry with backoff, and its own outcome.\n\nYou do not have to watch it happen, but you can: the receipts arrive in the Inbox and each platform's result is recorded, so a partial failure (three platforms ok, one refused) is visible instead of guessed.",
|
||||
es: "Cuando llega el momento, el despachador reparte la publicación a todas las plataformas destino a través de la compuerta previa al despacho. Cada plataforma recibe su propio intento, su propio reintento con espera progresiva y su propio resultado.\n\nNo hace falta que lo mires, pero puedes: los acuses llegan al Inbox y el resultado de cada plataforma queda registrado, así que un fallo parcial (tres plataformas bien, una rechazada) se ve en vez de adivinarse.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Receipts", es: "Acuses de recibo" },
|
||||
body: {
|
||||
en: "A receipt is the proof of what actually went out: the text as published, the platform, the timestamp, and the remote reference — the ID the platform assigned. Receipts land in the **Inbox** and are kept in the archive.\n\nKeep them. When a platform throttles, deletes or \"loses\" a post, the receipt plus the archived copy is what lets your collective show what was said and when.",
|
||||
es: "Un acuse es la prueba de lo que realmente salió: el texto tal como se publicó, la plataforma, la marca de tiempo y la referencia remota, el ID que asignó la plataforma. Los acuses llegan al **Inbox** y se guardan en el archivo.\n\nConsérvalos. Cuando una plataforma limita, borra o \"pierde\" una publicación, el acuse más la copia archivada son lo que permite a tu colectivo demostrar qué se dijo y cuándo.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
slug: "streams",
|
||||
title: { en: "Streams — the column wall", es: "Streams — el muro de columnas" },
|
||||
blurb: {
|
||||
en: "Watch many platforms at once, add columns, trust that hostile content is rendered safely, and understand cooldowns.",
|
||||
es: "Mira muchas plataformas a la vez, añade columnas, confía en que el contenido hostil se muestra de forma segura y entiende los tiempos de espera.",
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
heading: { en: "What the wall is", es: "Qué es el muro" },
|
||||
body: {
|
||||
en: "**Streams** is the home screen: a wall of live columns side by side, in the style of the old TweetDeck. Each column is one feed — a Mastodon timeline, a Telegram channel, a Bluesky feed, a saved search — scrolling as new posts arrive.\n\nThe point is situational awareness during an action: instead of switching apps, you see the street, the press, the trolls and your own channels in one glance.",
|
||||
es: "**Streams** es la pantalla de inicio: un muro de columnas en vivo una al lado de la otra, al estilo del viejo TweetDeck. Cada columna es una fuente — una línea de tiempo de Mastodon, un canal de Telegram, un feed de Bluesky, una búsqueda guardada — que se desplaza cuando llegan publicaciones nuevas.\n\nLa idea es tener lectura de situación durante una acción: en vez de saltar entre apps, ves la calle, la prensa, los trolls y tus propios canales de un vistazo.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Adding a column", es: "Añadir una columna" },
|
||||
body: {
|
||||
en: "Press **Columns** at the top of the streams page (on mobile it is the columns button in the top bar). Pick a feed from the list of connected accounts, or choose **All feeds** to watch everything at once. The column opens immediately at the right edge of your wall.\n\nTo watch a search instead of a feed, run the search from **Search** and use **Save a search as a column** — then the column shows every new post matching those words, on every platform you follow.",
|
||||
es: "Pulsa **Columns** en la parte superior de la página de streams (en el móvil es el botón de columnas en la barra de arriba). Elige una fuente de la lista de cuentas conectadas, o marca **All feeds** para verlo todo junto. La columna se abre de inmediato en el borde derecho de tu muro.\n\nPara vigilar una búsqueda y no una fuente, hazla desde **Search** y usa **Save a search as a column**: la columna mostrará cada publicación nueva que coincida con esas palabras, en todas las plataformas que sigues.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Moving around the wall", es: "Moverse por el muro" },
|
||||
body: {
|
||||
en: "Columns scroll independently: drag within one column to read its history, and scroll the page sideways to reach the next. With a keyboard, **j** and **k** move between posts, **r** reposts and **q** quotes — the shortcut hints are printed under the wall.\n\n**Load older posts** at the bottom of a column pages further back in time. Loading older content never disturbs the live top of the column, so you cannot lose the newest post by reading history.",
|
||||
es: "Las columnas se desplazan de forma independiente: arrastra dentro de una columna para leer su historial y desplázate de lado en la página para llegar a la siguiente. Con teclado, **j** y **k** mueven entre publicaciones, **r** repite y **q** cita — las pistas de atajos están impresas bajo el muro.\n\n**Load older posts** al pie de una columna avanza más atrás en el tiempo. Cargar contenido viejo nunca molesta la parte superior en vivo de la columna, así que no puedes perder la publicación más nueva por leer el historial.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Acting on a post", es: "Actuar sobre una publicación" },
|
||||
body: {
|
||||
en: "Hover or tap a post to get its actions: **Repost** (or **Boost** on Mastodon), **Quote** with your own commentary, and **Share** where the platform supports forwarding. **Open compose** takes the text into a new draft when you want to answer at length.\n\nActions only appear for platforms that truly allow them — there is no \"no amplify\" dead button. If an account is read-only, the column still shows the feed; the buttons simply do not offer what the platform forbids.",
|
||||
es: "Pasa el cursor o toca una publicación para ver sus acciones: **Repost** (o **Boost** en Mastodon), **Quote** con tu propio comentario y **Share** donde la plataforma permita reenviar. **Open compose** lleva el texto a un borrador nuevo cuando quieres responder con calma.\n\nLas acciones solo aparecen en plataformas que de verdad las permiten: no hay botones muertos. Si una cuenta es de solo lectura, la columna muestra la fuente igual, pero los botones no ofrecen lo que la plataforma prohíbe.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Safe rendering: why a nasty post cannot hurt you", es: "Renderizado seguro: por qué una publicación maliciosa no puede dañarte" },
|
||||
body: {
|
||||
en: "Columns show content written by strangers, some of them hostile. TMBH never runs that content as-is: it is sanitised and rendered inside a locked frame, so hidden scripts, tracking pixels and auto-loading junk are stripped before anything reaches your browser.\n\nThis has been tested against deliberately hostile HTML. Practically: you can leave the wall open all day without a post executing anything on your machine. Media you do see is proxied through the server, so platforms do not learn your members' IP addresses.",
|
||||
es: "Las columnas muestran contenido escrito por desconocidos, algunos hostiles. TMBH nunca ejecuta ese contenido tal cual: se limpia y se renderiza dentro de un marco cerrado, de modo que scripts ocultos, píxeles rastreadores y basura de carga automática se eliminan antes de llegar a tu navegador.\n\nEsto se probó contra HTML deliberadamente hostil. En la práctica: puedes dejar el muro abierto todo el día sin que una publicación ejecute nada en tu máquina. Los medios que sí ves pasan por el proxy del servidor, así las plataformas no conocen las IP de tu gente.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Cooldowns and refresh: why columns pause", es: "Enfriamientos y refresco: por qué las columnas se pausan" },
|
||||
body: {
|
||||
en: "Each source has a cooldown — a minimum pause between fetches. When a platform is slow, refuses, or rate-limits us, the source backs off progressively (the pause grows) instead of hammering it and burning the account. The column shows this state instead of pretending to be fresh.\n\nSo a paused or slow column is the system protecting your account, not a bug. **Refresh** forces one immediate fetch, but it cannot override the platform's limits; if a source stays degraded for long, check the Connections page, where health is tracked per account.",
|
||||
es: "Cada fuente tiene un enfriamiento: una pausa mínima entre consultas. Cuando una plataforma va lenta, se niega o nos limita, la fuente retrocede de forma progresiva (la pausa crece) en vez de golpearla y quemar la cuenta. La columna muestra ese estado en vez de fingir frescura.\n\nAsí que una columna pausada o lenta es el sistema protegiendo tu cuenta, no un fallo. **Refresh** fuerza una consulta inmediata, pero no puede saltarse los límites de la plataforma; si una fuente queda degradada mucho tiempo, revisa la página de Connections, donde se sigue la salud por cuenta.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "connections",
|
||||
title: { en: "Connections", es: "Conexiones" },
|
||||
blurb: {
|
||||
en: "Connect platform accounts, read their health, set per-account rules, and keep several accounts per platform.",
|
||||
es: "Conecta cuentas de plataformas, lee su salud, define reglas por cuenta y mantén varias cuentas por plataforma.",
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
heading: { en: "What a connection is", es: "Qué es una conexión" },
|
||||
body: {
|
||||
en: "A connection is one account of one platform, linked to the workspace: your collective's Mastodon account, its Telegram channel, its Twitch channel, and so on. Nothing can be published, watched or bridged until the connection exists.\n\nCredentials never sit in the browser. They are handed to the encrypted vault (see Settings & security) and only an opaque handle is stored with the connection, so a stolen laptop leaks nothing.",
|
||||
es: "Una conexión es una cuenta de una plataforma, vinculada al espacio de trabajo: la cuenta de Mastodon del colectivo, su canal de Telegram, su canal de Twitch, etc. Nada puede publicarse, vigilarse ni unirse hasta que exista la conexión.\n\nLas credenciales nunca quedan en el navegador. Se entregan a la bóveda cifrada (mira Ajustes y seguridad) y con la conexión solo se guarda un identificador opaco, así que un portátil robado no filtra nada.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Connecting an account", es: "Conectar una cuenta" },
|
||||
body: {
|
||||
en: "Open **Connections** and press **Connect account**. Choose the **Provider** (the platform), then fill what it asks: for Mastodon the **Instance URL** like https://your-instance.example and the OAuth login; for a Telegram bot the **Bot token**; for other providers the equivalent fields, one step at a time.\n\nFinish with **Connect**. The new account appears in the list with a health indicator. If the platform refuses, the error is shown in full — wrong instance, expired token, missing permission — so you can fix it instead of guessing.",
|
||||
es: "Abre **Connections** y pulsa **Connect account**. Elige el **Provider** (la plataforma) y completa lo que pida: para Mastodon la **Instance URL** tipo https://tu-instancia.ejemplo y el login por OAuth; para un bot de Telegram el **Bot token**; para otras plataformas sus campos equivalentes, un paso por vez.\n\nTermina con **Connect**. La cuenta nueva aparece en la lista con su indicador de salud. Si la plataforma se niega, el error se muestra completo — instancia equivocada, token vencido, permiso faltante — para que lo arregles en vez de adivinar.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Reading health", es: "Leer la salud" },
|
||||
body: {
|
||||
en: "Every connection shows a state: live (working), degraded (slow or partially limited), or failing. Degraded usually means the platform is rate-limiting us and the system has backed off — the account recovers by itself once the pause expires.\n\nA failing connection needs a human: press **Reconnect** to renew the credential. Channels you can act on are grouped under **Channels**; the page also lists per-account capacity so you can see which account is close to its posting limits before you schedule a fan-out.",
|
||||
es: "Cada conexión muestra un estado: en vivo (funcionando), degradada (lenta o parcialmente limitada) o fallando. Degradada suele significar que la plataforma nos está limitando y el sistema retrocedió; la cuenta se recupera sola cuando expira la pausa.\n\nUna conexión fallida necesita persona: pulsa **Reconnect** para renovar la credencial. Los canales sobre los que puedes actuar se agrupan bajo **Channels**; la página también lista la capacidad por cuenta, para ver cuál está cerca de sus límites de publicación antes de programar una difusión múltiple.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Several accounts per platform", es: "Varias cuentas por plataforma" },
|
||||
body: {
|
||||
en: "You can connect more than one account on the same platform — the neighbourhood Mastodon plus the press-account Mastodon, two Telegram channels, and so on. Use the **All accounts** filter at the top of the page to see every account at once, or filter by platform.\n\nWhen composing, each connected account is a separate target you can tick or leave out, so a post can go to one Telegram channel and not the other. Keeping accounts separate like this is what lets a burned account be removed without touching the rest.",
|
||||
es: "Puedes conectar más de una cuenta en la misma plataforma: el Mastodon del barrio más el Mastodon de prensa, dos canales de Telegram, etc. Usa el filtro **All accounts** arriba en la página para ver todas las cuentas juntas, o filtra por plataforma.\n\nAl redactar, cada cuenta conectada es un destino aparte que puedes marcar o dejar fuera, así una publicación puede ir a un canal de Telegram y no al otro. Mantener las cuentas separadas así permite retirar una cuenta quemada sin tocar las demás.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Per-account rules", es: "Reglas por cuenta" },
|
||||
body: {
|
||||
en: "Each connection carries its own rules: posting cadence, cooldown, whether its feed feeds the wall, whether it joins the chat bridge. Set these when you connect or edit them later — they live with the account, not globally, because one platform's limits are not another's.\n\nUse rules to protect scarce accounts: a hard-won press account can be limited to a couple of posts a day while the throwaway accounts carry the volume.",
|
||||
es: "Cada conexión lleva sus propias reglas: cadencia de publicación, enfriamiento, si su fuente alimenta el muro, si se une al puente de chat. Se definen al conectar o se editan después; viven con la cuenta, no de forma global, porque los límites de una plataforma no son los de otra.\n\nUsa las reglas para proteger las cuentas escasas: una cuenta de prensa costosa de conseguir puede limitarse a un par de publicaciones diarias mientras las cuentas desechables cargan el volumen.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Removing a connection", es: "Quitar una conexión" },
|
||||
body: {
|
||||
en: "Open the account and press **Remove**, then confirm. Removing a connection stops all publishing and watching for that account, and revokes its credential handle from the vault.\n\nDo this as soon as an account is compromised or seized — a removed connection cannot be used to post in your name. History (posts, receipts, archived copies) is not deleted; it stays in the archive as evidence.",
|
||||
es: "Abre la cuenta y pulsa **Remove**, luego confirma. Quitar una conexión detiene toda publicación y vigilancia de esa cuenta y revoca su credencial en la bóveda.\n\nHazlo apenas una cuenta esté comprometida o intervenida: una conexión quitada no puede usarse para publicar en tu nombre. El historial (publicaciones, acuses, copias archivadas) no se borra; queda en el archivo como evidencia.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "live-control-room",
|
||||
title: { en: "Live control room", es: "Sala de control en vivo" },
|
||||
blurb: {
|
||||
en: "Open the multiview, put a source on preview, TAKE it to program, build scenes, add overlays, and mix audio.",
|
||||
es: "Abre la multivista, pon una fuente en previsualización, haz TAKE a programa, arma escenas, añade sobreimpresos y mezcla el audio.",
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
heading: { en: "What the control room is for", es: "Para qué sirve la sala de control" },
|
||||
body: {
|
||||
en: "The **Control Room** is the web version of a TV gallery: you watch every live source at once in a grid, choose what the audience sees, and dress it with titles and tickers. It exists so a collective with laptops and a phone camera can produce a broadcast that looks deliberate.\n\nEverything runs in the browser — no OBS install, no capture cards. Output goes to a PeerTube channel or any generic RTMP/RTMPS destination.",
|
||||
es: "La **Sala de control** es la versión web de una cabina de TV: ves todas las fuentes en vivo a la vez en una cuadrícula, eliges lo que ve la audiencia y lo vistes con títulos y cintillos. Existe para que un colectivo con portátiles y la cámara del móvil produzca una transmisión con pinta de intencional.\n\nTodo corre en el navegador: sin instalar OBS y sin capturadoras. La salida va a un canal de PeerTube o a cualquier destino genérico RTMP/RTMPS.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Opening it and reading the grid", es: "Abrirla y leer la cuadrícula" },
|
||||
body: {
|
||||
en: "Open **Control Room** from the sidebar. The **multiview layout** selector lets you switch between grid shapes and a columns view, so you can fit four sources on a laptop or a dozen on a big screen. Each tile shows the source name and its state.\n\nTiles are labelled: **PGM** is what is on air right now, **PST** is the preview, and sources that are only samples show **SAMPLE** until the real feed connects. If the production channel is missing, pick it from **Production channel** and press **Reconnect**.",
|
||||
es: "Abre **Control Room** desde la barra lateral. El selector de **multiview layout** permite cambiar entre formas de cuadrícula y una vista de columnas, para meter cuatro fuentes en un portátil o una docena en una pantalla grande. Cada recuadro muestra el nombre de la fuente y su estado.\n\nLos recuadros están rotulados: **PGM** es lo que está al aire ahora, **PST** es la previsualización, y las fuentes que son muestra dicen **SAMPLE** hasta que se conecte la señal real. Si falta el canal de producción, elígelo en **Production channel** y pulsa **Reconnect**.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Preview, program, TAKE and CUT", es: "Previsualización, programa, TAKE y CUT" },
|
||||
body: {
|
||||
en: "The professional habit is: never switch blind. Tap a source to send it to **PST** (preview) and check it — is the frame right, is the audio up? The audience still sees the old source the whole time.\n\nWhen preview looks good, press **TAKE** to move it to program with a transition, or **CUT** for an instant hard change. Use CUT when something must stop now (a dox attempt on a guest), TAKE the rest of the time so the broadcast breathes. **Lock program** freezes the output so not even Auto-Director can change it — the switcher then shows **LOCKED** until you unlock.",
|
||||
es: "El hábito profesional es: nunca cambiar a ciegas. Toca una fuente para mandarla a **PST** (previsualización) y revísala: ¿el encuadre está bien, el audio subió? La audiencia sigue viendo la fuente vieja todo ese tiempo.\n\nCuando la previsualización luzca bien, pulsa **TAKE** para pasarla a programa con transición, o **CUT** para un cambio seco e inmediato. Usa CUT cuando algo deba parar ya (un intento de doxx sobre una persona invitada), y TAKE el resto del tiempo para que la transmisión respire. **Lock program** congela la salida de modo que ni el Autodirector pueda cambiarla: el conmutador muestra **LOCKED** hasta que la liberes.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Scenes and layouts", es: "Escenas y composiciones" },
|
||||
body: {
|
||||
en: "A scene is a saved arrangement: one source full screen, picture-in-picture, split screens, and so on. Build a layout once — speaker big, slide small, camera corner — and reuse it all night instead of dragging tiles by hand.\n\nUse the **PST scene** selector on the switcher bar to choose the next scene, then TAKE it. **Select a scene** lists what exists; scenes are part of the production configuration, so an operator change mid-stream does not lose the rundown.",
|
||||
es: "Una escena es una composición guardada: una fuente a pantalla completa, imagen dentro de imagen, pantallas divididas, etc. Arma la composición una vez — oradora grande, cartel chico, cámara en la esquina — y reusala toda la noche en vez de arrastrar recuadros a mano.\n\nUsa el selector **PST scene** en la barra del conmutador para elegir la próxima escena y luego haz TAKE. **Select a scene** lista las existentes; las escenas son parte de la configuración de producción, así que un cambio de operador en mitad de la transmisión no pierde el guion.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Overlays: lower thirds, chyrons, clocks, logos", es: "Sobreimpresos: tercios inferiores, cintillos, relojes y logotipos" },
|
||||
body: {
|
||||
en: "Overlays are the graphics laid on top of the program: **lower_third** (name and role under a speaker), **chyron** (the scrolling ticker along the bottom), **clock**, and **logo**. Open the **Overlay editor**, pick the type, and fill its fields — **Title** for the name line and **Chyron text** for the ticker.\n\nChoose a variant and colour (dark/red/outline read best on busy footage), then **Render overlay** to see it composited before anyone else does. If the render shows **Render failed**, the text is usually too long for the box — shorten it rather than fighting the layout.",
|
||||
es: "Los sobreimpresos son los gráficos colocados encima del programa: **lower_third** (nombre y rol bajo quien habla), **chyron** (el cintillo que corre abajo), **clock** y **logo**. Abre el **Overlay editor**, elige el tipo y completa sus campos: **Title** para la línea de nombre y **Chyron text** para el cintillo.\n\nElige variante y color (dark/red/outline se leen mejor sobre imágenes movidas) y pulsa **Render overlay** para verlo compuesto antes que nadie. Si el render muestra **Render failed**, casi siempre el texto es demasiado largo para la caja: acórtalo en vez de pelear con la composición.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Audio mixing", es: "Mezcla de audio" },
|
||||
body: {
|
||||
en: "Open the **Audio monitor** panel. Each source has its own fader, so you can keep the translation feed low while the street mic carries. **Monitor select** chooses what you hear in your headphones; **Follow program** automatically listens to whatever is on air, and **Custom monitor mix** lets you build your own.\n\nMix with your ears, not your eyes: set levels before the stream starts, during silence, and write the fader positions down — the next operator will thank you. Remember the mix you monitor and the mix the audience gets are controlled separately on purpose.",
|
||||
es: "Abre el panel **Audio monitor**. Cada fuente tiene su fader, así que puedes dejar baja la traducción mientras manda el micrófono de la calle. **Monitor select** elige lo que escuchas en tus auriculares; **Follow program** escucha automáticamente lo que está al aire, y **Custom monitor mix** te deja armar tu propia mezcla.\n\nMezcla con los oídos, no con los ojos: fija los niveles antes de empezar, durante el silencio, y anota las posiciones de los faders: quien opere después te lo agradecerá. Recuerda que la mezcla que vigilas y la que recibe la audiencia se controlan por separado, a propósito.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Going live", es: "Salir al aire" },
|
||||
body: {
|
||||
en: "Output is defined as destinations: a PeerTube channel or a generic RTMP/RTMPS address, one at a time — a destination is either an address or a vault credential handle, never both at once. When you go live, the credential is redeemed from the vault for the session, so the stream key is never pasted by hand.\n\nThe tally lights on each source confirm what is on program, in green, from the switcher's own state rather than a promise. When the stream ends, stop the destination and the credential handle is released.",
|
||||
es: "La salida se define como destinos: un canal de PeerTube o una dirección RTMP/RTMPS genérica, de a uno — un destino es o una dirección o una credencial de la bóveda, nunca ambas a la vez. Al salir al aire, la credencial se canje de la bóveda para esa sesión, así la clave de transmisión nunca se pega a mano.\n\nLas luces de tally en cada fuente confirman qué está en programa, en verde, según el estado real del conmutador y no una promesa. Al terminar la transmisión, detén el destino y la credencial se libera.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "auto-director",
|
||||
title: { en: "Auto-Director", es: "Autodirector" },
|
||||
blurb: {
|
||||
en: "Teach the switcher which source wins, how long a source must be live before it can be cut, and what happens when everything falls over.",
|
||||
es: "Enseña al conmutador qué fuente gana, cuánto debe estar viva una fuente antes de poder cortarla y qué pasa cuando todo se cae.",
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
heading: { en: "What Auto-Director does", es: "Qué hace el Autodirector" },
|
||||
body: {
|
||||
en: "Auto-Director is the switcher driving itself. When a higher-priority source comes online, it is previewed and taken to program automatically, following the rules your collective wrote. It exists for the nights when one person is handling chat, audio and three cameras at once.\n\nIt is a colleague with perfect attention and no taste: it follows your rules exactly, so write rules that encode your collective's judgement about what matters.",
|
||||
es: "El Autodirector es el conmutador manejándose solo. Cuando aparece en línea una fuente de mayor prioridad, la previsualiza y la pasa a programa automáticamente, siguiendo las reglas que tu colectivo escribió. Existe para las noches en que una sola persona atiende chat, audio y tres cámaras a la vez.\n\nEs un colega con atención perfecta y sin criterio propio: sigue tus reglas al pie de la letra, así que escribe reglas que codifiquen el juicio de tu colectivo sobre qué importa.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Setting priority rules", es: "Definir las reglas de prioridad" },
|
||||
body: {
|
||||
en: "Priorities are an ordered list of sources: first in the list wins when it is live. Put the breaking street feed first, the speaker second, the slide deck third, and so on. A source not in the list is never auto-taken — that is how you keep the ambient camera from stealing program.\n\nRules are part of the production configuration, so they survive page reloads and operator changes. Change them in rehearsal, not during the action; a mid-stream rule change takes effect on the next switch, which surprises everyone but you.",
|
||||
es: "Las prioridades son una lista ordenada de fuentes: la primera de la lista gana cuando está en vivo. Pon el corte de calle primero, a quien oradora segundo, las diapositivas tercero, y así. Una fuente fuera de la lista nunca se toma automáticamente: así evitas que la cámara ambiente robe el programa.\n\nLas reglas son parte de la configuración de producción, así que sobreviven a recargas de página y cambios de operador. Cámbialas en el ensayo, no durante la acción: un cambio de reglas en plena transmisión surte efecto en el próximo cambio y sorprende a todos menos a ti.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Dwell: no strobe broadcasts", es: "Permanencia: nada de transmisiones estroboscópicas" },
|
||||
body: {
|
||||
en: "A minimum dwell is the shortest time a source must stay on program before anything else can replace it. Without it, two flapping feeds would cut back and forth every second and the audience would get seasick.\n\nSet dwell longer than the typical burst: five minutes for a march, shorter for a press conference. The switcher queues the pending switch and takes it when the dwell expires, so nothing is lost — it just waits its turn.",
|
||||
es: "La permanencia mínima es el tiempo más corto que una fuente debe quedarse en programa antes de que otra la reemplace. Sin ella, dos señales intermitentes cortarían de un lado a otro cada segundo y la audiencia marearía.\n\nDefine una permanencia mayor que la ráfaga típica: cinco minutos para una marcha, menos para una rueda de prensa. El conmutador deja el cambio en cola y lo ejecuta al expirar, así no se pierde nada: solo espera su turno.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Manual, auto, and the override window", es: "Manual, auto y la ventana de anulación" },
|
||||
body: {
|
||||
en: "The switcher bar shows the current mode: **MANUAL** means every change is yours; **AUTO** means Auto-Director is driving; **LOCKED** means program is frozen and nothing moves. While in AUTO you can still grab a source manually — that opens an **override** window where your hand beats the rules.\n\nChoose how long the override lasts: **until_released** (until you let go), **five_minutes**, **until_next_event**, or **until_source_offline**. After it expires, Auto-Director resumes command without needing to be re-armed.",
|
||||
es: "La barra del conmutador muestra el modo actual: **MANUAL** significa que cada cambio es tuyo; **AUTO**, que el Autodirector conduce; **LOCKED**, que el programa está congelado y nada se mueve. En AUTO igual puedes tomar una fuente a mano: eso abre una ventana de **override** donde tu mano vence a las reglas.\n\nElige cuánto dura la anulación: **until_released** (hasta que la sueltes), **five_minutes**, **until_next_event** o **until_source_offline**. Al expirar, el Autodirector retoma el mando sin necesidad de reactivarlo.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Fallback behaviour", es: "Comportamiento de respaldo" },
|
||||
body: {
|
||||
en: "When the program source goes offline mid-stream, Auto-Director does not freeze on a dead frame: it falls to the next live source in priority order, and if nothing is live it goes to the designated hold state rather than black.\n\nThis is why the last entry in your priority list should be something that is always available — a logo card or a looped clip. The fallback is the difference between a glitch and a broadcast that looks like it meant to do that.",
|
||||
es: "Cuando la fuente de programa se cae en plena transmisión, el Autodirector no se congela en un cuadro muerto: cae a la siguiente fuente viva según el orden de prioridad, y si no hay nada vivo pasa al estado de espera designado en vez de a negro.\n\nPor eso la última entrada de tu lista debería ser algo siempre disponible: una tarjeta con el logotipo o un clip en bucle. El respaldo es la diferencia entre una falla y una transmisión que parece planeada.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
slug: "chat-bridge",
|
||||
title: { en: "Chat bridge", es: "Puente de chat" },
|
||||
blurb: {
|
||||
en: "Bring Telegram, Twitch and Discord into one overlay, moderate from one place, and let flood governors absorb raids.",
|
||||
es: "Reúne Telegram, Twitch y Discord en una sola superposición, modera desde un lugar y deja que los gobernadores de flujo absorban los raids.",
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
heading: { en: "What bridging means", es: "Qué significa unir los chats" },
|
||||
body: {
|
||||
en: "During a stream your audience is split: some on Telegram, some in Twitch chat, some in Discord. The chat bridge pulls all of them into one **Live chat** overlay, with each message labelled by its platform, so moderators read one conversation instead of three windows.\n\nBridging is read-and-reply, not teleportation: people stay on their platform, and your replies go back out to the right one. Reply targets are explicit, so you never answer a Twitch message into the wrong channel.",
|
||||
es: "Durante una transmisión tu audiencia está repartida: parte en Telegram, parte en el chat de Twitch, parte en Discord. El puente de chat junta todo en una superposición de **Live chat**, con cada mensaje etiquetado por plataforma, así quien modera lee una sola conversación en vez de tres ventanas.\n\nUnir no es teletransportar: la gente se queda en su plataforma y tus respuestas salen a la correcta. Los destinos de respuesta son explícitos, así que nunca contestas un mensaje de Twitch en el canal equivocado.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Opening the overlay", es: "Abrir la superposición" },
|
||||
body: {
|
||||
en: "The **Live chat** panel opens over the streams wall or the control room, wherever the action is. Use **Filter platform** to focus — all, or a single platform — when one room is on fire and the rest can wait.\n\nIf a platform shows as read-only (the **Read-only live chat** marker), your collective has not connected write access for it: you can still see everything, you just cannot post back yet. Ask an Admin to connect the account.",
|
||||
es: "El panel de **Live chat** se abre sobre el muro de streams o la sala de control, donde esté la acción. Usa **Filter platform** para concentrarte — todas, o una sola — cuando una sala se incendia y el resto puede esperar.\n\nSi una plataforma aparece como de solo lectura (la marca **Read-only live chat**), tu colectivo no conectó permiso de escritura: puedes verlo todo, pero todavía no responder. Pide a un Admin que conecte la cuenta.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Replying and crossposting", es: "Responder y republicar en cruz" },
|
||||
body: {
|
||||
en: "Pick the reply target, type in **Write a reply…** and **Send**. A single answer can go to several platforms at once when it makes sense — a correction, for example, belongs everywhere the mistake travelled.\n\n**Crosspost to…** pushes a message you choose to other connected rooms, clearly framed as coming from the collective. Use it for announcements (\"stream moving, new link in bio\"), not for arguing: crossposted arguments read as panic.",
|
||||
es: "Elige el destino de la respuesta, escribe en **Write a reply…** y **Send**. Una sola respuesta puede ir a varias plataformas a la vez cuando tiene sentido: una corrección, por ejemplo, pertenece a todos lados donde viajó el error.\n\n**Crosspost to…** lleva un mensaje que elijas a otras salas conectadas, claramente enmarcado como del colectivo. Úsalo para anuncios (\"la transmisión se muda, enlace nuevo en la bio\"), no para discutir: las discusiones republicadas en cruz se leen como pánico.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Moderation tools", es: "Herramientas de moderación" },
|
||||
body: {
|
||||
en: "Moderators get per-message actions: **Delete** removes a message from the overlay, and a timeout removes its author for ten minutes (**Timeout 10 min**). Actions apply on the originating platform too, so a deleted message is deleted where it was written.\n\nIf a moderation action fails — the bot lacks rights in that room — the panel says **Moderation failed** instead of pretending it worked. Fix by giving the bot the moderation permission it needs on that platform, then retry.",
|
||||
es: "Quien modera tiene acciones por mensaje: **Delete** quita un mensaje de la superposición, y un tiempo de espera expulsa a su autor por diez minutos (**Timeout 10 min**). Las acciones se aplican también en la plataforma de origen, así que un mensaje borrado queda borrado donde fue escrito.\n\nSi una acción de moderación falla — al bot le faltan permisos en esa sala — el panel dice **Moderation failed** en vez de fingir que funcionó. Se arregla dándole al bot el permiso de moderación que necesita en esa plataforma, y reintenta.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Flood governors and raids", es: "Gobernadores de flujo y raids" },
|
||||
body: {
|
||||
en: "A flood governor is a valve on how many messages per second are let through from each room. When a room is raided — hundreds of identical messages at once — the governor drops the excess before it reaches your moderators, and the overlay keeps working instead of melting.\n\nGovernors are per-room, so a flood on Twitch does not silence Telegram. Legitimate heavy traffic still gets through; only the machine-gun rate is cut. If a raid is sustained, the connection's backoff also widens, exactly like on the streams wall.",
|
||||
es: "Un gobernador de flujo es una válvula sobre cuántos mensajes por segundo se dejan pasar de cada sala. Cuando un raid golpea — cientos de mensajes idénticos a la vez — el gobernador descarta el exceso antes de que llegue a quien modera, y la superposición sigue funcionando en vez de derretirse.\n\nLos gobernadores son por sala, así que un flood en Twitch no silencia a Telegram. El tráfico legítimo intenso pasa igual; solo se corta la cadencia de ametralladora. Si el raid se sostiene, la espera de la conexión también se amplía, igual que en el muro de streams.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Moderation culture, briefly", es: "Cultura de moderación, en breve" },
|
||||
body: {
|
||||
en: "Agree your moderation line before the stream, not during: what gets deleted, what gets timed out, what gets answered. Write it down where every moderator can see it — the wiki is a fine place.\n\nDelete doxx and slurs instantly, time out sealions, answer good-faith questions once and in public so a hundred people read the answer. Nobody should be moderating alone: two short shifts beat one heroic one.",
|
||||
es: "Acuerden la línea de moderación antes de la transmisión, no durante: qué se borra, qué recibe tiempo de espera, qué se responde. Escríbanla donde toda la moderación pueda verla; esta wiki sirve.\n\nBorra doxx e insultos al instante, expulsa temporalmente a quien acosa con falsa buena fe, responde las preguntas honestas una vez y en público para que cien personas lean la respuesta. Nadie debería moderar solo: dos turnos cortos valen más que uno heroico.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "polls-and-voting",
|
||||
title: { en: "Polls and voting", es: "Encuestas y votaciones" },
|
||||
blurb: {
|
||||
en: "Run a vote in the bridged chats, configure quorum arms, vote from Telegram or the web, and let the result post itself.",
|
||||
es: "Haz una votación en los chats unidos, configura los brazos del quórum, vota desde Telegram o la web y deja que el resultado se publique solo.",
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
heading: { en: "What polls are for", es: "Para qué sirven las encuestas" },
|
||||
body: {
|
||||
en: "A poll turns a scattered chat into a decision with a record. It is the tool for the questions that used to be settled by whoever shouted loudest: do we publish the statement, which frame leads the video, do we name the company.\n\nEvery poll has a configuration, a live tally both places can see, and a written outcome that lands in the audit log — so the decision can be revisited without relitigating what was decided.",
|
||||
es: "Una encuesta convierte un chat disperso en una decisión con registro. Es la herramienta para las preguntas que antes resolvía quien gritaba más fuerte: publicamos el comunicado, qué plano abre el video, nombramos a la empresa.\n\nCada encuesta tiene una configuración, un recuento en vivo visible en ambos lados y un resultado escrito que cae en el registro de auditoría, así la decisión puede revisarse sin reabrir lo ya decidido.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Creating a poll", es: "Crear una encuesta" },
|
||||
body: {
|
||||
en: "Open the chat overlay and create a poll from the poll action. Write the question in plain language, one readable sentence, and give each option a short label — long options get truncated on Telegram.\n\nSet the **timebox**, the hard end of the vote, so it cannot drag forever, and choose whether the vote is anonymous. Anonymous is right for anything where a public position could expose a member.",
|
||||
es: "Abre la superposición de chat y crea una encuesta desde la acción de encuesta. Escribe la pregunta en lenguaje claro, una frase legible, y da a cada opción una etiqueta corta: las opciones largas se cortan en Telegram.\n\nFija el **timebox**, el fin duro de la votación, para que no se arrastre eternamente, y elige si el voto es anónimo. Anónimo es lo correcto para todo lo que una posición pública pueda exponer a alguien.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Configuring quorum", es: "Configurar el quórum" },
|
||||
body: {
|
||||
en: "Quorum is how you say what a valid decision looks like. There are two kinds of arms: **x of y admins** (a role count) and **x of y participants** (whoever actually voted), and they can be joined with **AND** (both must be satisfied) or **OR** (either is enough).\n\nExample: 1 of 2 admins AND 3 of 5 participants means the vote passes only with an admin's yes and broader support — a guard against a single admin deciding alone, and against a flash mob deciding without any steward. A **veto** arm can also be enabled so one named role can stop a result outright, and **min-dwell** requires a voter to have been present for some time before their vote counts.",
|
||||
es: "El quórum es cómo dices qué aspecto tiene una decisión válida. Hay dos tipos de brazos: **x de y admins** (un conteo por rol) y **x de y participantes** (quienes efectivamente votaron), y se pueden unir con **AND** (ambos deben cumplirse) o **OR** (basta uno).\n\nEjemplo: 1 de 2 admins Y 3 de 5 participantes significa que la votación pasa solo con un sí de un admin y apoyo más amplio: una guarda contra un admin decidiendo solo y contra una turba decidiendo sin ninguna mayordomía. También puede activarse un brazo de **veto** para que un rol nombrado frene el resultado de raíz, y una **permanencia mínima** que exige haber estado presente un tiempo antes de que el voto cuente.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Voting from Telegram or the web", es: "Votar desde Telegram o la web" },
|
||||
body: {
|
||||
en: "The bot posts the poll into each bridged chat with tappable options, and the same poll is on the web with buttons. Vote wherever you are: a tap on the phone in the street counts exactly the same as a click on a laptop.\n\nThe tally is shared — the bot's message updates as votes arrive, and the web shows the same numbers. Because it is one vote, not a recount, nobody can vote twice on two platforms.",
|
||||
es: "El bot publica la encuesta en cada chat unido con opciones tocables, y la misma encuesta está en la web con botones. Vota donde estés: un toque en el móvil en la calle cuenta exactamente lo mismo que un clic en un portátil.\n\nEl recuento es compartido: el mensaje del bot se actualiza conforme llegan votos y la web muestra los mismos números. Como es una sola votación y no un recuento doble, nadie puede votar dos veces en dos plataformas.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "When quorum passes", es: "Cuando se alcanza el quórum" },
|
||||
body: {
|
||||
en: "The moment quorum is met — inside the timebox — the poll closes and the outcome is recorded with the exact tally. If the poll is bound to a draft, the result flows straight into the approval pipeline: an approved poll can authorise the pending post, which then schedules and dispatches on its own.\n\nIf quorum is not met by the end of the timebox, the poll is recorded as not passed. That is a valid outcome, not a failure: it means the group did not decide, and the draft stays where it was.",
|
||||
es: "En el momento en que se alcanza el quórum — dentro del tiempo límite — la encuesta se cierra y el resultado queda registrado con el recuento exacto. Si la encuesta está ligada a un borrador, el resultado entra directo al circuito de aprobación: una encuesta aprobada puede autorizar la publicación pendiente, que luego se programa y despacha sola.\n\nSi el quórum no se alcanza al terminar el tiempo, la encuesta queda registrada como no aprobada. Es un resultado válido, no un fracaso: significa que el grupo no decidió, y el borrador se queda donde estaba.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Reading the blocked queue", es: "Leer la cola de bloqueados" },
|
||||
body: {
|
||||
en: "Polls and approvals share the blocked queue: anything stopped shows there with the gap to quorum spelled out — which arm is missing, how many votes short, whose veto landed. That turns \"it didn't pass\" into actionable information.\n\nReopen by addressing the named gap, not by rerunning the vote louder. A poll re-run without changes is how collectives burn out their own voters.",
|
||||
es: "Las encuestas y las aprobaciones comparten la cola de bloqueados: todo lo frenado aparece ahí con la brecha hasta el quórum explicada — qué brazo falta, cuántos votos faltan, de quién fue el veto. Eso convierte \"no pasó\" en información accionable.\n\nReabre atendiendo la brecha nombrada, no repitiendo la votación más fuerte. Repetir una encuesta sin cambios es como los colectivos agotan a su propia gente votante.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "archive",
|
||||
title: { en: "Archive", es: "Archivo" },
|
||||
blurb: {
|
||||
en: "How content-addressed encrypted storage works, how to search it, how to export, and why deleted posts survive here.",
|
||||
es: "Cómo funciona el almacenamiento cifrado por contenido, cómo buscarlo, cómo exportarlo y por qué lo borrado sobrevive aquí.",
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
heading: { en: "What the archive is", es: "Qué es el archivo" },
|
||||
body: {
|
||||
en: "The **Archive** keeps a copy of everything that flowed through the workspace: posts published, posts received from followed sources, their media, and their receipts. Storage is content-addressed and encrypted on S3-compatible storage your collective controls.\n\n\"Content-addressed\" means each item is filed under a fingerprint of its own content rather than a chosen name. Two consequences matter to you: nothing can be silently swapped (a different file has a different address), and identical items are stored once no matter how many times they arrive.",
|
||||
es: "El **Archivo** guarda una copia de todo lo que pasó por el espacio de trabajo: lo publicado, lo recibido de las fuentes seguidas, sus medios y sus acuses. El almacenamiento es por contenido, cifrado y en un S3 compatible que tu colectivo controla.\n\n\"Por contenido\" significa que cada ítem se archiva bajo la huella de su propio contenido y no bajo un nombre elegido. Dos consecuencias te importan: nada puede cambiarse en silencio (un archivo distinto tiene dirección distinta) y los ítems idénticos se guardan una sola vez, lleguen cuantas veces lleguen.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Why deleted platform content survives here", es: "Por qué lo borrado en las plataformas sobrevive aquí" },
|
||||
body: {
|
||||
en: "When a platform deletes a post — by moderation, by government request, or by \"accident\" — it deletes its copy. Your archive's copy was taken at publish time, addressed by its own fingerprint, and encrypted on your storage. The platform has no lever to reach it.\n\nThis is the archive's political job: the record of what your collective said and what was done to it cannot be edited by the party being documented. Keep the archive storage in hands your assembly trusts; that is now the primary copy of your history.",
|
||||
es: "Cuando una plataforma borra una publicación — por moderación, por pedido gubernamental o por \"accidente\" — borra su copia. La copia de tu archivo se tomó al publicar, quedó identificada por su propia huella y cifrada en tu almacenamiento. La plataforma no tiene palanca para alcanzarla.\n\nEsa es la tarea política del archivo: el registro de lo que dijo tu colectivo y de lo que se le hizo no puede editarlo la parte documentada. Mantén el almacenamiento del archivo en manos que tu asamblea confíe; esa es ahora la copia primaria de tu historia.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Browsing and searching", es: "Navegar y buscar" },
|
||||
body: {
|
||||
en: "Open **Archive** to browse items newest first, or use **Search**, which runs full-text search across the archive: every word of every stored post is indexed. Search results carry their attribution — channel, author signature, forward origin — so you can tell who said it before you amplify it.\n\nSearch also reaches the same index from the streams wall, so a phrase you half-remember from a march can be found months later. Saved searches can be pinned as columns to watch a topic continuously.",
|
||||
es: "Abre **Archive** para navegar los ítems más nuevos primero, o usa **Search**, que hace búsqueda de texto completo sobre el archivo: cada palabra de cada publicación guardada está indexada. Los resultados traen su atribución — canal, firma de autor, origen del reenvío — así sabes quién lo dijo antes de amplificarlo.\n\nLa búsqueda también llega al mismo índice desde el muro de streams, así que una frase que recuerdas a medias de una marcha aparece meses después. Las búsquedas guardadas pueden fijarse como columnas para vigilar un tema de continuo.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Turning an archived item back into a post", es: "Convertir un ítem archivado en publicación" },
|
||||
body: {
|
||||
en: "Archived items are not read-only tombstones. From an item you can create a draft — the text and media come along, and attribution can be kept or dropped with the toggle. The draft then enters the normal approval pipeline like any other.\n\nThis is the repost workflow that respects the process: nothing leaves the archive straight to the public; it passes the same review as fresh writing. Keep attribution on unless the source asked otherwise — naming sources is how networks stay trustworthy.",
|
||||
es: "Los ítems archivados no son lápidas de solo lectura. Desde un ítem puedes crear un borrador: el texto y los medios vienen con él, y la atribución puede conservarse o quitarse con el interruptor. El borrador entra luego al circuito de aprobación normal, como cualquier otro.\n\nEste es el flujo de republicación que respeta el proceso: nada sale del archivo directo al público; pasa la misma revisión que lo recién escrito. Conserva la atribución salvo que la fuente pidiera lo contrario: nombrar las fuentes es como las redes se mantienen confiables.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Exporting", es: "Exportar" },
|
||||
body: {
|
||||
en: "The archive exports to **CSV** (open in any spreadsheet) and **JSON** (for tools and scripts). Export is how the record leaves the building: hand a copy to lawyers, journalists, or another collective that will carry it if your server is seized.\n\nDo exports on a schedule, not on the worst day. A monthly JSON export stored somewhere separate from the server is cheap insurance; the audit log's own export (see that manual) documents who exported what.",
|
||||
es: "El archivo exporta a **CSV** (se abre en cualquier hoja de cálculo) y **JSON** (para herramientas y scripts). Exportar es cómo el registro sale del edificio: entrega una copia a abogacía, prensa u otro colectivo que la cargue si tu servidor es intervenido.\n\nHaz exportaciones con calendario, no el peor día. Un JSON mensual guardado en un lugar separado del servidor es un seguro barato; la exportación del propio registro de auditoría (mira ese manual) documenta quién exportó qué.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "video-transcoding",
|
||||
title: { en: "Video upload and transcoding", es: "Subida y transcodificación de video" },
|
||||
blurb: {
|
||||
en: "Upload footage, watch it move through the transcode queue, and understand the jailed workers that do the heavy lifting.",
|
||||
es: "Sube material, mira cómo avanza por la cola de transcodificación y entiende a los trabajadores enjaulados que hacen el esfuerzo.",
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
heading: { en: "Why transcoding exists", es: "Por qué existe la transcodificación" },
|
||||
body: {
|
||||
en: "A phone records huge files that most platforms and most viewers' connections cannot handle. Transcoding converts your upload into the sizes and formats needed for web playback, thumbnails and live use — without you owning editing software.\n\nYou upload once; the system produces the variants. Upload the highest quality your camera gives, because every later copy is made from that master.",
|
||||
es: "Un móvil graba archivos enormes que ni las plataformas ni las conexiones de la mayoría pueden manejar. La transcodificación convierte lo que subes en los tamaños y formatos necesarios para reproducir en la web, miniaturas y uso en vivo, sin que tengas software de edición.\n\nTú subes una vez; el sistema produce las variantes. Sube con la máxima calidad que dé tu cámara, porque cada copia posterior se hace desde ese máster.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Uploading", es: "Subir" },
|
||||
body: {
|
||||
en: "Open **Video** and drop your file on **Drop a video here**, or tap to choose it from the device. Large files upload in the background — you can leave the page, the upload continues and the library shows its progress.\n\nName uploads something findable (date-place-what), because in three months \"IMG_4472.mp4\" is a rumour. Once the upload lands, the item appears in the video library with a status chip showing where it is in the pipeline.",
|
||||
es: "Abre **Video** y suelta tu archivo en **Drop a video here**, o tócalo para elegirlo desde el dispositivo. Los archivos grandes se suben en segundo plano: puedes salir de la página, la subida sigue y la biblioteca muestra su progreso.\n\nNombra las subidas de forma localizable (fecha-lugar-qué), porque en tres meses \"IMG_4472.mp4\" es un rumor. Al llegar la subida, el ítem aparece en la biblioteca de video con un chip de estado que indica dónde está en el circuito.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "The pipeline and its states", es: "El circuito y sus estados" },
|
||||
body: {
|
||||
en: "An upload moves through states: uploaded, probing, queued, transcoding, then ready. **Probe metadata** in the detail drawer shows what was found in the file — duration, resolution, codecs — which is how you check a suspicious clip before using it.\n\nIf a job fails, the item says so with the reason; fix what it names (usually a corrupt file or an unsupported codec) and re-upload. Failed jobs never block the rest of the queue.",
|
||||
es: "Una subida pasa por estados: subida, sondeo, en cola, transcodificando y lista. **Probe metadata** en el cajón de detalles muestra qué se encontró en el archivo — duración, resolución, códecs — y así revisas un clip sospechoso antes de usarlo.\n\nSi un trabajo falla, el ítem lo dice con el motivo; corrige lo que señale (casi siempre un archivo corrupto o un códec no soportado) y vuelve a subirlo. Los trabajos fallidos nunca bloquean el resto de la cola.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Distributed workers", es: "Trabajadores distribuidos" },
|
||||
body: {
|
||||
en: "Transcoding runs on worker machines — literally the GPUs and laptops of collective members — linked over Tailscale, a private network that makes them reachable without exposing anything to the public internet. Jobs are handed to whichever worker is free, and a capacity queue holds the excess until a worker can take it.\n\nSo a long queue does not mean broken; it means busy. If your collective needs faster turns, the fix is another machine running the worker, not faster clicking.",
|
||||
es: "La transcodificación corre en máquinas trabajadoras — literalmente las GPU y portátiles de gente del colectivo — enlazadas por Tailscale, una red privada que las hace alcanzables sin exponer nada al internet público. Los trabajos se entregan a la máquina libre, y una cola de capacidad retiene el exceso hasta que alguna pueda tomarlo.\n\nUna cola larga no significa roto: significa ocupado. Si tu colectivo necesita respuestas más rápidas, la solución es otra máquina corriendo el trabajador, no clickear más rápido.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Hardware encoding", es: "Codificación por hardware" },
|
||||
body: {
|
||||
en: "Workers use NVENC, the encoder chip built into NVIDIA cards, when one is present. Hardware encoding turns minutes of CPU grinding into seconds — a graphics card from the RTX 3090 class has been proven end to end on this pipeline.\n\nThis is why the collective's gamer machine is genuinely useful infrastructure: plugged in as a worker, it clears the video queue while everyone sleeps. Software encoding still happens on machines without a card, just slower.",
|
||||
es: "Las máquinas usan NVENC, el chip codificador integrado en las tarjetas NVIDIA, cuando hay una presente. La codificación por hardware convierte minutos de moliente CPU en segundos; una tarjeta de la clase de la RTX 3090 está probada de punta a punta en este circuito.\n\nPor eso la máquina gamer del colectivo es infraestructura de verdad: enchufada como trabajadora, despeja la cola de video mientras todos duermen. En máquinas sin tarjeta se codifica por software igual, solo más lento.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "The sandbox: why untrusted video is safe", es: "El sandbox: por qué el video no confiable es seguro" },
|
||||
body: {
|
||||
en: "Video files are untrusted input: malformed files are a classic attack vector. Every FFmpeg job therefore runs in a sandbox — its own user ID, a seccomp filter (a list of system calls it may make), and no network access at all. Each job gets a fresh jail, and resource caps stop one job from starving the machine.\n\nFor you this means: upload footage from anyone, including hostile strangers, without the upload being able to touch the worker machine. Security here is structural, not a matter of trusting people.",
|
||||
es: "Los archivos de video son material no confiable: los archivos malformados son un vector de ataque clásico. Por eso cada trabajo de FFmpeg corre en un sandbox: su propio UID, un filtro seccomp (una lista de llamadas al sistema permitidas) y ninguna clase de acceso a red. Cada trabajo recibe una jaula nueva, y los topes de recursos impiden que un trabajo hambre la máquina.\n\nPara ti esto significa: sube material de cualquiera, incluidos desconocidos hostiles, sin que la subida pueda tocar la máquina trabajadora. La seguridad aquí es estructural, no cuestión de confiar en personas.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
slug: "analytics",
|
||||
title: { en: "Analytics", es: "Analítica" },
|
||||
blurb: {
|
||||
en: "Read the monthly snapshots, understand what the deltas mean, and break numbers down per provider.",
|
||||
es: "Lee las instantáneas mensuales, entiende qué significan las diferencias y desglosa los números por proveedor.",
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
heading: { en: "What analytics counts — and refuses to count", es: "Qué cuenta la analítica — y qué se niega a contar" },
|
||||
body: {
|
||||
en: "The **Analytics** page shows what your collective did: drafts written, publications sent, attempts per platform, failures, channels, archived items. It counts your outgoing work and the health of your accounts.\n\nWhat it does not do is track your audience like a corporation would. There is no profiling of individuals, no follower demographics, no \"engagement\" panopticon. Reach numbers belong to the platforms; your numbers describe your own machinery.",
|
||||
es: "La página de **Analytics** muestra lo que hizo tu colectivo: borradores escritos, publicaciones enviadas, intentos por plataforma, fallos, canales, ítems archivados. Cuenta tu trabajo de salida y la salud de tus cuentas.\n\nLo que no hace es rastrear a tu audiencia como lo haría una corporación. No hay perfilado de personas, ni demografía de seguidores, ni panóptico de \"interacción\". Los números de alcance son de las plataformas; los tuyos describen tu propia maquinaria.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Monthly snapshots", es: "Instantáneas mensuales" },
|
||||
body: {
|
||||
en: "Numbers are stored as **Month** snapshots: one frozen picture per month, so a month never changes after it closes. The page shows the current month and the previous month side by side under **Snapshot history**.\n\nBecause each snapshot is fixed, the history is trustworthy — you cannot accidentally rewrite last month by running a report again. New activity lands in the current month's snapshot only.",
|
||||
es: "Los números se guardan como instantáneas por **Month**: una foto congelada por mes, así un mes cerrado nunca cambia. La página muestra el mes en curso y el anterior lado a lado, bajo **Snapshot history**.\n\nComo cada instantánea queda fija, el historial es confiable: no puedes reescribir el mes pasado por accidente volviendo a correr un informe. La actividad nueva cae solo en la instantánea del mes en curso.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Reading the deltas", es: "Leer las diferencias" },
|
||||
body: {
|
||||
en: "A delta is the difference between this month and the one before, shown next to each figure. A delta answers \"did we do more or less than last month\" — publications up, failures down is the healthy shape.\n\nRead deltas with the calendar, not alone: a month with a big mobilisation should show more of everything; a quiet month does not mean the collective is dying. A rising **Failures** line while **Attempts** stays flat is the one pattern that deserves immediate attention — that is accounts degrading, not effort dropping.",
|
||||
es: "La diferencia (delta) es el cambio entre este mes y el anterior, mostrada junto a cada cifra. Responde a \"hicimos más o menos que el mes pasado\": publicaciones arriba y fallos abajo es la forma sana.\n\nLee las diferencias junto al calendario, no solas: un mes con movilización grande debería mostrar más de todo; un mes tranquilo no significa que el colectivo se muere. Una línea de **Failures** que sube mientras **Attempts** se mantiene plana es el único patrón que merece atención inmediata: eso son cuentas degradándose, no esfuerzo cayendo.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Per-provider breakdown", es: "Desglose por proveedor" },
|
||||
body: {
|
||||
en: "The per-provider breakdown splits the same figures by platform: **Attempts** and **Failures** per provider, plus **Channels** and **Archived** counts. This is where you see which platform is quietly eating your posts.\n\nUse it for account strategy: a provider with persistent failures gets the fan-out reduced before it burns the account entirely, and the healthiest providers take the volume. Combined with the Connections page you can match failures to specific accounts and reconnect only what is actually broken.",
|
||||
es: "El desglose por proveedor separa las mismas cifras por plataforma: **Attempts** y **Failures** por proveedor, más conteos de **Channels** y **Archived**. Aquí ves qué plataforma se está comiendo tus publicaciones en silencio.\n\nÚsalo para la estrategia de cuentas: a un proveedor con fallos persistentes se le reduce la difusión antes de que queme la cuenta del todo, y los más sanos cargan el volumen. Junto con la página de Connections puedes emparejar fallos con cuentas concretas y reconectar solo lo que de verdad está roto.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "team-management",
|
||||
title: { en: "Team management", es: "Gestión del equipo" },
|
||||
blurb: {
|
||||
en: "Invite members, understand each role, control invite links, and remove access when someone leaves.",
|
||||
es: "Invita miembros, entiende cada rol, controla los enlaces de invitación y quita el acceso cuando alguien se va.",
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
heading: { en: "Where team lives", es: "Dónde está el equipo" },
|
||||
body: {
|
||||
en: "Open **Team** (Admins and Owners only). The page lists every member with their **Role** and **Status**, plus the active **Invite links** and their counters.\n\nOnly roles you are allowed to grant appear as choices; an Admin who cannot mint Owners simply will not see that option, which prevents accidental privilege escalation better than a warning dialog.",
|
||||
es: "Abre **Team** (solo Admin y Owner). La página lista a cada miembro con su **Role** y **Status**, además de los **Invite links** activos y sus contadores.\n\nSolo aparecen como opciones los roles que puedes conceder; un Admin que no puede crear Owners simplemente no ve esa opción, lo que evita escaladas de privilegios accidentales mejor que un aviso emergente.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Inviting a member by email", es: "Invitar a una persona por correo" },
|
||||
body: {
|
||||
en: "Enter their address (teammate@studio.io), pick a role, and press **Send invite**. They receive a link that creates their account inside your workspace with the role you chose.\n\nBefore you send, ask yourself whether this person needs write access or just needs to see. Viewer is the safest role that still lets someone follow the work; you can always raise it later, and raising is easier than recovering from a leaked Editor password.",
|
||||
es: "Escribe su correo (compañera@studio.io), elige un rol y pulsa **Send invite**. Recibirán un enlace que crea su cuenta dentro de tu espacio de trabajo con el rol que elegiste.\n\nAntes de enviar, pregúntate si esa persona necesita escribir o solo ver. Viewer es el rol más seguro que aún permite seguir el trabajo; siempre puedes subirlo después, y subir es más fácil que recuperarse de una contraseña de Editor filtrada.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Invite links, and why they expire", es: "Enlaces de invitación y por qué caducan" },
|
||||
body: {
|
||||
en: "Press **Create link** to mint a shareable invite instead of an email: set **Max uses (optional)** and an expiry under **Expires**, then hand the link over Signal or in person. The **Uses** counter shows how many people have joined through it.\n\nLinks exist because actions happen fast, but every live link is a door. **Revoke** any link the moment the people you expected have joined, and let the rest expire on their own — a link nobody can revoke is a liability, not a convenience.",
|
||||
es: "Pulsa **Create link** para crear una invitación compartible en vez de un correo: fija **Max uses (opcional)** y una caducidad en **Expires**, y pasa el enlace por Signal o en persona. El contador de **Uses** muestra cuánta gente entró por él.\n\nLos enlaces existen porque las acciones son rápidas, pero cada enlace vivo es una puerta. **Revoke** cualquier enlace apenas entre la gente que esperabas, y deja que el resto caduque solo: un enlace que nadie puede revocar es un pasivo, no una comodidad.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Choosing the right role", es: "Elegir el rol correcto" },
|
||||
body: {
|
||||
en: "Grant the smallest role that lets the person do their job:\n\n- **Viewer** — follows streams, calendar, archive; changes nothing. Right for most of the collective.\n- **Moderator** — runs the bridged chats during streams.\n- **Editor** — writes drafts and requests approval.\n- **Approver** — votes and blocks in review.\n- **Publisher** — dispatches approved posts and owns the schedule.\n- **BroadcastOperator** — runs the control room and destinations.\n- **Admin** — members, connections, settings.\n- **Owner** — everything, including transfer and deletion. Keep Owners to the people the assembly would trust with the keys to the space.\n\nThe strongest protection is structural: whoever writes should not be the sole approver, and whoever dispatches should not be the only auditor.",
|
||||
es: "Concede el rol más pequeño que le permita hacer su tarea:\n\n- **Viewer** — sigue streams, calendario y archivo; no cambia nada. Para la mayoría del colectivo.\n- **Moderator** — lleva los chats unidos durante las transmisiones.\n- **Editor** — escribe borradores y pide aprobación.\n- **Approver** — vota y bloquea en revisión.\n- **Publisher** — despacha lo aprobado y cuida la programación.\n- **BroadcastOperator** — maneja la sala de control y los destinos.\n- **Admin** — miembros, conexiones, ajustes.\n- **Owner** — todo, incluida la transferencia y el borrado. Reserva Owner para quien la asamblea dejaría con las llaves del local.\n\nLa protección más fuerte es estructural: quien escribe no debería ser el único que aprueba, y quien despacha no debería ser el único que audita.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Changing or removing access", es: "Cambiar o quitar el acceso" },
|
||||
body: {
|
||||
en: "Open the member row to change their role, or press remove and confirm with **Confirm remove**. Removal takes effect immediately: their session stops working and their invite is spent. Every one of these actions is written to the audit log with your name on it.\n\nRemove access the day someone leaves the collective, not the week after. Parting on good terms is compatible with locking the door — most leaks are old accounts nobody bothered to close.",
|
||||
es: "Abre la fila de la persona para cambiar su rol, o pulsa quitar y confirma con **Confirm remove**. El retiro surte efecto de inmediato: su sesión deja de funcionar y su invitación queda gastada. Cada una de estas acciones queda escrita en el registro de auditoría con tu nombre.\n\nQuita el acceso el día que alguien se va del colectivo, no la semana siguiente. Despedirse bien es compatible con cerrar la puerta: la mayoría de las filtraciones son cuentas viejas que nadie se tomó el trabajo de cerrar.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "audit-log",
|
||||
title: { en: "Audit log", es: "Registro de auditoría" },
|
||||
blurb: {
|
||||
en: "What gets recorded, how the hash chain makes the log tamper-evident, and how to export it for safekeeping.",
|
||||
es: "Qué se registra, cómo la cadena de hashes hace evidente cualquier manipulación y cómo exportarlo para resguardarlo.",
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
heading: { en: "What the audit log is", es: "Qué es el registro de auditoría" },
|
||||
body: {
|
||||
en: "Open **Audit** to see the **Audit trail**: one line per meaningful action, with **When** it happened, **Actor** (who did it), **Action** (what) and **Target** (to what). Publishes, approvals, blocks, role changes, connections, exports, removals — all of it.\n\nThe log answers the questions that come up when things go wrong: who approved that post, who removed that account, who exported the archive last month. It is the collective's memory of its own decisions.",
|
||||
es: "Abre **Audit** para ver el **Audit trail**: una línea por acción relevante, con **When** (cuándo), **Actor** (quién), **Action** (qué) y **Target** (sobre qué). Publicaciones, aprobaciones, bloqueos, cambios de rol, conexiones, exportaciones, retiros: todo.\n\nEl registro responde las preguntas que aparecen cuando algo sale mal: quién aprobó esa publicación, quién quitó esa cuenta, quién exportó el archivo el mes pasado. Es la memoria del colectivo sobre sus propias decisiones.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Filtering the trail", es: "Filtrar el rastro" },
|
||||
body: {
|
||||
en: "Use the filters at the top: **All actions** / **All actors** narrow the list to one kind of event or one person. Filtering by actor plus action is how you reconstruct a timeline — everything a given person did around a specific incident.\n\nThe trail is long by design; the filters are how you read it. Do not scroll a year of entries when \"approvals by this member, this week\" is two clicks away.",
|
||||
es: "Usa los filtros de arriba: **All actions** / **All actors** acotan la lista a un tipo de evento o a una persona. Filtrar por actor más acción es como reconstruyes una línea de tiempo: todo lo que hizo una persona alrededor de un incidente concreto.\n\nEl rastro es largo a propósito; los filtros son cómo se lee. No desplaces un año de entradas cuando \"aprobaciones de esta persona, esta semana\" está a dos clics.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "The hash chain: tampering that shows", es: "La cadena de hashes: manipular se nota" },
|
||||
body: {
|
||||
en: "Every entry carries a cryptographic hash computed from its own content plus the hash of the entry before it — a chain. Change any entry after the fact and its hash no longer matches the next entry's link, so the break is visible.\n\nIn practice: nobody, including an Owner, can quietly rewrite who approved what. The log can be lost if storage is destroyed, but it cannot be edited in place without the damage being detectable. That asymmetry is what makes the log evidence rather than a diary.",
|
||||
es: "Cada entrada lleva un hash criptográfico calculado a partir de su propio contenido más el hash de la entrada anterior: una cadena. Cambia una entrada después de hecho y su hash deja de coincidir con el enlace de la siguiente, así que la rotura se ve.\n\nEn la práctica: nadie, incluido un Owner, puede reescribir en silencio quién aprobó qué. El registro puede perderse si se destruye el almacenamiento, pero no puede editarse sin que el daño sea detectable. Esa asimetría es lo que convierte el registro en evidencia y no en diario.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Exporting for safekeeping", es: "Exportar para resguardar" },
|
||||
body: {
|
||||
en: "Export the trail to CSV from the audit page, and store the copy somewhere other than the server. A log that lives only on the machine that could be seized protects nobody.\n\nA healthy rhythm: export after any contested decision, and at least monthly. The export action is itself audited, so the record shows that a copy left — which is exactly what you want a court or a journalists' collective to be able to see.",
|
||||
es: "Exporta el rastro a CSV desde la página de auditoría y guarda la copia en un lugar distinto del servidor. Un registro que vive solo en la máquina que pueden intervenir no protege a nadie.\n\nUn ritmo sano: exporta tras cada decisión discutida y al menos una vez al mes. La exportación está ella misma auditada, así que el registro muestra que salió una copia: exactamente lo que quieres que un tribunal o un colectivo de prensa pueda ver.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "settings-security",
|
||||
title: { en: "Settings and security", es: "Ajustes y seguridad" },
|
||||
blurb: {
|
||||
en: "What the Settings page tells you, how sessions work, and how the encrypted vault protects every credential.",
|
||||
es: "Qué te dice la página de Ajustes, cómo funcionan las sesiones y cómo la bóveda cifrada protege cada credencial.",
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
heading: { en: "What Settings shows", es: "Qué muestra Ajustes" },
|
||||
body: {
|
||||
en: "Open **Settings** to see exactly what the server knows about your current session: username, workspace, role, permissions, and the session state. There are no fake toggles on this page — if the API has no preference for it, the app does not pretend to save one.\n\nWorkspace-level configuration (members, connections, destinations) lives in **Team** and **Connections**, not here. Settings is about you and your session, nothing else.",
|
||||
es: "Abre **Settings** para ver exactamente lo que el servidor sabe de tu sesión actual: usuario, espacio de trabajo, rol, permisos y el estado de la sesión. No hay interruptores de mentira en esta página: si la API no tiene esa preferencia, la app no finge guardarla.\n\nLa configuración a nivel del espacio (miembros, conexiones, destinos) está en **Team** y **Connections**, no aquí. Ajustes trata de ti y de tu sesión, nada más.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Signing out and shared devices", es: "Cerrar sesión y dispositivos compartidos" },
|
||||
body: {
|
||||
en: "**Sign out** (from Settings or from **More**) ends the session on this device and clears the stored token. On a shared machine — a social centre laptop, a borrowed phone — sign out every single time; the two seconds are the whole defence.\n\nIf you suspect a session is still live somewhere you cannot reach, tell an Admin: access is removed on the server side by removing or re-roling the member, which kills their sessions too.",
|
||||
es: "**Sign out** (desde Settings o desde **More**) termina la sesión en este dispositivo y borra el token guardado. En una máquina compartida — un portátil del centro social, un móvil prestado — cierra sesión cada vez; esos dos segundos son toda la defensa.\n\nSi sospechas que una sesión sigue viva donde no puedes llegar, avisa a un Admin: el acceso se quita del lado del servidor retirando o cambiando el rol de la persona, lo que también mata sus sesiones.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "The encrypted vault", es: "La bóveda cifrada" },
|
||||
body: {
|
||||
en: "Every credential the workspace holds — platform tokens, bot tokens, stream keys — lives in an **encrypted vault**. The app stores only a handle: an opaque reference that lets the system use the credential without ever exposing the secret to a browser.\n\nYou will see this in three places: connecting an account, going live (the RTMP key is redeemed from the vault at the moment of the stream, never typed), and connections being removed (the handle is revoked). Nobody can read your Twitch key out of the UI, because the UI never receives it.",
|
||||
es: "Cada credencial que guarda el espacio de trabajo — tokens de plataformas, tokens de bots, claves de transmisión — vive en una **bóveda cifrada**. La app guarda solo un identificador: una referencia opaca que permite al sistema usar la credencial sin exponer jamás el secreto a un navegador.\n\nLo verás en tres momentos: al conectar una cuenta, al salir al aire (la clave RTMP se canjea de la bóveda en el momento de la transmisión, nunca se teclea) y al quitar conexiones (el identificador se revoca). Nadie puede leer tu clave de Twitch desde la interfaz, porque la interfaz nunca la recibe.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Practical security habits for the collective", es: "Hábitos prácticos de seguridad para el colectivo" },
|
||||
body: {
|
||||
en: "Security here is mostly habits, not settings:\n\n- Long passwords, one per person; never share accounts — roles exist so you do not have to.\n- Invite links expire; revoke them once the expected people are in.\n- Remove access the day someone leaves.\n- Keep archive and audit exports somewhere other than the server.\n- Assume any platform account can be lost; the archive and the audit log are what survive.\n\nThe threat model is not exotic hackers: it is a seized laptop, a flipped member, a platform ban. All three are survivable if the credentials were compartmentalised and the records were exported.",
|
||||
es: "La seguridad aquí es sobre todo hábitos, no ajustes:\n\n- Contraseñas largas, una por persona; nunca compartan cuentas: los roles existen para que no haga falta.\n- Los enlaces de invitación caducan; revócalos apenas entre la gente esperada.\n- Quita el acceso el día que alguien se va.\n- Guarda las exportaciones de archivo y auditoría en un lugar distinto del servidor.\n- Asume que cualquier cuenta de plataforma puede perderse; el archivo y el registro de auditoría son lo que sobrevive.\n\nEl modelo de amenaza no son hackers exóticos: es un portátil intervenido, un miembro que cambia de bando, un ban de plataforma. Los tres son sobrevivibles si las credenciales estaban compartimentadas y los registros exportados.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "intake-firehose",
|
||||
title: { en: "Intake firehose", es: "Toma de fuentes" },
|
||||
blurb: {
|
||||
en: "Subscribe the workspace to RSS, Telegram, Mastodon and Bluesky sources; watch them flow into columns and the archive.",
|
||||
es: "Suscribe el espacio de trabajo a fuentes RSS, Telegram, Mastodon y Bluesky; mira cómo fluyen a las columnas y al archivo.",
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
heading: { en: "What intake is", es: "Qué es la toma de fuentes" },
|
||||
body: {
|
||||
en: "Intake is the workspace's subscription system: RSS or Atom feeds, Telegram channels, Mastodon accounts and Bluesky feeds you follow, so their new content flows in continuously. Subscribed content lands in two places: the streams wall as columns to read, and the archive as searchable, kept copies.\n\nThis is how a small collective runs a newsroom: you do not need people watching twenty accounts, you need the machine watching them and people reading what matters.",
|
||||
es: "La toma es el sistema de suscripciones del espacio de trabajo: fuentes RSS o Atom, canales de Telegram, cuentas de Mastodon y feeds de Bluesky que sigues, para que su contenido nuevo entre de continuo. Lo suscrito llega a dos lugares: el muro de streams como columnas para leer, y el archivo como copias guardadas y buscables.\n\nAsí un colectivo pequeño maneja una redacción: no necesitas gente vigilando veinte cuentas, necesitas la máquina vigilándolas y gente leyendo lo que importa.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Subscribing to a source", es: "Suscribir una fuente" },
|
||||
body: {
|
||||
en: "Subscriptions are managed as connections: open **Connections**, press **Connect account**, choose the provider and give it what it needs — the feed URL for RSS, the @channel or numeric chat ID for Telegram, the account for Mastodon or Bluesky. Confirm with **Connect**.\n\nOnce connected, the source is available everywhere a feed can be picked: add it as a column on **Streams** with **Columns**, filter the wall to it, or search its accumulated content from **Search**. There is no separate \"intake\" page to remember — subscriptions are just accounts the workspace follows.",
|
||||
es: "Las suscripciones se gestionan como conexiones: abre **Connections**, pulsa **Connect account**, elige el proveedor y dale lo que pida — la URL del feed para RSS, el @canal o ID numérico de chat para Telegram, la cuenta para Mastodon o Bluesky. Confirma con **Connect**.\n\nUna vez conectada, la fuente queda disponible dondequiera que se pueda elegir un feed: añádela como columna en **Streams** con **Columns**, filtra el muro a ella, o busca su contenido acumulado desde **Search**. No hay una página aparte de \"toma\" que recordar: las suscripciones son simplemente cuentas que el espacio de trabajo sigue.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "From intake to column", es: "De la toma a la columna" },
|
||||
body: {
|
||||
en: "Each subscribed source can be added to the wall as its own column, and **All feeds** merges everything into one firehose view when a night is moving fast. Saved searches turn a phrase (a neighbourhood, a company, a hashtag) into a standing column across all subscribed sources.\n\nRemember the cooldown: each source is fetched on its own pace, so a column shows content as it is fetched, not literally live. That pace is what keeps the accounts healthy.",
|
||||
es: "Cada fuente suscrita puede añadirse al muro como su propia columna, y **All feeds** funde todo en una vista de manguera cuando la noche avanza rápido. Las búsquedas guardadas convierten una frase (un barrio, una empresa, un hashtag) en una columna permanente sobre todas las fuentes suscritas.\n\nRecuerda el enfriamiento: cada fuente se consulta a su propio ritmo, así que una columna muestra el contenido a medida que se consulta, no literalmente en vivo. Ese ritmo es lo que mantiene sanas las cuentas.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "From intake to archive", es: "De la toma al archivo" },
|
||||
body: {
|
||||
en: "Everything that arrives through intake is written to the archive with its attribution — channel title, author signature, forward origin — and its media. That means a channel you follow today is quotable in six months, even if the original was deleted.\n\nProtected-content sources (channels that block copying) are archived too, with the flag set; your record of what arrived is not the same as the platform's permission to redistribute it, so still pass reuse through review and keep attribution on.",
|
||||
es: "Todo lo que llega por la toma se escribe en el archivo con su atribución — título del canal, firma de autor, origen del reenvío — y sus medios. Eso significa que un canal que sigues hoy es citable en seis meses, aunque el original se haya borrado.\n\nLas fuentes con contenido protegido (canales que bloquean la copia) también se archivan, con la marca activada; tu registro de lo que llegó no es lo mismo que el permiso de la plataforma para redistribuirlo, así que pasa igualmente el reúso por revisión y conserva la atribución.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Per-host budgets and backoff", es: "Presupuestos por host y retroceso" },
|
||||
body: {
|
||||
en: "Each host the intake fetches from has a **rate budget**: a ceiling on how often it may be contacted. Within one platform, each source also has its own cooldown, and when a host is slow or refuses, the system backs off progressively — the pause doubles rather than retries piling up.\n\nBudgets exist so one hungry subscription cannot burn the workspace's standing with a whole platform. If a source's column looks stale for a long while, that is usually backoff in effect: check its health on **Connections** and **Reconnect** only when the credential is the problem.",
|
||||
es: "Cada host del que la toma consulta tiene un **presupuesto de tasa**: un techo sobre cuántas veces puede contactárselo. Dentro de una plataforma, cada fuente tiene además su propio enfriamiento, y cuando un host va lento o se niega, el sistema retrocede de forma progresiva: la pausa se duplica en vez de acumularse reintentos.\n\nLos presupuestos existen para que una suscripción hambrienta no queme la reputación del espacio ante toda una plataforma. Si la columna de una fuente se ve desactualizada un buen rato, suele ser el retroceso actuando: revisa su salud en **Connections** y usa **Reconnect** solo cuando el problema sea la credencial.",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: { en: "Live source monitoring", es: "Monitoreo de fuentes en vivo" },
|
||||
body: {
|
||||
en: "Live sources can announce themselves instead of being polled: a webhook ingest where the source calls us, verified with HMAC — a shared-secret signature on each delivery, so a forged call is rejected before it touches anything. HLS streams are checked by a probe driver that verifies a playlist is really playable.\n\nFor you this shows up as reliability: a live column that says live really is live, and when a source dies the wall says so instead of showing a frozen picture. Combine intake monitoring with the Control Room to cut between real sources as they come online.",
|
||||
es: "Las fuentes en vivo pueden anunciarse en vez de ser consultadas: una entrada por webhook donde la fuente nos llama, verificada con HMAC — una firma de secreto compartido en cada entrega, así que una llamada falsificada se rechaza antes de tocar nada. Los streams HLS los revisa un controlador de sondeo que verifica que una lista de reproducción de verdad funcione.\n\nPara ti esto se traduce en confiabilidad: una columna en vivo que dice \"en vivo\" de verdad lo está, y cuando una fuente muere el muro lo dice en vez de mostrar una imagen congelada. Combina el monitoreo de la toma con la Sala de control para cortar entre fuentes reales a medida que aparecen.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
Loading…
Add table
Add a link
Reference in a new issue