Compare commits
3
Commits
latest
..
c4158ee245
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4158ee245 | ||
|
|
61c68761df | ||
|
|
c04b8b47eb |
@@ -21,6 +21,10 @@ código e os commits seguem em inglês, como o resto do repositório.
|
|||||||
| Spotify por OAuth (atividade com capa e progresso) | `75316b08` | Token só no servidor; `state` assinado; atividades por fonte para não brigar com o detector do Electron |
|
| Spotify por OAuth (atividade com capa e progresso) | `75316b08` | Token só no servidor; `state` assinado; atividades por fonte para não brigar com o detector do Electron |
|
||||||
| Menu do próprio nome | `f5451e1b` | Perfil, status e copiar ID. Sem Clips/trocar conta — não existem aqui |
|
| Menu do próprio nome | `f5451e1b` | Perfil, status e copiar ID. Sem Clips/trocar conta — não existem aqui |
|
||||||
| Cronômetro da call | `f5451e1b` | `startedAt` do servidor; zera sozinho porque sala vazia é destruída |
|
| Cronômetro da call | `f5451e1b` | `startedAt` do servidor; zera sozinho porque sala vazia é destruída |
|
||||||
|
| Correções do Spotify (sincronia, sumiço, barra) | `c899253e` | Pausa virou estado; horário do servidor no `ready` corrige relógio |
|
||||||
|
| Soundboard no app desktop | `c899253e` | `window.prompt` não existe no Electron; campo inline no lugar |
|
||||||
|
| Áudio do sistema sem eco | `d525bbb8` `5b848432` | Módulo nativo com isolamento por processo + ganho e preset de música |
|
||||||
|
| Instaladores publicados pelo Gitea | `ce6bba05` | GitHub compila, Gitea distribui; atualização automática ligada |
|
||||||
| Fundação de i18n (en + pt-BR) | `688a1335` | `src/i18n/`; pt-BR é parcial e cai para inglês. Aba Idioma nas configurações |
|
| Fundação de i18n (en + pt-BR) | `688a1335` | `src/i18n/`; pt-BR é parcial e cai para inglês. Aba Idioma nas configurações |
|
||||||
| Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade |
|
| Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade |
|
||||||
|
|
||||||
@@ -72,6 +76,9 @@ fixas por `t('chave')`. O que faltar cai para o inglês sozinho.
|
|||||||
- Configurações → Privacidade
|
- Configurações → Privacidade
|
||||||
- Configurações → Conexões (nasceu bilíngue)
|
- Configurações → Conexões (nasceu bilíngue)
|
||||||
- Seletor de GIF
|
- Seletor de GIF
|
||||||
|
- Chat: composer e mensagens (placeholder, anexos, responder, reações)
|
||||||
|
- Barra lateral: servidores, canais e menus de contexto
|
||||||
|
- Lista de membros (grupos por cargo, carregamento)
|
||||||
- Registro de auditoria e Estatísticas (nasceram bilíngues)
|
- Registro de auditoria e Estatísticas (nasceram bilíngues)
|
||||||
|
|
||||||
### Fila sugerida de tradução
|
### Fila sugerida de tradução
|
||||||
@@ -83,11 +90,6 @@ modais de convite · configurações restantes · telas de erro
|
|||||||
|
|
||||||
| Problema | Causa provável | Correção |
|
| Problema | Causa provável | Correção |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Enviar som ao soundboard não funciona no app desktop** (funciona no navegador) | `SoundboardPopover` pede o nome do som com `window.prompt`, que o **Electron não implementa** — não abre nada e devolve vazio, então o fluxo aborta em silêncio, sem erro. `window.prompt` aparece em exatamente um lugar no projeto: esse. O resto do código já o evitava | Trocar por um campo de texto dentro do próprio popover (ou um modal reutilizável). Some o prompt e passa a funcionar igual nos dois. **Vale criar o modal de entrada genérico**, já que não existe nenhum e outras features vão precisar |
|
|
||||||
| **Eco absurdo ao compartilhar tela com som** (app desktop) | O `loopback` do Electron captura a mistura de saída do sistema **inteiro**, que inclui o próprio Backspace tocando a voz dos outros. Essa voz volta para eles dentro da transmissão, com atraso. Não é eco acústico — é digital, então **fone não resolve**. As constraints do web (`restrictOwnAudio`) nunca chegam: o handler ignora o `_request` e monta o stream a partir do enum. É também por isso que `shareAudio` já vinha desligado por padrão no app | **Implementado (2026-09-01), não testado.** Trocado por `electron-native-screenshare`: captura com isolamento por processo — só a janela compartilhada (include) ou tudo menos este app (exclude). O PCM vai do processo principal ao renderer por IPC e vira faixa publicada no LiveKit. **Exige gerar instalador novo e reinstalar**, e só pode ser confirmado numa máquina Windows |
|
|
||||||
| **Bloco do Spotify dessincronizado** (mostra a faixa errada por um tempo) | Soma de duas esperas: a consulta roda a cada **20s** (`useSpotifyActivity`) e o envio pelo WebSocket ainda passa por um **debounce de 5s** no `activityStore`. Na pior hipótese os outros veem a música anterior por ~25s | Consultar de novo perto do fim da faixa (a duração é conhecida) em vez de só por intervalo fixo, e encurtar o debounce para esta fonte |
|
|
||||||
| **Bloco do Spotify some sozinho** | Três caminhos apagam a atividade inteira: faixa **pausada** (`is_playing: false` devolve `null`), o **vão entre faixas** (o Spotify responde `204`) e qualquer falha transitória. Some e volta = a piscada que você viu | Manter a última faixa conhecida por alguns segundos antes de apagar, e enviar um estado *pausado* explícito em vez de sumir com o bloco |
|
|
||||||
| **Barra de progresso errada / andando pausada** | Duas causas independentes: (1) o progresso é derivado de carimbos calculados com o relógio do **servidor** e desenhado contra o relógio de **quem olha** — se os relógios divergem, a barra fica deslocada; (2) a barra continua avançando localmente depois que a pessoa pausa, até a próxima consulta | Enviar o horário do servidor junto no payload para o cliente corrigir a diferença, e congelar a barra quando o estado for pausado |
|
|
||||||
|
|
||||||
|
|
||||||
## App desktop — já existia (verificado 2026-08-31)
|
## App desktop — já existia (verificado 2026-08-31)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import type { MessageWithUser, Embed, User } from '@backspace/shared';
|
import type { MessageWithUser, Embed, User } from '@backspace/shared';
|
||||||
import { MarkdownRenderer } from './MarkdownRenderer';
|
import { MarkdownRenderer } from './MarkdownRenderer';
|
||||||
@@ -131,6 +132,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
|||||||
const editMessage = useChatStore((s) => s.editMessage);
|
const editMessage = useChatStore((s) => s.editMessage);
|
||||||
const deleteMessage = useChatStore((s) => s.deleteMessage);
|
const deleteMessage = useChatStore((s) => s.deleteMessage);
|
||||||
const members = useSpaceStore((s) => s.members);
|
const members = useSpaceStore((s) => s.members);
|
||||||
|
const tr = useT();
|
||||||
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||||
|
|
||||||
const pending = isPendingMessage(message) ? message.__pending : null;
|
const pending = isPendingMessage(message) ? message.__pending : null;
|
||||||
@@ -676,7 +678,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
|||||||
className={`p-1 hover:bg-interactive-hover rounded transition-colors text-[14px] leading-none ${
|
className={`p-1 hover:bg-interactive-hover rounded transition-colors text-[14px] leading-none ${
|
||||||
showReactionPicker ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
|
showReactionPicker ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
|
||||||
}`}
|
}`}
|
||||||
title="Add reaction"
|
title={tr('chat.message.addReaction')}
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm1-13h-2v4H7v2h4v4h2v-4h4v-2h-4V7z" />
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm1-13h-2v4H7v2h4v4h2v-4h4v-2h-4V7z" />
|
||||||
@@ -687,7 +689,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
|||||||
<button
|
<button
|
||||||
onClick={() => setReplyTo(message)}
|
onClick={() => setReplyTo(message)}
|
||||||
className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center"
|
className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center"
|
||||||
title="Reply"
|
title={tr('chat.message.reply')}
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M10 9V5L3 12L10 19V14.9C15 14.9 18.5 16.5 21 20C20 15 17 10 10 9Z" />
|
<path d="M10 9V5L3 12L10 19V14.9C15 14.9 18.5 16.5 21 20C20 15 17 10 10 9Z" />
|
||||||
@@ -700,7 +702,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
|||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
}}
|
}}
|
||||||
className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center"
|
className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center"
|
||||||
title="Edit"
|
title={tr('chat.message.edit')}
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react';
|
import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
import { useChatStore } from '../../stores/chatStore';
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
import { isDmChannel, getChannelOrigin, useSpaceStore } from '../../stores/spaceStore';
|
import { isDmChannel, getChannelOrigin, useSpaceStore } from '../../stores/spaceStore';
|
||||||
import { wsSend } from '../../hooks/useWebSocket';
|
import { wsSend } from '../../hooks/useWebSocket';
|
||||||
@@ -94,6 +95,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
// Feature flags
|
// Feature flags
|
||||||
|
const tr = useT();
|
||||||
const gifEnabled = useSettingsStore((s) => s.gifEnabled);
|
const gifEnabled = useSettingsStore((s) => s.gifEnabled);
|
||||||
|
|
||||||
// Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES
|
// Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES
|
||||||
@@ -775,7 +777,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
{chatReplyTo && (
|
{chatReplyTo && (
|
||||||
<div className="bg-interactive-hover rounded-t-lg px-4 py-2 flex items-center justify-between border-b border-white/[0.06]">
|
<div className="bg-interactive-hover rounded-t-lg px-4 py-2 flex items-center justify-between border-b border-white/[0.06]">
|
||||||
<div className="flex items-center gap-1 text-[14px] text-txt-message truncate">
|
<div className="flex items-center gap-1 text-[14px] text-txt-message truncate">
|
||||||
<span className="opacity-60">Replying to</span>
|
<span className="opacity-60">{tr('chat.composer.replyingTo')}</span>
|
||||||
<span className="font-bold">
|
<span className="font-bold">
|
||||||
{chatReplyTo.user.displayName ?? chatReplyTo.user.username}
|
{chatReplyTo.user.displayName ?? chatReplyTo.user.username}
|
||||||
</span>
|
</span>
|
||||||
@@ -783,7 +785,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
<button
|
<button
|
||||||
onClick={() => chatSetReplyTo(null)}
|
onClick={() => chatSetReplyTo(null)}
|
||||||
className="text-txt-tertiary hover:text-txt-primary transition-colors"
|
className="text-txt-tertiary hover:text-txt-primary transition-colors"
|
||||||
aria-label="Cancel reply"
|
aria-label={tr('chat.composer.cancelReply')}
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
|
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
|
||||||
@@ -867,7 +869,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
<button
|
<button
|
||||||
onClick={() => removeStagedTransfer(t.id)}
|
onClick={() => removeStagedTransfer(t.id)}
|
||||||
className="absolute -top-2 -right-2 w-7 h-7 bg-accent-rose hover:bg-accent-rose/80 shadow-elevation-high rounded-lg flex items-center justify-center text-white transition-colors z-10"
|
className="absolute -top-2 -right-2 w-7 h-7 bg-accent-rose hover:bg-accent-rose/80 shadow-elevation-high rounded-lg flex items-center justify-center text-white transition-colors z-10"
|
||||||
aria-label="Remove attachment"
|
aria-label={tr('chat.composer.removeAttachment')}
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||||
<path d="M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" />
|
<path d="M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" />
|
||||||
@@ -886,8 +888,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
<button
|
<button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
className="w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0"
|
className="w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0"
|
||||||
title="Attach file"
|
title={tr('chat.composer.attach')}
|
||||||
aria-label="Attach file"
|
aria-label={tr('chat.composer.attach')}
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
|
||||||
@@ -915,14 +917,16 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={canAttachFiles ? handlePaste : undefined}
|
onPaste={canAttachFiles ? handlePaste : undefined}
|
||||||
placeholder={placeholder ?? `Message ${channelName.startsWith('@') ? channelName : `#${channelName}`}`}
|
placeholder={placeholder ?? tr('chat.composer.placeholder', {
|
||||||
|
channel: channelName.startsWith('@') ? channelName : `#${channelName}`,
|
||||||
|
})}
|
||||||
className="input-embedded flex-1 py-[10px] px-1 resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin"
|
className="input-embedded flex-1 py-[10px] px-1 resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin"
|
||||||
rows={1}
|
rows={1}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Active-upload indicator */}
|
{/* Active-upload indicator */}
|
||||||
{anyActiveOrQueued && (
|
{anyActiveOrQueued && (
|
||||||
<div className="p-3 text-txt-tertiary" title="Uploading…" aria-label="Uploading">
|
<div className="p-3 text-txt-tertiary" title={tr('chat.composer.uploadingEllipsis')} aria-label={tr('chat.composer.uploading')}>
|
||||||
<svg className="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none">
|
<svg className="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
@@ -934,7 +938,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
{failedCount > 0 && (
|
{failedCount > 0 && (
|
||||||
<span
|
<span
|
||||||
className="text-[12px] font-medium text-accent-rose px-1 flex-shrink-0"
|
className="text-[12px] font-medium text-accent-rose px-1 flex-shrink-0"
|
||||||
title="Remove or retry the failed attachment to send"
|
title={tr('chat.composer.failedAttachment')}
|
||||||
>
|
>
|
||||||
{failedCount} failed
|
{failedCount} failed
|
||||||
</span>
|
</span>
|
||||||
@@ -956,8 +960,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
className={`w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
|
className={`w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
|
||||||
activePopover === 'gif' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
|
activePopover === 'gif' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
|
||||||
}`}
|
}`}
|
||||||
title="GIF"
|
title={tr('chat.composer.gif')}
|
||||||
aria-label="GIF picker"
|
aria-label={tr('chat.composer.gifPicker')}
|
||||||
>
|
>
|
||||||
{/* Outlined badge, not a filled block: the solid rectangle read as
|
{/* Outlined badge, not a filled block: the solid rectangle read as
|
||||||
a plain square rather than a GIF picker. Letters reuse the
|
a plain square rather than a GIF picker. Letters reuse the
|
||||||
@@ -977,8 +981,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
className={`w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
|
className={`w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
|
||||||
activePopover === 'emoji' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
|
activePopover === 'emoji' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
|
||||||
}`}
|
}`}
|
||||||
title="Emoji"
|
title={tr('chat.composer.emoji')}
|
||||||
aria-label="Emoji picker"
|
aria-label={tr('chat.composer.emojiPicker')}
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5s.67 1.5 1.5 1.5zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5s.67 1.5 1.5 1.5zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
|
||||||
@@ -991,8 +995,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
onClick={() => void handleSubmit()}
|
onClick={() => void handleSubmit()}
|
||||||
disabled={anyUnshippable}
|
disabled={anyUnshippable}
|
||||||
className="w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] bg-accent-primary hover:bg-accent-primary-hover text-white transition-all duration-150 flex-shrink-0 disabled:opacity-50"
|
className="w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] bg-accent-primary hover:bg-accent-primary-hover text-white transition-all duration-150 flex-shrink-0 disabled:opacity-50"
|
||||||
aria-label="Send message"
|
aria-label={tr('chat.composer.sendMessage')}
|
||||||
title="Send"
|
title={tr('chat.composer.send')}
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M3.4 20.4l17.45-7.48a1 1 0 000-1.84L3.4 3.6a.993.993 0 00-1.39.91L2 9.12c0 .5.37.93.87.99L17 12 2.87 13.88c-.5.07-.87.5-.87 1l.01 4.61c0 .71.73 1.2 1.39.91z" />
|
<path d="M3.4 20.4l17.45-7.48a1 1 0 000-1.84L3.4 3.6a.993.993 0 00-1.39.91L2 9.12c0 .5.37.93.87.99L17 12 2.87 13.88c-.5.07-.87.5-.87 1l.01 4.61c0 .71.73 1.2 1.39.91z" />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import type { Channel } from '@backspace/shared';
|
import type { Channel } from '@backspace/shared';
|
||||||
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore';
|
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore';
|
||||||
@@ -26,6 +27,7 @@ import { useAudioDevices } from '../../hooks/useAudioDevices';
|
|||||||
import { DropdownItem } from '../modals/settingsPanels/_shared/SettingsPickerPrimitives';
|
import { DropdownItem } from '../modals/settingsPanels/_shared/SettingsPickerPrimitives';
|
||||||
|
|
||||||
export function ChannelSidebar() {
|
export function ChannelSidebar() {
|
||||||
|
const tr = useT();
|
||||||
const spaces = useSpaceStore((s) => s.spaces);
|
const spaces = useSpaceStore((s) => s.spaces);
|
||||||
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||||
const loadingSpaceId = useSpaceStore((s) => s.loadingSpaceId);
|
const loadingSpaceId = useSpaceStore((s) => s.loadingSpaceId);
|
||||||
@@ -318,7 +320,7 @@ export function ChannelSidebar() {
|
|||||||
items.push({
|
items.push({
|
||||||
key: 'create-channel',
|
key: 'create-channel',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Create Channel',
|
label: tr('sidebar.createChannel'),
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
<path d="M2.5 12.5v-9l5-2v9l-5 2zm6-9v9l5-2v-9l-5 2z" opacity="0.5" />
|
<path d="M2.5 12.5v-9l5-2v9l-5 2zm6-9v9l5-2v-9l-5 2z" opacity="0.5" />
|
||||||
@@ -330,7 +332,7 @@ export function ChannelSidebar() {
|
|||||||
items.push({
|
items.push({
|
||||||
key: 'create-category',
|
key: 'create-category',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Create Category',
|
label: tr('sidebar.createCategory'),
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
|
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
|
||||||
@@ -343,7 +345,7 @@ export function ChannelSidebar() {
|
|||||||
items.push({
|
items.push({
|
||||||
key: 'invite',
|
key: 'invite',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Invite People',
|
label: tr('sidebar.invitePeople'),
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M21 3H24V5H21V8H19V5H16V3H19V0H21V3ZM10 12C12.21 12 14 10.21 14 8C14 5.79 12.21 4 10 4C7.79 4 6 5.79 6 8C6 10.21 7.79 12 10 12ZM10 13C6.69 13 1 14.66 1 18V20H19V18C19 14.66 13.31 13 10 13Z" />
|
<path d="M21 3H24V5H21V8H19V5H16V3H19V0H21V3ZM10 12C12.21 12 14 10.21 14 8C14 5.79 12.21 4 10 4C7.79 4 6 5.79 6 8C6 10.21 7.79 12 10 12ZM10 13C6.69 13 1 14.66 1 18V20H19V18C19 14.66 13.31 13 10 13Z" />
|
||||||
@@ -355,7 +357,7 @@ export function ChannelSidebar() {
|
|||||||
items.push({
|
items.push({
|
||||||
key: 'settings',
|
key: 'settings',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Space Settings',
|
label: tr('sidebar.spaceSettings'),
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1112 8.4a3.6 3.6 0 010 7.2z" />
|
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1112 8.4a3.6 3.6 0 010 7.2z" />
|
||||||
@@ -373,7 +375,7 @@ export function ChannelSidebar() {
|
|||||||
{
|
{
|
||||||
key: 'leave-group',
|
key: 'leave-group',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Leave Group',
|
label: tr('sidebar.leaveGroup'),
|
||||||
danger: true,
|
danger: true,
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
@@ -453,7 +455,7 @@ export function ChannelSidebar() {
|
|||||||
<path d="M3 18a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-1c0-2.76-5.37-4-8-4s-8 1.24-8 4v1Z" />
|
<path d="M3 18a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-1c0-2.76-5.37-4-8-4s-8 1.24-8 4v1Z" />
|
||||||
<path d="M3.5 13.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" opacity=".5" />
|
<path d="M3.5 13.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" opacity=".5" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="font-medium text-[16px]">Friends</span>
|
<span className="font-medium text-[16px]">{tr('sidebar.friends')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Placeholder nav items */}
|
{/* Placeholder nav items */}
|
||||||
@@ -463,7 +465,7 @@ export function ChannelSidebar() {
|
|||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0">
|
||||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="font-medium text-[16px]">Coming Soon</span>
|
<span className="font-medium text-[16px]">{tr('sidebar.comingSoon')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-3 px-2 h-[42px] rounded-[6px] mb-[2px] text-txt-tertiary cursor-default opacity-50"
|
className="flex items-center gap-3 px-2 h-[42px] rounded-[6px] mb-[2px] text-txt-tertiary cursor-default opacity-50"
|
||||||
@@ -471,11 +473,11 @@ export function ChannelSidebar() {
|
|||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0">
|
||||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 14c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6z" />
|
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 14c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="font-medium text-[16px]">Coming Soon</span>
|
<span className="font-medium text-[16px]">{tr('sidebar.comingSoon')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-[18px] px-2 mb-1 flex items-center justify-between group">
|
<div className="mt-[18px] px-2 mb-1 flex items-center justify-between group">
|
||||||
<span className="text-[12px] font-bold text-txt-tertiary tracking-wider">Direct Messages</span>
|
<span className="text-[12px] font-bold text-txt-tertiary tracking-wider">{tr('sidebar.directMessages')}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => openModal('newDm')}
|
onClick={() => openModal('newDm')}
|
||||||
className="text-txt-tertiary hover:text-txt-primary transition-colors"
|
className="text-txt-tertiary hover:text-txt-primary transition-colors"
|
||||||
@@ -584,7 +586,7 @@ export function ChannelSidebar() {
|
|||||||
{/* Channels — dynamic category layout */}
|
{/* Channels — dynamic category layout */}
|
||||||
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto pt-3 px-2 no-scrollbar" style={{ paddingBottom: floatingPanelHeight + 24 }} onDrop={containerHandlers.onDrop} onDragOver={containerHandlers.onDragOver} onContextMenu={handleSidebarContextMenu}>
|
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto pt-3 px-2 no-scrollbar" style={{ paddingBottom: floatingPanelHeight + 24 }} onDrop={containerHandlers.onDrop} onDragOver={containerHandlers.onDragOver} onContextMenu={handleSidebarContextMenu}>
|
||||||
{showChannelSkeleton ? (
|
{showChannelSkeleton ? (
|
||||||
<div className="px-2 pt-3" role="status" aria-label="Loading channels">
|
<div className="px-2 pt-3" role="status" aria-label={tr('sidebar.loadingChannels')}>
|
||||||
{/* Category group 1 */}
|
{/* Category group 1 */}
|
||||||
<div className="skeleton skeleton-bar h-2 w-[45%] ml-2 mb-3" />
|
<div className="skeleton skeleton-bar h-2 w-[45%] ml-2 mb-3" />
|
||||||
{Array.from({ length: 3 }, (_, i) => (
|
{Array.from({ length: 3 }, (_, i) => (
|
||||||
@@ -706,7 +708,7 @@ export function ChannelSidebar() {
|
|||||||
{
|
{
|
||||||
key: 'category-settings',
|
key: 'category-settings',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Category Settings',
|
label: tr('sidebar.categorySettings'),
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1115.6 12 3.611 3.611 0 0112 15.6z" />
|
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1115.6 12 3.611 3.611 0 0112 15.6z" />
|
||||||
@@ -717,7 +719,7 @@ export function ChannelSidebar() {
|
|||||||
{
|
{
|
||||||
key: 'delete-category',
|
key: 'delete-category',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Delete Category',
|
label: tr('sidebar.deleteCategory'),
|
||||||
danger: true,
|
danger: true,
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
@@ -758,7 +760,7 @@ export function ChannelSidebar() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{catChannels.length === 0 && (
|
{catChannels.length === 0 && (
|
||||||
<div className="px-2 py-2 text-[12px] text-txt-tertiary italic opacity-40">No channels</div>
|
<div className="px-2 py-2 text-[12px] text-txt-tertiary italic opacity-40">{tr('sidebar.noChannels')}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -777,7 +779,7 @@ export function ChannelSidebar() {
|
|||||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="flex-shrink-0 opacity-70">
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="flex-shrink-0 opacity-70">
|
||||||
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
|
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-[12px]">Create Channel</span>
|
<span className="text-[12px]">{tr('sidebar.createChannel')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -787,7 +789,7 @@ export function ChannelSidebar() {
|
|||||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="flex-shrink-0 opacity-70">
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="flex-shrink-0 opacity-70">
|
||||||
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
|
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-[12px]">Create Category</span>
|
<span className="text-[12px]">{tr('sidebar.createCategory')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -845,6 +847,7 @@ function UserAreaPanel({
|
|||||||
onDeafenToggle: () => void;
|
onDeafenToggle: () => void;
|
||||||
onSettingsClick: (tab?: string) => void;
|
onSettingsClick: (tab?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const tr = useT();
|
||||||
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
|
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
|
||||||
const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null);
|
const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null);
|
||||||
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
|
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
|
||||||
@@ -959,7 +962,7 @@ function UserAreaPanel({
|
|||||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
|
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-[15px] font-semibold text-txt-primary text-left">Input Device</div>
|
<div className="text-[15px] font-semibold text-txt-primary text-left">{tr('sidebar.inputDevice')}</div>
|
||||||
<div className="text-[13px] text-txt-tertiary truncate text-left">{selectedInputLabel}</div>
|
<div className="text-[13px] text-txt-tertiary truncate text-left">{selectedInputLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary flex-shrink-0 ml-2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary flex-shrink-0 ml-2">
|
||||||
@@ -1004,7 +1007,7 @@ function UserAreaPanel({
|
|||||||
|
|
||||||
{/* Input Volume */}
|
{/* Input Volume */}
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
<div className="text-[15px] font-semibold text-txt-primary mb-2">Input Volume</div>
|
<div className="text-[15px] font-semibold text-txt-primary mb-2">{tr('sidebar.inputVolume')}</div>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
@@ -1039,7 +1042,7 @@ function UserAreaPanel({
|
|||||||
onClick={() => onSettingsClick('voice')}
|
onClick={() => onSettingsClick('voice')}
|
||||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
|
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
|
||||||
>
|
>
|
||||||
<span className="text-[15px] font-semibold text-txt-primary">Voice Settings</span>
|
<span className="text-[15px] font-semibold text-txt-primary">{tr('sidebar.voiceSettings')}</span>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
|
||||||
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -1057,7 +1060,7 @@ function UserAreaPanel({
|
|||||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
|
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-[15px] font-semibold text-txt-primary text-left">Output Device</div>
|
<div className="text-[15px] font-semibold text-txt-primary text-left">{tr('sidebar.outputDevice')}</div>
|
||||||
<div className="text-[13px] text-txt-tertiary truncate text-left">{selectedOutputLabel}</div>
|
<div className="text-[13px] text-txt-tertiary truncate text-left">{selectedOutputLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary flex-shrink-0 ml-2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary flex-shrink-0 ml-2">
|
||||||
@@ -1102,7 +1105,7 @@ function UserAreaPanel({
|
|||||||
|
|
||||||
{/* Output Volume */}
|
{/* Output Volume */}
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
<div className="text-[15px] font-semibold text-txt-primary mb-2">Output Volume</div>
|
<div className="text-[15px] font-semibold text-txt-primary mb-2">{tr('sidebar.outputVolume')}</div>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
@@ -1125,7 +1128,7 @@ function UserAreaPanel({
|
|||||||
onClick={() => onSettingsClick('voice')}
|
onClick={() => onSettingsClick('voice')}
|
||||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
|
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
|
||||||
>
|
>
|
||||||
<span className="text-[15px] font-semibold text-txt-primary">Voice Settings</span>
|
<span className="text-[15px] font-semibold text-txt-primary">{tr('sidebar.voiceSettings')}</span>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
|
||||||
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
import type { MemberWithUser, Activity } from '@backspace/shared';
|
import type { MemberWithUser, Activity } from '@backspace/shared';
|
||||||
import { useSpaceStore } from '../../stores/spaceStore';
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
@@ -21,7 +22,7 @@ function getMemberGroup(member: MemberWithUser, ownerId: string | undefined) {
|
|||||||
const ownerRole = member.roles?.find(r => r.position > 0);
|
const ownerRole = member.roles?.find(r => r.position > 0);
|
||||||
return {
|
return {
|
||||||
key: '__owner__',
|
key: '__owner__',
|
||||||
label: 'OWNER',
|
label: '__OWNER__',
|
||||||
color: ownerRole?.color ?? 'rgb(var(--accent-rose))',
|
color: ownerRole?.color ?? 'rgb(var(--accent-rose))',
|
||||||
position: Infinity,
|
position: Infinity,
|
||||||
};
|
};
|
||||||
@@ -40,7 +41,10 @@ function getMemberGroup(member: MemberWithUser, ownerId: string | undefined) {
|
|||||||
// No explicit roles — just @everyone
|
// No explicit roles — just @everyone
|
||||||
return {
|
return {
|
||||||
key: '__online__',
|
key: '__online__',
|
||||||
label: 'ONLINE',
|
// Marcado em vez de traduzido aqui: esta funcao nao e um componente e nao
|
||||||
|
// pode usar o hook. Nomes de cargo passam adiante sem traducao — sao dados
|
||||||
|
// do usuario, nao interface.
|
||||||
|
label: '__ONLINE__',
|
||||||
color: undefined,
|
color: undefined,
|
||||||
position: -1,
|
position: -1,
|
||||||
};
|
};
|
||||||
@@ -111,6 +115,7 @@ export function MemberSidebar() {
|
|||||||
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||||
const loadingSpaceId = useSpaceStore((s) => s.loadingSpaceId);
|
const loadingSpaceId = useSpaceStore((s) => s.loadingSpaceId);
|
||||||
const memberListOpen = useUIStore((s) => s.memberListOpen);
|
const memberListOpen = useUIStore((s) => s.memberListOpen);
|
||||||
|
const tr = useT();
|
||||||
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||||
const userActivities = useActivityStore((s) => s.userActivities);
|
const userActivities = useActivityStore((s) => s.userActivities);
|
||||||
|
|
||||||
@@ -183,7 +188,7 @@ export function MemberSidebar() {
|
|||||||
return (
|
return (
|
||||||
<div className="w-60 bg-surface-members flex-shrink-0 overflow-y-auto select-none no-scrollbar hidden md:block border-l border-border-hard">
|
<div className="w-60 bg-surface-members flex-shrink-0 overflow-y-auto select-none no-scrollbar hidden md:block border-l border-border-hard">
|
||||||
{showMemberSkeleton ? (
|
{showMemberSkeleton ? (
|
||||||
<div className="px-3 pt-4" role="status" aria-label="Loading members">
|
<div className="px-3 pt-4" role="status" aria-label={tr('sidebar.loadingMembers')}>
|
||||||
{/* Role group 1 */}
|
{/* Role group 1 */}
|
||||||
<div className="skeleton skeleton-bar h-2 w-[40%] mb-3" style={{ animationDelay: '0s' }} />
|
<div className="skeleton skeleton-bar h-2 w-[40%] mb-3" style={{ animationDelay: '0s' }} />
|
||||||
{Array.from({ length: 2 }, (_, i) => (
|
{Array.from({ length: 2 }, (_, i) => (
|
||||||
@@ -207,7 +212,11 @@ export function MemberSidebar() {
|
|||||||
{roleGroups.map(([key, group]) => (
|
{roleGroups.map(([key, group]) => (
|
||||||
<div key={key} className="mb-4">
|
<div key={key} className="mb-4">
|
||||||
<h3 className="text-[10.5px] font-bold text-txt-tertiary uppercase tracking-[0.06em] px-2 mb-1">
|
<h3 className="text-[10.5px] font-bold text-txt-tertiary uppercase tracking-[0.06em] px-2 mb-1">
|
||||||
{group.label} — {group.members.length}
|
{group.label === '__ONLINE__'
|
||||||
|
? tr('sidebar.groupOnline')
|
||||||
|
: group.label === '__OWNER__'
|
||||||
|
? tr('sidebar.groupOwner')
|
||||||
|
: group.label} — {group.members.length}
|
||||||
</h3>
|
</h3>
|
||||||
{group.members.map((m) => renderMember(m))}
|
{group.members.map((m) => renderMember(m))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,6 +56,47 @@ export const en = {
|
|||||||
'accountMenu.copyId': 'Copy User ID',
|
'accountMenu.copyId': 'Copy User ID',
|
||||||
'accountMenu.copied': 'Copied',
|
'accountMenu.copied': 'Copied',
|
||||||
|
|
||||||
|
// Sidebar — spaces, channels and members
|
||||||
|
'sidebar.friends': 'Friends',
|
||||||
|
'sidebar.directMessages': 'Direct Messages',
|
||||||
|
'sidebar.noChannels': 'No channels',
|
||||||
|
'sidebar.comingSoon': 'Coming Soon',
|
||||||
|
'sidebar.loadingChannels': 'Loading channels',
|
||||||
|
'sidebar.loadingMembers': 'Loading members',
|
||||||
|
'sidebar.groupOnline': 'ONLINE',
|
||||||
|
'sidebar.groupOwner': 'OWNER',
|
||||||
|
'sidebar.createChannel': 'Create Channel',
|
||||||
|
'sidebar.createCategory': 'Create Category',
|
||||||
|
'sidebar.categorySettings': 'Category Settings',
|
||||||
|
'sidebar.deleteCategory': 'Delete Category',
|
||||||
|
'sidebar.invitePeople': 'Invite People',
|
||||||
|
'sidebar.spaceSettings': 'Space Settings',
|
||||||
|
'sidebar.leaveGroup': 'Leave Group',
|
||||||
|
'sidebar.voiceSettings': 'Voice Settings',
|
||||||
|
'sidebar.inputDevice': 'Input Device',
|
||||||
|
'sidebar.inputVolume': 'Input Volume',
|
||||||
|
'sidebar.outputDevice': 'Output Device',
|
||||||
|
'sidebar.outputVolume': 'Output Volume',
|
||||||
|
|
||||||
|
// Chat — composer and messages
|
||||||
|
'chat.composer.placeholder': 'Message {channel}',
|
||||||
|
'chat.composer.attach': 'Attach file',
|
||||||
|
'chat.composer.emoji': 'Emoji',
|
||||||
|
'chat.composer.gif': 'GIF',
|
||||||
|
'chat.composer.gifPicker': 'GIF picker',
|
||||||
|
'chat.composer.emojiPicker': 'Emoji picker',
|
||||||
|
'chat.composer.send': 'Send',
|
||||||
|
'chat.composer.sendMessage': 'Send message',
|
||||||
|
'chat.composer.uploading': 'Uploading',
|
||||||
|
'chat.composer.uploadingEllipsis': 'Uploading…',
|
||||||
|
'chat.composer.removeAttachment': 'Remove attachment',
|
||||||
|
'chat.composer.failedAttachment': 'Remove or retry the failed attachment to send',
|
||||||
|
'chat.composer.replyingTo': 'Replying to',
|
||||||
|
'chat.composer.cancelReply': 'Cancel reply',
|
||||||
|
'chat.message.addReaction': 'Add reaction',
|
||||||
|
'chat.message.edit': 'Edit',
|
||||||
|
'chat.message.reply': 'Reply',
|
||||||
|
|
||||||
// Statistics
|
// Statistics
|
||||||
'stats.title': 'Statistics',
|
'stats.title': 'Statistics',
|
||||||
'stats.range.7': 'Last 7 days',
|
'stats.range.7': 'Last 7 days',
|
||||||
|
|||||||
@@ -55,6 +55,47 @@ export const ptBR: Partial<Dictionary> = {
|
|||||||
'accountMenu.copyId': 'Copiar ID do usuário',
|
'accountMenu.copyId': 'Copiar ID do usuário',
|
||||||
'accountMenu.copied': 'Copiado',
|
'accountMenu.copied': 'Copiado',
|
||||||
|
|
||||||
|
// Barra lateral — servidores, canais e membros
|
||||||
|
'sidebar.friends': 'Amigos',
|
||||||
|
'sidebar.directMessages': 'Mensagens diretas',
|
||||||
|
'sidebar.noChannels': 'Nenhum canal',
|
||||||
|
'sidebar.comingSoon': 'Em breve',
|
||||||
|
'sidebar.loadingChannels': 'Carregando canais',
|
||||||
|
'sidebar.loadingMembers': 'Carregando membros',
|
||||||
|
'sidebar.groupOnline': 'DISPONÍVEIS',
|
||||||
|
'sidebar.groupOwner': 'DONO',
|
||||||
|
'sidebar.createChannel': 'Criar canal',
|
||||||
|
'sidebar.createCategory': 'Criar categoria',
|
||||||
|
'sidebar.categorySettings': 'Configurações da categoria',
|
||||||
|
'sidebar.deleteCategory': 'Excluir categoria',
|
||||||
|
'sidebar.invitePeople': 'Convidar pessoas',
|
||||||
|
'sidebar.spaceSettings': 'Configurações do servidor',
|
||||||
|
'sidebar.leaveGroup': 'Sair do grupo',
|
||||||
|
'sidebar.voiceSettings': 'Configurações de voz',
|
||||||
|
'sidebar.inputDevice': 'Dispositivo de entrada',
|
||||||
|
'sidebar.inputVolume': 'Volume de entrada',
|
||||||
|
'sidebar.outputDevice': 'Dispositivo de saída',
|
||||||
|
'sidebar.outputVolume': 'Volume de saída',
|
||||||
|
|
||||||
|
// Chat — composer e mensagens
|
||||||
|
'chat.composer.placeholder': 'Conversar em {channel}',
|
||||||
|
'chat.composer.attach': 'Anexar arquivo',
|
||||||
|
'chat.composer.emoji': 'Emoji',
|
||||||
|
'chat.composer.gif': 'GIF',
|
||||||
|
'chat.composer.gifPicker': 'Seletor de GIF',
|
||||||
|
'chat.composer.emojiPicker': 'Seletor de emoji',
|
||||||
|
'chat.composer.send': 'Enviar',
|
||||||
|
'chat.composer.sendMessage': 'Enviar mensagem',
|
||||||
|
'chat.composer.uploading': 'Enviando',
|
||||||
|
'chat.composer.uploadingEllipsis': 'Enviando…',
|
||||||
|
'chat.composer.removeAttachment': 'Remover anexo',
|
||||||
|
'chat.composer.failedAttachment': 'Remova ou tente de novo o anexo que falhou para poder enviar',
|
||||||
|
'chat.composer.replyingTo': 'Respondendo a',
|
||||||
|
'chat.composer.cancelReply': 'Cancelar resposta',
|
||||||
|
'chat.message.addReaction': 'Adicionar reação',
|
||||||
|
'chat.message.edit': 'Editar',
|
||||||
|
'chat.message.reply': 'Responder',
|
||||||
|
|
||||||
// Estatísticas
|
// Estatísticas
|
||||||
'stats.title': 'Estatísticas',
|
'stats.title': 'Estatísticas',
|
||||||
'stats.range.7': 'Últimos 7 dias',
|
'stats.range.7': 'Últimos 7 dias',
|
||||||
|
|||||||
Reference in New Issue
Block a user