memi-board 0.44.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +54 -3
  2. package/dist/ar-BCQdRw_S.js +622 -0
  3. package/dist/ar-BCQdRw_S.js.map +1 -0
  4. package/dist/composables/fetchPublicSeo.d.ts +4 -1
  5. package/dist/composables/useMemiBoardAutoTranslate.d.ts +33 -0
  6. package/dist/composables/useMemiBoardModeration.d.ts +5 -1
  7. package/dist/composables/useMemiBoardReports.d.ts +6 -1
  8. package/dist/composables/useMemiBoardSections.d.ts +2 -1
  9. package/dist/composables/useMemiBoardUsers.d.ts +4 -0
  10. package/dist/composables/useMemiBoardWritingAssistant.d.ts +9 -1
  11. package/dist/config.d.ts +5 -0
  12. package/dist/de-ddWQNaqs.js +622 -0
  13. package/dist/de-ddWQNaqs.js.map +1 -0
  14. package/dist/en-BVUBLkxc.js +648 -0
  15. package/dist/en-BVUBLkxc.js.map +1 -0
  16. package/dist/es-7JHjGVlM.js +622 -0
  17. package/dist/es-7JHjGVlM.js.map +1 -0
  18. package/dist/fr-C3NCMNoy.js +622 -0
  19. package/dist/fr-C3NCMNoy.js.map +1 -0
  20. package/dist/id-C8hCAMPJ.js +622 -0
  21. package/dist/id-C8hCAMPJ.js.map +1 -0
  22. package/dist/index.d.ts +10 -1
  23. package/dist/index.js +1218 -1487
  24. package/dist/index.js.map +1 -1
  25. package/dist/ja-oTWri4hY.js +622 -0
  26. package/dist/ja-oTWri4hY.js.map +1 -0
  27. package/dist/module.mjs +55 -31
  28. package/dist/module.mjs.map +1 -1
  29. package/dist/pt-B9EH4scA.js +622 -0
  30. package/dist/pt-B9EH4scA.js.map +1 -0
  31. package/dist/runtime/components/Attachments.vue +4 -2
  32. package/dist/runtime/components/AuthorMenu.vue +14 -12
  33. package/dist/runtime/components/AvatarCropModal.vue +8 -5
  34. package/dist/runtime/components/CategorySettings.vue +65 -43
  35. package/dist/runtime/components/CommentFeed.vue +9 -7
  36. package/dist/runtime/components/CommentForm.vue +16 -10
  37. package/dist/runtime/components/CommentItem.vue +25 -26
  38. package/dist/runtime/components/CommentList.vue +7 -4
  39. package/dist/runtime/components/CommentThread.vue +7 -5
  40. package/dist/runtime/components/Detail.vue +20 -15
  41. package/dist/runtime/components/Editor.vue +145 -135
  42. package/dist/runtime/components/Footer.vue +14 -13
  43. package/dist/runtime/components/List.vue +31 -29
  44. package/dist/runtime/components/ListDefault.vue +4 -3
  45. package/dist/runtime/components/ListDense.vue +6 -5
  46. package/dist/runtime/components/ListImage.vue +4 -2
  47. package/dist/runtime/components/ListVideo.vue +3 -2
  48. package/dist/runtime/components/ListViewSwitch.vue +10 -6
  49. package/dist/runtime/components/MiniCard.vue +3 -2
  50. package/dist/runtime/components/MiniComments.vue +5 -4
  51. package/dist/runtime/components/MiniList.vue +3 -2
  52. package/dist/runtime/components/MiniPicker.vue +10 -9
  53. package/dist/runtime/components/MiniPost.vue +3 -2
  54. package/dist/runtime/components/MiniPosts.vue +5 -4
  55. package/dist/runtime/components/Profile.vue +12 -7
  56. package/dist/runtime/components/ProfileEditModal.vue +14 -13
  57. package/dist/runtime/components/ReportButton.vue +20 -10
  58. package/dist/runtime/components/ReportList.vue +11 -10
  59. package/dist/runtime/components/Section.vue +3 -2
  60. package/dist/runtime/components/Settings.vue +84 -62
  61. package/dist/runtime/components/ShareButton.vue +3 -1
  62. package/dist/runtime/components/SignIn.vue +14 -8
  63. package/dist/runtime/components/SwipeHint.vue +5 -3
  64. package/dist/runtime/components/UserCommentList.vue +8 -6
  65. package/dist/runtime/components/UserPostList.vue +6 -4
  66. package/dist/runtime/components/Users.vue +16 -10
  67. package/dist/runtime/composables/useMemiBoardSeo.ts +16 -10
  68. package/dist/slugify-6bG6GSO_.js +942 -0
  69. package/dist/slugify-6bG6GSO_.js.map +1 -0
  70. package/dist/storage.js +72 -71
  71. package/dist/storage.js.map +1 -1
  72. package/dist/types.d.ts +4 -0
  73. package/dist/utils/boardSeo.d.ts +5 -5
  74. package/dist/utils/compressImage.d.ts +2 -1
  75. package/dist/utils/formatDate.d.ts +6 -4
  76. package/dist/utils/imeSafeSubmit.d.ts +2 -0
  77. package/dist/utils/moderation-prompt.d.ts +18 -1
  78. package/dist/utils/moderation-strike.d.ts +2 -1
  79. package/dist/utils/section.d.ts +15 -2
  80. package/dist/zh-D5rDYV6E.js +622 -0
  81. package/dist/zh-D5rDYV6E.js.map +1 -0
  82. package/package.json +2 -3
  83. package/dist/slugify-Cm_pi2al.js +0 -124
  84. package/dist/slugify-Cm_pi2al.js.map +0 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pt-B9EH4scA.js","names":[],"sources":["../src/i18n/messages/pt.ts"],"sourcesContent":["import type { MemiBoardMessages } from '../translate'\n\n/** 짧은 화면 문구만 번역한다. 빠진 키는 en → ko 순으로 폴백된다. */\nconst messages: MemiBoardMessages = {\n attachments: {\n attach: 'Anexar arquivos ({count} / {max})',\n maxFiles: 'Você pode anexar até {max} arquivos.',\n },\n auth: {\n writeRestricted: 'Publicar e comentar está temporariamente restrito por causa de avisos de conteúdo acumulados.',\n strikeSuffix: ' (Aviso {count}/{threshold})',\n strikeRestricted: ' Você atingiu {count} avisos, então publicar e comentar está restrito.',\n notInitialized: '[memi-board] O Firebase Auth não foi inicializado.',\n },\n authorMemo: {\n tooLong: 'As notas podem ter até {max} caracteres.',\n },\n authorMenu: {\n viewProfile: 'Ver perfil',\n viewPosts: 'Ver publicações',\n viewComments: 'Ver comentários',\n memoAriaLabel: 'Nota sobre este autor',\n memoTitle: 'Nota privada',\n memoPlaceholder: 'Anote algo sobre este autor…',\n bad: 'Ruim',\n good: 'Bom',\n },\n avatarCropModal: {\n title: 'Recortar foto do perfil',\n description: 'Exibida em formato quadrado (1:1). Ajuste a área.',\n confirm: 'Recortar e enviar',\n },\n boardSettings: {\n signInRequired: 'Faça login.',\n idAndNameRequired: 'Informe um ID e um nome para o fórum.',\n deleteIdRequired: 'É necessário o ID do fórum para excluí-lo.',\n nameRequired: 'Informe um nome para o fórum.',\n },\n boardUsers: {\n unsupportedRole: 'Função não suportada.',\n roleDescription: {\n admin: 'Gerencia as funções dos usuários, as configurações do fórum e todo o conteúdo.',\n user: 'Pode escrever publicações e comentários, e editar ou excluir apenas o próprio conteúdo.',\n },\n },\n commentFeed: {\n empty: 'Ainda não há comentários.',\n emptyLiked: 'Ainda não há comentários curtidos.',\n loadFailed: 'Não foi possível carregar a lista: {error}',\n blinded: 'Este comentário foi ocultado por um administrador.',\n },\n commentForm: {\n hiddenBoardDenied: 'Somente a equipe responsável pode comentar em fóruns ocultos.',\n denied: 'Você não tem permissão para comentar neste fórum.',\n signInRequired: 'Faça login.',\n writeRestricted: 'Publicar está temporariamente restrito por causa de avisos de conteúdo acumulados.',\n moderationBlocked: 'Há conteúdo que não pode ser publicado.',\n replyTo: 'Responder a {name}',\n placeholder: 'Escreva um comentário',\n submitReply: 'Responder',\n submit: 'Comentar',\n signInToComment: 'Faça login para deixar um comentário.',\n },\n commentItem: {\n editRestricted: 'A edição de comentários está temporariamente restrita.',\n moderationBlocked: 'Há conteúdo que não pode ser publicado.',\n editFailed: 'Não foi possível atualizar o comentário.',\n blindFailed: 'Não foi possível alterar o estado de ocultação.',\n likeFailed: 'Não foi possível processar a curtida.',\n blinded: 'Este comentário foi ocultado por um administrador.',\n edit: 'Editar comentário',\n blind: 'Ocultar comentário',\n unblind: 'Reexibir comentário',\n delete: 'Excluir comentário',\n },\n commentList: {\n loading: 'Carregando comentários',\n empty: 'Ainda não há comentários.',\n loadMore: 'Mais comentários',\n loadingMore: 'Carregando mais comentários',\n },\n commentThread: {\n deleteConfirm: 'Excluir este comentário?',\n deleteFailed: 'Não foi possível excluir o comentário.',\n hideReplies: 'Ocultar respostas',\n showReplies: '{count} respostas',\n loadMoreReplies: 'Mais respostas',\n },\n comments: {\n bodyRequired: 'Escreva um comentário.',\n bodyTooLong: 'Os comentários podem ter até {max} caracteres.',\n replyTargetNotFound: 'O comentário que você está respondendo não foi encontrado.',\n threadNotFound: 'A conversa de comentários não foi encontrada.',\n hasRepliesCannotDelete: 'No momento, não é possível excluir comentários com respostas.',\n },\n common: {\n action: {\n cancel: 'Cancelar',\n save: 'Salvar',\n saved: 'Salvo',\n edit: 'Editar',\n delete: 'Excluir',\n confirm: 'OK',\n close: 'Fechar',\n add: 'Adicionar',\n more: 'Ver mais',\n prev: 'Anterior',\n next: 'Próximo',\n retry: 'Tentar novamente',\n reply: 'Responder',\n like: 'Curtir',\n unlike: 'Descurtir',\n share: 'Compartilhar',\n report: 'Denunciar',\n signIn: 'Entrar',\n signOut: 'Sair',\n write: 'Escrever',\n toTop: 'Topo',\n },\n label: {\n anonymous: 'Anônimo',\n user: 'Usuário',\n image: 'Imagem',\n draft: 'Rascunho',\n loading: 'Carregando…',\n },\n role: {\n admin: 'Administrador',\n staff: 'Equipe',\n user: 'Usuário',\n },\n date: {\n justNow: 'Agora mesmo',\n fewSecondsAgo: 'Há alguns segundos',\n checking: 'Verificando horário',\n createdAt: 'Criado: {time}',\n updatedAt: 'Atualizado: {time}',\n },\n },\n detail: {\n deleteConfirm: 'Excluir esta publicação? Isso não pode ser desfeito.',\n notFound: 'Publicação não encontrada.',\n draftPreview: 'Prévia não publicada',\n draftNotice: 'Esta publicação ainda não foi publicada. Só você pode vê-la até publicá-la.',\n publish: 'Publicar',\n backToList: 'Voltar à lista',\n postNav: 'Navegação entre publicações',\n list: 'Lista',\n comments: 'Comentários',\n },\n editor: {\n titlePlaceholder: 'Título',\n contentPlaceholder: 'Escreva algo… Digite / para abrir o menu de blocos.',\n imageContentPlaceholder: 'Escreva algo…',\n tagsPlaceholder: 'Digite uma tag e pressione Enter',\n skipModeration: 'Ignorar filtro de palavrões (admin)',\n submitEdit: 'Salvar alterações',\n submitNext: 'Próximo: prévia',\n apply: 'Aplicar',\n imageOptimizing: 'Otimizando e enviando a imagem…',\n cover: {\n imageLink: 'Link da imagem',\n addPhoto: 'Adicionar fotos',\n alt: 'Foto {n}',\n representative: 'Capa',\n setRepresentative: 'Usar como capa',\n setRepresentativeAria: 'Usar a foto {n} como capa',\n remove: 'Remover foto',\n add: 'Adicionar',\n uploading: 'Enviando…',\n dropOrClick: 'Solte fotos ou clique para adicionar',\n emptyHint: 'Selecione várias · Escolha a capa após o envio · Arraste para reordenar',\n uploadTitle: 'Enviar',\n },\n uploadFailed: 'Falha no envio',\n aiAssistant: 'Assistente de escrita com AI',\n insertBlock: 'Inserir bloco',\n blockMenu: 'Menu de blocos',\n imageDialog: {\n title: 'Adicionar imagem',\n chooseFile: 'Escolher imagem do dispositivo',\n orLink: 'ou link da imagem',\n urlLabel: 'URL da imagem',\n previewAlt: 'Prévia da imagem externa',\n addLink: 'Adicionar link',\n },\n customDialog: {\n title: 'Reescrever com um estilo personalizado',\n label: 'Como você quer reescrever?',\n help: 'ex.: em tom descontraído / em estilo formal / em tópicos concisos',\n placeholder: 'Em tom descontraído',\n },\n aiPreview: {\n title: 'Revisar resultado da AI',\n currentTitle: 'Título atual',\n newTitle: 'Novo título',\n currentContent: 'Conteúdo atual',\n appendedContent: 'Conteúdo a adicionar',\n before: 'Antes',\n after: 'Depois',\n },\n menu: {\n ai: 'AI',\n aiContinue: 'Continuar com AI',\n style: 'Estilo',\n insert: 'Inserir',\n paragraph: 'Parágrafo',\n heading1: 'Título 1',\n heading2: 'Título 2',\n heading3: 'Título 3',\n bulletList: 'Lista com marcadores',\n orderedList: 'Lista numerada',\n blockquote: 'Citação',\n codeBlock: 'Bloco de código',\n horizontalRule: 'Divisor',\n image: 'Imagem',\n turnInto: 'Transformar em',\n resetFormatting: 'Redefinir formatação',\n duplicate: 'Duplicar',\n moveUp: 'Mover para cima',\n moveDown: 'Mover para baixo',\n delete: 'Excluir',\n },\n toolbar: {\n link: 'Link',\n image: 'Imagem',\n format: 'Formatar',\n bold: 'Negrito',\n italic: 'Itálico',\n underline: 'Sublinhado',\n strike: 'Tachado',\n title: 'Título',\n heading: 'Cabeçalho',\n subheading: 'Subtítulo',\n body: 'Corpo',\n mono: 'Monoespaçado',\n bulletList: 'Lista com marcadores',\n orderedList: 'Lista numerada',\n blockquote: 'Citação em bloco',\n codeBlock: 'Bloco de código',\n horizontalRule: 'Divisor',\n clearFormatting: 'Limpar formatação',\n undo: 'Desfazer',\n redo: 'Refazer',\n },\n ai: {\n proofread: 'Verificar ortografia',\n polish: 'Aprimorar texto',\n continue: 'Continuar escrevendo',\n summarize: 'Resumir brevemente',\n translateEn: 'Traduzir para inglês',\n translateKo: 'Traduzir para coreano',\n title: 'Gerar título a partir do texto',\n custom: 'Reescrever com um estilo personalizado…',\n failed: 'A tarefa de AI falhou. Tente novamente.',\n },\n youtubePrompt: 'Insira um link do YouTube.',\n errors: {\n coverMax: 'Você pode enviar até {max} fotos.',\n imageUrlRequired: 'Insira uma URL de imagem.',\n imageUrlInvalid: 'Insira uma URL de imagem válida.',\n imageUrlHttps: 'Somente URLs de imagem HTTPS são permitidas.',\n imageUrlCredentials: 'URLs com dados de login não são permitidas.',\n imageTimeout: 'A imagem está demorando demais para carregar.',\n imageNotDisplayable: 'Não é possível exibir esta imagem.',\n editorNotReady: 'O editor não está pronto. Tente novamente.',\n imageLinkFailed: 'Não foi possível adicionar o link da imagem.',\n imageOnly: 'Somente arquivos de imagem podem ser enviados.',\n imageTooLarge: 'As imagens originais devem ter {mb}MB ou menos.',\n imageUploadFailed: 'Falha ao enviar a imagem.',\n pasteAsFile: 'Cole ou arraste as fotos como arquivos na área acima.',\n postNotFound: 'Publicação não encontrada.',\n loadPermission: 'Você não tem permissão para carregar esta publicação. Verifique se você fez login.',\n loadFailed: 'Não foi possível carregar a publicação: {message}',\n titleRequired: 'Insira um título.',\n photoRequired: 'Envie pelo menos uma foto.',\n bodyRequired: 'Escreva algum texto ou anexe uma imagem.',\n signInRequired: 'É necessário fazer login.',\n boardMissing: 'Nenhum fórum foi especificado.',\n writeRestricted: 'Publicar e comentar está temporariamente restrito por causa de avisos de conteúdo acumulados.',\n moderationBlocked: 'Há conteúdo que não pode ser publicado.',\n },\n hint: {\n reviewing: 'Revisando o conteúdo…',\n saving: 'Salvando…',\n },\n },\n footer: {\n info: {\n title: 'Informações do fórum',\n about: 'Sobre este fórum',\n history: 'Histórico de versões',\n },\n stack: {\n ui: 'Interface e experiência',\n data: 'Dados e login',\n writing: 'Escrita e praticidade',\n },\n },\n likes: {\n signInRequired: 'Faça login.',\n commentNotFound: 'Comentário não encontrado.',\n },\n list: {\n settings: 'Configurações',\n loadFailed: 'Não foi possível carregar a lista: {error}',\n empty: 'Ainda não há publicações.',\n photo: 'Foto',\n likeCount: 'Curtidas {count}',\n info: {\n title: 'Informações do fórum',\n about: 'Sobre este fórum',\n history: 'Histórico de versões',\n },\n stack: {\n framework: 'Framework e linguagem',\n ui: 'UI e estilos',\n backend: 'Backend e dados',\n },\n tech: {\n nuxt: 'Framework SSR baseado em Vue',\n vue: 'Composition API · script setup',\n typescript: 'Dados e componentes com tipagem segura',\n nuxtUi: 'UI de fórum, formulários e modais',\n tailwind: 'Estilos responsivos · claro/escuro',\n lucide: 'Ícones leves e consistentes',\n dayjs: 'Datas legíveis e tempos relativos',\n firestore: 'Banco de dados em tempo real para publicações e comentários',\n auth: 'Login e permissões seguros',\n storage: 'Armazenamento de imagens e anexos',\n vuefire: 'Conexão em tempo real entre Nuxt e Firebase',\n },\n },\n listViewSwitch: {\n default: 'Padrão',\n dense: 'Compacta',\n video: 'Vídeo',\n },\n miniComments: {\n empty: 'Ainda não há comentários.',\n blinded: 'Este comentário foi ocultado por um administrador.',\n },\n miniList: {\n empty: 'Ainda não há publicações.',\n },\n miniPicker: {\n selectedCount: 'Selecionadas {count} / {max}',\n layoutHint: '2 ou mais aparecem em carrossel; 4, em grade',\n searchPlaceholder: 'Buscar títulos',\n loading: 'Carregando publicações…',\n noMore: 'Não há mais publicações para escolher.',\n },\n miniPost: {\n empty: 'Nenhuma publicação selecionada.',\n },\n moderation: {\n writeRestricted: 'Publicar e comentar está temporariamente restrito por causa de avisos de conteúdo acumulados.',\n },\n moderationPrompt: {\n localHit: 'Expressões como “{hit}” não são permitidas. Reescreva o texto.',\n localGeneric: 'Há palavrões no texto, então não é possível publicar. Reescreva o texto.',\n genericBlocked: 'Há conteúdo que não pode ser publicado. Edite o texto.',\n categories: {\n abuse: 'palavrões ou insultos',\n spam: 'spam ou publicidade',\n adult: 'conteúdo sexual ou adulto',\n violence: 'violência ou ameaças',\n other: 'conteúdo impróprio',\n },\n },\n moderationStrike: {\n liftSoon: 'liberado em breve',\n liftInHours: 'liberado em cerca de {hours} h',\n liftInDays: 'liberado em cerca de {days} d',\n liftInDaysHours: 'liberado em cerca de {days} d {hours} h',\n },\n posts: {\n boardIdRequired: '[memi-board] boardId é obrigatório.',\n photoRequired: 'Adicione uma foto.',\n bodyRequired: 'Escreva algum texto ou anexe uma imagem.',\n titleRequired: 'Insira um título.',\n },\n profile: {\n notFound: 'Usuário não encontrado.',\n noName: 'Sem nome',\n joined: 'Entrou {time}',\n lastVisit: 'Visto por último {time}',\n },\n profileEditModal: {\n imageOnly: 'Somente arquivos de imagem podem ser selecionados.',\n nameRequired: 'Insira um nome.',\n saveFailed: 'Não foi possível salvar o perfil.',\n title: 'Editar perfil',\n changePhoto: 'Alterar foto',\n name: 'Nome',\n bio: 'Bio',\n bioPlaceholder: 'Conte um pouco sobre você',\n },\n reportButton: {\n failed: 'Não foi possível enviar a denúncia.',\n reported: 'Denunciado',\n title: 'Denunciar publicação',\n done: 'Denúncia recebida. A equipe vai analisá-la.',\n reason: 'Motivo',\n detailPlaceholder: 'Detalhes (opcional)',\n submit: 'Denunciar',\n },\n reportList: {\n staffOnly: 'Somente a equipe pode ver as denúncias.',\n loadFailed: 'Não foi possível carregar a lista: {error}',\n empty: 'Nenhuma denúncia pendente.',\n untitled: 'Publicação sem título',\n author: 'por {name}',\n dismiss: 'Descartar',\n confirm: 'Resolver',\n },\n reports: {\n reason: {\n spam: 'Spam / publicidade',\n abuse: 'Ofensa / ódio / assédio',\n adult: 'Conteúdo sexual',\n illegal: 'Conteúdo ilegal',\n other: 'Outro',\n },\n fallbackLabel: 'Denúncia',\n signInRequired: 'Faça login.',\n cannotReportOwn: 'Você não pode denunciar sua própria publicação.',\n reasonRequired: 'Selecione um motivo.',\n detailTooLong: 'Os detalhes podem ter até {max} caracteres.',\n },\n sectionUtil: {\n kind: {\n list: 'Lista de publicações',\n post: 'Publicações',\n comments: 'Comentários',\n },\n sort: {\n latest: 'Mais recentes',\n likes: 'Mais curtidas',\n },\n untitledPost: 'Publicação',\n view: {\n media: 'Mídia',\n list: 'Lista',\n },\n },\n sections: {\n defaultTitle: 'Seção {n}',\n },\n seo: {\n allCategory: 'Tudo',\n boardDescription: 'Fórum {name}',\n allDescription: 'Fórum: avisos e novidades da comunidade.',\n postFallbackTitle: 'Publicação | {site}',\n postFallbackDescription: 'Fórum',\n photo: 'Foto',\n postDescriptionFallback: 'Publicação do fórum',\n listTitle: '{name} | {site}',\n listTitleAll: 'Fórum | {site}',\n listDescription: '{name}: confira as publicações mais recentes.',\n listDescriptionAll: 'Fórum: avisos e histórias.',\n },\n settings: {\n loading: 'Carregando configurações…',\n noAccess: 'Você não tem acesso',\n notFound: {\n title: 'Fórum não encontrado',\n checkAddress: 'Verifique o endereço.',\n checkBoardAddress: 'Verifique o endereço do fórum.',\n },\n newBoard: {\n title: 'Este fórum ainda não existe',\n description: 'Preencha os dados abaixo e salve para criá-lo com este ID.',\n },\n noLabel: 'Sem rótulo',\n option: {\n default: 'Padrão',\n },\n id: {\n label: 'ID da categoria',\n help: 'Usado na URL e nos dados',\n },\n writeRole: {\n label: 'Quem pode publicar',\n help: 'Função mínima necessária para publicar nesta categoria',\n helpShort: 'Função mínima necessária para publicar',\n options: {\n user: 'Usuários ou acima',\n staff: 'Equipe ou acima',\n admin: 'Somente administradores',\n },\n },\n commentWriteRole: {\n label: 'Quem pode comentar',\n help: 'Função mínima necessária para comentar nesta categoria',\n helpShort: 'Função mínima necessária para comentar',\n hiddenFixed: 'Somente a equipe responsável (fixo em fóruns ocultos)',\n },\n staff: {\n label: 'Equipe responsável',\n help: 'Somente a equipe selecionada pode configurar e gerenciar este fórum. Deixe vazio para apenas administradores',\n placeholder: 'Somente administradores (sem equipe definida)',\n },\n label: {\n label: 'Nome de exibição',\n help: 'O nome que os usuários veem',\n },\n description: {\n label: 'Descrição',\n help: 'Exibida no topo do fórum e em outros lugares',\n placeholder: 'Uma breve descrição deste fórum (opcional)',\n },\n visibility: {\n label: 'Visibilidade',\n options: {\n public: {\n label: 'Visível',\n description: 'Exibido na lista completa e nos filtros',\n },\n hidden: {\n label: 'Oculto',\n description: 'Diário ou privado. Somente a equipe responsável pode comentar',\n },\n },\n },\n listView: {\n label: 'Visualização da lista',\n help: 'Como a lista de publicações é exibida',\n options: {\n dense: 'Compacta',\n video: 'Vídeo',\n },\n },\n editorType: {\n label: 'Formulário de publicação',\n help: 'Como as publicações são escritas',\n options: {\n default: 'Escrever um título e um texto',\n image: 'Adicionar várias fotos e uma foto de capa',\n },\n },\n locale: {\n label: 'Idioma do fórum',\n auto: 'Automático (seguir o idioma do site)',\n },\n action: {\n moveDown: 'Para baixo',\n goToBoard: 'Ir para o fórum',\n deleteBoard: 'Excluir fórum',\n create: 'Criar',\n },\n newCategoryPlaceholder: 'Nome da nova categoria',\n savedCategory: 'Categoria “{label}” salva.',\n savedSettings: 'Configurações do fórum salvas.',\n created: 'Fórum criado.',\n error: {\n orderFailed: 'Não foi possível salvar a ordem das categorias.',\n deleteFailed: 'Não foi possível excluir o fórum e seus dados.',\n saveFailed: 'Não foi possível salvar as configurações do fórum.',\n deleteAllFailed: 'Não foi possível excluir os dados do fórum.',\n },\n danger: {\n title: 'Zona de perigo',\n confirmLabel: 'Digite “{phrase}” para confirmar',\n phrase: 'excluir dados do fórum',\n button: 'Excluir todos os dados',\n done: 'Todos os dados do fórum foram excluídos.',\n },\n },\n shareButton: {\n copied: 'Link copiado',\n },\n signIn: {\n withGoogle: 'Entrar com o Google',\n withApple: 'Entrar com a Apple',\n or: 'ou',\n name: 'Nome',\n email: 'E-mail',\n password: 'Senha',\n signUp: 'Cadastrar-se',\n toSignUp: 'Não tem uma conta? Cadastre-se',\n toSignIn: 'Já tem uma conta? Entrar',\n },\n storage: {\n sourceTooLarge: 'A imagem original deve ter {max}MB ou menos.',\n imageOnly: 'Somente arquivos de imagem podem ser enviados.',\n canvasUnavailable: 'Canvas não está disponível',\n compressFailed: 'Falha na compressão',\n loadFailed: 'Falha ao carregar a imagem',\n },\n swipeHint: {\n title: 'Experimente deslizar para os lados',\n description: 'Deslize para a esquerda para ver a próxima publicação e para a direita para ver a anterior.',\n },\n userCommentList: {\n loadFailed: 'Não foi possível carregar a lista: {error}',\n empty: 'Ainda não há comentários.',\n blinded: 'Este comentário foi ocultado por um administrador.',\n likes: '{count} curtidas',\n },\n userPostList: {\n loadFailed: 'Não foi possível carregar a lista: {error}',\n empty: 'Ainda não há publicações.',\n },\n users: {\n changeRoleFailed: 'Não foi possível alterar a função.',\n loading: 'Carregando usuários…',\n noPermission: {\n title: 'Sem permissão',\n description: 'Somente administradores do fórum podem gerenciar as funções dos usuários.',\n },\n empty: 'Nenhum usuário fez login no fórum ainda.',\n noName: 'Sem nome',\n moderationWarnings: '{count} avisos de conteúdo',\n },\n writingAssistant: {\n loginRequired: 'Faça login para usar o assistente de escrita com AI.',\n userMissing: 'Não foi possível verificar suas informações de usuário. Tente novamente.',\n contentRequired: 'Primeiro, escreva algum conteúdo para a AI trabalhar.',\n customRequired: 'Escreva uma instrução de como reescrever.',\n emptyResult: 'A AI não conseguiu gerar um resultado. Tente novamente.',\n failed: 'A tarefa de AI falhou. Tente novamente.',\n actions: {\n proofread: 'Verificar ortografia',\n polish: 'Aprimorar frases',\n continue: 'Continuar escrevendo',\n summarize: 'Resumir',\n translateEn: 'Traduzir para inglês',\n translateKo: 'Traduzir para coreano',\n title: 'Gerar título a partir do texto',\n custom: 'Reescrever com um estilo personalizado…',\n },\n },\n}\n\nexport default messages\n"],"mappings":"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"}
@@ -2,6 +2,7 @@
2
2
  import { ref } from 'vue'
3
3
  import type { Attachment } from 'memi-board/runtime'
4
4
  import { useMemiBoardStorage } from 'memi-board/storage'
5
+ import { useMemiBoardI18n } from 'memi-board/runtime'
5
6
 
6
7
  const props = withDefaults(defineProps<{
7
8
  modelValue: Attachment[]
@@ -17,6 +18,7 @@ const props = withDefaults(defineProps<{
17
18
  const emit = defineEmits<{ 'update:modelValue': [attachments: Attachment[]] }>()
18
19
 
19
20
  const { uploadAttachment, deleteAttachment } = useMemiBoardStorage()
21
+ const { t } = useMemiBoardI18n()
20
22
 
21
23
  const fileInput = ref<HTMLInputElement | null>(null)
22
24
  const uploading = ref(false)
@@ -145,7 +147,7 @@ function formatSize(bytes: number): string {
145
147
  >
146
148
  <UButton
147
149
  icon="i-lucide-paperclip"
148
- :label="`파일 첨부 (${modelValue.length} / ${maxFiles})`"
150
+ :label="t('attachments.attach', { count: modelValue.length, max: maxFiles })"
149
151
  variant="outline"
150
152
  color="neutral"
151
153
  size="sm"
@@ -167,7 +169,7 @@ function formatSize(bytes: number): string {
167
169
  v-else-if="editable"
168
170
  class="text-xs text-muted"
169
171
  >
170
- 최대 {{ maxFiles }}개까지 첨부할 수 있습니다.
172
+ {{ t('attachments.maxFiles', { max: maxFiles }) }}
171
173
  </p>
172
174
  </div>
173
175
  </template>
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref, watch } from 'vue'
3
- import { AUTHOR_MEMO_MAX_LENGTH, useMemiBoardAuth, useMemiBoardAuthorMemo } from 'memi-board/runtime'
3
+ import { AUTHOR_MEMO_MAX_LENGTH, useMemiBoardAuth, useMemiBoardAuthorMemo, useMemiBoardI18n } from 'memi-board/runtime'
4
4
 
5
5
  const props = withDefaults(defineProps<{
6
6
  authorUid: string
@@ -20,14 +20,16 @@ const props = withDefaults(defineProps<{
20
20
  truncate: false,
21
21
  })
22
22
 
23
+ const { t } = useMemiBoardI18n()
24
+
23
25
  const authorProfileLink = computed(() => props.authorProfileTo?.(props.authorUid))
24
26
  const authorPostsLink = computed(() => props.authorPostsTo?.(props.authorUid))
25
27
  const authorCommentsLink = computed(() => props.authorCommentsTo?.(props.authorUid))
26
28
 
27
29
  const items = computed(() => [[
28
- { label: '프로필 보기', icon: 'i-lucide-user-round', to: authorProfileLink.value, disabled: !authorProfileLink.value },
29
- { label: '작성글 보기', icon: 'i-lucide-notebook-text', to: authorPostsLink.value, disabled: !authorPostsLink.value },
30
- { label: '작성한 댓글 보기', icon: 'i-lucide-message-square-text', to: authorCommentsLink.value, disabled: !authorCommentsLink.value },
30
+ { label: t('authorMenu.viewProfile'), icon: 'i-lucide-user-round', to: authorProfileLink.value, disabled: !authorProfileLink.value },
31
+ { label: t('authorMenu.viewPosts'), icon: 'i-lucide-notebook-text', to: authorPostsLink.value, disabled: !authorPostsLink.value },
32
+ { label: t('authorMenu.viewComments'), icon: 'i-lucide-message-square-text', to: authorCommentsLink.value, disabled: !authorCommentsLink.value },
31
33
  ]])
32
34
 
33
35
  // 다른 작성자에 대해 나만 보는 메모 — 로그인 안 했거나 내 카드면 아이콘 자체를 숨긴다.
@@ -74,8 +76,8 @@ async function onDeleteMemo() {
74
76
  >
75
77
  <UDropdownMenu :items="items" :content="{ align: 'start' }">
76
78
  <UButton
77
- :avatar="showAvatar ? { src: authorPhoto ?? undefined, alt: authorName ?? '익명' } : undefined"
78
- :label="authorName ?? '익명'"
79
+ :avatar="showAvatar ? { src: authorPhoto ?? undefined, alt: authorName ?? t('common.label.anonymous') } : undefined"
80
+ :label="authorName ?? t('common.label.anonymous')"
79
81
  variant="ghost"
80
82
  color="neutral"
81
83
  size="xs"
@@ -97,20 +99,20 @@ async function onDeleteMemo() {
97
99
  type="button"
98
100
  class="shrink-0 rounded-md p-1 transition-colors hover:bg-elevated/60"
99
101
  :class="memoSentiment === 'good' ? 'text-primary' : memoSentiment === 'bad' ? 'text-error' : 'text-muted'"
100
- aria-label="이 작성자에 대한 메모"
102
+ :aria-label="t('authorMenu.memoAriaLabel')"
101
103
  >
102
104
  <UIcon name="i-lucide-notebook-pen" class="size-3.5" />
103
105
  </button>
104
106
  <template #content>
105
107
  <div class="flex flex-col gap-2 p-3">
106
108
  <p class="text-xs text-muted">
107
- 나만 보는 메모
109
+ {{ t('authorMenu.memoTitle') }}
108
110
  </p>
109
111
  <UTextarea
110
112
  v-model="memoText"
111
113
  :rows="3"
112
114
  :maxlength="AUTHOR_MEMO_MAX_LENGTH"
113
- placeholder="이 작성자에 대해 적어두기…"
115
+ :placeholder="t('authorMenu.memoPlaceholder')"
114
116
  class="w-full"
115
117
  />
116
118
  <div class="flex items-center justify-between gap-2">
@@ -124,7 +126,7 @@ async function onDeleteMemo() {
124
126
  <UButton
125
127
  v-if="memoText || memoSentiment"
126
128
  size="xs"
127
- label="삭제"
129
+ :label="t('common.action.delete')"
128
130
  color="neutral"
129
131
  variant="ghost"
130
132
  :disabled="memoSaving"
@@ -132,7 +134,7 @@ async function onDeleteMemo() {
132
134
  />
133
135
  <UButton
134
136
  size="xs"
135
- label="나쁨"
137
+ :label="t('authorMenu.bad')"
136
138
  color="error"
137
139
  :loading="memoPendingSentiment === 'bad'"
138
140
  :disabled="memoSaving"
@@ -140,7 +142,7 @@ async function onDeleteMemo() {
140
142
  />
141
143
  <UButton
142
144
  size="xs"
143
- label="좋음"
145
+ :label="t('authorMenu.good')"
144
146
  color="primary"
145
147
  :loading="memoPendingSentiment === 'good'"
146
148
  :disabled="memoSaving"
@@ -2,6 +2,7 @@
2
2
  import { ref } from 'vue'
3
3
  import { Cropper } from 'vue-advanced-cropper'
4
4
  import 'vue-advanced-cropper/dist/style.css'
5
+ import { useMemiBoardI18n } from 'memi-board/runtime'
5
6
 
6
7
  const open = defineModel<boolean>('open', { default: false })
7
8
 
@@ -21,6 +22,8 @@ const emit = defineEmits<{
21
22
  cancel: []
22
23
  }>()
23
24
 
25
+ const { t } = useMemiBoardI18n()
26
+
24
27
  const cropperRef = ref<InstanceType<typeof Cropper> | null>(null)
25
28
 
26
29
  function close() {
@@ -56,8 +59,8 @@ async function confirm() {
56
59
  <template>
57
60
  <UModal
58
61
  v-model:open="open"
59
- title="프로필 사진 자르기"
60
- description="정사각형(1:1)으로 보여집니다. 영역을 맞춰 주세요."
62
+ :title="t('avatarCropModal.title')"
63
+ :description="t('avatarCropModal.description')"
61
64
  :ui="{ content: 'sm:max-w-lg' }"
62
65
  >
63
66
  <template #body>
@@ -74,7 +77,7 @@ async function confirm() {
74
77
  </div>
75
78
  </ClientOnly>
76
79
  <p class="mt-3 text-xs text-muted">
77
- 드래그로 위치·확대 조절 · {{ outputSize }}×{{ outputSize }} 정사각 JPEG로 저장됩니다
80
+ {{ t('avatarCropModal.hint', { size: outputSize }) }}
78
81
  </p>
79
82
  </template>
80
83
 
@@ -86,14 +89,14 @@ async function confirm() {
86
89
  :disabled="loading"
87
90
  @click="close"
88
91
  >
89
- 취소
92
+ {{ t('common.action.cancel') }}
90
93
  </UButton>
91
94
  <UButton
92
95
  icon="i-lucide-check"
93
96
  :loading="loading"
94
97
  @click="confirm"
95
98
  >
96
- 자르고 업로드
99
+ {{ t('avatarCropModal.confirm') }}
97
100
  </UButton>
98
101
  </div>
99
102
  </template>
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, onMounted, ref, watch } from 'vue'
3
- import { useMemiBoardAuth, useMemiBoardSettings, useMemiBoardUsers } from 'memi-board/runtime'
3
+ import { MEMI_BOARD_LOCALES, useMemiBoardAuth, useMemiBoardI18n, useMemiBoardSettings, useMemiBoardUsers } from 'memi-board/runtime'
4
4
  import type { BoardCategory, BoardEditorType, BoardListView, BoardVisibility, BoardWriteRole } from 'memi-board/runtime'
5
5
  import MemiBoardOptionCards from './OptionCards.vue'
6
6
 
@@ -23,25 +23,35 @@ const props = withDefaults(defineProps<{
23
23
 
24
24
  const emit = defineEmits<{ saved: [category: BoardCategory] }>()
25
25
 
26
- const listViewOptions: Array<{ label: string, value: BoardListView }> = [
27
- { label: '일반', value: 'default' },
28
- { label: '조밀', value: 'dense' },
29
- { label: '이미지', value: 'image' },
30
- { label: '영상', value: 'video' },
31
- ]
32
- const editorTypeOptions: Array<{ label: string, value: BoardEditorType, description: string }> = [
33
- { label: '일반', value: 'default', description: '제목과 본문을 작성' },
34
- { label: '이미지', value: 'image', description: '여러 사진과 대표사진을 작성' },
35
- ]
36
- const writeRoleOptions: Array<{ label: string, value: BoardWriteRole }> = [
37
- { label: '일반 이상', value: 'user' },
38
- { label: '스태프 이상', value: 'staff' },
39
- { label: '관리자만', value: 'admin' },
40
- ]
41
- const visibilityOptions: Array<{ label: string, value: BoardVisibility, description?: string }> = [
42
- { label: '보임', value: 'public', description: '전체 목록·필터에 표시' },
43
- { label: '숨김', value: 'hidden', description: '일기장·비공개. 댓글은 담당 스태프만' },
44
- ]
26
+ const { t } = useMemiBoardI18n()
27
+ const listViewOptions = computed<Array<{ label: string, value: BoardListView }>>(() => [
28
+ { label: t('settings.option.default'), value: 'default' },
29
+ { label: t('settings.listView.options.dense'), value: 'dense' },
30
+ { label: t('common.label.image'), value: 'image' },
31
+ { label: t('settings.listView.options.video'), value: 'video' },
32
+ ])
33
+ const editorTypeOptions = computed<Array<{ label: string, value: BoardEditorType, description: string }>>(() => [
34
+ { label: t('settings.option.default'), value: 'default', description: t('settings.editorType.options.default') },
35
+ { label: t('common.label.image'), value: 'image', description: t('settings.editorType.options.image') },
36
+ ])
37
+ const writeRoleOptions = computed<Array<{ label: string, value: BoardWriteRole }>>(() => [
38
+ { label: t('settings.writeRole.options.user'), value: 'user' },
39
+ { label: t('settings.writeRole.options.staff'), value: 'staff' },
40
+ { label: t('settings.writeRole.options.admin'), value: 'admin' },
41
+ ])
42
+ const visibilityOptions = computed<Array<{ label: string, value: BoardVisibility, description?: string }>>(() => [
43
+ { label: t('settings.visibility.options.public.label'), value: 'public', description: t('settings.visibility.options.public.description') },
44
+ { label: t('settings.visibility.options.hidden.label'), value: 'hidden', description: t('settings.visibility.options.hidden.description') },
45
+ ])
46
+ // reka-ui Select 는 빈 문자열 값을 허용하지 않아 '자동'을 sentinel 로 두고 저장 시 '' 로 바꾼다.
47
+ const LOCALE_AUTO = 'auto'
48
+ const localeOptions = computed(() => [
49
+ { label: t('settings.locale.auto'), value: LOCALE_AUTO },
50
+ ...MEMI_BOARD_LOCALES.map((item: { code: string, name: string }) => ({ label: item.name, value: item.code })),
51
+ ])
52
+ function fromLocaleOption(value: string) {
53
+ return value === LOCALE_AUTO ? '' : value
54
+ }
45
55
 
46
56
  const { isAdmin, rolePending } = useMemiBoardAuth()
47
57
  const { categories, settingsPending, saveCategory } = useMemiBoardSettings()
@@ -73,6 +83,7 @@ function emptyDraft(id: string): BoardCategory {
73
83
  writeRole: 'user',
74
84
  commentWriteRole: 'user',
75
85
  allowedStaffUids: [],
86
+ locale: '',
76
87
  }
77
88
  }
78
89
 
@@ -92,6 +103,7 @@ function toDraft(category: BoardCategory): BoardCategory {
92
103
  writeRole: category.writeRole ?? 'user',
93
104
  commentWriteRole: category.commentWriteRole ?? 'user',
94
105
  allowedStaffUids: category.allowedStaffUids ?? [],
106
+ locale: category.locale ?? '',
95
107
  }
96
108
  }
97
109
 
@@ -144,7 +156,7 @@ async function save() {
144
156
  emit('saved', snapshot)
145
157
  }
146
158
  catch (cause) {
147
- error.value = cause instanceof Error ? cause.message : '게시판 설정을 저장하지 못했습니다.'
159
+ error.value = cause instanceof Error ? cause.message : t('settings.error.saveFailed')
148
160
  }
149
161
  finally {
150
162
  saving.value = false
@@ -153,33 +165,33 @@ async function save() {
153
165
  </script>
154
166
 
155
167
  <template>
156
- <div v-if="pending" class="py-16 text-center text-sm text-muted">설정을 불러오고 있습니다.</div>
157
- <UAlert v-else-if="!canManage" color="neutral" variant="subtle" icon="i-lucide-lock" title="접근 권한이 없습니다" />
158
- <UAlert v-else-if="!draft" color="neutral" variant="subtle" icon="i-lucide-circle-help" title="없는 게시판입니다" description="게시판 주소를 다시 확인해 주세요." />
168
+ <div v-if="pending" class="py-16 text-center text-sm text-muted">{{ t('settings.loading') }}</div>
169
+ <UAlert v-else-if="!canManage" color="neutral" variant="subtle" icon="i-lucide-lock" :title="t('settings.noAccess')" />
170
+ <UAlert v-else-if="!draft" color="neutral" variant="subtle" icon="i-lucide-circle-help" :title="t('settings.notFound.title')" :description="t('settings.notFound.checkBoardAddress')" />
159
171
  <div v-else class="flex flex-col gap-5">
160
172
  <UAlert
161
173
  v-if="isNew"
162
174
  color="neutral"
163
175
  variant="subtle"
164
176
  icon="i-lucide-circle-plus"
165
- title="아직 없는 게시판입니다"
166
- description="아래 정보를 입력하고 저장하면 이 ID로 새로 만들어집니다."
177
+ :title="t('settings.newBoard.title')"
178
+ :description="t('settings.newBoard.description')"
167
179
  />
168
180
  <div class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-center">
169
- <div><p class="text-sm font-medium">카테고리 ID</p><p class="text-xs text-muted">주소와 데이터 기준값</p></div>
181
+ <div><p class="text-sm font-medium">{{ t('settings.id.label') }}</p><p class="text-xs text-muted">{{ t('settings.id.help') }}</p></div>
170
182
  <UInput :model-value="draft.id" disabled class="font-mono" />
171
183
  </div>
172
184
  <div class="flex flex-col gap-2">
173
- <div><p class="text-sm font-medium">글쓰기 권한</p><p class="text-xs text-muted">글을 쓸 수 있는 최소 역할</p></div>
185
+ <div><p class="text-sm font-medium">{{ t('settings.writeRole.label') }}</p><p class="text-xs text-muted">{{ t('settings.writeRole.helpShort') }}</p></div>
174
186
  <MemiBoardOptionCards v-model="draft.writeRole" :options="writeRoleOptions" @update:model-value="saved = false" />
175
187
  </div>
176
188
  <div class="flex flex-col gap-2">
177
189
  <div>
178
- <p class="text-sm font-medium">댓글쓰기 권한</p>
190
+ <p class="text-sm font-medium">{{ t('settings.commentWriteRole.label') }}</p>
179
191
  <p class="text-xs text-muted">
180
192
  {{ draft.visibility === 'hidden'
181
- ? '숨김 게시판은 댓글이 공개 피드에 안 나오고, 담당 스태프만 작성할 수 있습니다'
182
- : '댓글을 쓸 수 있는 최소 역할' }}
193
+ ? t('settings.commentWriteRole.hiddenHelp')
194
+ : t('settings.commentWriteRole.helpShort') }}
183
195
  </p>
184
196
  </div>
185
197
  <MemiBoardOptionCards
@@ -192,55 +204,65 @@ async function save() {
192
204
  v-else
193
205
  class="rounded-lg border border-default px-3 py-2.5 text-sm text-muted"
194
206
  >
195
- 담당 스태프만 (숨김 보드 고정)
207
+ {{ t('settings.commentWriteRole.hiddenFixed') }}
196
208
  </p>
197
209
  </div>
198
210
  <div v-if="needsStaffPicker" class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-center">
199
- <div><p class="text-sm font-medium">담당 스태프</p><p class="text-xs text-muted">지정한 스태프만 이 보드를 설정·관리합니다. 비우면 관리자만</p></div>
211
+ <div><p class="text-sm font-medium">{{ t('settings.staff.label') }}</p><p class="text-xs text-muted">{{ t('settings.staff.help') }}</p></div>
200
212
  <USelectMenu
201
213
  v-model="draft.allowedStaffUids"
202
214
  :items="staffOptions"
203
215
  value-key="value"
204
216
  label-key="label"
205
217
  multiple
206
- placeholder="관리자만 (스태프 미지정)"
218
+ :placeholder="t('settings.staff.placeholder')"
207
219
  @update:model-value="saved = false"
208
220
  />
209
221
  </div>
210
222
  <div class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-center">
211
- <div><p class="text-sm font-medium">표시 라벨</p><p class="text-xs text-muted">사용자에게 보이는 이름</p></div>
223
+ <div><p class="text-sm font-medium">{{ t('settings.label.label') }}</p><p class="text-xs text-muted">{{ t('settings.label.help') }}</p></div>
212
224
  <UInput v-model="draft.label" @update:model-value="saved = false" />
213
225
  </div>
214
226
  <div class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-start">
215
- <div><p class="text-sm font-medium">설명</p><p class="text-xs text-muted">게시판 상단 등에 보이는 안내 문구</p></div>
227
+ <div><p class="text-sm font-medium">{{ t('settings.description.label') }}</p><p class="text-xs text-muted">{{ t('settings.description.help') }}</p></div>
216
228
  <UTextarea
217
229
  v-model="draft.description"
218
230
  :rows="3"
219
231
  autoresize
220
232
  :maxrows="6"
221
- placeholder="이 게시판에 대한 짧은 설명 (선택)"
233
+ :placeholder="t('settings.description.placeholder')"
222
234
  @update:model-value="saved = false"
223
235
  />
224
236
  </div>
225
237
  <div v-if="canManageStaffAssignment" class="flex flex-col gap-2">
226
238
  <div>
227
- <p class="text-sm font-medium">공개 범위</p>
228
- <p class="text-xs text-muted">숨김이면 전체 필터에 안 나오고, 글은 관리자·담당 스태프·작성자만 읽을 수 있습니다. 댓글은 담당 스태프만 쓸 수 있습니다</p>
239
+ <p class="text-sm font-medium">{{ t('settings.visibility.label') }}</p>
240
+ <p class="text-xs text-muted">{{ t('settings.visibility.help') }}</p>
229
241
  </div>
230
242
  <MemiBoardOptionCards v-model="draft.visibility" :options="visibilityOptions" @update:model-value="saved = false" />
231
243
  </div>
232
244
  <div class="flex flex-col gap-2">
233
- <div><p class="text-sm font-medium">리스트뷰</p><p class="text-xs text-muted">게시글 목록 표시 방식</p></div>
245
+ <div><p class="text-sm font-medium">{{ t('settings.listView.label') }}</p><p class="text-xs text-muted">{{ t('settings.listView.help') }}</p></div>
234
246
  <MemiBoardOptionCards v-model="draft.listView" :options="listViewOptions" @update:model-value="saved = false" />
235
247
  </div>
236
248
  <div class="flex flex-col gap-2">
237
- <div><p class="text-sm font-medium">입력 폼</p><p class="text-xs text-muted">글쓰기 화면의 작성 방식</p></div>
249
+ <div><p class="text-sm font-medium">{{ t('settings.editorType.label') }}</p><p class="text-xs text-muted">{{ t('settings.editorType.help') }}</p></div>
238
250
  <MemiBoardOptionCards v-model="draft.editorType" :options="editorTypeOptions" @update:model-value="saved = false" />
239
251
  </div>
252
+ <div class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-center">
253
+ <div><p class="text-sm font-medium">{{ t('settings.locale.label') }}</p><p class="text-xs text-muted">{{ t('settings.locale.help') }}</p></div>
254
+ <USelect
255
+ :model-value="draft.locale || LOCALE_AUTO"
256
+ :items="localeOptions"
257
+ value-key="value"
258
+ label-key="label"
259
+ @update:model-value="draft.locale = fromLocaleOption($event as string); saved = false"
260
+ />
261
+ </div>
240
262
  <div class="flex justify-end border-t border-default pt-4">
241
- <UButton :label="isNew ? '만들기' : '저장'" :icon="isNew ? 'i-lucide-plus' : 'i-lucide-save'" :loading="saving" @click="save" />
263
+ <UButton :label="isNew ? t('settings.action.create') : t('common.action.save')" :icon="isNew ? 'i-lucide-plus' : 'i-lucide-save'" :loading="saving" @click="save" />
242
264
  </div>
243
265
  <UAlert v-if="error" color="error" variant="subtle" :description="error" />
244
- <UAlert v-if="saved" color="success" variant="subtle" :description="isNew ? '게시판을 만들었습니다.' : '게시판 설정을 저장했습니다.'" />
266
+ <UAlert v-if="saved" color="success" variant="subtle" :description="isNew ? t('settings.created') : t('settings.savedSettings')" />
245
267
  </div>
246
268
  </template>
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref, resolveComponent, toRef } from 'vue'
3
- import { formatRelativeDate, useMemiBoardCommentFeed } from 'memi-board/runtime'
3
+ import { useMemiBoardCommentFeed, useMemiBoardI18n } from 'memi-board/runtime'
4
4
  import type { CommentFeedSort, CommentModel } from 'memi-board/runtime'
5
5
 
6
6
  const NuxtLink = resolveComponent('NuxtLink')
@@ -18,6 +18,8 @@ const props = withDefaults(defineProps<{
18
18
 
19
19
  const emit = defineEmits<{ select: [comment: CommentModel] }>()
20
20
 
21
+ const { t, formatRelativeDate } = useMemiBoardI18n()
22
+
21
23
  const { comments, commentsPending, hasMore, loadingMore, loadError, loadMore } = useMemiBoardCommentFeed(
22
24
  toRef(props, 'sort'),
23
25
  { pageSize: props.pageSize },
@@ -39,7 +41,7 @@ function boardName(comment: CommentModel): string {
39
41
  }
40
42
 
41
43
  const emptyMessage = computed(() =>
42
- props.sort === 'likes' ? '좋아요를 받은 댓글이 아직 없습니다.' : '아직 댓글이 없습니다.',
44
+ props.sort === 'likes' ? t('commentFeed.emptyLiked') : t('commentFeed.empty'),
43
45
  )
44
46
  </script>
45
47
 
@@ -57,7 +59,7 @@ const emptyMessage = computed(() =>
57
59
  v-else-if="loadError"
58
60
  class="py-8 text-center text-sm text-error"
59
61
  >
60
- 목록을 불러오지 못했습니다: {{ loadError }}
62
+ {{ t('commentFeed.loadFailed', { error: String(loadError) }) }}
61
63
  </p>
62
64
 
63
65
  <p
@@ -83,11 +85,11 @@ const emptyMessage = computed(() =>
83
85
  class="truncate font-medium text-highlighted"
84
86
  :class="authorLink(comment) ? 'hover:underline' : ''"
85
87
  >
86
- {{ comment.authorName || '익명' }}
88
+ {{ comment.authorName || t('common.label.anonymous') }}
87
89
  </component>
88
90
  <span v-if="boardName(comment)">· {{ boardName(comment) }}</span>
89
91
  <span>· {{ formatRelativeDate(comment.createdAt, now) }}</span>
90
- <span v-if="comment.parentId || comment.isReply">· 답글</span>
92
+ <span v-if="comment.parentId || comment.isReply">· {{ t('common.action.reply') }}</span>
91
93
  </div>
92
94
  <component
93
95
  :is="commentLink(comment) ? NuxtLink : 'button'"
@@ -99,7 +101,7 @@ const emptyMessage = computed(() =>
99
101
  v-if="comment.isBlinded"
100
102
  class="text-sm text-muted italic"
101
103
  >
102
- 관리자에 의해 블라인드된 댓글입니다.
104
+ {{ t('commentFeed.blinded') }}
103
105
  </p>
104
106
  <p
105
107
  v-else
@@ -129,7 +131,7 @@ const emptyMessage = computed(() =>
129
131
  <UButton
130
132
  variant="outline"
131
133
  color="neutral"
132
- label="더 보기"
134
+ :label="t('common.action.more')"
133
135
  block
134
136
  class="w-full"
135
137
  :loading="loadingMore"