diff --git a/.dialyzerignore.exs b/.dialyzerignore.exs
index e6c0996..0b780d6 100644
--- a/.dialyzerignore.exs
+++ b/.dialyzerignore.exs
@@ -1 +1,7 @@
-[{"deps/phoenix/lib/phoenix/router.ex", :pattern_match}]
+# Falso positivo do backend gerado por `use Gettext.Backend`: o Dialyzer
+# reclama de `call_without_opaque` ao chamar `Gettext.Plural.plural/2` com o
+# tipo opaco `%Expo.PluralForms{}` do proprio Gettext/Expo. Nao e codigo nosso
+# e some quando o Gettext relaxar a opacidade; ignoramos so este arquivo/aviso.
+[
+ {"lib/taina_web/gettext.ex", :call_without_opaque}
+]
diff --git a/.gitignore b/.gitignore
index 49ba466..12aa48e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,3 +41,6 @@ result
# Ybira dev storage
/priv/storage/
+
+# esbuild output (built from assets/ via `mix assets.build`)
+/priv/static/assets/
diff --git a/AGENTS.md b/AGENTS.md
index a69d8ab..9721288 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -86,3 +86,177 @@ This is a web application written using the Phoenix web framework.
+
+## Frontend / UI
+
+Rules for building Tainá's web UI (LiveViews, components, CSS, JS hooks). These
+are non-negotiable; when in doubt, read the cited examples in the repo.
+
+### Source of truth
+
+- The aesthetic is **already committed** in Penpot ("Cofre da Comunidade - UI
+ v1") and mirrored 1:1 in `assets/css/tokens.css`. **Do not invent a look.**
+ You are executing a design, not freestyling one — precision over novelty.
+- `tekoa/` docs win over instinct. For non-trivial UI, run the
+ `community-ux-reviewer` agent before shipping; it grounds on the docs and
+ argues the product, not just the pixels.
+
+### Tokens — never raw values
+
+- Two levels: **primitives** (raw palette/scale) → **semantic** (what
+ components consume). Components consume **semantic only** — never a primitive,
+ never a raw hex or px in `.heex` or component CSS.
+- A new value is born as a **semantic token first** (and must trace back to
+ Penpot). No magic numbers in markup.
+- Typography: use the `type-*` utility classes and the type scale. Fonts are
+ fixed — Bricolage Grotesque (display) / Schibsted Grotesk (body) / JetBrains
+ Mono (mono). **Don't add fonts.**
+- Color: dark-first night palette via semantic tokens (`--text-*`,
+ `--surface-*`, `--border-*`, `--brand-*`). Each subsystem has a service accent
+ (`--service-ybira` forest, `--service-jaci` moon, …) — use it, don't recolor.
+- No AI slop: no Inter/Roboto/Arial/system fonts, no purple-on-white, no
+ cookie-cutter layouts. The character is the dark Tupi-Guarani vault — keep it.
+
+### Functional Core / Imperative Shell (LiveView **and** JS)
+
+This is the single most important rule and it applies to the frontend too.
+
+- **LiveView callbacks are the imperative shell.** `mount`, `handle_params`,
+ `handle_event` stay thin — they only `assign` / `stream` / `push_patch` /
+ `put_flash`. Every decision lives in **pure private helpers that take data and
+ return data**, not the socket. See `gallery_live.ex`: `merge_groups/2`,
+ `step/2`, `group_title/1`, `viewer_index/2`, `video?/1` — logic out of the
+ callbacks, callbacks just wire results in.
+- **Domain logic lives in contexts** (Maracá / Ybira / Jaci). The LiveView
+ *calls* the context and shapes the result for the view; it never reimplements
+ business rules. Always pass through `current_scope` (RLS boundary).
+- `assign_new/3` for derived/cached state that must survive the HTTP → WebSocket
+ handoff (see `hooks.ex` `storage_stats` — computed once, not re-queried).
+- **JS hooks are the edge, kept minimal.** Before writing a hook, check a native
+ binding (`phx-viewport-*`, `phx-drop-target`, `JS.*`) already solves it
+ (`hooks.js` opens with exactly this reminder). A hook only touches DOM events
+ and browser APIs (clipboard, share, keyboard/swipe); it `pushEvent`s and lets
+ the **server decide and own state** (see `Clipboard`, `ViewerNav`). Keep pure
+ transforms separate from DOM mutation. Functional Core / Imperative Shell —
+ including in JS.
+
+### Components & layout
+
+- **Reuse `core_components`** (`.button`, `.modal`, `.confirm_dialog`,
+ `.empty_state`, `.segmented`, `.icon`, `.icon_button`). Don't hand-roll a
+ primitive that already exists.
+- CSS: **BEM** for component classes (`block__element--modifier`, e.g.
+ `photo-grid__item`); **utility classes** for layout/spacing (`row between`,
+ `col gap-5`, `center`, `mt-4`, `type-*`). One LiveView per screen; wrap in
+ `Layouts.app`.
+- **Every screen ships empty / error / permission states** — not just the happy
+ path. `<.empty_state>` exists for this; use it.
+- All user-facing strings go through `gettext` / `ngettext`. **pt-BR first.**
+ Never anglicize the Tupi-Guarani names (Tekoa, Maracá, Ybira, Jaci, Guará).
+
+### Motion & accessibility
+
+- Motion is **CSS-first and restrained** — one high-impact moment beats
+ scattered micro-animations. Respect `prefers-reduced-motion`.
+- Touch targets ≥ `--size-touch` (44px). Icon buttons need a `label`. Images
+ need `alt`. Keyboard + swipe navigation where it matters (the `ViewerNav`
+ pattern). Visible focus states.
+
+### Build for the audience (communitarian, non-technical)
+
+These are build directives distilled from `community-ux-reviewer` — apply them
+*while building*, not only in review.
+
+- **Design for the least-technical person in the community**, not power users.
+ No jargon, no assumed technical knowledge, no dead-ends.
+- **Commons, not a personal account.** Favor shared spaces, collective memory,
+ and stewardship ("who keeps this organized") over individualist patterns:
+ profiles, vanity metrics, likes/followers, algorithmic feeds, and
+ attention-bait notifications. Default to shared-by-norm with **explicit,
+ legible consent**, not private silos.
+- **One community per box** — hard-enforced. No multi-community switching, no
+ public discovery, no cross-instance feed. The "network" is this one Tekoa.
+- **Onboarding is by invitation** — arriving as belonging, not signing up alone.
+- **LiveView / Raspberry Pi feasibility lens on every interaction.** Server-
+ rendered, mobile-first, low client state. No offline-PWA assumptions, no heavy
+ client-side state machines. Don't fight the architecture.
+- **Respect the scope cuts.** MVP = the community's file + photo vault. Chat
+ (Guará) is out of MVP — never propose chat as the way to be "more
+ communitarian"; find that value in files / photos / people / governance.
+
+### Plain text: no AI-typography, no emoji
+
+Text Tainá ships or that people read as docs reads like a human wrote it: UI
+copy / gettext, docs (including the `tekoa` repo), production comments and
+moduledocs under `lib/`, and commit/PR prose. No AI-typography tells, no emoji.
+Use plain ASCII:
+
+- `—` / `–`: comma for an aside, colon for a definition (`**Label**:`, headings,
+ titles), or a period to split a sentence. Choose by context.
+- `·` / `•`: comma in metadata (`PDF, 2,4 MB`), or ` / ` when a comma clashes.
+- `…` to `...`; curly quotes `" " ' '` to straight `" '`; `×` to `x`; `→` to
+ `->` or a word ("para", "depois").
+- Emoji: remove. Do/don't markers become text (`✅` / `❌` to `Bom:` / `Evite:`).
+- Keep intentional ASCII-art diagrams (box-drawing `─ │ └ ├` and their arrows):
+ deliberate, not typography.
+
+Humanize the sentence, don't just swap the glyph; align to the docs voice and
+`community-ux-reviewer`. `test/**` comments are out of scope (the `→` / `—`
+shorthand there is useful and stays); only fix a test when an assertion must
+match changed UI copy. Verify with a unicode grep after editing. Note: `rtk`
+rewrites a *leading* `grep`/`find`, which silently breaks unicode scans, so run
+`find … -print0 | xargs -0 grep -P '…'` or `perl -CSD -i -pe`.
+
+### Social model & transparency (Tainá-specific, non-negotiable)
+
+Decided 2026-06-15 (canonical record: `tekoa/tecnico/RFC_003_GOVERNANCA_E_TRANSPARENCIA.md`).
+
+- **Two zones, not one pile.** Content lives in **praça** (commons —
+ shared-by-norm, every morador reads; placing a file there IS the reversible
+ consent) or **casa** (personal — private-by-default; only the owner reads,
+ and *everyone else, including the zelador,* must `request_access` → owner
+ approves). New files default to **casa**. Read paths in Ybira/Jaci enforce
+ this (`zone == :praca OR Maraca.authorize?`), never tekoa-wide.
+- **Zelador, not admin.** Reframe the role as **zelador(a)** — caretaker of the
+ machine (disk, backups, updates, invites) with **zero data authority and zero
+ unilateral social power**. `member`→**morador(a)**. A tekoa may have
+ **multiple zeladores**. Tainá is pre-alpha → **rename the enum cleanly** in a
+ migration (`admin`→`:zelador`, `member`→`:morador`) — no cookie-session
+ caution needed. Predicates `Maraca.zelador?/1`/`morador?/1`. The zelador has
+ **no read shortcut** to a morador's casa — confirm this stays true.
+- **Transparency is a core pillar, not a feature.** Three layers, all MVP:
+ the **Mural** (append-only social ledger — who did what), the **Painel /
+ "Saúde da comunidade"** (system state in plain pt-BR), and the **telemetry +
+ structured-logging substrate** (`:telemetry`, `telemetry_metrics`, `Logger`
+ metadata). **SOVEREIGN — never phone home.** Telemetry/metrics/logs stay on
+ the community's box; shipping community data to any SaaS (PostHog, etc.)
+ betrays data sovereignty. Accessibility (WCAG contrast/labels/focus) is *part
+ of* accessible transparency, not separate polish.
+- **Honest framing.** No encryption exists yet. UI must say casa privacy is a
+ *software + trust* promise ("promessa de software, não cadeado matemático"),
+ never a cryptographic guarantee. Only future Ybira **E2E** (not convergent)
+ would let us honestly say the zelador *cannot* read casa files.
+- **Governance is collective.** Sensitive acts (remove member, appoint/revoke
+ zelador) are community-voted *proposals* (assembleia), never a god-button —
+ but that track is beyond RFC_002 and ships only after its tekoa RFC lands.
+- **Naming is tiered — one source of truth.** **Tupi-Guarani** for
+ subsystem/brand proper nouns only (Tekoa, Maracá, Ybira, Jaci, Guará).
+ **Familiar pt-BR** for the everyday social vocabulary, where the *same word*
+ is the DB atom, the context term, **and** the UI label — `:casa`, `:praca`,
+ `:zelador`, `:morador`, mural, pedido, assembleia — no English-atom →
+ pt-BR-label translation seam. **English** only for invisible plumbing users
+ never read (Repo, Scope, Telemetry). This is the deliberate exception to
+ "code in English" — same justification as the intentional Tupi naming, and it
+ serves the non-technical pt-BR audience.
+
+### Penpot is the UI source of truth — verify, then build
+
+- The Penpot design system is **complete and code-implemented** (foundations,
+ components, tokens, mobile + desktop); `assets/css/tokens.css` is imported
+ directly from Penpot. **Do not re-import tokens or invent foundations** —
+ compose new screens from the existing components + tokens.
+- The Penpot **MCP is live**. **Before non-trivial UI work, verify the live
+ file first** via the `community-ux-reviewer` agent (it holds the
+ `mcp__penpot__*` tools; the main loop does not). **Editing Penpot is
+ authorized** — refactor/improve boards within the existing token/component set
+ when it helps.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..2d28abd
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,42 @@
+# CLAUDE.md — Tainá
+
+Project memory for the Tainá app (this git repo). Overrides the user-level `~/.claude/CLAUDE.md` where they conflict. The workspace-level `../CLAUDE.md` is just a pointer to this file, to `AGENTS.md`, and to the `community-ux-reviewer` agent.
+
+## Source of truth: `../tekoa/`
+
+**`../tekoa/` is the canonical documentation and the source of truth for this project.** Before proposing architecture, scope, or behavior, read the relevant doc in `../tekoa/` and align to it. If code and `../tekoa/` disagree, `../tekoa/` wins — surface the drift rather than inventing a third answer.
+
+- `../tekoa/ROADMAP.md` — current MVP roadmap (v3.0). What's in/out of scope, and ordering. **Check this before suggesting features.**
+- `../tekoa/tecnico/RFC_ARQUITETURA.md` — the full architecture RFC. The authority on structure and subsystem boundaries.
+- `../tekoa/tecnico/RFC_002_MVP.md` — the MVP RFC that supersedes the older "PWA Monolith" v2.0 design; holds the rationale for current decisions.
+- `../tekoa/guias/contribuindo-github.md` — contribution workflow.
+- `../tekoa/CONTRIBUTING.md`, `../tekoa/CODE_OF_CONDUCT.md` — process and conduct.
+
+`../tekoa/` is a separate docs repo (`taina-labs/tekoa`) checked out as a sibling at the workspace root — it is **not** part of this app repo. Do not edit it as a side effect of app work; doc changes are deliberate and go through the docs repo.
+
+## What Tainá is
+
+Self-hosted private-cloud platform — a community's file/photo vault with painless install (per `ROADMAP.md`). Modular monolith, backend-first. AGPL. Tupi-Guarani-named subsystems: **Maracá** (auth/invites/authz), **Ybira** (content-addressed storage, source of truth for all file services — extend by composition, not inheritance), **Jaci** (photo gallery), **Guará** (chat, post-MVP).
+
+## Stack
+
+- Elixir 1.20 / OTP 28, **Phoenix 1.8 + LiveView**.
+- **PostgreSQL 18 with Row-Level Security** — per-community isolation. Treat RLS as a hard boundary, not an optimization.
+- Oban (jobs), Bandit (server), bcrypt, nanoid.
+- Nix flake + direnv for the dev env (`flake.nix`, `.envrc`); `docker-compose.yml` for Postgres.
+
+## Commands (run here, in the app dir)
+
+- `mix setup` — deps + `ecto.create` + `ecto.migrate` + seeds.
+- `mix test` — auto-creates/migrates the test DB first.
+- `mix test test/path_test.exs` / `mix test --failed` — focused runs.
+- `mix precommit` — **run when done.** Compiles `--warning-as-errors`, `deps.unlock --unused`, `format`, `test`. Fix everything it reports.
+- `mix ecto.reset` — drop + recreate when migrations get messy.
+- Quality tooling present: Credo, Dialyxir (PLTs in `priv/plts`), Styler.
+
+## Conventions
+
+- Phoenix/Ecto/Elixir specifics live in `AGENTS.md` — follow it. Notably: **`Req` is the HTTP client here** (Phoenix default), not Finch — this repo overrides my usual global preference.
+- **UI/frontend work**: follow the **Frontend / UI** section of `AGENTS.md` (tokens-only/Penpot as source of truth, Functional Core / Imperative Shell on LiveViews *and* JS hooks, `core_components` reuse, communitarian build directives). Run the `community-ux-reviewer` agent for non-trivial UI.
+- Otherwise my user-level conventions apply: Functional Core / Imperative Shell, `@behaviour` over `use` macros, `{:ok,_}`/`{:error,_}` tuples, minimal deps, pattern matching over validators.
+- One module per file. Predicate functions end in `?` (never `is_` prefix outside guards).
diff --git a/assets/css/app.css b/assets/css/app.css
new file mode 100644
index 0000000..bed4188
--- /dev/null
+++ b/assets/css/app.css
@@ -0,0 +1,23 @@
+/* Entry point, empacotado pelo esbuild junto com js/app.js.
+ Ordem importa: tokens primeiro, utilitários por último (precedência). */
+
+@import "./tokens.css";
+@import "./base.css";
+@import "./layouts.css";
+@import "./components.css";
+@import "./utilities.css";
+
+/* Estados de conexão do LiveView (feedback discreto, sem JS extra) */
+.phx-loading {
+ cursor: wait;
+}
+
+.phx-click-loading {
+ opacity: var(--opacity-muted);
+ pointer-events: none;
+}
+
+/* uploads: entrada inválida */
+.phx-no-feedback .field__error {
+ display: none;
+}
diff --git a/assets/css/base.css b/assets/css/base.css
new file mode 100644
index 0000000..a1a70fc
--- /dev/null
+++ b/assets/css/base.css
@@ -0,0 +1,179 @@
+/* Reset enxuto + fontes auto-hospedadas + estilos de tipo do Penpot.
+ Fontes variáveis (eixo wght) servidas de /fonts, nenhuma requisição externa. */
+
+@font-face {
+ font-family: "Bricolage Grotesque";
+ src: url("/fonts/bricolage-grotesque-latin-wght.woff2")
+ format("woff2-variations");
+ font-weight: 200 800;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "Schibsted Grotesk";
+ src: url("/fonts/schibsted-grotesk-latin-wght.woff2")
+ format("woff2-variations");
+ font-weight: 400 900;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "JetBrains Mono";
+ src: url("/fonts/jetbrains-mono-latin-wght.woff2") format("woff2-variations");
+ font-weight: 100 800;
+ font-display: swap;
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+* {
+ margin: 0;
+}
+
+html {
+ height: 100%;
+ color-scheme: dark;
+}
+
+body {
+ min-height: 100%;
+ background: var(--bg-app);
+ color: var(--text-primary);
+ font-family: var(--font-family-body);
+ font-size: var(--font-size-md);
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
+}
+
+img,
+svg,
+video {
+ display: block;
+ max-width: 100%;
+}
+
+input,
+button,
+textarea,
+select {
+ font: inherit;
+ color: inherit;
+}
+
+button {
+ cursor: pointer;
+ background: none;
+ border: none;
+ padding: 0;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+:focus-visible {
+ outline: var(--border-thick) solid var(--brand-primary);
+ outline-offset: 2px;
+}
+
+::placeholder {
+ color: var(--text-faint);
+}
+
+::selection {
+ background: var(--brand-primary);
+ color: var(--text-on-brand);
+}
+
+/* Sem barras de rolagem visíveis em nenhuma tela, o scroll continua,
+ só a barra some (Firefox + WebKit/Blink). */
+* {
+ scrollbar-width: none;
+}
+
+*::-webkit-scrollbar {
+ width: 0;
+ height: 0;
+ display: none;
+}
+
+/* ---------- estilos de tipo (tokens type.* do Penpot) ---------- */
+
+.type-display {
+ font-family: var(--font-family-display);
+ font-size: var(--font-size-display);
+ font-weight: var(--font-weight-bold);
+ letter-spacing: var(--tracking-tight);
+ line-height: 1.05;
+}
+
+.type-h1 {
+ font-family: var(--font-family-display);
+ font-size: var(--font-size-3xl);
+ font-weight: var(--font-weight-bold);
+ letter-spacing: var(--tracking-tight);
+ line-height: 1.1;
+}
+
+.type-h2 {
+ font-family: var(--font-family-display);
+ font-size: var(--font-size-2xl);
+ font-weight: var(--font-weight-semibold);
+ line-height: 1.15;
+}
+
+.type-h3 {
+ font-family: var(--font-family-display);
+ font-size: var(--font-size-lg);
+ font-weight: var(--font-weight-semibold);
+ line-height: 1.25;
+}
+
+.type-body {
+ font-family: var(--font-family-body);
+ font-size: var(--font-size-md);
+ font-weight: var(--font-weight-regular);
+ line-height: 1.5;
+}
+
+.type-body-sm {
+ font-family: var(--font-family-body);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-regular);
+ line-height: 1.45;
+}
+
+.type-label {
+ font-family: var(--font-family-body);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-semibold);
+ line-height: 1.2;
+}
+
+.type-caption {
+ font-family: var(--font-family-body);
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-medium);
+ line-height: 1.3;
+}
+
+.type-overline {
+ font-family: var(--font-family-body);
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-semibold);
+ letter-spacing: var(--tracking-wide);
+ line-height: 1.2;
+ text-transform: uppercase;
+}
+
+.type-mono-sm {
+ font-family: var(--font-family-mono);
+ font-size: 0.8125rem;
+ font-weight: var(--font-weight-regular);
+ line-height: 1.4;
+}
diff --git a/assets/css/components.css b/assets/css/components.css
new file mode 100644
index 0000000..a36be6f
--- /dev/null
+++ b/assets/css/components.css
@@ -0,0 +1,1169 @@
+/* Componentes do design system (página "01 - Componentes" do Penpot).
+ Cada bloco aqui tem um function component correspondente em
+ TainaWeb.CoreComponents, mantenha os dois em sincronia. */
+
+/* ---------- botões ---------- */
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ height: var(--size-control-lg);
+ padding-inline: var(--space-6);
+ border-radius: var(--radius-lg);
+ font-family: var(--font-family-body);
+ font-size: var(--font-size-md);
+ font-weight: var(--font-weight-semibold);
+ transition:
+ background-color 120ms ease,
+ color 120ms ease,
+ border-color 120ms ease;
+}
+
+.btn--primary {
+ background: var(--brand-primary);
+ color: var(--text-on-brand);
+}
+
+.btn--primary:hover {
+ background: var(--brand-primary-hover);
+}
+
+.btn--primary:active {
+ background: var(--brand-primary-pressed);
+}
+
+.btn--secondary {
+ background: transparent;
+ border: var(--border-hairline) solid var(--border-strong);
+ color: var(--text-primary);
+}
+
+.btn--secondary:hover {
+ background: var(--surface-raised);
+}
+
+.btn--ghost {
+ background: transparent;
+ color: var(--brand-primary);
+ padding-inline: var(--space-3);
+}
+
+.btn--ghost:hover {
+ color: var(--brand-primary-hover);
+}
+
+.btn--service {
+ background: var(--service-ybira);
+ color: var(--color-forest-900);
+}
+
+.btn--service:hover {
+ background: var(--color-forest-400);
+}
+
+.btn--danger {
+ background: transparent;
+ color: var(--feedback-error);
+}
+
+.btn--sm {
+ height: var(--size-control-sm);
+ padding-inline: var(--space-4);
+ font-size: var(--font-size-sm);
+ border-radius: var(--radius-md);
+}
+
+.btn--md {
+ height: var(--size-control-md);
+}
+
+.btn:disabled,
+.btn[aria-disabled="true"] {
+ opacity: var(--opacity-disabled);
+ cursor: not-allowed;
+}
+
+.btn--fab {
+ position: fixed;
+ right: var(--space-4);
+ bottom: calc(var(--size-nav-mobile) + var(--space-4));
+ z-index: 30;
+ width: var(--size-control-lg);
+ height: var(--size-control-lg);
+ padding: 0;
+ border-radius: var(--radius-pill);
+ background: var(--brand-primary);
+ color: var(--text-on-brand);
+ box-shadow: var(--elevation-lifted);
+}
+
+@media (min-width: 768px) {
+ .btn--fab {
+ display: none;
+ }
+}
+
+/* botão de ícone (appbar, kebab, ações de linha) */
+.icon-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--size-touch);
+ height: var(--size-touch);
+ border-radius: var(--radius-md);
+ color: var(--text-secondary);
+}
+
+.icon-btn:hover {
+ background: var(--surface-raised);
+ color: var(--text-primary);
+}
+
+.icon-btn--danger {
+ color: var(--feedback-error);
+}
+
+/* ---------- campos ---------- */
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.field > label {
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-semibold);
+ line-height: 1.2;
+}
+
+.field__control {
+ position: relative;
+ display: flex;
+ align-items: center;
+}
+
+.field input[type="text"],
+.field input[type="email"],
+.field input[type="password"],
+.field input[type="number"],
+.field input[type="search"],
+.field select,
+.field textarea {
+ width: 100%;
+ height: var(--size-control-md);
+ padding-inline: var(--space-4);
+ background: var(--field-bg);
+ border: var(--border-hairline) solid var(--border-default);
+ border-radius: var(--radius-md);
+ color: var(--text-primary);
+ transition: border-color 120ms ease;
+}
+
+.field textarea {
+ height: auto;
+ min-height: var(--size-control-lg);
+ padding-block: var(--space-3);
+}
+
+.field input:focus,
+.field select:focus,
+.field textarea:focus {
+ outline: none;
+ border-color: var(--brand-primary);
+}
+
+.field__control > .icon {
+ position: absolute;
+ right: var(--space-4);
+ color: var(--text-faint);
+ pointer-events: none;
+}
+
+.field__control:has(.icon) input {
+ padding-right: var(--space-12);
+}
+
+.field__help {
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-medium);
+ line-height: 1.3;
+ color: var(--text-faint);
+}
+
+.field__error {
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-medium);
+ line-height: 1.3;
+ color: var(--feedback-error);
+}
+
+.field--invalid input {
+ border-color: var(--feedback-error);
+}
+
+/* busca (membros, topbar desktop) */
+.search {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ height: var(--size-control-md);
+ padding-inline: var(--space-4);
+ background: rgb(15 23 41 / 0.6);
+ border: var(--border-hairline) solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ color: var(--text-faint);
+}
+
+.search input {
+ flex: 1;
+ background: none;
+ border: none;
+ color: var(--text-primary);
+}
+
+.search input:focus {
+ outline: none;
+}
+
+/* ---------- cards ---------- */
+
+.card {
+ background: var(--surface-default);
+ border: var(--border-hairline) solid var(--border-subtle);
+ border-radius: var(--radius-lg);
+ padding: var(--space-4);
+}
+
+.card--raised {
+ background: var(--surface-raised);
+}
+
+/* card de serviço da home (Arquivos, Fotos, ...) */
+.service-card {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--space-3);
+ padding: var(--space-4);
+ background: var(--surface-default);
+ border: var(--border-hairline) solid var(--border-subtle);
+ border-radius: var(--radius-lg);
+ transition:
+ border-color 120ms ease,
+ background-color 120ms ease;
+}
+
+.service-card:hover {
+ border-color: var(--border-strong);
+ background: var(--surface-raised);
+}
+
+.service-card[aria-disabled="true"] {
+ pointer-events: none;
+}
+
+.service-card[aria-disabled="true"] .service-card__title,
+.service-card[aria-disabled="true"] .service-card__meta {
+ color: var(--text-faint);
+}
+
+.service-card__title {
+ font-family: var(--font-family-display);
+ font-size: var(--font-size-lg);
+ font-weight: var(--font-weight-semibold);
+ line-height: 1.25;
+}
+
+.service-card__meta {
+ font-size: var(--font-size-sm);
+ color: var(--text-muted);
+}
+
+/* grade de cards da home: 2 colunas no mobile, 4 no desktop */
+.cards-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: var(--space-3);
+}
+
+@media (min-width: 768px) {
+ .cards-grid {
+ grid-template-columns: repeat(4, 1fr);
+ gap: var(--space-4);
+ }
+}
+
+/* legenda do uso por tipo (armazenamento) */
+.legend-dot {
+ width: var(--space-3);
+ height: var(--space-3);
+ border-radius: var(--radius-pill);
+ flex-shrink: 0;
+}
+
+.legend-dot--photos {
+ background: var(--service-jaci);
+}
+.legend-dot--videos {
+ background: var(--service-nhaman);
+}
+.legend-dot--documents {
+ background: var(--service-ybira);
+}
+.legend-dot--others {
+ background: var(--color-night-300);
+}
+
+/* quadradinho de ícone com cor do serviço */
+.service-square {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--space-10);
+ height: var(--space-10);
+ border-radius: var(--radius-md);
+ flex-shrink: 0;
+}
+
+.service-square--lg {
+ width: var(--space-12);
+ height: var(--space-12);
+}
+
+.service-square--ybira {
+ background: var(--service-ybira-soft);
+ color: var(--color-forest-300);
+}
+.service-square--jaci {
+ background: var(--service-jaci-soft);
+ color: var(--color-moon-400);
+}
+.service-square--nhaman {
+ background: var(--color-night-700);
+ color: var(--service-nhaman);
+}
+.service-square--guara {
+ background: var(--color-night-700);
+ color: var(--text-faint);
+}
+.service-square--doc {
+ background: var(--color-night-700);
+ color: var(--brand-ember);
+}
+.service-square--video {
+ background: var(--color-night-700);
+ color: var(--color-sky-300);
+}
+.service-square--neutral {
+ background: var(--color-night-700);
+ color: var(--text-muted);
+}
+
+/* ---------- chips & badges ---------- */
+
+.chip {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ height: var(--size-control-sm);
+ padding-inline: var(--space-4);
+ border-radius: var(--radius-pill);
+ border: var(--border-hairline) solid var(--border-default);
+ background: transparent;
+ color: var(--text-secondary);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-semibold);
+ transition:
+ background-color 120ms ease,
+ color 120ms ease;
+}
+
+.chip[aria-pressed="true"],
+.chip--selected {
+ background: var(--brand-primary);
+ border-color: var(--brand-primary);
+ color: var(--text-on-brand);
+}
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ height: 24px;
+ padding-inline: var(--space-3);
+ border-radius: var(--radius-pill);
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-semibold);
+}
+
+.badge--zelador {
+ background: var(--brand-primary);
+ color: var(--text-on-brand);
+}
+
+.badge--soon {
+ background: var(--surface-high);
+ color: var(--text-muted);
+}
+
+.badge--active {
+ border: var(--border-hairline) solid var(--feedback-success);
+ color: var(--feedback-success);
+}
+
+.badge--inactive {
+ color: var(--text-faint);
+}
+
+.badge--ybira {
+ background: var(--service-ybira-soft);
+ color: var(--color-forest-300);
+}
+.badge--jaci {
+ background: var(--service-jaci-soft);
+ color: var(--color-moon-400);
+}
+
+/* ---------- linhas de lista ---------- */
+
+.list {
+ display: flex;
+ flex-direction: column;
+}
+
+.list > * + * {
+ border-top: var(--border-hairline) solid var(--border-subtle);
+}
+
+.list-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding-block: var(--space-3);
+ min-height: var(--size-control-lg);
+ text-align: left;
+ width: 100%;
+}
+
+.list-row__body {
+ flex: 1;
+ min-width: 0;
+}
+
+.list-row__title {
+ font-weight: var(--font-weight-semibold);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.list-row__meta {
+ font-size: var(--font-size-sm);
+ color: var(--text-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.list-row__actions {
+ display: flex;
+ align-items: center;
+ gap: var(--space-1);
+ flex-shrink: 0;
+}
+
+/* alvo de soltar (arrastar arquivo/pasta sobre uma pasta) */
+.is-drop {
+ outline: var(--border-thick) dashed var(--brand-primary);
+ outline-offset: -2px;
+ border-radius: var(--radius-md);
+ background: rgb(242 166 90 / 0.08);
+}
+
+[data-drag-id] {
+ cursor: grab;
+}
+
+/* ---------- grade de arquivos (toggle lista/grade) ---------- */
+
+.tiles {
+ display: grid;
+ /* mobile: 2 por linha, tiles maiores que cabem em tela estreita */
+ grid-template-columns: repeat(2, 1fr);
+ gap: var(--space-3);
+}
+
+@media (min-width: 768px) {
+ .tiles {
+ grid-template-columns: repeat(4, 1fr);
+ gap: var(--space-4);
+ }
+}
+
+.tile {
+ display: flex;
+ flex-direction: column;
+ /* grid item precisa encolher abaixo do conteúdo, senão nome longo estoura a
+ coluna e cria scroll horizontal, queremos só vertical no mobile */
+ min-width: 0;
+ gap: var(--space-1);
+ padding: var(--space-3);
+ background: var(--surface-default);
+ border: var(--border-hairline) solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ transition:
+ border-color 120ms ease,
+ background-color 120ms ease;
+}
+
+@media (min-width: 768px) {
+ .tile {
+ gap: var(--space-2);
+ padding: var(--space-4);
+ border-radius: var(--radius-lg);
+ }
+}
+
+/* nas telas estreitas o nome cabe melhor menor */
+.tile__title {
+ font-size: var(--font-size-sm);
+}
+
+@media (min-width: 768px) {
+ .tile__title {
+ font-size: var(--font-size-md);
+ }
+}
+
+.tile:hover {
+ border-color: var(--border-strong);
+ background: var(--surface-raised);
+}
+
+.tile__title {
+ font-weight: var(--font-weight-semibold);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.tile__meta {
+ font-size: var(--font-size-sm);
+ color: var(--text-muted);
+}
+
+/* ---------- avatar ---------- */
+
+.avatar {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--space-10);
+ height: var(--space-10);
+ border-radius: var(--radius-pill);
+ font-weight: var(--font-weight-bold);
+ flex-shrink: 0;
+}
+
+.avatar--sm {
+ width: var(--space-8);
+ height: var(--space-8);
+ font-size: var(--font-size-sm);
+}
+
+/* variações de cor determinísticas (hash do nome -> 0..3) */
+.avatar--0 {
+ background: rgb(242 166 90 / 0.16);
+ color: var(--brand-primary);
+}
+.avatar--1 {
+ background: rgb(63 161 113 / 0.16);
+ color: var(--color-forest-300);
+}
+.avatar--2 {
+ background: rgb(227 188 102 / 0.16);
+ color: var(--color-moon-400);
+}
+.avatar--3 {
+ background: rgb(91 163 245 / 0.16);
+ color: var(--color-sky-300);
+}
+.avatar--off {
+ background: var(--surface-raised);
+ color: var(--text-faint);
+}
+
+/* ---------- progresso ---------- */
+
+.progress {
+ display: flex;
+ width: 100%;
+ height: 6px;
+ border-radius: var(--radius-pill);
+ background: var(--surface-high);
+ overflow: hidden;
+}
+
+.progress__fill {
+ border-radius: var(--radius-pill);
+ background: var(--brand-primary);
+ transition: width 200ms ease;
+}
+
+.progress__fill--success {
+ background: var(--feedback-success);
+}
+.progress__fill--jaci {
+ background: var(--service-jaci);
+}
+.progress__fill--sky {
+ background: var(--service-nhaman);
+}
+.progress__fill--ybira {
+ background: var(--service-ybira);
+}
+.progress__fill--neutral {
+ background: var(--color-night-300);
+}
+
+/* ---------- controle segmentado (Grade / Linha do tempo) ---------- */
+
+.segmented {
+ display: inline-flex;
+ gap: var(--space-2);
+}
+
+.segmented__option {
+ display: inline-flex;
+ align-items: center;
+ height: var(--size-control-sm);
+ padding-inline: var(--space-4);
+ border-radius: var(--radius-pill);
+ border: var(--border-hairline) solid var(--border-default);
+ color: var(--text-secondary);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-semibold);
+}
+
+.segmented__option[aria-current="true"] {
+ background: var(--brand-primary);
+ border-color: var(--brand-primary);
+ color: var(--text-on-brand);
+}
+
+/* ---------- passos do wizard ---------- */
+
+.steps {
+ display: flex;
+ gap: var(--space-2);
+}
+
+.steps__dot {
+ width: var(--space-4);
+ height: 6px;
+ border-radius: var(--radius-pill);
+ background: var(--color-night-500);
+}
+
+.steps__dot--current {
+ width: var(--space-8);
+ background: var(--brand-primary);
+}
+
+/* ---------- radio cards (setup - armazenamento) ---------- */
+
+.radio-card {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ width: 100%;
+ text-align: left;
+ padding: var(--space-4);
+ background: var(--surface-default);
+ border: var(--border-hairline) solid var(--border-subtle);
+ border-radius: var(--radius-lg);
+}
+
+.radio-card--selected {
+ border-color: var(--feedback-success);
+}
+
+.radio-card[aria-disabled="true"] {
+ opacity: var(--opacity-muted);
+ cursor: not-allowed;
+}
+
+.radio-card__ring {
+ width: var(--space-6);
+ height: var(--space-6);
+ border-radius: var(--radius-pill);
+ border: var(--border-thick) solid var(--border-strong);
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.radio-card--selected .radio-card__ring {
+ border-color: var(--feedback-success);
+}
+
+.radio-card--selected .radio-card__ring::after {
+ content: "";
+ width: var(--space-3);
+ height: var(--space-3);
+ border-radius: var(--radius-pill);
+ background: var(--feedback-success);
+}
+
+/* ---------- dropzone (upload) ---------- */
+
+.dropzone {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ padding: var(--space-8) var(--space-4);
+ border: var(--border-thick) dashed var(--color-dawn-600);
+ border-radius: var(--radius-lg);
+ text-align: center;
+ color: var(--text-muted);
+ transition:
+ background-color 120ms ease,
+ border-color 120ms ease;
+}
+
+.dropzone.is-dragover {
+ border-color: var(--brand-primary);
+ background: rgb(242 166 90 / 0.08);
+}
+
+.dropzone .icon {
+ color: var(--brand-primary);
+}
+
+/* ---------- grade de fotos ---------- */
+
+.photo-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 2px;
+}
+
+@media (min-width: 768px) {
+ .photo-grid {
+ grid-template-columns: repeat(6, 1fr);
+ gap: var(--space-1);
+ }
+}
+
+.photo-grid__item {
+ position: relative;
+ aspect-ratio: 1;
+ background: var(--surface-raised);
+ overflow: hidden;
+}
+
+@media (min-width: 768px) {
+ .photo-grid__item {
+ border-radius: var(--radius-sm);
+ }
+}
+
+.photo-grid__item img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+/* tile de vídeo (Jaci ainda não gera thumbnail de vídeo, D8): fundo escuro
+ com ícone de play centralizado. */
+.photo-grid__video {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ background: var(--surface-high);
+ color: var(--text-secondary);
+}
+
+.photo-grid__tag {
+ position: absolute;
+ right: var(--space-2);
+ bottom: var(--space-2);
+ padding: 2px var(--space-2);
+ border-radius: var(--radius-sm);
+ background: rgb(6 10 20 / 0.64);
+ color: var(--text-primary);
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-semibold);
+}
+
+/* pré-visualização de texto (Ybira - Preview) */
+.text-preview {
+ align-self: stretch;
+ width: 100%;
+ max-height: 70dvh;
+ overflow: auto;
+ margin: 0;
+ padding: var(--space-4);
+ font-family: var(--font-family-mono);
+ font-size: 13px;
+ line-height: 1.5;
+ color: var(--text-secondary);
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ text-align: left;
+}
+
+/* moldura da midia no preview (imagem/video) */
+.media-frame {
+ min-height: 280px;
+ overflow: hidden;
+}
+
+/* preview de documento (PDF via iframe) */
+.doc-frame {
+ height: 70dvh;
+ border: 0;
+}
+
+/* ---------- visualizador fullscreen ---------- */
+
+.viewer {
+ position: fixed;
+ inset: 0;
+ z-index: 50;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-deep);
+}
+
+.viewer__stage {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+}
+
+.viewer__stage img,
+.viewer__stage video {
+ max-width: 100%;
+ max-height: 100%;
+ object-fit: contain;
+}
+
+.viewer__nav {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--text-muted);
+}
+
+.viewer__nav--prev {
+ left: var(--space-2);
+}
+.viewer__nav--next {
+ right: var(--space-2);
+}
+
+.viewer__caption {
+ padding: var(--space-3) var(--space-4);
+ background: rgb(6 10 20 / 0.5);
+ font-size: var(--font-size-sm);
+}
+
+.viewer__actions {
+ display: flex;
+ justify-content: space-around;
+ padding: var(--space-3) var(--space-4);
+ padding-bottom: calc(var(--space-3) + env(safe-area-inset-bottom));
+}
+
+.viewer__action {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-1);
+ color: var(--text-secondary);
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-medium);
+}
+
+.viewer__action--danger {
+ color: var(--feedback-error);
+}
+
+/* ---------- modal / sheet ---------- */
+
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ /* acima da bottom-nav (30) e do visualizador fullscreen (50); abaixo do
+ flash (60), que deve aparecer sobre tudo. */
+ z-index: 55;
+ display: flex;
+ align-items: flex-end;
+ justify-content: center;
+ background: rgb(6 10 20 / 0.64);
+}
+
+@media (min-width: 768px) {
+ .modal-overlay {
+ align-items: center;
+ }
+}
+
+.modal {
+ width: 100%;
+ max-width: 480px;
+ max-height: 85dvh;
+ overflow-y: auto;
+ background: var(--surface-default);
+ border: var(--border-hairline) solid var(--border-default);
+ border-radius: var(--radius-xl) var(--radius-xl) 0 0;
+ padding: var(--space-6) var(--space-4);
+ box-shadow: var(--elevation-lifted);
+}
+
+@media (min-width: 768px) {
+ .modal {
+ border-radius: var(--radius-xl);
+ padding: var(--space-6);
+ }
+}
+
+/* ---------- flash ---------- */
+
+.flash-group {
+ position: fixed;
+ top: var(--space-4);
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 60;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ width: min(calc(100% - var(--space-8)), 420px);
+}
+
+.flash {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-3) var(--space-4);
+ border-radius: var(--radius-md);
+ background: var(--surface-raised);
+ border: var(--border-hairline) solid var(--border-default);
+ box-shadow: var(--elevation-soft);
+ font-size: var(--font-size-sm);
+ width: 100%;
+ text-align: left;
+}
+
+.flash--info {
+ border-color: var(--feedback-info);
+}
+
+.flash--error {
+ border-color: var(--feedback-error);
+}
+
+.flash .icon {
+ flex-shrink: 0;
+}
+
+.flash--info .icon {
+ color: var(--feedback-info);
+}
+.flash--error .icon {
+ color: var(--feedback-error);
+}
+
+/* ---------- estado vazio ---------- */
+
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-12) var(--space-4);
+ text-align: center;
+ color: var(--text-muted);
+}
+
+.empty-state .icon {
+ color: var(--text-faint);
+}
+
+/* ---------- kebab menu (dropdown) ---------- */
+
+.menu {
+ position: relative;
+}
+
+.menu__list {
+ position: absolute;
+ right: 0;
+ top: calc(100% + var(--space-1));
+ z-index: 30;
+ min-width: 180px;
+ padding: var(--space-2);
+ background: var(--surface-raised);
+ border: var(--border-hairline) solid var(--border-default);
+ border-radius: var(--radius-md);
+ box-shadow: var(--elevation-lifted);
+ /* Fechado por padrão; o JS.toggle (display: "flex") abre/fecha via estilo
+ inline. Não dependemos do atributo HTML `hidden`, que perderia para um
+ `display` no CSS. */
+ display: none;
+ flex-direction: column;
+}
+
+.menu__item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-sm);
+ font-size: var(--font-size-sm);
+ text-align: left;
+ width: 100%;
+}
+
+.menu__item:hover {
+ background: var(--surface-high);
+}
+
+.menu__item--danger {
+ color: var(--feedback-error);
+}
+
+/* ---------- transições de abertura (visualizador, modais) ---------- */
+
+.viewer {
+ animation: viewer-in 180ms ease backwards;
+}
+
+.modal-overlay {
+ animation: overlay-in 150ms ease backwards;
+}
+
+.modal {
+ animation: sheet-in 240ms cubic-bezier(0.2, 0.7, 0.3, 1) backwards;
+}
+
+@keyframes viewer-in {
+ from {
+ opacity: 0;
+ transform: scale(0.98);
+ }
+
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+@keyframes overlay-in {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes sheet-in {
+ from {
+ opacity: 0;
+ transform: translateY(16px);
+ }
+
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .viewer,
+ .modal,
+ .modal-overlay {
+ animation: none;
+ }
+}
+
+/* ---------- tabela (arquivos desktop) ---------- */
+
+.table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.table th {
+ padding: var(--space-3) var(--space-3);
+ text-align: left;
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-semibold);
+ letter-spacing: var(--tracking-wide);
+ text-transform: uppercase;
+ color: var(--text-faint);
+ border-bottom: var(--border-hairline) solid var(--border-default);
+}
+
+.table td {
+ padding: var(--space-3) var(--space-3);
+ border-bottom: var(--border-hairline) solid var(--border-subtle);
+ font-size: var(--font-size-sm);
+ color: var(--text-muted);
+}
+
+.table td:first-child {
+ color: var(--text-primary);
+ font-weight: var(--font-weight-semibold);
+}
+
+/* ---------- breadcrumb ---------- */
+
+.breadcrumb {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: var(--font-size-sm);
+ color: var(--text-muted);
+}
+
+.breadcrumb a:hover {
+ color: var(--text-primary);
+}
+
+.breadcrumb__sep {
+ color: var(--text-faint);
+}
+
+/* ---------- QR ---------- */
+
+.qr-card {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 200px;
+ height: 200px;
+ margin-inline: auto;
+ padding: var(--space-3);
+ background: white;
+ border-radius: var(--radius-lg);
+}
+
+.qr-card svg {
+ width: 100%;
+ height: 100%;
+}
diff --git a/assets/css/layouts.css b/assets/css/layouts.css
new file mode 100644
index 0000000..964043e
--- /dev/null
+++ b/assets/css/layouts.css
@@ -0,0 +1,287 @@
+/* Cascas de página: shell autenticado (bottom-nav mobile / sidebar desktop)
+ e layout de auth (céu noturno com horizonte). Um breakpoint só: 768px. */
+
+/* ---------- shell autenticado ---------- */
+
+.shell {
+ min-height: 100dvh;
+ display: flex;
+ flex-direction: column;
+}
+
+.shell__content {
+ flex: 1;
+ width: 100%;
+ padding: var(--space-4);
+ padding-bottom: calc(var(--size-nav-mobile) + var(--space-6));
+ /* `backwards` (não `both`): aplica o estado inicial antes de começar, mas
+ NÃO persiste o `transform` ao terminar, um transform permanente criaria
+ um stacking context que prenderia modais (position: fixed) atrás da
+ bottom-nav. */
+ animation: page-in 220ms ease backwards;
+}
+
+/* Transição suave de página: cada navegação (live_redirect) remonta o
+ conteúdo, então a animação toca de novo. Respeita quem pediu menos
+ movimento. */
+@keyframes page-in {
+ from {
+ opacity: 0;
+ transform: translateY(var(--space-2));
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .shell__content {
+ animation: none;
+ }
+}
+
+/* appbar mobile (telas internas com voltar + título) */
+.appbar {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ height: var(--size-appbar);
+}
+
+.appbar__title {
+ flex: 1;
+ text-align: center;
+ font-family: var(--font-family-display);
+ font-size: var(--font-size-lg);
+ font-weight: var(--font-weight-semibold);
+}
+
+/* a appbar centraliza o título entre ações; espaçador mantém o equilíbrio */
+.appbar__spacer {
+ width: var(--size-touch);
+ flex-shrink: 0;
+}
+
+/* navegação inferior (mobile) */
+.bottom-nav {
+ position: fixed;
+ inset-inline: 0;
+ bottom: 0;
+ z-index: 30;
+ display: flex;
+ height: calc(var(--size-nav-mobile) + env(safe-area-inset-bottom));
+ padding-bottom: env(safe-area-inset-bottom);
+ background: var(--bg-deep);
+ border-top: var(--border-hairline) solid var(--border-subtle);
+}
+
+.bottom-nav__item {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-1);
+ color: var(--text-muted);
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-medium);
+}
+
+.bottom-nav__item[aria-current="page"] {
+ color: var(--brand-primary);
+}
+
+/* sidebar + topbar (desktop) */
+.sidebar {
+ display: none;
+}
+
+.topbar {
+ display: none;
+}
+
+@media (min-width: 768px) {
+ .shell {
+ flex-direction: row;
+ }
+
+ .bottom-nav {
+ display: none;
+ }
+
+ .sidebar {
+ display: flex;
+ flex-direction: column;
+ width: 248px;
+ flex-shrink: 0;
+ position: sticky;
+ top: 0;
+ height: 100dvh;
+ padding: var(--space-4);
+ background: var(--bg-deep);
+ border-right: var(--border-hairline) solid var(--border-subtle);
+ }
+
+ .sidebar__logo {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3) var(--space-6);
+ font-family: var(--font-family-display);
+ font-size: var(--font-size-xl);
+ font-weight: var(--font-weight-bold);
+ }
+
+ .sidebar__logo .icon {
+ color: var(--brand-primary);
+ }
+
+ .sidebar__nav {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ flex: 1;
+ }
+
+ .sidebar__item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ height: var(--size-control-md);
+ padding-inline: var(--space-3);
+ border-radius: var(--radius-md);
+ color: var(--text-secondary);
+ font-weight: var(--font-weight-medium);
+ }
+
+ .sidebar__item:hover {
+ background: var(--surface-default);
+ color: var(--text-primary);
+ }
+
+ .sidebar__item[aria-current="page"] {
+ background: var(--surface-raised);
+ color: var(--text-primary);
+ }
+
+ .sidebar__item[aria-current="page"] .icon {
+ color: var(--brand-primary);
+ }
+
+ .shell__main {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .topbar {
+ display: flex;
+ align-items: center;
+ gap: var(--space-4);
+ height: var(--size-appbar);
+ padding-inline: var(--space-6);
+ border-bottom: var(--border-hairline) solid var(--border-subtle);
+ }
+
+ .shell__content {
+ max-width: 1160px;
+ padding: var(--space-6);
+ padding-bottom: var(--space-12);
+ }
+}
+
+@media (max-width: 767.98px) {
+ .shell__main {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ }
+}
+
+/* ---------- layout de auth (setup, login, convite) ---------- */
+
+.auth-layout {
+ min-height: 100dvh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: var(--space-6) var(--space-4);
+ background:
+ radial-gradient(
+ 120% 55% at 50% 118%,
+ rgb(226 103 74 / 0.35) 0%,
+ rgb(185 109 44 / 0.18) 38%,
+ transparent 70%
+ ),
+ var(--bg-deep);
+ background-attachment: scroll;
+ position: relative;
+ overflow: hidden;
+}
+
+/* estrelas: dois layers de pontinhos via radial-gradients repetidos */
+.auth-layout::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background-image:
+ radial-gradient(
+ 1px 1px at 12% 18%,
+ rgb(250 247 240 / 0.7) 50%,
+ transparent 50%
+ ),
+ radial-gradient(
+ 1px 1px at 78% 9%,
+ rgb(250 247 240 / 0.5) 50%,
+ transparent 50%
+ ),
+ radial-gradient(
+ 1.5px 1.5px at 55% 24%,
+ rgb(250 247 240 / 0.6) 50%,
+ transparent 50%
+ ),
+ radial-gradient(
+ 1px 1px at 31% 38%,
+ rgb(250 247 240 / 0.4) 50%,
+ transparent 50%
+ ),
+ radial-gradient(
+ 1px 1px at 89% 33%,
+ rgb(250 247 240 / 0.55) 50%,
+ transparent 50%
+ ),
+ radial-gradient(
+ 1.5px 1.5px at 8% 52%,
+ rgb(250 247 240 / 0.35) 50%,
+ transparent 50%
+ ),
+ radial-gradient(
+ 1px 1px at 67% 44%,
+ rgb(250 247 240 / 0.45) 50%,
+ transparent 50%
+ ),
+ radial-gradient(
+ 1px 1px at 42% 7%,
+ rgb(250 247 240 / 0.5) 50%,
+ transparent 50%
+ );
+}
+
+.auth-layout__card {
+ width: 100%;
+ max-width: 420px;
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ position: relative;
+ z-index: 1;
+}
+
+/* a "estrela da manhã" do logo */
+.spark {
+ color: var(--brand-primary);
+}
diff --git a/assets/css/tokens.css b/assets/css/tokens.css
new file mode 100644
index 0000000..8650c01
--- /dev/null
+++ b/assets/css/tokens.css
@@ -0,0 +1,162 @@
+/* Design tokens, espelho 1:1 do Penpot ("Cofre da Comunidade - UI v1").
+ Dois níveis, como lá: primitivos (paleta/escala bruta) e semânticos (o que
+ os componentes consomem). Componentes NUNCA usam primitivos direto, se um
+ valor novo for preciso, nasce primeiro como token semântico. */
+
+:root {
+ /* ---------- primitives - color ---------- */
+ --color-night-950: #060a14;
+ --color-night-900: #0a101f;
+ --color-night-800: #0f1729;
+ --color-night-700: #15203a;
+ --color-night-600: #1d2c4e;
+ --color-night-500: #28395f;
+ --color-night-400: #3a4d7a;
+ --color-night-300: #56689b;
+ --color-night-200: #8c9ac0;
+ --color-night-100: #bfc8df;
+ --color-night-50: #eef1f8;
+
+ --color-dawn-300: #ffd9a3;
+ --color-dawn-400: #fcbe74;
+ --color-dawn-500: #f2a65a;
+ --color-dawn-600: #dd8a3e;
+ --color-dawn-700: #b96d2c;
+
+ --color-ember-400: #f08a6c;
+ --color-ember-500: #e2674a;
+ --color-ember-600: #c44e33;
+
+ --color-star-50: #faf7f0;
+ --color-star-100: #f1ecdf;
+ --color-star-200: #e4dcc8;
+
+ --color-forest-300: #82cba4;
+ --color-forest-400: #5fb88a;
+ --color-forest-500: #3fa171;
+ --color-forest-600: #2f8159;
+ --color-forest-700: #246244;
+ --color-forest-900: #122e22;
+
+ --color-moon-300: #f4dfa6;
+ --color-moon-400: #eccd80;
+ --color-moon-500: #e3bc66;
+ --color-moon-600: #c49a48;
+ --color-moon-700: #9c7836;
+ --color-moon-900: #46350f;
+
+ --color-guara-500: #de5b3f;
+ --color-guara-600: #be4327;
+
+ --color-sky-300: #92c3f8;
+ --color-sky-500: #5ba3f5;
+ --color-sky-600: #3f84d6;
+ --color-sky-700: #2f66ac;
+
+ --color-red-500: #e5484d;
+ --color-red-600: #c03538;
+ --color-amber-500: #efb54a;
+
+ /* ---------- primitives - scale ---------- */
+ --space-1: 4px;
+ --space-2: 8px;
+ --space-3: 12px;
+ --space-4: 16px;
+ --space-5: 20px;
+ --space-6: 24px;
+ --space-8: 32px;
+ --space-10: 40px;
+ --space-12: 48px;
+ --space-16: 64px;
+
+ --radius-sm: 8px;
+ --radius-md: 12px;
+ --radius-lg: 16px;
+ --radius-xl: 24px;
+ --radius-pill: 999px;
+
+ --border-hairline: 1px;
+ --border-thick: 2px;
+
+ --size-touch: 44px;
+ --size-control-sm: 36px;
+ --size-control-md: 48px;
+ --size-control-lg: 56px;
+ --size-nav-mobile: 72px;
+ --size-appbar: 64px;
+
+ --font-size-xs: 0.75rem;
+ --font-size-sm: 0.875rem;
+ --font-size-md: 1rem;
+ --font-size-lg: 1.125rem;
+ --font-size-xl: 1.375rem;
+ --font-size-2xl: 1.75rem;
+ --font-size-3xl: 2.25rem;
+ --font-size-display: 2.75rem;
+
+ --font-weight-regular: 400;
+ --font-weight-medium: 500;
+ --font-weight-semibold: 600;
+ --font-weight-bold: 700;
+ --font-weight-extrabold: 800;
+
+ --font-family-display: "Bricolage Grotesque", system-ui, sans-serif;
+ --font-family-body: "Schibsted Grotesk", system-ui, sans-serif;
+ --font-family-mono: "JetBrains Mono", ui-monospace, monospace;
+
+ --tracking-tight: -0.5px;
+ --tracking-normal: 0;
+ --tracking-wide: 1.5px;
+
+ --opacity-disabled: 0.4;
+ --opacity-muted: 0.72;
+ --opacity-scrim: 0.64;
+
+ --elevation-soft: 0 4px 16px 0 rgb(6 10 20 / 0.5);
+ --elevation-lifted: 0 12px 32px -4px rgb(6 10 20 / 0.65);
+
+ /* ---------- semantic ---------- */
+ --bg-app: var(--color-night-900);
+ --bg-deep: var(--color-night-950);
+
+ --surface-default: var(--color-night-800);
+ --surface-raised: var(--color-night-700);
+ --surface-high: var(--color-night-600);
+ --surface-overlay: rgb(10 16 31 / 0.88);
+
+ --border-subtle: var(--color-night-700);
+ --border-default: var(--color-night-600);
+ --border-strong: var(--color-night-500);
+
+ --text-primary: var(--color-star-50);
+ --text-secondary: var(--color-night-100);
+ --text-muted: var(--color-night-200);
+ --text-faint: var(--color-night-300);
+ --text-on-brand: var(--color-night-950);
+
+ --brand-primary: var(--color-dawn-500);
+ --brand-primary-hover: var(--color-dawn-400);
+ --brand-primary-pressed: var(--color-dawn-600);
+ --brand-ember: var(--color-ember-500);
+
+ --service-ybira: var(--color-forest-500);
+ --service-ybira-soft: var(--color-forest-900);
+ --service-jaci: var(--color-moon-500);
+ --service-jaci-soft: var(--color-moon-900);
+ --service-nhaman: var(--color-sky-500);
+ --service-guara: var(--color-guara-500);
+
+ --feedback-success: var(--color-forest-400);
+ --feedback-warning: var(--color-amber-500);
+ --feedback-error: var(--color-red-500);
+ --feedback-info: var(--color-sky-500);
+
+ /* larguras de leitura: containers centrados das telas internas */
+ --measure-sm: 480px;
+ --measure-md: 560px;
+ --measure: 640px;
+ --measure-wide: 720px;
+
+ /* preenchimento dos campos de formulario (night-800 translucido) */
+ --field-bg: rgb(15 23 41 / 0.6);
+}
diff --git a/assets/css/utilities.css b/assets/css/utilities.css
new file mode 100644
index 0000000..417ee5b
--- /dev/null
+++ b/assets/css/utilities.css
@@ -0,0 +1,278 @@
+/* Utilitários curados, sempre amarrados a tokens, nada de valor arbitrário.
+ A regra: se uma combinação se repete em 3+ telas, vira componente em
+ components.css; utilitário é para composição pontual de layout/espaço. */
+
+/* layout */
+.row {
+ display: flex;
+ align-items: center;
+}
+
+.col {
+ display: flex;
+ flex-direction: column;
+}
+
+.center {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.items-center {
+ align-items: center;
+}
+
+.self-center {
+ align-self: center;
+}
+
+.between {
+ justify-content: space-between;
+}
+
+.wrap {
+ flex-wrap: wrap;
+}
+
+.flex-1 {
+ flex: 1;
+ min-width: 0;
+}
+
+.w-full {
+ width: 100%;
+}
+
+/* larguras de leitura (containers centrados das telas internas) */
+.measure-sm {
+ max-width: var(--measure-sm);
+}
+
+.measure-md {
+ max-width: var(--measure-md);
+}
+
+.measure {
+ max-width: var(--measure);
+}
+
+.measure-wide {
+ max-width: var(--measure-wide);
+}
+
+.contents {
+ display: contents;
+}
+
+.grid {
+ display: grid;
+}
+
+/* gaps */
+.gap-1 {
+ gap: var(--space-1);
+}
+.gap-2 {
+ gap: var(--space-2);
+}
+.gap-3 {
+ gap: var(--space-3);
+}
+.gap-4 {
+ gap: var(--space-4);
+}
+.gap-5 {
+ gap: var(--space-5);
+}
+.gap-6 {
+ gap: var(--space-6);
+}
+.gap-8 {
+ gap: var(--space-8);
+}
+
+/* padding */
+.p-0 {
+ padding: 0;
+}
+.p-2 {
+ padding: var(--space-2);
+}
+.p-3 {
+ padding: var(--space-3);
+}
+.p-4 {
+ padding: var(--space-4);
+}
+.p-5 {
+ padding: var(--space-5);
+}
+.p-6 {
+ padding: var(--space-6);
+}
+.px-2 {
+ padding-inline: var(--space-2);
+}
+.px-3 {
+ padding-inline: var(--space-3);
+}
+.px-4 {
+ padding-inline: var(--space-4);
+}
+.px-5 {
+ padding-inline: var(--space-5);
+}
+.px-6 {
+ padding-inline: var(--space-6);
+}
+.py-2 {
+ padding-block: var(--space-2);
+}
+.py-3 {
+ padding-block: var(--space-3);
+}
+.py-4 {
+ padding-block: var(--space-4);
+}
+.py-6 {
+ padding-block: var(--space-6);
+}
+
+/* margin */
+.mt-1 {
+ margin-top: var(--space-1);
+}
+.mt-2 {
+ margin-top: var(--space-2);
+}
+.mt-3 {
+ margin-top: var(--space-3);
+}
+.mt-4 {
+ margin-top: var(--space-4);
+}
+.mt-6 {
+ margin-top: var(--space-6);
+}
+.mt-8 {
+ margin-top: var(--space-8);
+}
+.mt-10 {
+ margin-top: var(--space-10);
+}
+.mb-1 {
+ margin-bottom: var(--space-1);
+}
+.mb-2 {
+ margin-bottom: var(--space-2);
+}
+.mb-3 {
+ margin-bottom: var(--space-3);
+}
+.mb-4 {
+ margin-bottom: var(--space-4);
+}
+.mb-6 {
+ margin-bottom: var(--space-6);
+}
+.mb-8 {
+ margin-bottom: var(--space-8);
+}
+.mx-auto {
+ margin-inline: auto;
+}
+
+/* texto */
+.text-primary {
+ color: var(--text-primary);
+}
+.text-secondary {
+ color: var(--text-secondary);
+}
+.text-muted {
+ color: var(--text-muted);
+}
+.text-faint {
+ color: var(--text-faint);
+}
+.text-brand {
+ color: var(--brand-primary);
+}
+.text-success {
+ color: var(--feedback-success);
+}
+.text-warning {
+ color: var(--feedback-warning);
+}
+.text-error {
+ color: var(--feedback-error);
+}
+.text-center {
+ text-align: center;
+}
+.text-right {
+ text-align: right;
+}
+
+.truncate {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* superfícies e bordas */
+.surface-default {
+ background: var(--surface-default);
+}
+.surface-raised {
+ background: var(--surface-raised);
+}
+.surface-high {
+ background: var(--surface-high);
+}
+
+.radius-sm {
+ border-radius: var(--radius-sm);
+}
+.radius-md {
+ border-radius: var(--radius-md);
+}
+.radius-lg {
+ border-radius: var(--radius-lg);
+}
+.radius-xl {
+ border-radius: var(--radius-xl);
+}
+.radius-pill {
+ border-radius: var(--radius-pill);
+}
+
+.border-subtle {
+ border: var(--border-hairline) solid var(--border-subtle);
+}
+.border-default {
+ border: var(--border-hairline) solid var(--border-default);
+}
+.border-strong {
+ border: var(--border-hairline) solid var(--border-strong);
+}
+
+.divider {
+ border: 0;
+ border-top: var(--border-hairline) solid var(--border-subtle);
+ margin-block: var(--space-4);
+}
+
+/* responsivo, um breakpoint só (mobile-first) */
+@media (max-width: 767.98px) {
+ .hide-mobile {
+ display: none !important;
+ }
+}
+
+@media (min-width: 768px) {
+ .hide-desktop {
+ display: none !important;
+ }
+}
diff --git a/assets/js/app.js b/assets/js/app.js
new file mode 100644
index 0000000..74bd5f5
--- /dev/null
+++ b/assets/js/app.js
@@ -0,0 +1,32 @@
+import "phoenix_html";
+import { Socket } from "phoenix";
+import { LiveSocket } from "phoenix_live_view";
+import topbar from "../vendor/topbar";
+import hooks from "./hooks";
+
+const csrfToken = document
+ .querySelector("meta[name='csrf-token']")
+ ?.getAttribute("content");
+
+if (!csrfToken) {
+ console.warn("CSRF token ausente no ");
+}
+
+const liveSocket = new LiveSocket("/live", Socket, {
+ longPollFallbackMs: 2500,
+ params: csrfToken ? { _csrf_token: csrfToken } : {},
+ hooks,
+});
+
+// Barra de progresso nas navegações live e em uploads longos.
+topbar.config({
+ barColors: { 0: "#F2A65A" },
+ shadowColor: "rgba(6, 10, 20, .4)",
+});
+window.addEventListener("phx:page-loading-start", (_info) => topbar.show(300));
+window.addEventListener("phx:page-loading-stop", (_info) => topbar.hide());
+
+liveSocket.connect();
+
+// Exposto para debug no console (liveSocket.enableDebug(), etc.).
+window.liveSocket = liveSocket;
diff --git a/assets/js/hooks.js b/assets/js/hooks.js
new file mode 100644
index 0000000..1b2386b
--- /dev/null
+++ b/assets/js/hooks.js
@@ -0,0 +1,145 @@
+// Hooks mínimos, todo o resto é LiveView. Antes de adicionar um hook novo,
+// verifique se um binding nativo (phx-viewport-*, phx-drop-target, JS.*)
+// já resolve.
+
+// Copia o atributo data-copy para a área de transferência e avisa o servidor
+// (feedback "copiado!" fica por conta do LiveView).
+const Clipboard = {
+ mounted() {
+ this.el.addEventListener("click", () => {
+ const text = this.el.dataset.copy;
+ if (!text) return;
+ navigator.clipboard
+ .writeText(text)
+ .then(() => this.pushEvent("copied", {}));
+ });
+ },
+};
+
+// Navegação do visualizador de fotos: setas/Esc no teclado e swipe no touch.
+const ViewerNav = {
+ mounted() {
+ this.onKey = (e) => {
+ if (e.key === "ArrowLeft") this.pushEvent("prev", {});
+ if (e.key === "ArrowRight") this.pushEvent("next", {});
+ if (e.key === "Escape") this.pushEvent("close", {});
+ };
+ window.addEventListener("keydown", this.onKey);
+
+ this.startX = null;
+ this.el.addEventListener(
+ "touchstart",
+ (e) => {
+ this.startX = e.touches[0].clientX;
+ },
+ { passive: true },
+ );
+ this.el.addEventListener(
+ "touchend",
+ (e) => {
+ if (this.startX === null) return;
+ const delta = e.changedTouches[0].clientX - this.startX;
+ if (delta > 48) this.pushEvent("prev", {});
+ if (delta < -48) this.pushEvent("next", {});
+ this.startX = null;
+ },
+ { passive: true },
+ );
+ },
+ destroyed() {
+ window.removeEventListener("keydown", this.onKey);
+ },
+};
+
+// Realce visual da dropzone durante o arrasto (o upload em si é o
+// live_file_input com phx-drop-target).
+const DragClass = {
+ mounted() {
+ const toggle = (on) => this.el.classList.toggle("is-dragover", on);
+ this.el.addEventListener("dragover", (e) => {
+ e.preventDefault();
+ toggle(true);
+ });
+ this.el.addEventListener("dragleave", () => toggle(false));
+ this.el.addEventListener("drop", () => toggle(false));
+ },
+};
+
+// Web Share API (mobile): compartilha o link do convite pelo app que a
+// comunidade já usa. Sem suporte (desktop), o LiveView esconde o botão? Não,
+// degradamos copiando o link.
+const Share = {
+ mounted() {
+ this.el.addEventListener("click", () => {
+ const { title, url } = this.el.dataset;
+ if (navigator.share) {
+ navigator.share({ title, url }).catch(() => {});
+ } else {
+ navigator.clipboard
+ .writeText(url)
+ .then(() => this.pushEvent("copied", {}));
+ }
+ });
+ },
+};
+
+// Arrastar-e-soltar no navegador de arquivos: arrasta itens com [data-drag-id]
+// e solta sobre pastas (ou o "Início") marcadas com [data-drop-folder].
+// Delegação de eventos no container, um hook só cobre a lista inteira.
+const DnD = {
+ mounted() {
+ let dragged = null;
+ const clearDragState = () => {
+ dragged = null;
+ this.el
+ .querySelectorAll(".is-drop")
+ .forEach((el) => el.classList.remove("is-drop"));
+ };
+
+ this.el.addEventListener("dragstart", (e) => {
+ const item = e.target.closest("[data-drag-id]");
+ if (!item) return;
+ dragged = { id: item.dataset.dragId, kind: item.dataset.dragKind };
+ e.dataTransfer.effectAllowed = "move";
+ });
+
+ this.el.addEventListener("dragover", (e) => {
+ const drop = e.target.closest("[data-drop-folder]");
+ if (!drop || !dragged || drop.dataset.dropFolder === dragged.id) return;
+ e.preventDefault();
+ drop.classList.add("is-drop");
+ });
+
+ this.el.addEventListener("dragleave", (e) => {
+ const drop = e.target.closest("[data-drop-folder]");
+ if (drop) drop.classList.remove("is-drop");
+ });
+
+ this.el.addEventListener("drop", (e) => {
+ const drop = e.target.closest("[data-drop-folder]");
+ if (!drop || !dragged) return clearDragState();
+ e.preventDefault();
+ drop.classList.remove("is-drop");
+ const target = drop.dataset.dropFolder;
+ if (target !== dragged.id) {
+ this.pushEvent("move-item", { id: dragged.id, kind: dragged.kind, target });
+ }
+ clearDragState();
+ });
+
+ this.el.addEventListener("dragend", () => clearDragState());
+ },
+};
+
+// Auto-fecha o flash após 4s acionando o próprio phx-click do botão (que
+// limpa no servidor e esconde). Cancela o timer se o elemento sair antes.
+const FlashAutoDismiss = {
+ mounted() {
+ this.timer = setTimeout(() => this.el.click(), 4000);
+ },
+ destroyed() {
+ clearTimeout(this.timer);
+ },
+};
+
+export default { Clipboard, ViewerNav, DragClass, Share, FlashAutoDismiss, DnD };
diff --git a/assets/vendor/topbar.js b/assets/vendor/topbar.js
new file mode 100644
index 0000000..60c191b
--- /dev/null
+++ b/assets/vendor/topbar.js
@@ -0,0 +1,138 @@
+/**
+ * @license MIT
+ * topbar 3.0.0
+ * http://buunguyen.github.io/topbar
+ * Copyright (c) 2024 Buu Nguyen
+ */
+(function (window, document) {
+ "use strict";
+
+ var canvas,
+ currentProgress,
+ showing,
+ progressTimerId = null,
+ fadeTimerId = null,
+ delayTimerId = null,
+ addEvent = function (elem, type, handler) {
+ if (elem.addEventListener) elem.addEventListener(type, handler, false);
+ else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
+ else elem["on" + type] = handler;
+ },
+ options = {
+ autoRun: true,
+ barThickness: 3,
+ barColors: {
+ 0: "rgba(26, 188, 156, .9)",
+ ".25": "rgba(52, 152, 219, .9)",
+ ".50": "rgba(241, 196, 15, .9)",
+ ".75": "rgba(230, 126, 34, .9)",
+ "1.0": "rgba(211, 84, 0, .9)",
+ },
+ shadowBlur: 10,
+ shadowColor: "rgba(0, 0, 0, .6)",
+ className: null,
+ },
+ repaint = function () {
+ canvas.width = window.innerWidth;
+ canvas.height = options.barThickness * 5; // need space for shadow
+
+ var ctx = canvas.getContext("2d");
+ ctx.shadowBlur = options.shadowBlur;
+ ctx.shadowColor = options.shadowColor;
+
+ var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
+ for (var stop in options.barColors)
+ lineGradient.addColorStop(stop, options.barColors[stop]);
+ ctx.lineWidth = options.barThickness;
+ ctx.beginPath();
+ ctx.moveTo(0, options.barThickness / 2);
+ ctx.lineTo(
+ Math.ceil(currentProgress * canvas.width),
+ options.barThickness / 2,
+ );
+ ctx.strokeStyle = lineGradient;
+ ctx.stroke();
+ },
+ createCanvas = function () {
+ canvas = document.createElement("canvas");
+ var style = canvas.style;
+ style.position = "fixed";
+ style.top = style.left = style.right = style.margin = style.padding = 0;
+ style.zIndex = 100001;
+ style.display = "none";
+ if (options.className) canvas.classList.add(options.className);
+ addEvent(window, "resize", repaint);
+ },
+ topbar = {
+ config: function (opts) {
+ for (var key in opts)
+ if (options.hasOwnProperty(key)) options[key] = opts[key];
+ },
+ show: function (delay) {
+ if (showing) return;
+ if (delay) {
+ if (delayTimerId) return;
+ delayTimerId = setTimeout(() => topbar.show(), delay);
+ } else {
+ showing = true;
+ if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
+ if (!canvas) createCanvas();
+ if (!canvas.parentElement) document.body.appendChild(canvas);
+ canvas.style.opacity = 1;
+ canvas.style.display = "block";
+ topbar.progress(0);
+ if (options.autoRun) {
+ (function loop() {
+ progressTimerId = window.requestAnimationFrame(loop);
+ topbar.progress(
+ "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2),
+ );
+ })();
+ }
+ }
+ },
+ progress: function (to) {
+ if (typeof to === "undefined") return currentProgress;
+ if (typeof to === "string") {
+ to =
+ (to.indexOf("+") >= 0 || to.indexOf("-") >= 0
+ ? currentProgress
+ : 0) + parseFloat(to);
+ }
+ currentProgress = to > 1 ? 1 : to;
+ repaint();
+ return currentProgress;
+ },
+ hide: function () {
+ clearTimeout(delayTimerId);
+ delayTimerId = null;
+ if (!showing) return;
+ showing = false;
+ if (progressTimerId != null) {
+ window.cancelAnimationFrame(progressTimerId);
+ progressTimerId = null;
+ }
+ (function loop() {
+ if (topbar.progress("+.1") >= 1) {
+ canvas.style.opacity -= 0.05;
+ if (canvas.style.opacity <= 0.05) {
+ canvas.style.display = "none";
+ fadeTimerId = null;
+ return;
+ }
+ }
+ fadeTimerId = window.requestAnimationFrame(loop);
+ })();
+ },
+ };
+
+ if (typeof module === "object" && typeof module.exports === "object") {
+ module.exports = topbar;
+ } else if (typeof define === "function" && define.amd) {
+ define(function () {
+ return topbar;
+ });
+ } else {
+ this.topbar = topbar;
+ }
+}).call(this, window, document);
diff --git a/config/config.exs b/config/config.exs
index 855159d..22969f8 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -1,6 +1,16 @@
# General application configuration
import Config
+# esbuild empacota JS e CSS (imports nativos); sem Tailwind, CSS puro com
+# tokens do Penpot (ver assets/css/tokens.css).
+config :esbuild,
+ version: "0.25.4",
+ taina: [
+ args: ~w(js/app.js css/app.css --bundle --target=es2022 --outdir=../priv/static/assets --external:/fonts/*),
+ cd: Path.expand("../assets", __DIR__),
+ env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
+ ]
+
# Configures Elixir's Logger
config :logger, :default_formatter,
format: "$time $metadata[$level] $message\n",
@@ -30,6 +40,9 @@ config :taina, TainaWeb.Endpoint,
pubsub_server: Taina.PubSub,
live_view: [signing_salt: "rV2D3nM4"]
+# UI pt-BR primeiro (RFC 002); outros idiomas entram como .po quando houver demanda.
+config :taina, TainaWeb.Gettext, default_locale: "pt_BR"
+
config :taina,
ecto_repos: [Taina.Repo],
generators: [timestamp_type: :utc_datetime]
diff --git a/config/dev.exs b/config/dev.exs
index 5b09665..2edfea7 100644
--- a/config/dev.exs
+++ b/config/dev.exs
@@ -26,7 +26,19 @@ config :taina, TainaWeb.Endpoint,
code_reloader: true,
debug_errors: true,
secret_key_base: "318cH01aO0gWN9FThKaSqgINCNwdQL1PkE5zPLns0FQDdgKzFDfBj+VoM8OtCme/",
- watchers: []
+ watchers: [
+ esbuild: {Esbuild, :install_and_run, [:taina, ~w(--sourcemap=inline --watch)]}
+ ]
+
+# Watch templates and assets for browser live reload
+config :taina, TainaWeb.Endpoint,
+ live_reload: [
+ patterns: [
+ ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg|woff2)$",
+ ~r"priv/gettext/.*(po)$",
+ ~r"lib/taina_web/(controllers|live|components)/.*(ex|heex)$"
+ ]
+ ]
# Local file storage for Ybira uploads
config :taina, :storage_root, Path.expand("../priv/storage", __DIR__)
diff --git a/config/prod.exs b/config/prod.exs
index 3e65563..2d75caf 100644
--- a/config/prod.exs
+++ b/config/prod.exs
@@ -2,3 +2,6 @@ import Config
# Do not print debug messages in production
config :logger, level: :info
+
+# Assets fingerprinted por `mix assets.deploy` (esbuild --minify + phx.digest).
+config :taina, TainaWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
diff --git a/config/test.exs b/config/test.exs
index cf832f6..05bd4c4 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -26,7 +26,7 @@ else
pool_size: System.schedulers_online() * 2
end
-# Jobs rodam síncronos (inline) no processo de teste — dentro do sandbox Ecto,
+# Jobs rodam síncronos (inline) no processo de teste, dentro do sandbox Ecto,
# sem filas nem cron de verdade.
config :taina, Oban, testing: :inline
diff --git a/lib/taina/jaci.ex b/lib/taina/jaci.ex
index 1092fe4..7f76732 100644
--- a/lib/taina/jaci.ex
+++ b/lib/taina/jaci.ex
@@ -1,12 +1,12 @@
defmodule Taina.Jaci do
@moduledoc """
- Jaci-lite — a galeria de fotos da comunidade.
+ Jaci-lite, a galeria de fotos da comunidade.
Implementa `Taina.Jaci.Behaviour`; as regras de negócio estão lá, nos
`@callback`. Jaci é uma camada de **leitura** sobre o Ybira (RFC 002, D4):
consulta os arquivos de imagem (`mime_type image/*`) que o Ybira guarda e os
apresenta como grade (`list_photos/2`) e linha do tempo (`timeline/2`). Não
- grava nem muta nada — upload, thumbnail e soft delete são do Ybira.
+ grava nem muta nada, upload, thumbnail e soft delete são do Ybira.
Como todo context, recebe um `Taina.Scope` e roda dentro de
`Repo.with_tekoa/2` (isolamento RLS). A paginação segue o keyset do Ybira: a
@@ -28,7 +28,7 @@ defmodule Taina.Jaci do
# Expressão SQL da data efetiva: captura do EXIF quando houver, senão upload.
# Espelha `Timeline.effective_datetime/1` e o índice de
- # `*_jaci_photo_indexes` — os três precisam concordar.
+ # `*_jaci_photo_indexes`, os três precisam concordar.
defmacrop effective_ts(file) do
quote do
fragment("COALESCE((? ->> 'taken_at')::timestamp, ?)", unquote(file).metadata, unquote(file).inserted_at)
@@ -40,7 +40,7 @@ defmodule Taina.Jaci do
Repo.with_tekoa(scope.tekoa.public_id, fn ->
query =
from f in YbiraFile,
- where: is_nil(f.deleted_at) and like(f.mime_type, "image/%"),
+ where: is_nil(f.deleted_at) and (like(f.mime_type, "image/%") or like(f.mime_type, "video/%")),
# id serial monótono = ordem de upload; alinha com o cursor (id-only).
order_by: [desc: f.id]
@@ -94,7 +94,7 @@ defmodule Taina.Jaci do
base =
from f in YbiraFile,
- where: is_nil(f.deleted_at) and like(f.mime_type, "image/%"),
+ where: is_nil(f.deleted_at) and (like(f.mime_type, "image/%") or like(f.mime_type, "video/%")),
order_by: [desc: effective_ts(f), desc: f.id],
select: %{file: f, ts: effective_ts(f)}
diff --git a/lib/taina/jaci/behaviour.ex b/lib/taina/jaci/behaviour.ex
index 8fb1b9b..8a9d703 100644
--- a/lib/taina/jaci/behaviour.ex
+++ b/lib/taina/jaci/behaviour.ex
@@ -1,11 +1,12 @@
defmodule Taina.Jaci.Behaviour do
@moduledoc """
- Contrato público do Jaci-lite — a galeria de fotos da comunidade.
+ Contrato público do Jaci-lite, a galeria de fotos da comunidade.
Jaci é uma camada de **leitura** sobre o Ybira (RFC 002, D4): ele consulta as
- imagens (`mime_type image/*`) que o Ybira guarda e as apresenta como grade e
- linha do tempo. Não armazena nada próprio, não muta arquivos — quem faz upload,
- thumbnail e soft delete é o Ybira. Composição, não herança.
+ imagens e vídeos (`mime_type image/*` e `video/*`) que o Ybira guarda e os
+ apresenta como grade e linha do tempo. Não armazena nada próprio, não muta
+ arquivos, quem faz upload, thumbnail e soft delete é o Ybira. Composição, não
+ herança.
Espelha o padrão de `Taina.Ybira.Behaviour`: as regras de negócio vivem aqui
(nos `@callback`); `Taina.Jaci` implementa com `@impl true`. Toda função recebe
diff --git a/lib/taina/jaci/timeline.ex b/lib/taina/jaci/timeline.ex
index 4518153..310478f 100644
--- a/lib/taina/jaci/timeline.ex
+++ b/lib/taina/jaci/timeline.ex
@@ -4,7 +4,7 @@ defmodule Taina.Jaci.Timeline do
decide a data de uma foto e agrupa uma lista já ordenada em baldes por dia.
A data efetiva de uma foto é a captura do EXIF (`metadata["taken_at"]`) quando
- existe; senão, o instante do upload (`inserted_at`) — o "mtime" da RFC 002,
+ existe; senão, o instante do upload (`inserted_at`), o "mtime" da RFC 002,
Fase 3. O EXIF não carrega timezone, então trabalhamos em `NaiveDateTime`.
"""
@@ -30,7 +30,7 @@ defmodule Taina.Jaci.Timeline do
@doc """
Agrupa fotos **já ordenadas por data efetiva decrescente** em baldes diários
consecutivos. Como a entrada vem ordenada, todas as fotos de um mesmo dia são
- contíguas — `chunk_by` basta. Em paginação, um dia pode se repartir entre o
+ contíguas, `chunk_by` basta. Em paginação, um dia pode se repartir entre o
fim de uma página e o início da próxima; a UI funde grupos de mesma data
adjacentes.
"""
diff --git a/lib/taina/maraca.ex b/lib/taina/maraca.ex
index 152c7f6..4911c5a 100644
--- a/lib/taina/maraca.ex
+++ b/lib/taina/maraca.ex
@@ -2,22 +2,22 @@ defmodule Taina.Maraca do
@moduledoc """
Interface pública para autenticação e autorização do serviço Maraca.
- Implementa `Taina.Maraca.Behaviour` — consulte cada callback para regras de
+ Implementa `Taina.Maraca.Behaviour`, consulte cada callback para regras de
negócio detalhadas.
## Filosofia
- - **Usuários são donos de seus dados** - Propriedade explícita
- - **Admins são facilitadores, não deuses** - Devem solicitar acesso
+ - **Moradores são donos de seus dados** - Propriedade explícita
+ - **Zeladores cuidam da máquina, não são deuses** - Devem pedir acesso
- **Permissões explícitas** - Nunca implícitas
- **Auditabilidade total** - Todas as tentativas de acesso registradas
- ## Convites sem e-mail
+ ## Identidade sem e-mail (RFC_003, seção 4)
- Conforme a RFC 002 (D6), convites são por **link**: `invite_user/4` retorna
- o Ava com o token cru no campo virtual `email_confirmation_token` — o
- chamador monta a URL/QR code e entrega pelo canal que a comunidade já usa.
- E-mail (Swoosh) é opcional e fica para depois.
+ Convites são por **link/QR**: `invite_user/3` cria um Ava pendente e devolve
+ o token cru no campo virtual `invite_token`; o chamador monta a URL/QR e
+ entrega pelo canal que a comunidade já usa. Não há e-mail: login é por nome
+ (`username`) e a recuperação é mediada pelo zelador (`mint_reset_link/2`).
## Ordem de Resolução de Permissões
@@ -44,7 +44,7 @@ defmodule Taina.Maraca do
@reset_max_age_seconds 60 * 60
@grantable_actions ~w(read write delete)a
- # Mapa de tipos de recurso → {schema, campo de dono}. A propriedade é o
+ # Mapa de tipos de recurso -> {schema, campo de dono}. A propriedade é o
# segundo nível da resolução de permissões; tipos fora deste mapa só podem
# ser autorizados por permissão explícita.
@owned_resources %{
@@ -56,63 +56,96 @@ defmodule Taina.Maraca do
## Bootstrap
@impl true
- def bootstrap(tekoa_attrs, admin_attrs) do
+ def bootstrap(tekoa_attrs, zelador_attrs) do
if Repo.exists?(Tekoa, skip_tekoa_id: true) do
{:error, :already_bootstrapped}
else
- Repo.transact(fn -> create_tekoa_with_admin(tekoa_attrs, admin_attrs) end)
+ Repo.transact(fn -> create_tekoa_with_zelador(tekoa_attrs, zelador_attrs) end)
end
end
- defp create_tekoa_with_admin(tekoa_attrs, admin_attrs) do
+ defp create_tekoa_with_zelador(tekoa_attrs, zelador_attrs) do
with {:ok, tekoa} <- Repo.insert(Tekoa.changeset(%Tekoa{}, tekoa_attrs)),
- {:ok, ava} <- insert_bootstrap_admin(tekoa, admin_attrs) do
+ {:ok, ava} <- insert_bootstrap_zelador(tekoa, zelador_attrs) do
{:ok, %{tekoa: tekoa, ava: ava}}
end
end
- defp insert_bootstrap_admin(%Tekoa{} = tekoa, attrs) do
+ defp insert_bootstrap_zelador(%Tekoa{} = tekoa, attrs) do
base = %{
username: attrs[:username],
- email: attrs[:email],
- role: :admin,
+ display_name: attrs[:display_name],
+ role: :zelador,
tekoa_id: tekoa.id
}
%Ava{}
|> Ava.changeset(base)
- |> Ava.confirmation_changeset(Map.take(attrs, [:username, :password, :password_confirmation]))
+ |> Ava.accept_invite_changeset(Map.take(attrs, [:username, :display_name, :password, :password_confirmation]))
|> Repo.insert()
end
- ## Convite e confirmação
+ @impl true
+ def bootstrapped? do
+ Repo.exists?(Tekoa, skip_tekoa_id: true)
+ end
+
+ @impl true
+ def get_tekoa do
+ case Repo.one(Tekoa, skip_tekoa_id: true) do
+ %Tekoa{} = tekoa -> {:ok, tekoa}
+ nil -> {:error, :not_bootstrapped}
+ end
+ end
+
+ ## Membros
+
+ @impl true
+ def list_members(%Scope{} = scope) do
+ Repo.with_tekoa(scope.tekoa.public_id, fn ->
+ members =
+ Repo.all(
+ from a in Ava,
+ where: a.tekoa_id == ^scope.tekoa.id,
+ order_by: [asc: fragment("CASE WHEN ? = 'zelador' THEN 0 ELSE 1 END", a.role), asc: a.inserted_at]
+ )
+
+ {:ok, members}
+ end)
+ end
+
+ @impl true
+ def count_members(%Scope{} = scope) do
+ Repo.with_tekoa(scope.tekoa.public_id, fn ->
+ {:ok, Repo.aggregate(from(a in Ava, where: a.tekoa_id == ^scope.tekoa.id), :count)}
+ end)
+ end
+
+ ## Convite e aceite
@impl true
- def invite_user(%Ava{} = admin, %Tekoa{} = tekoa, email, opts \\ []) do
- if admin?(admin) and admin.tekoa_id == tekoa.id do
+ def invite_user(%Ava{} = zelador, %Tekoa{} = tekoa, opts \\ []) do
+ if zelador?(zelador) and zelador.tekoa_id == tekoa.id do
attrs = %{
- email: email,
tekoa_id: tekoa.id,
- invited_by_id: admin.id,
- role: Keyword.get(opts, :role, :member)
+ invited_by_id: zelador.id,
+ role: Keyword.get(opts, :role, :morador)
}
Repo.with_tekoa(tekoa.public_id, fn ->
Repo.insert(Ava.invitation_changeset(%Ava{}, attrs))
end)
else
- {:error, :not_admin}
+ {:error, :not_zelador}
end
end
@impl true
- def confirm_email(token, password, password_confirmation, username) do
- with %Ava{} = ava <- find_by_token(:email_confirmation_token_hash, token),
- false <- token_expired?(ava.email_confirmation_sent_at, @invitation_max_age_seconds) do
- attrs = %{username: username, password: password, password_confirmation: password_confirmation}
-
+ def accept_invite(token, attrs) do
+ with %Ava{} = ava <- find_by_token(:invite_token_hash, token),
+ false <- token_expired?(ava.invite_sent_at, @invitation_max_age_seconds) do
Repo.with_tekoa(tekoa_public_id(ava), fn ->
- Repo.update(Ava.confirmation_changeset(ava, attrs))
+ Repo.update(Ava.accept_invite_changeset(ava, attrs))
end)
else
_ -> {:error, :invalid_token}
@@ -122,10 +155,12 @@ defmodule Taina.Maraca do
## Autenticação e sessão
@impl true
- def authenticate(email, password, %Tekoa{} = tekoa) do
+ def authenticate(username, password, %Tekoa{} = tekoa) do
+ username = normalize_username(username)
+
{:ok, result} =
Repo.with_tekoa(tekoa.public_id, fn ->
- {:ok, Repo.get_by(Ava, email: email, tekoa_id: tekoa.id)}
+ {:ok, Repo.get_by(Ava, username: username, tekoa_id: tekoa.id)}
end)
verify_credentials(result, password)
@@ -136,9 +171,10 @@ defmodule Taina.Maraca do
{:error, :invalid_credentials}
end
- defp verify_credentials(%Ava{confirmed_at: nil}, _password) do
+ # Convite ainda não aceito: sem senha definida, não há o que verificar.
+ defp verify_credentials(%Ava{password_hash: nil}, _password) do
Bcrypt.no_user_verify()
- {:error, :email_not_confirmed}
+ {:error, :invalid_credentials}
end
defp verify_credentials(%Ava{} = ava, password) do
@@ -149,6 +185,10 @@ defmodule Taina.Maraca do
end
end
+ defp normalize_username(username) do
+ username |> to_string() |> String.trim() |> String.downcase()
+ end
+
@impl true
def create_session(%Ava{} = ava) do
%{ava_id: ava.public_id, tekoa_id: tekoa_public_id(ava), role: ava.role}
@@ -187,7 +227,7 @@ defmodule Taina.Maraca do
@impl true
def update_tekoa_quota(%Scope{} = scope, quota_bytes) when is_integer(quota_bytes) do
- if admin?(scope.ava) do
+ if zelador?(scope.ava) do
Repo.with_tekoa(scope.tekoa.public_id, fn ->
Tekoa
|> Repo.get!(scope.tekoa.id)
@@ -202,13 +242,19 @@ defmodule Taina.Maraca do
## Reset de senha
@impl true
- def request_password_reset(email, %Tekoa{} = tekoa) do
- Repo.with_tekoa(tekoa.public_id, fn ->
- case Repo.get_by(Ava, email: email, tekoa_id: tekoa.id) do
- nil -> {:ok, :email_sent}
- %Ava{} = ava -> Repo.update(Ava.password_reset_request_changeset(ava))
- end
- end)
+ def mint_reset_link(%Scope{} = scope, %Ava{} = member) do
+ if zelador?(scope.ava) do
+ Repo.with_tekoa(scope.tekoa.public_id, fn -> do_mint_reset_link(scope, member) end)
+ else
+ {:error, :unauthorized}
+ end
+ end
+
+ defp do_mint_reset_link(%Scope{} = scope, %Ava{} = member) do
+ case Repo.get_by(Ava, id: member.id, tekoa_id: scope.tekoa.id) do
+ nil -> {:error, :not_found}
+ %Ava{} = ava -> Repo.update(Ava.password_reset_request_changeset(ava))
+ end
end
@impl true
@@ -322,29 +368,32 @@ defmodule Taina.Maraca do
|> Repo.preload([:ava, :granted_by], skip_tekoa_id: true)
end
- ## Acesso administrativo (com aprovação do dono)
+ ## Acesso do zelador (com aprovação do dono)
@impl true
- def request_access(%Ava{} = admin, %Ava{} = owner, resource_type, resource_id, reason) do
+ def request_access(%Ava{} = zelador, %Ava{} = owner, resource_type, resource_id, reason) do
cond do
- not admin?(admin) ->
- {:error, :not_admin}
+ owner.tekoa_id != zelador.tekoa_id ->
+ {:error, :cross_tekoa_owner}
+
+ not zelador?(zelador) ->
+ {:error, :not_zelador}
- authorize?(admin, :read, resource_type, resource_id) ->
+ authorize?(zelador, :read, resource_type, resource_id) ->
{:error, :already_has_access}
true ->
attrs = %{
- requester_id: admin.id,
+ requester_id: zelador.id,
owner_id: owner.id,
resource_type: resource_type,
resource_id: resource_id,
reason: reason,
- tekoa_id: admin.tekoa_id
+ tekoa_id: zelador.tekoa_id
}
result =
- Repo.with_tekoa(tekoa_public_id(admin), fn ->
+ Repo.with_tekoa(tekoa_public_id(zelador), fn ->
Repo.insert(AccessRequest.create_changeset(%AccessRequest{}, attrs))
end)
@@ -424,12 +473,16 @@ defmodule Taina.Maraca do
## Predicados
@impl true
- def admin?(%Ava{role: :admin}), do: true
- def admin?(%Ava{}), do: false
+ def zelador?(%Ava{role: :zelador}), do: true
+ def zelador?(%Ava{}), do: false
+
+ @impl true
+ def morador?(%Ava{role: :morador}), do: true
+ def morador?(%Ava{}), do: false
@impl true
- def email_confirmed?(%Ava{confirmed_at: nil}), do: false
- def email_confirmed?(%Ava{}), do: true
+ def activated?(%Ava{activated_at: nil}), do: false
+ def activated?(%Ava{}), do: true
## Helpers internos
diff --git a/lib/taina/maraca/README.md b/lib/taina/maraca/README.md
index 5a8cd1a..fb83e5e 100644
--- a/lib/taina/maraca/README.md
+++ b/lib/taina/maraca/README.md
@@ -13,12 +13,12 @@ Maracá vem do Tupi-Guarani e designa o chocalho cerimonial usado em rituais imp
O Maraca oferece:
- Criação e gerenciamento de Tekoas (comunidades)
-- Cadastro seguro de Avas (pessoas/usuários) com criptografia de senhas
-- Confirmação de e-mail obrigatória para novos membros
+- Cadastro seguro de Avas (pessoas) com criptografia de senhas
+- Convites por link/QR, sem e-mail; login por nome de usuário
- Sistema de permissões granular por recurso (arquivos, pastas, conversas)
-- Controle de acesso baseado em papéis (administrador, membro)
+- Controle de acesso baseado em papéis (zelador, morador)
- Isolamento total de dados entre comunidades (Row-Level Security)
-- Solicitação de acesso com aprovação explícita (admins não acessam dados sem permissão)
+- Pedido de acesso com aprovação explícita (zeladores não acessam dados sem permissão)
- Lixeira com recuperação de dados excluídos (15 dias configurável)
## Para quem é
@@ -30,10 +30,10 @@ O Maraca oferece:
## Vantagens
-- Usuários são donos de seus dados (admins não têm acesso automático)
+- Moradores são donos de seus dados (zeladores não têm acesso automático)
- Isolamento completo entre comunidades no nível do banco de dados
- Permissões explícitas e amigáveis para iniciantes
-- Padrões seguros por padrão (confirmação de e-mail, criptografia bcrypt)
+- Padrões seguros por padrão (senha bcrypt, tokens de uso único)
- Arquitetura extensível para federação entre Tekoas no futuro
- Sem rastreamento, sem venda de dados, sem algoritmos manipulativos
@@ -47,7 +47,7 @@ Este projeto está em desenvolvimento ativo. Maraca é o serviço fundamental so
- Criptografia de senhas com bcrypt
- Row-Level Security (RLS) no PostgreSQL para isolamento de dados
- Sistema de permissões com biblioteca Permit
-- Confirmação de e-mail com tokens seguros
+- Convites e redefinição por token (link/QR), sem e-mail
- Soft delete com recuperação de dados (lixeira)
## Contribuindo
diff --git a/lib/taina/maraca/access_request.ex b/lib/taina/maraca/access_request.ex
index 479d3f3..0586a23 100644
--- a/lib/taina/maraca/access_request.ex
+++ b/lib/taina/maraca/access_request.ex
@@ -1,22 +1,23 @@
defmodule Taina.Maraca.AccessRequest do
@moduledoc """
- Representa uma solicitação de acesso de um administrador a um recurso de usuário.
+ Representa um pedido de acesso de um zelador ao recurso de uma pessoa.
- Este schema é fundamental para a filosofia do Tainá: **administradores são facilitadores,
- não deuses**. Ao contrário de sistemas tradicionais onde admins têm acesso irrestrito,
- no Tainá eles devem solicitar permissão explícita do dono do recurso.
+ Este schema é fundamental para a filosofia do Tainá: **zeladores cuidam da
+ máquina, não são deuses**. Ao contrário de sistemas tradicionais onde quem
+ administra tem acesso irrestrito, no Tainá o zelador pede permissão explícita
+ ao dono do recurso.
## Filosofia e fluxo
- 1. **Admin tenta acessar** - Sistema nega acesso (não possui Permission)
- 2. **Admin solicita acesso** - Cria AccessRequest com justificativa
- 3. **Dono é notificado** - Recebe solicitação via PubSub/email
+ 1. **Zelador tenta acessar** - Sistema nega acesso (não possui Permission)
+ 2. **Zelador pede acesso** - Cria AccessRequest com justificativa
+ 3. **Dono é notificado** - Recebe solicitação via PubSub
4. **Dono decide** - Aprova (cria Permission temporária) ou nega
5. **Rastreabilidade** - Todas as solicitações ficam registradas
## Campos principais
- * `requester_id` - ID do Ava que solicita acesso (geralmente um admin)
+ * `requester_id` - ID do Ava que pede acesso (geralmente um zelador)
* `owner_id` - ID do Ava dono do recurso (quem pode aprovar/negar)
* `resource_type` - Tipo do recurso (ex: "ybira_file", "guara_chat")
* `resource_id` - ID público do recurso específico
@@ -39,9 +40,9 @@ defmodule Taina.Maraca.AccessRequest do
## Exemplos de uso
- # Admin solicita acesso a arquivo de usuário (criação)
+ # Zelador solicita acesso a arquivo de morador (criação)
iex> create_changeset(%AccessRequest{}, %{
- ...> requester_id: admin.id,
+ ...> requester_id: zelador.id,
...> owner_id: user.id,
...> resource_type: "ybira_file",
...> resource_id: file.public_id,
@@ -68,7 +69,7 @@ defmodule Taina.Maraca.AccessRequest do
Todas as solicitações são permanentemente registradas para auditoria. Isso garante:
- Transparência sobre quem acessou dados de quem
- Rastreabilidade para compliance (LGPD, GDPR)
- - Confiança entre usuários e administradores
+ - Confiança entre moradores e zeladores
"""
use Ecto.Schema
@@ -113,7 +114,7 @@ defmodule Taina.Maraca.AccessRequest do
@doc """
Changeset para criar uma nova AccessRequest.
- Este changeset é usado quando um admin solicita acesso a um recurso.
+ Este changeset é usado quando um zelador solicita acesso a um recurso.
O status é SEMPRE definido como :pending - não pode ser controlado pelo solicitante.
## Campos obrigatórios
@@ -137,7 +138,7 @@ defmodule Taina.Maraca.AccessRequest do
## Exemplos
iex> create_changeset(%AccessRequest{}, %{
- ...> requester_id: admin.id,
+ ...> requester_id: zelador.id,
...> owner_id: user.id,
...> resource_type: "ybira_file",
...> resource_id: file.public_id,
diff --git a/lib/taina/maraca/ava.ex b/lib/taina/maraca/ava.ex
index ef993a7..4a70b64 100644
--- a/lib/taina/maraca/ava.ex
+++ b/lib/taina/maraca/ava.ex
@@ -2,65 +2,52 @@ defmodule Taina.Maraca.Ava do
@moduledoc """
Representa uma pessoa que usa o Tainá dentro de uma comunidade.
- Cada Ava (pessoa/usuário) pertence a uma Tekoa (comunidade) e pode ter diferentes
- níveis de permissão: admin (administrador) ou member (membro). O nome vem do Tupi-Guarani
+ Cada Ava (pessoa) pertence a uma Tekoa (comunidade) e tem um papel: zelador(a)
+ (cuida da máquina) ou morador(a) (qualquer membro). O nome vem do Tupi-Guarani
e representa a essência de uma pessoa na comunidade digital.
- ## Campos principais
-
- * `username` - Nome de usuário único dentro da comunidade
- * `email` - Email para comunicação e login
- * `role` - Função na comunidade (admin ou member)
- * `confirmed_at` - Data/hora em que o email foi confirmado
- * `public_id` - Identificador público seguro (não expõe o ID do banco)
- * `password_hash` - Hash bcrypt da senha do usuário
- * `email_confirmation_token_hash` - Hash SHA256 do token de confirmação (armazenado no DB)
- * `email_confirmation_token` - Token cru para confirmação (virtual, usado apenas em emails)
- * `reset_token_hash` - Hash SHA256 do token de reset (armazenado no DB)
- * `reset_token` - Token cru para reset de senha (virtual, usado apenas em emails)
- * `invited_by_id` - ID do Ava (admin) que convidou este usuário
- * `invited_at` - Data/hora do convite
-
- ## Fluxos de Autenticação
-
- Este schema suporta os seguintes fluxos através de changesets específicos:
-
- ### 1. Convite de Usuário (Admin)
-
- iex> changeset = Ava.invitation_changeset(%Ava{}, %{
- ...> email: "novo@example.com",
- ...> tekoa_id: 1,
- ...> invited_by_id: admin.id
- ...> })
-
- ### 2. Confirmação de Email e Ativação
-
- iex> changeset = Ava.confirmation_changeset(invited_ava, %{
- ...> username: "maria",
- ...> password: "senhasegura123",
- ...> password_confirmation: "senhasegura123"
- ...> })
-
- ### 3. Solicitação de Reset de Senha
-
- iex> changeset = Ava.password_reset_request_changeset(ava)
-
- ### 4. Completar Reset de Senha
-
- iex> changeset = Ava.password_reset_changeset(ava, %{
- ...> password: "novasenha123",
- ...> password_confirmation: "novasenha123"
- ...> })
+ ## Identidade: nome-primeiro, sem e-mail (RFC_003, seção 4)
+
+ * `username` - nome de usuário, único na comunidade. É a identidade e a chave
+ de acesso (login por nome). Modelado como handle (minúsculas, sem espaços)
+ para virar `nome@tekoa` na federação futura.
+ * `display_name` - nome de exibição, opcional. Texto livre (espaços, acentos),
+ é como o nome aparece para a comunidade. Trocá-lo nunca quebra o login.
+ * `role` - papel na comunidade (`:zelador` ou `:morador`).
+ * `activated_at` - quando a pessoa aceitou o convite e a conta ficou ativa.
+ * `public_id` - identificador público seguro (não expõe o id do banco).
+ * `password_hash` - hash bcrypt da senha.
+ * `invite_token_hash` - hash SHA256 do token de convite (no banco).
+ * `invite_token` - token cru do convite (virtual, vai no link/QR).
+ * `reset_token_hash` / `reset_token` - idem para o link de redefinição que o
+ zelador gera (recuperação mediada, RFC_003 seção 4).
+ * `invited_by_id` / `invited_at` - quem convidou e quando.
+
+ Não há e-mail: convites são por link/QR e a recuperação passa pelo zelador
+ (RFC_002 D6, RFC_003 seção 4). A conta fica pendente até o aceite do convite,
+ não até confirmar e-mail.
+
+ ## Fluxos de autenticação
+
+ Suportados por changesets específicos:
+
+ 1. **Convite** (`invitation_changeset/2`): cria um Ava pendente só com token
+ de convite e papel. Sem nome, sem senha ainda.
+ 2. **Aceite do convite** (`accept_invite_changeset/2`): a pessoa define nome
+ de usuário, nome de exibição (opcional) e senha; a conta ativa.
+ 3. **Pedido de redefinição** (`password_reset_request_changeset/1`): o
+ zelador gera um token de redefinição.
+ 4. **Redefinição** (`password_reset_changeset/2`): a pessoa define a nova
+ senha pelo link.
## Segurança
- - Senhas são sempre hasheadas com bcrypt antes de serem armazenadas
- - Tokens de confirmação e reset são gerados usando Nanoid (32 caracteres)
- - Apenas o hash SHA256 dos tokens é armazenado no banco (defesa em profundidade)
- - Tokens crus são retornados em campos virtuais apenas para envio de email
- - Verificação de tokens usa comparação de tempo constante (previne timing attacks)
- - Token de reset expira após 1 hora
- - Email deve ser confirmado antes do primeiro login
+ - Senhas são hasheadas com bcrypt antes de armazenadas.
+ - Tokens (convite e redefinição) são gerados com Nanoid (32 caracteres); só o
+ hash SHA256 é persistido (defesa em profundidade). O token cru fica num campo
+ virtual, usado apenas para montar o link/QR.
+ - Verificação de token usa comparação de tempo constante.
+ - Token de redefinição expira após 1 hora; token de convite, após 7 dias.
"""
use Ecto.Schema
@@ -74,19 +61,19 @@ defmodule Taina.Maraca.Ava do
@type t :: %__MODULE__{
id: integer() | nil,
username: String.t() | nil,
- email: String.t(),
- confirmed_at: DateTime.t() | nil,
+ display_name: String.t() | nil,
+ activated_at: DateTime.t() | nil,
public_id: String.t() | nil,
- role: :admin | :member,
+ role: :zelador | :morador,
password_hash: String.t() | nil,
- email_confirmation_token_hash: String.t() | nil,
- email_confirmation_sent_at: DateTime.t() | nil,
+ invite_token_hash: String.t() | nil,
+ invite_sent_at: DateTime.t() | nil,
reset_token_hash: String.t() | nil,
reset_token_sent_at: DateTime.t() | nil,
invited_at: DateTime.t() | nil,
password: String.t() | nil,
password_confirmation: String.t() | nil,
- email_confirmation_token: String.t() | nil,
+ invite_token: String.t() | nil,
reset_token: String.t() | nil,
tekoa_id: integer() | nil,
tekoa: Maraca.Tekoa.t() | NotLoaded.t() | nil,
@@ -99,17 +86,19 @@ defmodule Taina.Maraca.Ava do
@schema_prefix "maraca"
schema "avas" do
field :username, :string
- field :email, :string
- field :confirmed_at, :utc_datetime_usec
+ field :display_name, :string
+ field :activated_at, :utc_datetime_usec
field :public_id, PublicId, autogenerate: true
- field :role, Ecto.Enum, values: ~w(admin member)a, default: :member
+ field :role, Ecto.Enum, values: ~w(zelador morador)a, default: :morador
# Authentication fields
field :password_hash, :string
- field :email_confirmation_token_hash, :string
- field :email_confirmation_sent_at, :utc_datetime_usec
- # Password reset fields
+ # Invite token (link/QR): the invite carries the token, no e-mail.
+ field :invite_token_hash, :string
+ field :invite_sent_at, :utc_datetime_usec
+
+ # Password reset token (zelador-minted link)
field :reset_token_hash, :string
field :reset_token_sent_at, :utc_datetime_usec
@@ -119,7 +108,7 @@ defmodule Taina.Maraca.Ava do
# Virtual fields for password and token handling
field :password, :string, virtual: true
field :password_confirmation, :string, virtual: true
- field :email_confirmation_token, :string, virtual: true
+ field :invite_token, :string, virtual: true
field :reset_token, :string, virtual: true
belongs_to :tekoa, Maraca.Tekoa
@@ -129,151 +118,123 @@ defmodule Taina.Maraca.Ava do
end
@doc """
- Valida um Ava com as informações fornecidas.
+ Valida um Ava com nome de usuário e comunidade.
## Campos obrigatórios
- * `username` - deve ter entre 3 e 50 caracteres
- * `email` - deve ser um email válido e único na comunidade
+ * `username` - 3 a 50 caracteres, handle (minúsculas, números, `.`, `-`, `_`)
* `tekoa_id` - deve referenciar uma Tekoa existente
## Exemplos
- iex> changeset(%Ava{}, %{username: "maria", email: "maria@example.com", tekoa_id: 1})
+ iex> changeset(%Ava{}, %{username: "maria", tekoa_id: 1})
%Ecto.Changeset{valid?: true}
- iex> changeset(%Ava{}, %{username: "ab", email: "invalido"})
+ iex> changeset(%Ava{}, %{username: "ab"})
%Ecto.Changeset{valid?: false}
"""
def changeset(ava, attrs) do
+ # `activated_at` e `public_id` sao programaticos (put_change/autogenerate),
+ # nunca vem de input: fora do cast para nao dar para forjar ativacao ou id.
ava
- |> cast(attrs, [:username, :email, :role, :confirmed_at, :public_id, :tekoa_id])
- |> validate_required([:username, :email, :tekoa_id])
- |> validate_length(:username, min: 3, max: 50)
- |> validate_format(:email, ~r/^[^\s]+@[^\s]+\.[^\s]+$/, message: "deve ser um email válido")
- |> unique_constraint(:email, name: :avas_tekoa_id_email_index)
+ |> cast(attrs, [:username, :display_name, :role, :tekoa_id])
+ |> validate_required([:username, :tekoa_id])
+ |> validate_username()
+ |> validate_display_name()
+ |> validate_inclusion(:role, ~w(zelador morador)a)
|> unique_constraint(:username, name: :avas_tekoa_id_username_index)
- |> validate_inclusion(:role, ~w(admin member)a)
|> unique_constraint(:public_id)
end
@doc """
- Changeset para convite de usuário por um administrador.
+ Changeset para convite por um zelador.
- Cria um novo Ava não confirmado com token de confirmação de email.
- Usado no fluxo: Admin convida → Email enviado → Usuário confirma
+ Cria um Ava pendente: só token de convite e papel, sem nome nem senha (a
+ pessoa define no aceite). O convite não pede e-mail: o token viaja no link/QR.
## Campos obrigatórios
- * `email` - Email do usuário convidado
- * `tekoa_id` - Comunidade à qual pertence
- * `invited_by_id` - ID do admin que está convidando
+ * `tekoa_id` - comunidade à qual pertence
+ * `invited_by_id` - id do zelador que está convidando
## Gerado automaticamente
- * `email_confirmation_token_hash` - Hash SHA256 do token (armazenado no DB)
- * `email_confirmation_token` - Token cru (campo virtual, use para enviar no email)
- * `email_confirmation_sent_at` - Timestamp do envio do convite
- * `invited_at` - Timestamp do convite
-
- ## Segurança
-
- O token cru é gerado usando Nanoid (32 caracteres) e apenas o hash SHA256
- é persistido no banco. O token cru fica disponível no campo virtual
- `email_confirmation_token` do changeset para ser usado no email.
+ * `invite_token_hash` - hash SHA256 do token (no banco)
+ * `invite_token` - token cru (campo virtual, use para montar o link/QR)
+ * `invite_sent_at` / `invited_at` - timestamps do convite
## Exemplos
- iex> changeset = invitation_changeset(%Ava{}, %{
- ...> email: "novo@example.com",
- ...> tekoa_id: 1,
- ...> invited_by_id: 2
- ...> })
+ iex> changeset = invitation_changeset(%Ava{}, %{tekoa_id: 1, invited_by_id: 2})
iex> changeset.valid?
true
- iex> changeset.changes.email_confirmation_token
- "abc123..." # Token cru de 32 caracteres para usar no email
+ iex> changeset.changes.invite_token
+ "abc123..." # token cru de 32 caracteres para o link/QR
"""
def invitation_changeset(ava, attrs) do
raw_token = generate_token()
ava
- |> cast(attrs, [:email, :tekoa_id, :invited_by_id, :role])
- |> validate_required([:email, :tekoa_id, :invited_by_id])
- |> validate_format(:email, ~r/^[^\s]+@[^\s]+\.[^\s]+$/, message: "deve ser um email válido")
- |> put_change(:email_confirmation_token_hash, hash_token(raw_token))
- |> put_change(:email_confirmation_token, raw_token)
- |> put_change(:email_confirmation_sent_at, DateTime.utc_now())
+ |> cast(attrs, [:tekoa_id, :invited_by_id, :role])
+ |> validate_required([:tekoa_id, :invited_by_id])
+ |> validate_inclusion(:role, ~w(zelador morador)a)
+ |> put_change(:invite_token_hash, hash_token(raw_token))
+ |> put_change(:invite_token, raw_token)
+ |> put_change(:invite_sent_at, DateTime.utc_now())
|> put_change(:invited_at, DateTime.utc_now())
- |> unique_constraint(:email, name: :avas_tekoa_id_email_index)
- |> unique_constraint(:email_confirmation_token_hash)
+ |> unique_constraint(:invite_token_hash)
end
@doc """
- Changeset para confirmação de email e ativação de conta.
+ Changeset de aceite do convite: a pessoa cria a conta.
- Usado quando o usuário clica no link de confirmação e define sua senha.
- Valida token, define username/senha, e marca email como confirmado.
+ Define nome de usuário, nome de exibição (opcional) e senha; marca a conta como
+ ativa (`activated_at`) e queima o token de convite.
## Campos obrigatórios
- * `username` - Nome de usuário (3-50 caracteres)
- * `password` - Senha (mínimo 8 caracteres)
- * `password_confirmation` - Confirmação da senha (deve ser igual)
+ * `username` - nome de usuário (handle, 3 a 50 caracteres)
+ * `password` - senha (mínimo 8 caracteres)
+ * `password_confirmation` - confirmação (deve ser igual)
- ## Efeitos
+ ## Opcionais
- * Hash da senha é gerado e armazenado em `password_hash`
- * `confirmed_at` é definido com timestamp atual
- * `email_confirmation_token_hash` é removido (null)
+ * `display_name` - nome de exibição
## Exemplos
- iex> confirmation_changeset(invited_ava, %{
+ iex> accept_invite_changeset(invited_ava, %{
...> username: "maria",
...> password: "senhasegura123",
...> password_confirmation: "senhasegura123"
...> })
%Ecto.Changeset{valid?: true}
"""
- def confirmation_changeset(ava, attrs) do
+ def accept_invite_changeset(ava, attrs) do
ava
- |> cast(attrs, [:username, :password, :password_confirmation])
+ |> cast(attrs, [:username, :display_name, :password, :password_confirmation])
|> validate_required([:username, :password, :password_confirmation])
- |> validate_length(:username, min: 3, max: 50)
+ |> validate_username()
+ |> validate_display_name()
|> validate_length(:password, min: 8, message: "deve ter no mínimo 8 caracteres")
|> validate_confirmation(:password, message: "senha e confirmação não coincidem")
|> hash_password()
- |> put_change(:confirmed_at, DateTime.utc_now())
- |> put_change(:email_confirmation_token_hash, nil)
+ |> put_change(:activated_at, DateTime.utc_now())
+ |> put_change(:invite_token_hash, nil)
|> unique_constraint(:username, name: :avas_tekoa_id_username_index)
end
@doc """
- Changeset para solicitar reset de senha.
+ Changeset para o zelador pedir a redefinição de senha de uma pessoa.
- Gera um token de reset e define o timestamp de envio.
- O token expira após 1 hora.
+ Gera um token de redefinição e o timestamp de envio. O token expira após 1
+ hora. O zelador entrega o link pelo mesmo canal dos convites (RFC_003 seção 4).
## Efeitos
- * `reset_token_hash` - Hash SHA256 do token (armazenado no DB)
- * `reset_token` - Token cru (campo virtual, use para enviar no email)
- * `reset_token_sent_at` - Timestamp do envio
-
- ## Segurança
-
- O token cru é gerado usando Nanoid (32 caracteres) e apenas o hash SHA256
- é persistido no banco. O token cru fica disponível no campo virtual
- `reset_token` do changeset para ser usado no email.
-
- ## Exemplos
-
- iex> changeset = password_reset_request_changeset(ava)
- iex> changeset.valid?
- true
- iex> changeset.changes.reset_token
- "xyz789..." # Token cru de 32 caracteres para usar no email
+ * `reset_token_hash` - hash SHA256 do token (no banco)
+ * `reset_token` - token cru (campo virtual, para montar o link)
+ * `reset_token_sent_at` - timestamp do envio
"""
def password_reset_request_changeset(ava) do
raw_token = generate_token()
@@ -287,28 +248,14 @@ defmodule Taina.Maraca.Ava do
end
@doc """
- Changeset para completar reset de senha.
+ Changeset para completar a redefinição de senha pelo link.
- Valida nova senha, gera hash, e remove token de reset.
+ Valida a nova senha, gera o hash e remove o token de redefinição.
## Campos obrigatórios
- * `password` - Nova senha (mínimo 8 caracteres)
- * `password_confirmation` - Confirmação da nova senha
-
- ## Efeitos
-
- * `password_hash` é atualizado
- * `reset_token_hash` é removido (null)
- * `reset_token_sent_at` é removido (null)
-
- ## Exemplos
-
- iex> password_reset_changeset(ava, %{
- ...> password: "novasenha123",
- ...> password_confirmation: "novasenha123"
- ...> })
- %Ecto.Changeset{valid?: true}
+ * `password` - nova senha (mínimo 8 caracteres)
+ * `password_confirmation` - confirmação da nova senha
"""
def password_reset_changeset(ava, attrs) do
ava
@@ -326,14 +273,9 @@ defmodule Taina.Maraca.Ava do
Usa comparação de tempo constante para prevenir timing attacks.
- ## Parâmetros
-
- * `stored_hash` - Hash SHA256 armazenado no banco
- * `presented_token` - Token cru apresentado pelo usuário
-
## Exemplos
- iex> verify_token(ava.email_confirmation_token_hash, token_from_email)
+ iex> verify_token(ava.invite_token_hash, token_from_link)
true
iex> verify_token(ava.reset_token_hash, wrong_token)
@@ -347,6 +289,24 @@ defmodule Taina.Maraca.Ava do
def verify_token(nil, _presented_token), do: false
def verify_token(_stored_hash, nil), do: false
+ # O username é a chave de acesso e a semente do handle federado: normalizamos
+ # para minúsculas e restringimos o conjunto de caracteres (sem espaços).
+ defp validate_username(changeset) do
+ changeset
+ |> update_change(:username, &normalize_username/1)
+ |> validate_length(:username, min: 3, max: 50)
+ |> validate_format(:username, ~r/^[a-z0-9._-]+$/,
+ message: "use só minúsculas, números, ponto, hífen ou _, sem espaços"
+ )
+ end
+
+ defp normalize_username(nil), do: nil
+ defp normalize_username(username), do: username |> String.trim() |> String.downcase()
+
+ defp validate_display_name(changeset) do
+ validate_length(changeset, :display_name, max: 80)
+ end
+
defp hash_password(%Ecto.Changeset{valid?: true, changes: %{password: password}} = changeset) do
changeset
|> put_change(:password_hash, Bcrypt.hash_pwd_salt(password))
@@ -364,7 +324,7 @@ defmodule Taina.Maraca.Ava do
Hash SHA256 de um token cru, no mesmo formato persistido no banco.
Usado pelo contexto Maraca para localizar Avas por token
- (`email_confirmation_token_hash` / `reset_token_hash`).
+ (`invite_token_hash` / `reset_token_hash`).
"""
def hash_token(raw_token) do
:sha256 |> :crypto.hash(raw_token) |> Base.encode64(padding: false)
diff --git a/lib/taina/maraca/behaviour.ex b/lib/taina/maraca/behaviour.ex
index 6846e72..4b508b8 100644
--- a/lib/taina/maraca/behaviour.ex
+++ b/lib/taina/maraca/behaviour.ex
@@ -8,73 +8,71 @@ defmodule Taina.Maraca.Behaviour do
alias Taina.Scope
@doc """
- Convida um novo usuário para a Tekoa.
+ Convida uma pessoa para a Tekoa (por link/QR, sem e-mail).
## Regras de Negócio
- - Apenas admins podem convidar usuários
- - Gera token único de confirmação de email
- - Email deve ser único dentro da Tekoa
- - Email é enviado com link de confirmação
+ - Apenas zeladores podem convidar
+ - Cria um Ava pendente com token de convite; nome e senha vêm no aceite
+ - O token cru volta em `invite_token` (campo virtual) para montar o link/QR
+ - Papel padrão é `:morador`
- Token expira após 7 dias
## Parâmetros
- * `admin_ava` - Ava com role :admin que está convidando
- * `tekoa` - Tekoa à qual o usuário será adicionado
- * `email` - Email do usuário convidado
- * `opts` - Opções adicionais (role, etc.)
+ * `zelador_ava` - Ava com role :zelador que está convidando
+ * `tekoa` - Tekoa à qual a pessoa será adicionada
+ * `opts` - Opções (`:role`, padrão `:morador`)
## Retorno
- * `{:ok, %Ava{}}` - Convite criado, email enviado
- * `{:error, :not_admin}` - Apenas admins podem convidar
+ * `{:ok, %Ava{}}` - Convite criado (token cru em `invite_token`)
+ * `{:error, :not_zelador}` - Apenas zeladores podem convidar
* `{:error, %Ecto.Changeset{}}` - Validação falhou
## Exemplos
- iex> invite_user(admin, tekoa, "novo@example.com")
- {:ok, %Ava{email: "novo@example.com", confirmed_at: nil}}
+ iex> invite_user(zelador, tekoa, role: :morador)
+ {:ok, %Ava{invite_token: "abc...", activated_at: nil}}
- iex> invite_user(member, tekoa, "outro@example.com")
- {:error, :not_admin}
+ iex> invite_user(morador, tekoa, [])
+ {:error, :not_zelador}
"""
- @callback invite_user(Ava.t(), Tekoa.t(), String.t(), keyword()) ::
- {:ok, Ava.t()} | {:error, :not_admin | Ecto.Changeset.t()}
+ @callback invite_user(Ava.t(), Tekoa.t(), keyword()) ::
+ {:ok, Ava.t()} | {:error, :not_zelador | Ecto.Changeset.t()}
@doc """
- Confirma email do usuário e ativa a conta.
+ Aceita o convite e cria a conta (a pessoa escolhe nome e senha).
## Regras de Negócio
- - Token deve ser válido e não expirado (< 7 dias)
- - Senha deve ter no mínimo 8 caracteres
- - Senha e confirmação devem coincidir
- - Username deve ser único dentro da Tekoa (3-50 caracteres)
- - Define confirmed_at e remove token
+ - Token de convite deve ser válido e não expirado (< 7 dias)
+ - `username` é obrigatório, handle único na Tekoa (3-50 caracteres)
+ - `display_name` é opcional
+ - Senha mínima de 8 caracteres, igual à confirmação
+ - Define `activated_at` e queima o token de convite
## Parâmetros
- * `token` - Token recebido por email
- * `password` - Senha do usuário
- * `password_confirmation` - Confirmação da senha
- * `username` - Nome de usuário escolhido
+ * `token` - Token de convite (veio no link/QR)
+ * `attrs` - Mapa com `username`, `password`, `password_confirmation` e,
+ opcionalmente, `display_name` (chaves string ou atom)
## Retorno
- * `{:ok, %Ava{}}` - Conta confirmada e ativada
+ * `{:ok, %Ava{}}` - Conta criada e ativa
* `{:error, :invalid_token}` - Token inválido ou expirado
* `{:error, %Ecto.Changeset{}}` - Validação falhou
## Exemplos
- iex> confirm_email(token, "senha123", "senha123", "maria")
- {:ok, %Ava{confirmed_at: ~U[2026-01-22 20:00:00Z], username: "maria"}}
+ iex> accept_invite(token, %{"username" => "maria", "password" => "senha1234", "password_confirmation" => "senha1234"})
+ {:ok, %Ava{activated_at: ~U[2026-06-16 20:00:00Z], username: "maria"}}
- iex> confirm_email("invalid", "senha", "senha", "user")
+ iex> accept_invite("invalid", %{})
{:error, :invalid_token}
"""
- @callback confirm_email(String.t(), String.t(), String.t(), String.t()) ::
+ @callback accept_invite(String.t(), map()) ::
{:ok, Ava.t()} | {:error, :invalid_token | Ecto.Changeset.t()}
@doc """
@@ -102,7 +100,7 @@ defmodule Taina.Maraca.Behaviour do
## Exemplos
iex> authenticate("user@example.com", "senha123", tekoa)
- {:ok, %Ava{email: "user@example.com"}}
+ {:ok, %Ava{username: "maria"}}
iex> authenticate("user@example.com", "senhaerrada", tekoa)
{:error, :invalid_credentials}
@@ -130,7 +128,7 @@ defmodule Taina.Maraca.Behaviour do
## Exemplos
iex> create_session(ava)
- %{ava_id: "ava_123", tekoa_id: "tekoa_456", role: :member}
+ %{ava_id: "ava_123", tekoa_id: "tekoa_456", role: :morador}
"""
@callback create_session(Ava.t()) :: map()
@@ -158,21 +156,21 @@ defmodule Taina.Maraca.Behaviour do
@callback destroy_session(Plug.Conn.t()) :: Plug.Conn.t()
@doc """
- Bootstrap de primeira inicialização: cria a Tekoa única e o admin inicial.
+ Bootstrap de primeira inicialização: cria a Tekoa única e o zelador inicial.
## Regras de Negócio
- - Só funciona em instância vazia — se já existe Tekoa, retorna
+ - Só funciona em instância vazia, se já existe Tekoa, retorna
`{:error, :already_bootstrapped}` (reforçado pelo índice único
`single_tekoa_enforcement` no banco; ver RFC 002, D2)
- - Admin é criado já confirmado (`confirmed_at`), com senha definida e
- `role: :admin`
- - Tekoa + admin na mesma transação
+ - O zelador inicial é criado já ativo (`activated_at`), com senha e
+ `role: :zelador`
+ - Tekoa + zelador na mesma transação
## Parâmetros
* `tekoa_attrs` - `%{name: ..., storage_quota_bytes: ...}`
- * `admin_attrs` - `%{username: ..., email: ..., password: ..., password_confirmation: ...}`
+ * `zelador_attrs` - `%{username: ..., display_name: ..., password: ..., password_confirmation: ...}`
## Retorno
@@ -206,7 +204,7 @@ defmodule Taina.Maraca.Behaviour do
## Exemplos
iex> get_session_user(conn)
- {:ok, %Ava{email: "user@example.com"}}
+ {:ok, %Ava{username: "maria"}}
iex> get_session_user(%{"ava_id" => "V1StGXR8_Z5j"})
{:ok, %Ava{}}
@@ -219,9 +217,9 @@ defmodule Taina.Maraca.Behaviour do
## Regras de Negócio
- - Apenas admins podem alterar a cota (`scope.ava.role == :admin`)
+ - Apenas zeladores podem alterar a cota (`scope.ava.role == :zelador`)
- `quota_bytes` deve ser maior que zero
- - Demais (membros) recebem `{:error, :unauthorized}`
+ - Moradores recebem `{:error, :unauthorized}`
## Parâmetros
@@ -231,15 +229,15 @@ defmodule Taina.Maraca.Behaviour do
## Retorno
* `{:ok, %Tekoa{}}` - cota atualizada
- * `{:error, :unauthorized}` - quem pediu não é admin
+ * `{:error, :unauthorized}` - quem pediu não é zelador
* `{:error, %Ecto.Changeset{}}` - validação falhou (ex.: cota <= 0)
## Exemplos
- iex> update_tekoa_quota(admin_scope, 10 * 1024 * 1024 * 1024)
+ iex> update_tekoa_quota(zelador_scope, 10 * 1024 * 1024 * 1024)
{:ok, %Tekoa{storage_quota_bytes: 10737418240}}
- iex> update_tekoa_quota(member_scope, 1024)
+ iex> update_tekoa_quota(morador_scope, 1024)
{:error, :unauthorized}
"""
@callback update_tekoa_quota(Scope.t(), pos_integer()) ::
@@ -250,31 +248,36 @@ defmodule Taina.Maraca.Behaviour do
# ============================================================================
@doc """
- Solicita reset de senha.
+ Zelador gera um link de redefinição de senha para uma pessoa.
+
+ Sem e-mail, a recuperação é mediada: o zelador gera o token e entrega o link
+ pelo mesmo canal dos convites (RFC_003 seção 4). É cuidado-da-máquina, não dá
+ ao zelador a senha nem os dados de ninguém.
## Regras de Negócio
- - Gera reset_token único
- - Token expira após 1 hora
- - Email é enviado com link de reset
- - Funciona mesmo se email não existir (segurança)
+ - Apenas zeladores podem gerar o link
+ - Gera `reset_token` único; expira após 1 hora
+ - O token cru volta em `reset_token` (campo virtual) para montar o link
## Parâmetros
- * `email` - Email do usuário
- * `tekoa` - Tekoa onde o usuário está registrado
+ * `scope` - `Taina.Scope` de quem age (deve ser zelador)
+ * `member` - o Ava que recuperará o acesso
## Retorno
- * `{:ok, %Ava{}}` - Token gerado, email enviado
- * `{:ok, :email_sent}` - Email não existe, mas resposta igual (segurança)
+ * `{:ok, %Ava{}}` - Token gerado (cru em `reset_token`)
+ * `{:error, :unauthorized}` - Quem pediu não é zelador
+ * `{:error, :not_found}` - Pessoa não encontrada na Tekoa
## Exemplos
- iex> request_password_reset("user@example.com", tekoa)
+ iex> mint_reset_link(zelador_scope, member)
{:ok, %Ava{reset_token: "abc...", reset_token_sent_at: ~U[...]}}
"""
- @callback request_password_reset(String.t(), Tekoa.t()) :: {:ok, Ava.t() | :email_sent}
+ @callback mint_reset_link(Scope.t(), Ava.t()) ::
+ {:ok, Ava.t()} | {:error, :unauthorized | :not_found}
@doc """
Completa reset de senha.
@@ -288,7 +291,7 @@ defmodule Taina.Maraca.Behaviour do
## Parâmetros
- * `reset_token` - Token recebido por email
+ * `reset_token` - Token recebido pelo link de redefinição
* `new_password` - Nova senha
* `password_confirmation` - Confirmação da nova senha
@@ -321,7 +324,7 @@ defmodule Taina.Maraca.Behaviour do
## Regras de Negócio
- - Admins NÃO têm acesso automático (devem solicitar via AccessRequest)
+ - Zeladores NÃO têm acesso automático (devem solicitar via AccessRequest)
- Dono do recurso sempre tem todas as permissões
- Permissões explícitas são verificadas na tabela permissions
@@ -345,8 +348,8 @@ defmodule Taina.Maraca.Behaviour do
iex> authorize?(other_user, :read, "ybira_file", file.public_id)
false
- iex> authorize?(admin, :read, "ybira_file", user_file.public_id)
- false # Admin precisa solicitar acesso
+ iex> authorize?(zelador, :read, "ybira_file", user_file.public_id)
+ false # Zelador precisa solicitar acesso
"""
@callback authorize?(Ava.t(), atom(), String.t(), String.t()) :: boolean()
@@ -467,15 +470,15 @@ defmodule Taina.Maraca.Behaviour do
@callback list_permissions(String.t(), String.t()) :: [Permission.t()]
@doc """
- Admin solicita acesso a recurso de usuário.
+ Zelador pede acesso ao recurso de uma pessoa.
## Regras de Negócio
- - Apenas admins podem solicitar acesso
+ - Apenas zeladores podem pedir acesso (o zelador não tem atalho para a casa)
- Cria AccessRequest com status :pending (não pode ser alterado na criação)
- Owner é notificado via PubSub
- Justificativa (reason) é obrigatória (max 250 chars)
- - Não pode solicitar se já tem permissão
+ - Não pode pedir se já tem permissão
## Implementação
@@ -484,7 +487,7 @@ defmodule Taina.Maraca.Behaviour do
## Parâmetros
- * `admin_ava` - Admin solicitando acesso
+ * `zelador_ava` - Zelador pedindo acesso
* `owner_ava` - Dono do recurso
* `resource_type` - Tipo do recurso
* `resource_id` - ID do recurso
@@ -493,21 +496,22 @@ defmodule Taina.Maraca.Behaviour do
## Retorno
* `{:ok, %AccessRequest{}}` - Solicitação criada
- * `{:error, :not_admin}` - Apenas admins podem solicitar
- * `{:error, :already_has_access}` - Admin já tem permissão
+ * `{:error, :cross_tekoa_owner}` - Dono e zelador são de comunidades diferentes
+ * `{:error, :not_zelador}` - Apenas zeladores podem pedir
+ * `{:error, :already_has_access}` - Zelador já tem permissão
* `{:error, %Ecto.Changeset{}}` - Validação falhou
## Exemplos
- iex> request_access(admin, owner, "ybira_file", file.public_id, "Ticket #123")
+ iex> request_access(zelador, owner, "ybira_file", file.public_id, "Ticket #123")
{:ok, %AccessRequest{status: :pending}}
- iex> request_access(member, owner, "ybira_file", file.public_id, "Motivo")
- {:error, :not_admin}
+ iex> request_access(morador, owner, "ybira_file", file.public_id, "Motivo")
+ {:error, :not_zelador}
"""
@callback request_access(Ava.t(), Ava.t(), String.t(), String.t(), String.t()) ::
{:ok, AccessRequest.t()}
- | {:error, :not_admin | :already_has_access | Ecto.Changeset.t()}
+ | {:error, :cross_tekoa_owner | :not_zelador | :already_has_access | Ecto.Changeset.t()}
@doc """
Owner aprova solicitação de acesso.
@@ -518,7 +522,7 @@ defmodule Taina.Maraca.Behaviour do
- AccessRequest deve estar :pending
- Cria Permission com action :read
- Atualiza status para :approved
- - Admin é notificado via PubSub
+ - Zelador é notificado via PubSub
## Parâmetros
@@ -548,7 +552,7 @@ defmodule Taina.Maraca.Behaviour do
- Apenas owner pode negar
- AccessRequest deve estar :pending
- Atualiza status para :denied
- - Admin é notificado via PubSub
+ - Zelador é notificado via PubSub
- Request permanece no banco para auditoria
## Parâmetros
@@ -590,46 +594,73 @@ defmodule Taina.Maraca.Behaviour do
@callback list_access_requests(Ava.t()) :: [AccessRequest.t()]
@doc """
- Verifica se Ava é admin na sua Tekoa.
+ Verifica se o Ava é zelador(a) na sua Tekoa.
+ """
+ @callback zelador?(Ava.t()) :: boolean()
- ## Parâmetros
+ @doc """
+ Verifica se o Ava é morador(a) na sua Tekoa.
+ """
+ @callback morador?(Ava.t()) :: boolean()
- * `ava` - Ava a verificar
+ @doc """
+ Verifica se a conta está ativa (a pessoa já aceitou o convite).
## Retorno
- * `true` - É admin
- * `false` - Não é admin
+ * `true` - `activated_at` definido (convite aceito)
+ * `false` - Convite ainda pendente
+ """
+ @callback activated?(Ava.t()) :: boolean()
- ## Exemplos
+ @doc """
+ Verifica se a instância já passou pelo setup (existe a Tekoa única).
- iex> admin?(admin_ava)
- true
+ Consulta de sistema (`skip_tekoa_id: true`): roda antes de existir sessão,
+ no redirecionamento para o wizard de primeiro boot.
- iex> admin?(member_ava)
- false
+ ## Retorno
+
+ * `true` - Setup concluído, instância pronta
+ * `false` - Instância virgem, redirecionar para `/setup`
"""
- @callback admin?(Ava.t()) :: boolean()
+ @callback bootstrapped?() :: boolean()
@doc """
- Verifica se email do Ava foi confirmado.
+ Obtém a Tekoa única da instância (RFC 002, D2, modo single-tekoa).
- ## Parâmetros
+ Consulta de sistema (`skip_tekoa_id: true`): usada no login, antes de haver
+ scope, `authenticate/3` precisa da Tekoa e o usuário ainda não provou quem é.
+
+ ## Retorno
+
+ * `{:ok, %Tekoa{}}` - A Tekoa da instância
+ * `{:error, :not_bootstrapped}` - Setup ainda não rodou
+ """
+ @callback get_tekoa() :: {:ok, Tekoa.t()} | {:error, :not_bootstrapped}
+
+ @doc """
+ Lista os membros da Tekoa do scope, ordenados por papel (zeladores primeiro)
+ e data de entrada.
- * `ava` - Ava a verificar
+ ## Regras de Negócio
+
+ - Inclui contas ainda não confirmadas (convites pendentes) e desativadas,
+ a tela de membros mostra o estado de cada uma
+ - Isolamento via RLS (scope-first)
## Retorno
- * `true` - Email confirmado (confirmed_at não é nil)
- * `false` - Email não confirmado
+ * `{:ok, [%Ava{}]}` - Membros da comunidade
+ """
+ @callback list_members(Scope.t()) :: {:ok, [Ava.t()]}
- ## Exemplos
+ @doc """
+ Conta os membros da Tekoa do scope (card "Membros" da home).
- iex> email_confirmed?(confirmed_ava)
- true
+ ## Retorno
- iex> email_confirmed?(invited_ava)
- false
+ * `{:ok, count}` - Total de membros
"""
- @callback email_confirmed?(Ava.t()) :: boolean()
+ @callback count_members(Scope.t()) :: {:ok, non_neg_integer()}
end
diff --git a/lib/taina/maraca/permission.ex b/lib/taina/maraca/permission.ex
index 2808759..996fd03 100644
--- a/lib/taina/maraca/permission.ex
+++ b/lib/taina/maraca/permission.ex
@@ -4,13 +4,13 @@ defmodule Taina.Maraca.Permission do
No Tainá, seguimos o princípio de que **os usuários são donos de seus dados**. Isso significa
que permissões devem ser explícitas - nenhum acesso é concedido automaticamente, nem mesmo
- para administradores. Cada Permission registra quem pode fazer o quê com qual recurso.
+ para o zelador. Cada Permission registra quem pode fazer o quê com qual recurso.
## Filosofia
- Diferente de sistemas tradicionais onde administradores têm acesso irrestrito, no Tainá:
+ Diferente de sistemas tradicionais onde quem administra tem acesso irrestrito, no Tainá:
- Proprietários de recursos controlam totalmente o acesso
- - Administradores devem solicitar permissão via AccessRequest
+ - O zelador deve solicitar permissão via AccessRequest
- Todas as permissões são rastreáveis e revogáveis
## Campos principais
diff --git a/lib/taina/repo.ex b/lib/taina/repo.ex
index 41faff0..59d92ea 100644
--- a/lib/taina/repo.ex
+++ b/lib/taina/repo.ex
@@ -15,7 +15,7 @@ defmodule Taina.Repo do
@tekoa_context_key :taina_current_tekoa_id
# Tabelas de infraestrutura (schema `public`, sem RLS) que rodam fora de
- # qualquer Tekoa por natureza — hoje, a fila do Oban. O guard isenta SÓ estas:
+ # qualquer Tekoa por natureza, hoje, a fila do Oban. O guard isenta SÓ estas:
# qualquer outra tabela é tratada como dado de Tekoa e exige contexto. Assim,
# schemas novos de fases futuras ficam protegidos por padrão, e esquecer de
# isentar uma nova tabela de infra causa um erro barulhento, nunca um
@@ -74,7 +74,7 @@ defmodule Taina.Repo do
- Realizar operações em background jobs que manipulam dados de uma Tekoa
**IMPORTANTE**: Queries executadas fora de `with_tekoa/2` levantam exceção
- (via `prepare_query/3`), a menos que recebam a opção `skip_tekoa_id: true` —
+ (via `prepare_query/3`), a menos que recebam a opção `skip_tekoa_id: true`,
reservada para operações de sistema (bootstrap de autenticação, lookups
pré-contexto, jobs administrativos).
"""
@@ -107,12 +107,12 @@ defmodule Taina.Repo do
Toda query (`all`, `one`, `get*`, `update_all`, `delete_all`, preloads)
precisa rodar dentro de `with_tekoa/2` ou declarar explicitamente
`skip_tekoa_id: true`. Sem isso, levanta exceção em vez de retornar dados
- vazios silenciosamente — esquecer o contexto vira erro de desenvolvimento,
+ vazios silenciosamente, esquecer o contexto vira erro de desenvolvimento,
não vazamento ou comportamento fantasma em produção.
A única isenção automática são as tabelas de infraestrutura listadas em
`@infra_table_prefixes` (a fila do Oban): elas não guardam dado de Tekoa.
- Tudo o mais raiseia por padrão — o lado seguro.
+ Tudo o mais raiseia por padrão, o lado seguro.
Inserts/updates/deletes de structs não passam por este callback; para eles a
proteção é o RLS no banco (`WITH CHECK` das policies + `FORCE ROW LEVEL
@@ -142,8 +142,8 @@ defmodule Taina.Repo do
end
# Isenta apenas tabelas de infraestrutura, identificadas pelo nome (ex.:
- # `oban_jobs`, `oban_peers`). Lê só o nome da tabela na cláusula `from` — sem
- # introspecção de schema — e, no que não reconhecer, raiseia (lado seguro).
+ # `oban_jobs`, `oban_peers`). Lê só o nome da tabela na cláusula `from`, sem
+ # introspecção de schema, e, no que não reconhecer, raiseia (lado seguro).
defp infra_query?(%Ecto.Query{from: %{source: {table, _schema}}}) when is_binary(table) do
String.starts_with?(table, @infra_table_prefixes)
end
diff --git a/lib/taina/scope.ex b/lib/taina/scope.ex
index 6be6a10..482dd11 100644
--- a/lib/taina/scope.ex
+++ b/lib/taina/scope.ex
@@ -5,7 +5,7 @@ defmodule Taina.Scope do
Todo context público do Tainá recebe um `Scope` como primeiro argumento
(padrão de scopes do Phoenix 1.8). Isso garante que nenhuma operação de
- domínio aconteça sem saber a Tekoa — e, por consequência, sem o contexto
+ domínio aconteça sem saber a Tekoa, e, por consequência, sem o contexto
RLS correto via `Taina.Repo.with_tekoa/2`.
"""
diff --git a/lib/taina/ybira.ex b/lib/taina/ybira.ex
index edc840c..8f8eedf 100644
--- a/lib/taina/ybira.ex
+++ b/lib/taina/ybira.ex
@@ -1,8 +1,8 @@
defmodule Taina.Ybira do
@moduledoc """
- Ybira — sistema de arquivos da comunidade.
+ Ybira, sistema de arquivos da comunidade.
- Implementa `Taina.Ybira.Behaviour` — as regras de negócio de cada função estão
+ Implementa `Taina.Ybira.Behaviour`, as regras de negócio de cada função estão
documentadas lá, nos `@callback`.
Todas as funções públicas recebem um `Taina.Scope` (quem + qual Tekoa) e
@@ -16,7 +16,7 @@ defmodule Taina.Ybira do
## Lixeira (soft delete)
- Deletar um arquivo ou pasta só preenche `deleted_at` — os bytes ficam no disco
+ Deletar um arquivo ou pasta só preenche `deleted_at`, os bytes ficam no disco
e a cota não é devolvida na hora. O worker `Taina.Ybira.Workers.PurgeTrash`
apaga de vez o que está na lixeira há mais de 30 dias e só então recupera a
cota. Até lá, `restore_file/2` traz o arquivo de volta.
@@ -48,7 +48,7 @@ defmodule Taina.Ybira do
# Tipos MIME aceitos no upload. Detectados pelos magic bytes (não pela
# extensão); `application/octet-stream` cobre binários desconhecidos, mas
- # executáveis são detectados e ficam de fora — logo, rejeitados.
+ # executáveis são detectados e ficam de fora, logo, rejeitados.
@allowed_mime_types ~w[
image/jpeg image/png image/gif image/webp image/heic image/heif
video/mp4 video/quicktime video/webm video/avi
@@ -108,7 +108,7 @@ defmodule Taina.Ybira do
# Imagens ganham thumbnails + metadados (dimensões, EXIF) num job pós-upload.
# Enfileirado na mesma transação do insert: só roda se o upload commitar, e o
- # job só toca o disco/banco depois (a fila do Oban é isenta de RLS — ver
+ # job só toca o disco/banco depois (a fila do Oban é isenta de RLS, ver
# `Repo.prepare_query/3`). Em testes (`testing: :inline`) ele roda na hora.
defp maybe_enqueue_rendition(%Scope{} = scope, %Ybira.File{} = file) do
if image?(file.mime_type) do
@@ -208,6 +208,15 @@ defmodule Taina.Ybira do
end)
end
+ @impl true
+ def rename_file(%Scope{} = scope, file_public_id, new_name) when is_binary(file_public_id) do
+ Repo.with_tekoa(scope.tekoa.public_id, fn ->
+ with {:ok, file} <- fetch_owned_file(scope, file_public_id) do
+ Repo.update(Ybira.File.rename_changeset(file, new_name))
+ end
+ end)
+ end
+
@impl true
def move_file(%Scope{} = scope, file_public_id, folder_public_id) when is_binary(file_public_id) do
Repo.with_tekoa(scope.tekoa.public_id, fn ->
@@ -281,15 +290,42 @@ defmodule Taina.Ybira do
@impl true
def list_folder_contents(%Scope{} = scope, folder_public_id \\ nil, opts \\ []) do
+ sort = normalize_sort(Keyword.get(opts, :sort))
+ limit = Keyword.get(opts, :limit, @default_limit)
+ offset = Keyword.get(opts, :offset, 0)
+
Repo.with_tekoa(scope.tekoa.public_id, fn ->
with {:ok, folder_id} <- resolve_folder_id(folder_public_id) do
- folders = Repo.all(child_folders_query(folder_id))
- {files, next_cursor} = fetch_page(child_files_query(folder_id), opts)
+ folders = folder_id |> folders_in() |> apply_folder_sort(sort) |> Repo.all()
+
+ {files, next_cursor} =
+ folder_id
+ |> files_in()
+ |> apply_file_sort(sort)
+ |> paginate_offset(limit, offset)
+
{:ok, %{folders: folders, files: files, next_cursor: next_cursor}}
end
end)
end
+ defp paginate_offset(query, limit, offset) do
+ rows =
+ query
+ |> limit(^(limit + 1))
+ |> offset(^offset)
+ |> Repo.all()
+
+ if length(rows) > limit, do: {Enum.take(rows, limit), offset + limit}, else: {rows, nil}
+ end
+
+ @impl true
+ def list_folders(%Scope{} = scope) do
+ Repo.with_tekoa(scope.tekoa.public_id, fn ->
+ {:ok, Repo.all(from d in Ybira.Folder, where: is_nil(d.deleted_at), order_by: [asc: d.name])}
+ end)
+ end
+
## Cota / armazenamento
@impl true
@@ -310,6 +346,83 @@ defmodule Taina.Ybira do
end)
end
+ @impl true
+ def list_recent(%Scope{} = scope, opts \\ []) do
+ limit = Keyword.get(opts, :limit, 4)
+
+ Repo.with_tekoa(scope.tekoa.public_id, fn ->
+ files =
+ Repo.all(
+ from f in Ybira.File,
+ where: is_nil(f.deleted_at),
+ order_by: [desc: f.id],
+ limit: ^limit
+ )
+
+ {:ok, files}
+ end)
+ end
+
+ @impl true
+ def count_files(%Scope{} = scope) do
+ Repo.with_tekoa(scope.tekoa.public_id, fn ->
+ {:ok, Repo.aggregate(from(f in Ybira.File, where: is_nil(f.deleted_at)), :count)}
+ end)
+ end
+
+ @impl true
+ def count_photos(%Scope{} = scope) do
+ Repo.with_tekoa(scope.tekoa.public_id, fn ->
+ count =
+ Repo.aggregate(
+ from(f in Ybira.File,
+ where: is_nil(f.deleted_at) and like(f.mime_type, "image/%")
+ ),
+ :count
+ )
+
+ {:ok, count}
+ end)
+ end
+
+ @impl true
+ def storage_stats_by_kind(%Scope{} = scope) do
+ Repo.with_tekoa(scope.tekoa.public_id, fn ->
+ rows =
+ Repo.all(
+ from f in Ybira.File,
+ where: is_nil(f.deleted_at),
+ group_by: fragment("split_part(?, '/', 1)", f.mime_type),
+ select: {fragment("split_part(?, '/', 1)", f.mime_type), sum(f.file_size_bytes)}
+ )
+
+ {:ok, classify_storage_kinds(rows)}
+ end)
+ end
+
+ # Categorias da tela de armazenamento: o prefixo do MIME decide; tudo que
+ # não é mídia (application/*, text/*) conta como documento.
+ defp classify_storage_kinds(rows) do
+ Enum.reduce(rows, %{photos: 0, videos: 0, documents: 0, others: 0}, fn {prefix, bytes}, acc ->
+ key =
+ case prefix do
+ "image" -> :photos
+ "video" -> :videos
+ "application" -> :documents
+ "text" -> :documents
+ _other -> :others
+ end
+
+ Map.update!(acc, key, &(&1 + to_integer(bytes)))
+ end)
+ end
+
+ # `sum/1` sobre bigint volta como `Decimal` no Postgres; normalizamos para
+ # inteiro (bytes são sempre inteiros) antes de somar no acumulador.
+ defp to_integer(nil), do: 0
+ defp to_integer(%Decimal{} = bytes), do: Decimal.to_integer(bytes)
+ defp to_integer(bytes) when is_integer(bytes), do: bytes
+
@impl true
def purge_deleted_files(%DateTime{} = cutoff) do
files =
@@ -326,7 +439,7 @@ defmodule Taina.Ybira do
end)
# Fora da transação: se o disco falhar, o registro já foi (sem órfão no
- # banco). Pior caso é um arquivo solto no disco, não inconsistência — mas
+ # banco). Pior caso é um arquivo solto no disco, não inconsistência, mas
# logamos para dar visibilidade a problemas recorrentes de disco.
with {:error, reason} <- Elixir.File.rm(file.filepath) do
Logger.warning("PurgeTrash: falha ao remover arquivo do disco",
@@ -337,7 +450,7 @@ defmodule Taina.Ybira do
end
end)
- # Pastas na lixeira só guardam metadados (sem bytes nem cota) — apaga em lote.
+ # Pastas na lixeira só guardam metadados (sem bytes nem cota), apaga em lote.
# `delete_folder/2` faz soft delete em cascata; sem isto, os registros de
# pasta ficariam para sempre com `deleted_at` preenchido.
Repo.delete_all(
@@ -437,9 +550,9 @@ defmodule Taina.Ybira do
end
end
- # Só o dono muta seus recursos. Admins não têm acesso automático (RFC 002:
- # "admins são facilitadores, não deuses") — quando precisarem, pedem via
- # `Maraca.request_access/5`.
+ # Só o dono muta seus recursos. O zelador não tem acesso automático (RFC 003:
+ # "zero autoridade sobre dados, sem atalho para a casa de ninguém"), quando
+ # precisar, pede via `Maraca.request_access/5` e o dono aprova.
defp owner?(%Scope{ava: %Ava{id: id}}, owner_id), do: id == owner_id
# Mover `folder_id` para baixo de `new_parent_id` fecharia um ciclo se
@@ -453,7 +566,7 @@ defmodule Taina.Ybira do
end
# Uma única CTE recursiva sobe a cadeia de ancestrais de `start_id` (incluindo
- # ele) e responde se `folder_id` aparece lá — sem N+1 nem recursão Elixir
+ # ele) e responde se `folder_id` aparece lá, sem N+1 nem recursão Elixir
# ilimitada. Roda sob `with_tekoa`, então o RLS filtra por Tekoa.
defp ancestor_or_self?(start_id, folder_id) do
{:ok, %{rows: [[count]]}} =
@@ -511,29 +624,30 @@ defmodule Taina.Ybira do
List.flatten(rows)
end
- defp child_folders_query(nil) do
- from d in Ybira.Folder,
- where: is_nil(d.parent_id) and is_nil(d.deleted_at),
- order_by: [desc: d.inserted_at, desc: d.id]
- end
+ # Conteúdo da pasta (sem ordenação, quem chama aplica `apply_*_sort`).
+ defp folders_in(nil), do: from(d in Ybira.Folder, where: is_nil(d.parent_id) and is_nil(d.deleted_at))
+ defp folders_in(folder_id), do: from(d in Ybira.Folder, where: d.parent_id == ^folder_id and is_nil(d.deleted_at))
- defp child_folders_query(folder_id) do
- from d in Ybira.Folder,
- where: d.parent_id == ^folder_id and is_nil(d.deleted_at),
- order_by: [desc: d.inserted_at, desc: d.id]
- end
+ defp files_in(nil), do: from(f in Ybira.File, where: is_nil(f.folder_id) and is_nil(f.deleted_at))
+ defp files_in(folder_id), do: from(f in Ybira.File, where: f.folder_id == ^folder_id and is_nil(f.deleted_at))
- defp child_files_query(nil) do
- from f in Ybira.File,
- where: is_nil(f.folder_id) and is_nil(f.deleted_at),
- order_by: [desc: f.inserted_at, desc: f.id]
- end
+ # Ordenação dinâmica (nome/data/tamanho x asc/desc), com `id` como desempate
+ # estável. A paginação aqui é por offset (não keyset), o que permite qualquer
+ # ordem sem cursor composto, suficiente para uma caixa de comunidade.
+ @sort_fields ~w(name date size)a
+ @sort_dirs ~w(asc desc)a
- defp child_files_query(folder_id) do
- from f in Ybira.File,
- where: f.folder_id == ^folder_id and is_nil(f.deleted_at),
- order_by: [desc: f.inserted_at, desc: f.id]
- end
+ defp normalize_sort({field, dir}) when field in @sort_fields and dir in @sort_dirs, do: {field, dir}
+ defp normalize_sort(_other), do: {:date, :desc}
+
+ defp apply_file_sort(query, {:name, dir}), do: order_by(query, [f], [{^dir, f.original_filename}, {^dir, f.id}])
+ defp apply_file_sort(query, {:size, dir}), do: order_by(query, [f], [{^dir, f.file_size_bytes}, {^dir, f.id}])
+ defp apply_file_sort(query, {:date, dir}), do: order_by(query, [f], [{^dir, f.inserted_at}, {^dir, f.id}])
+
+ defp apply_folder_sort(query, {:name, dir}), do: order_by(query, [d], [{^dir, d.name}])
+ defp apply_folder_sort(query, {:date, dir}), do: order_by(query, [d], [{^dir, d.inserted_at}])
+ # Pastas não têm tamanho; ao ordenar por tamanho, mantêm ordem alfabética.
+ defp apply_folder_sort(query, {:size, _dir}), do: order_by(query, [d], asc: d.name)
defp fetch_attr(attrs, key) do
Map.get(attrs, key) || Map.get(attrs, to_string(key))
diff --git a/lib/taina/ybira/behaviour.ex b/lib/taina/ybira/behaviour.ex
index d103ff4..2888877 100644
--- a/lib/taina/ybira/behaviour.ex
+++ b/lib/taina/ybira/behaviour.ex
@@ -1,6 +1,6 @@
defmodule Taina.Ybira.Behaviour do
@moduledoc """
- Contrato público do Ybira — o sistema de arquivos da comunidade.
+ Contrato público do Ybira, o sistema de arquivos da comunidade.
Espelha o padrão de `Taina.Maraca.Behaviour`: toda a documentação de regra de
negócio vive aqui (nos `@callback`); `Taina.Ybira` implementa com `@impl true`.
@@ -8,7 +8,7 @@ defmodule Taina.Ybira.Behaviour do
documenta a fronteira do contexto num só lugar.
Toda função recebe um `Taina.Scope` (quem + qual Tekoa) e roda sob isolamento
- RLS — exceto `purge_deleted_files/1`, operação de sistema que cruza Tekoas.
+ RLS, exceto `purge_deleted_files/1`, operação de sistema que cruza Tekoas.
"""
alias Taina.Scope
@@ -67,7 +67,8 @@ defmodule Taina.Ybira.Behaviour do
@callback delete_file(Scope.t(), String.t()) :: {:ok, File.t()} | {:error, :not_found}
@doc """
- Restaura um arquivo da lixeira (limpa `deleted_at`). Dono ou admin.
+ Restaura um arquivo da lixeira (limpa `deleted_at`). Apenas o dono (o zelador
+ não tem atalho: pede via `Maraca.request_access/5` e o dono aprova).
"""
@callback restore_file(Scope.t(), String.t()) :: {:ok, File.t()} | {:error, :not_found}
@@ -77,8 +78,15 @@ defmodule Taina.Ybira.Behaviour do
@callback list_trash(Scope.t(), keyword) :: {:ok, page(File.t())}
@doc """
- Move um arquivo para outra pasta (`public_id`) ou para a raiz (`nil`). Dono ou
- admin.
+ Renomeia um arquivo (muda só o `original_filename`; os bytes e o nome no disco
+ ficam). Apenas o dono.
+ """
+ @callback rename_file(Scope.t(), String.t(), String.t()) ::
+ {:ok, File.t()} | {:error, :not_found | Ecto.Changeset.t()}
+
+ @doc """
+ Move um arquivo para outra pasta (`public_id`) ou para a raiz (`nil`). Apenas
+ o dono.
"""
@callback move_file(Scope.t(), String.t(), String.t() | nil) ::
{:ok, File.t()} | {:error, :not_found}
@@ -97,14 +105,14 @@ defmodule Taina.Ybira.Behaviour do
@callback get_folder(Scope.t(), String.t()) :: {:ok, Folder.t()} | {:error, :not_found}
@doc """
- Renomeia uma pasta. Dono ou admin.
+ Renomeia uma pasta. Apenas o dono.
"""
@callback rename_folder(Scope.t(), String.t(), String.t()) ::
{:ok, Folder.t()} | {:error, :not_found | Ecto.Changeset.t()}
@doc """
- Move uma pasta para baixo de outra (`public_id`) ou para a raiz (`nil`). Dono
- ou admin. Rejeita ciclos (mover para si mesma ou para uma descendente) com
+ Move uma pasta para baixo de outra (`public_id`) ou para a raiz (`nil`). Apenas
+ o dono. Rejeita ciclos (mover para si mesma ou para uma descendente) com
`{:error, :circular_reference}`.
"""
@callback move_folder(Scope.t(), String.t(), String.t() | nil) ::
@@ -112,19 +120,33 @@ defmodule Taina.Ybira.Behaviour do
@doc """
Deleta uma pasta em cascata (soft delete): a pasta, os arquivos dentro dela e
- as subpastas, recursivamente. Dono ou admin. Não devolve cota — quem faz isso
+ as subpastas, recursivamente. Apenas o dono. Não devolve cota, quem faz isso
é o `PurgeTrash`.
"""
@callback delete_folder(Scope.t(), String.t()) :: {:ok, :deleted} | {:error, :not_found}
@doc """
Lista o conteúdo de uma pasta (`public_id`) ou da raiz (`nil`): subpastas (todas)
- e arquivos não-deletados (paginados; `next_cursor` se refere aos arquivos).
+ e arquivos não-deletados (paginados por offset; `next_cursor` é o próximo
+ offset, ou `nil` no fim).
+
+ ## Opções
+
+ * `:sort` - `{campo, direção}`, campo em `:name | :date | :size`, direção em
+ `:asc | :desc` (default `{:date, :desc}`)
+ * `:limit` - itens por página (default: 50)
+ * `:offset` - deslocamento da página (default: 0)
"""
@callback list_folder_contents(Scope.t(), String.t() | nil, keyword) ::
- {:ok, %{folders: [Folder.t()], files: [File.t()], next_cursor: binary | nil}}
+ {:ok, %{folders: [Folder.t()], files: [File.t()], next_cursor: non_neg_integer | nil}}
| {:error, :not_found}
+ @doc """
+ Lista todas as pastas (não deletadas) da Tekoa do scope, em ordem alfabética,
+ base do seletor de destino de "Mover para...".
+ """
+ @callback list_folders(Scope.t()) :: {:ok, [Folder.t()]}
+
@doc """
Verifica se a Tekoa do scope comporta mais `byte_size` bytes.
"""
@@ -144,4 +166,31 @@ defmodule Taina.Ybira.Behaviour do
`{:ok, qtd_arquivos_apagados}`.
"""
@callback purge_deleted_files(DateTime.t()) :: {:ok, non_neg_integer}
+
+ @doc """
+ Últimos arquivos enviados (qualquer pasta, fora da lixeira), mais novos
+ primeiro, a lista "Recentes" da home. `opts`: `:limit` (padrão 4).
+ """
+ @callback list_recent(Scope.t(), keyword) :: {:ok, [File.t()]}
+
+ @doc """
+ Conta os arquivos ativos (fora da lixeira) da Tekoa do scope, card
+ "Arquivos" da home.
+ """
+ @callback count_files(Scope.t()) :: {:ok, non_neg_integer}
+
+ @doc """
+ Conta as fotos ativas (`mime_type image/*`, fora da lixeira), card "Fotos"
+ da home.
+ """
+ @callback count_photos(Scope.t()) :: {:ok, non_neg_integer}
+
+ @doc """
+ Uso de armazenamento por categoria de mídia (tela "Armazenamento" e card da
+ home desktop): bytes somados de arquivos ativos, agrupados em `:photos`,
+ `:videos`, `:documents` e `:others`.
+ """
+ @callback storage_stats_by_kind(Scope.t()) ::
+ {:ok,
+ %{photos: non_neg_integer, videos: non_neg_integer, documents: non_neg_integer, others: non_neg_integer}}
end
diff --git a/lib/taina/ybira/file.ex b/lib/taina/ybira/file.ex
index e9a64f1..ed89e27 100644
--- a/lib/taina/ybira/file.ex
+++ b/lib/taina/ybira/file.ex
@@ -133,6 +133,17 @@ defmodule Taina.Ybira.File do
|> unique_constraint(:public_id)
end
+ @doc """
+ Renomeia o arquivo: muda só o `original_filename` (o nome no disco,
+ `filename`, e os bytes não mudam).
+ """
+ def rename_changeset(file, new_name) do
+ file
+ |> cast(%{original_filename: new_name}, [:original_filename])
+ |> validate_required([:original_filename])
+ |> validate_length(:original_filename, min: 1, max: 255)
+ end
+
@doc """
Move o arquivo para a lixeira (soft delete), preenchendo `deleted_at`. Os
bytes ficam no disco até o `PurgeTrash` apagar de vez.
diff --git a/lib/taina/ybira/media.ex b/lib/taina/ybira/media.ex
index a83598c..79f63a8 100644
--- a/lib/taina/ybira/media.ex
+++ b/lib/taina/ybira/media.ex
@@ -1,12 +1,12 @@
defmodule Taina.Ybira.Media do
@moduledoc """
- Processamento de imagem do Ybira — o único lugar que fala com a libvips
+ Processamento de imagem do Ybira, o único lugar que fala com a libvips
(via `image`/`vix`). Isolar aqui mantém a dependência de mídia numa fronteira
fina: o resto do código pede dimensões, data de captura e thumbnails sem saber
qual biblioteca está por baixo.
Tudo é *best-effort* (RFC 002, D8): HEIC e arquivos corrompidos podem falhar
- na leitura sem derrubar o upload — quem chama trata o `{:error, _}` e segue.
+ na leitura sem derrubar o upload, quem chama trata o `{:error, _}` e segue.
A detecção de tipo já aconteceu no upload (magic bytes); aqui assumimos um
arquivo de imagem plausível.
"""
@@ -16,7 +16,7 @@ defmodule Taina.Ybira.Media do
@doc """
Lê metadados leves de uma imagem: dimensões e, quando houver, a data de
- captura do EXIF (`taken_at`, `NaiveDateTime` — o EXIF não carrega timezone).
+ captura do EXIF (`taken_at`, `NaiveDateTime`, o EXIF não carrega timezone).
Retorna `{:ok, %{width, height, taken_at}}` ou `{:error, term}`.
"""
@@ -68,7 +68,7 @@ defmodule Taina.Ybira.Media do
# O `image` aninha o EXIF em sub-mapas cujas chaves variam entre versões e
# câmeras. Em vez de acoplar a chaves específicas, varremos os valores
# procurando o formato de data/hora do EXIF e preferimos a chave "original"
- # (DateTimeOriginal — o instante da captura, não o da última edição).
+ # (DateTimeOriginal, o instante da captura, não o da última edição).
defp find_exif_datetime(exif) do
exif
|> collect_datetimes([])
diff --git a/lib/taina/ybira/mime_detector.ex b/lib/taina/ybira/mime_detector.ex
index f5001d9..4cd9dc8 100644
--- a/lib/taina/ybira/mime_detector.ex
+++ b/lib/taina/ybira/mime_detector.ex
@@ -1,7 +1,7 @@
defmodule Taina.Ybira.MimeDetector do
@moduledoc """
- Detecta o tipo MIME de um arquivo pelos *magic bytes* — os primeiros bytes do
- conteúdo — em vez de confiar na extensão do nome.
+ Detecta o tipo MIME de um arquivo pelos *magic bytes*, os primeiros bytes do
+ conteúdo, em vez de confiar na extensão do nome.
A extensão mente: qualquer um renomeia `malware.exe` para `foto.jpg`. Ler o
cabeçalho real fecha essa brecha e alimenta a allowlist de `Taina.Ybira`, que
@@ -71,7 +71,7 @@ defmodule Taina.Ybira.MimeDetector do
# Heurística: UTF-8 válido e sem byte NUL ⇒ tratamos como texto. O trecho lido
# pode cortar um caractere multibyte no fim; nesse caso `String.valid?` falha e
- # o arquivo vira octet-stream — que a allowlist ainda aceita.
+ # o arquivo vira octet-stream, que a allowlist ainda aceita.
defp printable_text?(<<>>), do: false
defp printable_text?(header) do
diff --git a/lib/taina/ybira/workers/purge_trash.ex b/lib/taina/ybira/workers/purge_trash.ex
index 1fa0bdd..940b43d 100644
--- a/lib/taina/ybira/workers/purge_trash.ex
+++ b/lib/taina/ybira/workers/purge_trash.ex
@@ -4,7 +4,7 @@ defmodule Taina.Ybira.Workers.PurgeTrash do
de 30 dias e devolve a cota da Tekoa (ver `Taina.Ybira.purge_deleted_files/1`).
Agendado por cron (todo dia às 03:00, em `config/config.exs`). Roda em nível de
- sistema, cruzando todas as Tekoas — por isso a lógica usa `skip_tekoa_id: true`.
+ sistema, cruzando todas as Tekoas, por isso a lógica usa `skip_tekoa_id: true`.
`unique` evita acúmulo de jobs idênticos numa mesma janela diária.
"""
diff --git a/lib/taina/ybira/workers/rendition.ex b/lib/taina/ybira/workers/rendition.ex
index 0d624f2..0b0dd3a 100644
--- a/lib/taina/ybira/workers/rendition.ex
+++ b/lib/taina/ybira/workers/rendition.ex
@@ -6,12 +6,12 @@ defmodule Taina.Ybira.Workers.Rendition do
arquivo é uma imagem (ver RFC 002, Fase 3 / D8).
As renditions são uma feature do Ybira (dono do arquivo, do `metadata` e do
- layout de storage); o Jaci só **lê** o resultado para montar a galeria —
+ layout de storage); o Jaci só **lê** o resultado para montar a galeria,
composição, não herança.
Tudo é *best-effort*: HEIC sem suporte na libvips ou um arquivo corrompido
não devem virar retentativa infinita nem job morto barulhento. Falhou,
- logamos e devolvemos `:ok` — a foto ainda existe, só não ganha thumbnail.
+ logamos e devolvemos `:ok`, a foto ainda existe, só não ganha thumbnail.
Roda em nível de sistema (cruza Tekoas), então recebe o `tekoa_public_id` nos
args e faz todo o trabalho dentro de `Repo.with_tekoa/2` para respeitar o RLS.
@@ -33,7 +33,7 @@ defmodule Taina.Ybira.Workers.Rendition do
def perform(%Oban.Job{args: %{"file_id" => file_id, "tekoa_public_id" => tekoa_public_id}}) do
Repo.with_tekoa(tekoa_public_id, fn ->
case Repo.get(YbiraFile, file_id) do
- # Arquivo deletado entre o upload e o processamento — nada a fazer.
+ # Arquivo deletado entre o upload e o processamento, nada a fazer.
nil -> {:ok, :gone}
file -> {:ok, render(file, tekoa_public_id)}
end
diff --git a/lib/taina_web.ex b/lib/taina_web.ex
index bd10a6e..5061539 100644
--- a/lib/taina_web.ex
+++ b/lib/taina_web.ex
@@ -17,7 +17,7 @@ defmodule TainaWeb do
those modules here.
"""
- def static_paths, do: ~w(assets robots.txt)
+ def static_paths, do: ~w(assets fonts robots.txt)
def router do
quote do
@@ -38,7 +38,8 @@ defmodule TainaWeb do
def controller do
quote do
- use Phoenix.Controller, formats: [:html, :json]
+ use Phoenix.Controller, formats: [:html, :json], layouts: [html: TainaWeb.Layouts]
+ use Gettext, backend: TainaWeb.Gettext
import Plug.Conn
@@ -50,6 +51,39 @@ defmodule TainaWeb do
quote do
use Phoenix.LiveView
+ unquote(html_helpers())
+ end
+ end
+
+ def live_component do
+ quote do
+ use Phoenix.LiveComponent
+
+ unquote(html_helpers())
+ end
+ end
+
+ def html do
+ quote do
+ use Phoenix.Component
+
+ # Import convenience functions for HTML rendering (forms, etc.)
+ import Phoenix.HTML
+
+ unquote(html_helpers())
+ end
+ end
+
+ defp html_helpers do
+ quote do
+ use Gettext, backend: TainaWeb.Gettext
+
+ import Phoenix.Controller, only: [get_csrf_token: 0]
+ import Phoenix.HTML
+ import TainaWeb.CoreComponents
+
+ alias Phoenix.LiveView.JS
+
unquote(verified_routes())
end
end
diff --git a/lib/taina_web/auth.ex b/lib/taina_web/auth.ex
index 3893798..35b478a 100644
--- a/lib/taina_web/auth.ex
+++ b/lib/taina_web/auth.ex
@@ -4,16 +4,16 @@ defmodule TainaWeb.Auth do
context exige. Resolve quem está logado uma única vez e deixa o `:current_scope`
pronto tanto para controllers/plugs (a conn) quanto para LiveViews (o socket).
- - `fetch_current_scope/2` — plug: assina `:current_scope` (um `Scope` ou `nil`).
- - `require_authenticated/2` — plug: barra requisições sem sessão.
- - `on_mount/4` — callbacks de LiveView (`:mount_current_scope`,
+ - `fetch_current_scope/2`, plug: assina `:current_scope` (um `Scope` ou `nil`).
+ - `require_authenticated/2`, plug: barra requisições sem sessão.
+ - `on_mount/4`, callbacks de LiveView (`:mount_current_scope`,
`:require_authenticated`).
O scope carrega o Ava **e** a Tekoa (`Maraca.get_session_user/1` pré-carrega a
Tekoa), então os contexts conseguem entrar em `Repo.with_tekoa/2` direto.
Quem ainda não tem a tela de login (próximo PR de UI) é mandado para
- `/login` — referenciado por string de propósito, já que a rota nasce com a UI.
+ `/login`, referenciado por string de propósito, já que a rota nasce com a UI.
"""
use TainaWeb, :verified_routes
@@ -33,12 +33,27 @@ defmodule TainaWeb.Auth do
@doc """
Plug: resolve a sessão e assina `:current_scope` (um `Taina.Scope` quando há
- login válido, senão `nil`). Não barra ninguém — só popula o scope.
+ login válido, senão `nil`). Não barra ninguém, só popula o scope.
"""
def fetch_current_scope(conn, _opts) do
assign(conn, :current_scope, scope_from(conn))
end
+ @doc """
+ Escreve a sessão do `ava` na conn, renovando o id do cookie (barra session
+ fixation). Usada pelos controllers de login, setup e aceite de convite,
+ LiveView não escreve cookie, então todo nascimento de sessão passa por aqui.
+ """
+ def log_in(conn, ava) do
+ session = Maraca.create_session(ava)
+
+ conn
+ |> Plug.Conn.configure_session(renew: true)
+ |> then(fn conn ->
+ Enum.reduce(session, conn, fn {key, value}, conn -> Plug.Conn.put_session(conn, key, value) end)
+ end)
+ end
+
@doc """
Plug: exige sessão autenticada. Sem `:current_scope`, redireciona para o login
e interrompe o pipeline. Use depois de `fetch_current_scope/2`.
@@ -57,8 +72,8 @@ defmodule TainaWeb.Auth do
@doc """
Callbacks de `on_mount` para LiveView:
- * `:mount_current_scope` — assina `:current_scope` (ou `nil`) e segue.
- * `:require_authenticated` — assina o scope e, sem login, redireciona para
+ * `:mount_current_scope`, assina `:current_scope` (ou `nil`) e segue.
+ * `:require_authenticated`, assina o scope e, sem login, redireciona para
o login e interrompe a montagem.
"""
def on_mount(:mount_current_scope, _params, session, socket) do
@@ -80,7 +95,7 @@ defmodule TainaWeb.Auth do
end
end
- # `assign_new` evita reconsultar o banco quando o LiveView remonta (HTTP →
+ # `assign_new` evita reconsultar o banco quando o LiveView remonta (HTTP ->
# WebSocket) com a mesma sessão.
defp mount_current_scope(socket, session) do
Component.assign_new(socket, :current_scope, fn -> resolve_scope(session) end)
diff --git a/lib/taina_web/components/core_components.ex b/lib/taina_web/components/core_components.ex
new file mode 100644
index 0000000..94e5669
--- /dev/null
+++ b/lib/taina_web/components/core_components.ex
@@ -0,0 +1,593 @@
+defmodule TainaWeb.CoreComponents do
+ @moduledoc """
+ Componentes do design system (Penpot, "Cofre da Comunidade - UI v1").
+ Cada componente tem o bloco CSS correspondente em `assets/css/components.css`,
+ mantenha os dois em sincronia. Sem Tailwind: classes semânticas + tokens.
+ """
+
+ use Phoenix.Component
+ use Gettext, backend: TainaWeb.Gettext
+
+ alias Phoenix.HTML.FormField
+ alias Phoenix.LiveView.JS
+ alias TainaWeb.Icons
+
+ # ---------- ícone ----------
+
+ @doc """
+ Ícone outline do design system, SVG inline (`TainaWeb.Icons`).
+
+ <.icon name="folder" />
+ <.icon name="trash" size={20} class="text-error" />
+ """
+ attr :name, :string, required: true, values: Icons.names()
+ attr :size, :integer, default: 24
+ attr :class, :string, default: nil
+
+ def icon(assigns) do
+ ~H"""
+
+ {Phoenix.HTML.raw(Icons.path!(@name))}
+
+ """
+ end
+
+ # ---------- botões ----------
+
+ @doc """
+ Botão do design system. `navigate`/`patch`/`href` viram link estilizado.
+
+ <.button variant="primary">Começar
+ <.button variant="secondary" navigate={~p"/convite"}>Tenho um convite
+ """
+ attr :variant, :string, default: "primary", values: ~w(primary secondary ghost service danger fab)
+ attr :size, :string, default: "lg", values: ~w(sm md lg)
+ attr :type, :string, default: "button"
+ attr :navigate, :string, default: nil
+ attr :patch, :string, default: nil
+ attr :href, :string, default: nil
+ attr :class, :string, default: nil
+ attr :rest, :global, include: ~w(disabled form method download)
+ slot :inner_block, required: true
+
+ def button(%{navigate: nil, patch: nil, href: nil} = assigns) do
+ ~H"""
+
+ {render_slot(@inner_block)}
+
+ """
+ end
+
+ def button(assigns) do
+ ~H"""
+ <.link navigate={@navigate} patch={@patch} href={@href} class={button_class(@variant, @size, @class)} {@rest}>
+ {render_slot(@inner_block)}
+
+ """
+ end
+
+ defp button_class("fab", _size, class), do: ["btn btn--fab", class]
+ defp button_class(variant, size, class), do: ["btn btn--#{variant} btn--#{size}", class]
+
+ @doc """
+ Botão só-ícone (appbar, kebab, ações de linha) com rótulo acessível.
+ """
+ attr :name, :string, required: true
+ attr :label, :string, required: true
+ attr :danger, :boolean, default: false
+ attr :navigate, :string, default: nil
+ attr :href, :string, default: nil
+ attr :rest, :global, include: ~w(download)
+
+ def icon_button(%{navigate: nil, href: nil} = assigns) do
+ ~H"""
+
+ <.icon name={@name} size={20} />
+
+ """
+ end
+
+ def icon_button(assigns) do
+ ~H"""
+ <.link
+ navigate={@navigate}
+ href={@href}
+ class={["icon-btn", @danger && "icon-btn--danger"]}
+ aria-label={@label}
+ title={@label}
+ {@rest}
+ >
+ <.icon name={@name} size={20} />
+
+ """
+ end
+
+ # ---------- campos ----------
+
+ @doc """
+ Campo de formulário com rótulo, ajuda e erros. Aceita `Phoenix.HTML.FormField`.
+
+ <.input field={@form[:username]} type="text" label="Nome de usuário" />
+ """
+ attr :id, :any, default: nil
+ attr :name, :any
+ attr :label, :string, default: nil
+ attr :value, :any
+ attr :type, :string, default: "text"
+ attr :help, :string, default: nil
+ attr :icon, :string, default: nil
+ attr :field, FormField, doc: "ex.: @form[:username]"
+ attr :errors, :list, default: []
+ attr :rest, :global, include: ~w(autocomplete placeholder required minlength maxlength inputmode autofocus)
+
+ def input(%{field: %FormField{} = field} = assigns) do
+ errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
+
+ assigns
+ |> assign(field: nil, id: assigns.id || field.id)
+ |> assign(:errors, Enum.map(errors, &translate_error/1))
+ |> assign_new(:name, fn -> field.name end)
+ |> assign_new(:value, fn -> field.value end)
+ |> input()
+ end
+
+ def input(assigns) do
+ ~H"""
+
+
{@label}
+
+
+ <.icon :if={@icon} name={@icon} size={20} />
+
+
{@help}
+
{error}
+
+ """
+ end
+
+ # ---------- cartões ----------
+
+ attr :raised, :boolean, default: false
+ attr :class, :string, default: nil
+ attr :rest, :global
+ slot :inner_block, required: true
+
+ def card(assigns) do
+ ~H"""
+
+ {render_slot(@inner_block)}
+
+ """
+ end
+
+ @doc """
+ Quadradinho de ícone com a cor do serviço (Ybira verde, Jaci dourado...).
+ """
+ attr :service, :string,
+ default: "neutral",
+ values: ~w(ybira jaci nhaman guara doc video neutral)
+
+ attr :icon, :string, required: true
+ attr :size, :string, default: "md", values: ~w(md lg)
+
+ def service_square(assigns) do
+ ~H"""
+
+ <.icon name={@icon} size={if @size == "lg", do: 24, else: 20} />
+
+ """
+ end
+
+ # ---------- chips & badges ----------
+
+ attr :selected, :boolean, default: false
+ attr :rest, :global
+ slot :inner_block, required: true
+
+ def chip(assigns) do
+ ~H"""
+
+ {render_slot(@inner_block)}
+
+ """
+ end
+
+ attr :variant, :string, required: true, values: ~w(zelador soon active inactive ybira jaci)
+ slot :inner_block, required: true
+
+ def badge(assigns) do
+ ~H"""
+ {render_slot(@inner_block)}
+ """
+ end
+
+ # ---------- listas ----------
+
+ @doc """
+ Linha de lista: ícone/avatar à esquerda, título + meta, ações à direita.
+
+ <.list_row title="Estatuto.pdf" meta="PDF, 1,1 MB">
+ <:leading><.service_square service="doc" icon="file" />
+ <:actions><.icon_button name="kebab" label="Mais opções" />
+
+ """
+ attr :title, :string, required: true
+ attr :meta, :string, default: nil
+ attr :meta_class, :string, default: nil
+ attr :navigate, :string, default: nil
+ attr :rest, :global
+ slot :leading
+ slot :actions
+
+ def list_row(assigns) do
+ ~H"""
+
+ {render_slot(@leading)}
+ <.link :if={@navigate} navigate={@navigate} class="list-row__body">
+
{@title}
+
{@meta}
+
+
+
+ {render_slot(@actions)}
+
+
+ """
+ end
+
+ # ---------- avatar ----------
+
+ @doc """
+ Avatar com inicial e cor determinística a partir do nome.
+ """
+ attr :name, :string, required: true
+ attr :size, :string, default: "md", values: ~w(sm md)
+ attr :inactive, :boolean, default: false
+
+ def avatar(assigns) do
+ ~H"""
+
+ {@name |> String.first() |> String.upcase()}
+
+ """
+ end
+
+ defp avatar_color(_name, true), do: "avatar--off"
+ defp avatar_color(name, false), do: "avatar--#{:erlang.phash2(name, 4)}"
+
+ # ---------- progresso ----------
+
+ @doc """
+ Barra de progresso. Para a versão segmentada (armazenamento por tipo),
+ passe `segments` como lista de `{cor, fração}`.
+ """
+ attr :value, :integer, default: nil, doc: "0..100"
+ attr :color, :string, default: nil, values: [nil, "success", "jaci", "sky", "ybira", "neutral"]
+ attr :segments, :list, default: nil, doc: ~s|ex.: [{"jaci", 0.5}, {"sky", 0.3}]|
+
+ def progress(%{segments: segments} = assigns) when is_list(segments) do
+ ~H"""
+
+ """
+ end
+
+ def progress(assigns) do
+ ~H"""
+
+ """
+ end
+
+ # ---------- controle segmentado ----------
+
+ @doc """
+ Alternador de visões (Grade / Linha do tempo).
+
+ <.segmented>
+ <:option patch={~p"/fotos"} current={@live_action == :grid}>Grade
+ <:option patch={~p"/fotos/linha-do-tempo"} current={@live_action == :timeline}>Linha do tempo
+
+ """
+ slot :option, required: true do
+ attr :patch, :string, required: true
+ attr :current, :boolean
+ end
+
+ def segmented(assigns) do
+ ~H"""
+
+ <.link
+ :for={option <- @option}
+ patch={option.patch}
+ class="segmented__option"
+ aria-current={to_string(option[:current] == true)}
+ >
+ {render_slot(option)}
+
+
+ """
+ end
+
+ # ---------- passos do wizard ----------
+
+ attr :total, :integer, default: 3
+ attr :current, :integer, required: true
+
+ def steps(assigns) do
+ ~H"""
+
+
+
+ """
+ end
+
+ # ---------- menu (kebab) ----------
+
+ @doc """
+ Menu suspenso acionado por botão de ícone, fechado com clique fora.
+
+ <.menu id={"file-menu-\#{file.public_id}"} icon="kebab" label="Mais opções">
+ <:item icon="pencil" click={JS.push("rename", value: %{id: file.public_id})}>Renomear
+ <:item icon="trash" danger click={JS.push("delete", value: %{id: file.public_id})}>Excluir
+
+ """
+ attr :id, :string, required: true
+ attr :icon, :string, default: "kebab"
+ attr :label, :string, required: true
+
+ slot :item, required: true do
+ attr :icon, :string
+ attr :click, :any, required: true
+ attr :danger, :boolean
+ end
+
+ def menu(assigns) do
+ ~H"""
+
+ """
+ end
+
+ # JS.exec_with não existe; compomos as duas ações concatenando os comandos.
+ defp js_exec_with(%JS{ops: ops}, %JS{ops: more}), do: %JS{ops: ops ++ more}
+
+ # ---------- modal ----------
+
+ @doc """
+ Modal (bottom sheet no mobile). Fecha em clique fora e Esc via `on_cancel`.
+ """
+ attr :id, :string, required: true
+ attr :show, :boolean, default: false
+ attr :on_cancel, JS, default: %JS{}
+ slot :inner_block, required: true
+
+ def modal(assigns) do
+ ~H"""
+
+
+ {render_slot(@inner_block)}
+
+
+ """
+ end
+
+ @doc """
+ Diálogo de confirmação (ações destrutivas). Reaproveita o `modal/1`.
+
+ <.confirm_dialog
+ id="confirm-delete"
+ show={@confirm != nil}
+ title={gettext("Excluir arquivo?")}
+ message={gettext("Ele vai para a lixeira.")}
+ on_confirm="confirm-delete"
+ on_cancel={JS.push("close-modal")}
+ />
+ """
+ attr :id, :string, required: true
+ attr :show, :boolean, default: false
+ attr :title, :string, required: true
+ attr :message, :string, default: nil
+ attr :confirm_label, :string, default: nil
+ attr :on_confirm, :any, required: true, doc: "evento (string) ou %JS{}"
+ attr :on_cancel, :any, required: true
+
+ def confirm_dialog(assigns) do
+ ~H"""
+ <.modal id={@id} show={@show} on_cancel={@on_cancel}>
+ {@title}
+ {@message}
+
+ <.button variant="secondary" size="md" class="flex-1" phx-click={@on_cancel}>
+ {gettext("Cancelar")}
+
+ <.button variant="danger" size="md" class="flex-1" phx-click={@on_confirm}>
+ {@confirm_label || gettext("Excluir")}
+
+
+
+ """
+ end
+
+ # ---------- flash ----------
+
+ attr :flash, :map, required: true
+
+ def flash_group(assigns) do
+ ~H"""
+
+ <.flash_message kind={:info} flash={@flash} />
+ <.flash_message kind={:error} flash={@flash} />
+
+ """
+ end
+
+ attr :kind, :atom, required: true
+ attr :flash, :map, required: true
+
+ defp flash_message(assigns) do
+ ~H"""
+ JS.hide()}
+ title={gettext("Fechar aviso")}
+ >
+ <.icon name={if @kind == :error, do: "alert", else: "info"} size={20} />
+ {message}
+
+ """
+ end
+
+ # ---------- estado vazio ----------
+
+ attr :icon, :string, default: "folder"
+ attr :title, :string, required: true
+ attr :hint, :string, default: nil
+ slot :inner_block
+
+ def empty_state(assigns) do
+ ~H"""
+
+ <.icon name={@icon} size={40} />
+
{@title}
+
{@hint}
+ {render_slot(@inner_block)}
+
+ """
+ end
+
+ # ---------- helpers de formatação ----------
+
+ @doc """
+ Aparência do arquivo por MIME: `{serviço, ícone}` para o `service_square/1`.
+ Segue a paleta do Penpot: imagem=Jaci (dourado), vídeo=azul, PDF=ember,
+ planilha/texto=verde, resto neutro.
+ """
+ def file_visual("image/" <> _rest), do: {"jaci", "image"}
+ def file_visual("video/" <> _rest), do: {"video", "file"}
+ def file_visual("audio/" <> _rest), do: {"video", "play"}
+ def file_visual("application/pdf"), do: {"doc", "file"}
+ def file_visual("text/" <> _rest), do: {"ybira", "file"}
+ def file_visual(_mime), do: {"neutral", "file"}
+
+ @doc """
+ Rótulo curto do tipo de arquivo para as linhas de lista ("PDF", "Imagem"...).
+ """
+ def file_kind("image/" <> _rest), do: gettext("Imagem")
+ def file_kind("video/" <> _rest), do: gettext("Vídeo")
+ def file_kind("audio/" <> _rest), do: gettext("Áudio")
+ def file_kind("application/pdf"), do: "PDF"
+ def file_kind("application/zip"), do: "ZIP"
+ def file_kind("text/" <> _rest), do: gettext("Texto")
+ def file_kind(_mime), do: gettext("Arquivo")
+
+ @doc """
+ Bytes em texto humano, convenção pt-BR (vírgula decimal): `format_bytes(2_400_000)` retorna `"2,4 MB"`.
+ """
+ def format_bytes(nil), do: "0 B"
+ def format_bytes(bytes) when bytes < 1024, do: "#{bytes} B"
+
+ def format_bytes(bytes) do
+ {value, unit} =
+ cond do
+ bytes >= 1024 ** 4 -> {bytes / 1024 ** 4, "TB"}
+ bytes >= 1024 ** 3 -> {bytes / 1024 ** 3, "GB"}
+ bytes >= 1024 ** 2 -> {bytes / 1024 ** 2, "MB"}
+ true -> {bytes / 1024, "KB"}
+ end
+
+ formatted =
+ if value >= 10 do
+ value |> round() |> Integer.to_string()
+ else
+ value |> Float.round(1) |> :erlang.float_to_binary(decimals: 1) |> String.replace(".", ",")
+ end
+
+ "#{formatted} #{unit}"
+ end
+
+ @doc """
+ Tempo relativo curto em pt-BR: "hoje", "ontem", "há 3 dias", "há 2 semanas".
+ """
+ def relative_time(%NaiveDateTime{} = at) do
+ days = Date.diff(Date.utc_today(), NaiveDateTime.to_date(at))
+
+ cond do
+ days <= 0 -> gettext("hoje")
+ days == 1 -> gettext("ontem")
+ days < 7 -> gettext("há %{count} dias", count: days)
+ days < 14 -> gettext("há 1 semana")
+ days < 30 -> gettext("há %{count} semanas", count: div(days, 7))
+ days < 60 -> gettext("há 1 mês")
+ true -> gettext("há %{count} meses", count: div(days, 30))
+ end
+ end
+
+ def relative_time(_other), do: ""
+
+ @doc """
+ Tradução de erros de changeset. As mensagens do Ecto chegam em inglês com
+ interpolação `%{count}`; aqui interpolamos e deixamos a tradução para os
+ arquivos `.po` (domínio "errors").
+ """
+ def translate_error({msg, opts}) do
+ if count = opts[:count] do
+ Gettext.dngettext(TainaWeb.Gettext, "errors", msg, msg, count, opts)
+ else
+ Gettext.dgettext(TainaWeb.Gettext, "errors", msg, opts)
+ end
+ end
+end
diff --git a/lib/taina_web/components/icons.ex b/lib/taina_web/components/icons.ex
new file mode 100644
index 0000000..c3c35cb
--- /dev/null
+++ b/lib/taina_web/components/icons.ex
@@ -0,0 +1,72 @@
+defmodule TainaWeb.Icons do
+ @moduledoc """
+ Ícones do design system (página "01 - Componentes" do Penpot) como SVG
+ inline, sem icon font, sem requisição extra. Os paths são o conjunto
+ outline do Lucide (ISC), 24x24, stroke 2, pontas arredondadas.
+
+ Uso via `TainaWeb.CoreComponents.icon/1`; este módulo só guarda os paths.
+ """
+
+ @paths %{
+ "home" => ~S( ),
+ "folder" =>
+ ~S( ),
+ "folder-plus" =>
+ ~S( ),
+ "image" =>
+ ~S( ),
+ "user" => ~S( ),
+ "search" => ~S( ),
+ "plus" => ~S( ),
+ "upload" =>
+ ~S( ),
+ "download" =>
+ ~S( ),
+ "share" =>
+ ~S( ),
+ "chevron-left" => ~S( ),
+ "chevron-right" => ~S( ),
+ "chevron-up" => ~S( ),
+ "chevron-down" => ~S( ),
+ "kebab" => ~S( ),
+ "dots" => ~S( ),
+ "trash" =>
+ ~S( ),
+ "grid" =>
+ ~S( ),
+ "list" =>
+ ~S( ),
+ "check" => ~S( ),
+ "close" => ~S( ),
+ "clock" => ~S( ),
+ "qr" =>
+ ~S( ),
+ "link" =>
+ ~S( ),
+ "shield" =>
+ ~S( ),
+ "file" =>
+ ~S( ),
+ "restore" => ~S( ),
+ "spark" =>
+ ~S( ),
+ "play" => ~S( ),
+ "logout" =>
+ ~S( ),
+ "pencil" => ~S( ),
+ "move" => ~S( ),
+ "disk" =>
+ ~S( ),
+ "info" => ~S( ),
+ "alert" =>
+ ~S( )
+ }
+
+ @names Map.keys(@paths)
+
+ @doc "Nomes de ícone disponíveis (para validação em `attr :name, values: ...`)."
+ def names, do: @names
+
+ @doc "Markup interno (paths) do ícone. Levanta se o nome não existir."
+ def path!(name), do: Map.fetch!(@paths, name)
+end
diff --git a/lib/taina_web/components/layouts.ex b/lib/taina_web/components/layouts.ex
new file mode 100644
index 0000000..e4d0527
--- /dev/null
+++ b/lib/taina_web/components/layouts.ex
@@ -0,0 +1,164 @@
+defmodule TainaWeb.Layouts do
+ @moduledoc """
+ Cascas de página. `root` é o documento HTML; `app/1` é o shell autenticado
+ (bottom-nav no mobile, sidebar no desktop, telas "02/03" do Penpot); e
+ `auth/1` é o céu noturno com horizonte das telas de setup/login/convite.
+
+ LiveViews envolvem o próprio conteúdo explicitamente:
+
+
+ """
+
+ use TainaWeb, :html
+
+ embed_templates "layouts/*"
+
+ @doc """
+ Shell autenticado. `active_tab` marca o item ativo na navegação
+ (`:home`, `:files`, `:photos`, `:members`, `:account`).
+ `storage_stats` (opcional) alimenta o mini-card de armazenamento da sidebar.
+ """
+ attr :flash, :map, required: true
+ attr :current_scope, Taina.Scope, default: nil
+ attr :active_tab, :atom, default: nil
+ attr :storage_stats, :map, default: nil, doc: "%{used_bytes: _, quota_bytes: _}"
+ slot :inner_block, required: true
+
+ def app(assigns) do
+ ~H"""
+ <.flash_group flash={@flash} />
+
+
+
+
+
+ {render_slot(@inner_block)}
+
+
+
+
+ <.bottom_nav_item navigate={~p"/"} icon="home" active={@active_tab == :home} label={gettext("Início")} />
+ <.bottom_nav_item
+ navigate={~p"/arquivos"}
+ icon="folder"
+ active={@active_tab == :files}
+ label={gettext("Arquivos")}
+ />
+ <.bottom_nav_item navigate={~p"/fotos"} icon="image" active={@active_tab == :photos} label={gettext("Fotos")} />
+ <.bottom_nav_item
+ navigate={~p"/conta"}
+ icon="user"
+ active={@active_tab in [:account, :members]}
+ label={gettext("Conta")}
+ />
+
+
+ """
+ end
+
+ attr :navigate, :string, required: true
+ attr :icon, :string, required: true
+ attr :label, :string, required: true
+ attr :active, :boolean, default: false
+
+ defp sidebar_item(assigns) do
+ ~H"""
+ <.link navigate={@navigate} class="sidebar__item" aria-current={@active && "page"}>
+ <.icon name={@icon} size={20} /> {@label}
+
+ """
+ end
+
+ attr :navigate, :string, required: true
+ attr :icon, :string, required: true
+ attr :label, :string, required: true
+ attr :active, :boolean, default: false
+
+ defp bottom_nav_item(assigns) do
+ ~H"""
+ <.link navigate={@navigate} class="bottom-nav__item" aria-current={@active && "page"}>
+ <.icon name={@icon} size={22} />
+ {@label}
+
+ """
+ end
+
+ defp storage_percent(%{used_bytes: used, quota_bytes: quota})
+ when is_integer(used) and is_integer(quota) and quota > 0 do
+ (used / quota * 100) |> round() |> min(100) |> max(0)
+ end
+
+ defp storage_percent(_stats), do: 0
+
+ @doc """
+ Layout das telas públicas (setup, login, convite): céu noturno, estrelas e
+ horizonte aquecido.
+ """
+ attr :flash, :map, required: true
+ slot :inner_block, required: true
+
+ def auth(assigns) do
+ ~H"""
+ <.flash_group flash={@flash} />
+
+
+ {render_slot(@inner_block)}
+
+
+ """
+ end
+
+ @doc """
+ Barra superior de telas internas mobile: voltar, título centrado e ação.
+ """
+ attr :title, :string, required: true
+ attr :back, :string, required: true
+ slot :action
+
+ def app_bar(assigns) do
+ ~H"""
+
+ <.icon_button name="chevron-left" label={gettext("Voltar")} navigate={@back} />
+ {@title}
+ {render_slot(@action)}
+
+
+ """
+ end
+end
diff --git a/lib/taina_web/components/layouts/root.html.heex b/lib/taina_web/components/layouts/root.html.heex
new file mode 100644
index 0000000..8a712fb
--- /dev/null
+++ b/lib/taina_web/components/layouts/root.html.heex
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+ <.live_title default={gettext("Tainá")} suffix={" - " <> gettext("Tainá")}>
+ {assigns[:page_title]}
+
+
+
+
+
+ {@inner_content}
+
+
diff --git a/lib/taina_web/controllers/file_controller.ex b/lib/taina_web/controllers/file_controller.ex
index b26fccc..1c403cc 100644
--- a/lib/taina_web/controllers/file_controller.ex
+++ b/lib/taina_web/controllers/file_controller.ex
@@ -75,7 +75,7 @@ defmodule TainaWeb.FileController do
end
# Tipos seguros de exibir abrem inline (mídia, PDF); o resto baixa como
- # anexo — nada de octet-stream/zip rodando no contexto da página.
+ # anexo, nada de octet-stream/zip rodando no contexto da página.
@inline_prefixes ["image/", "video/", "audio/", "application/pdf"]
defp content_disposition(file) do
diff --git a/lib/taina_web/controllers/invite_controller.ex b/lib/taina_web/controllers/invite_controller.ex
new file mode 100644
index 0000000..cab2a37
--- /dev/null
+++ b/lib/taina_web/controllers/invite_controller.ex
@@ -0,0 +1,31 @@
+defmodule TainaWeb.InviteController do
+ @moduledoc """
+ Aceite de convite: o `InviteAcceptLive` valida o formulário ao vivo e o
+ submit final chega como POST tradicional, `accept_invite/2` cria a conta
+ e a sessão nasce na mesma requisição.
+ """
+
+ use TainaWeb, :controller
+
+ alias Taina.Maraca
+
+ def accept(conn, %{"token" => token, "account" => params}) do
+ case Maraca.accept_invite(token, params) do
+ {:ok, ava} ->
+ conn
+ |> TainaWeb.Auth.log_in(ava)
+ |> put_flash(:info, gettext("Conta criada. Boas-vindas à comunidade!"))
+ |> redirect(to: ~p"/")
+
+ {:error, :invalid_token} ->
+ conn
+ |> put_flash(:error, gettext("Este convite expirou ou já foi usado. Peça um novo link."))
+ |> redirect(to: ~p"/login")
+
+ {:error, _changeset} ->
+ conn
+ |> put_flash(:error, gettext("Não foi possível criar a conta. Revise os dados e tente de novo."))
+ |> redirect(to: ~p"/convite/#{token}")
+ end
+ end
+end
diff --git a/lib/taina_web/controllers/password_controller.ex b/lib/taina_web/controllers/password_controller.ex
new file mode 100644
index 0000000..69e2301
--- /dev/null
+++ b/lib/taina_web/controllers/password_controller.ex
@@ -0,0 +1,31 @@
+defmodule TainaWeb.PasswordController do
+ @moduledoc """
+ Consome o link de redefinição que o zelador gerou: a pessoa define a senha
+ nova e a sessão nasce na mesma requisição (LiveView não escreve cookie). O
+ token só é validado de verdade aqui, no submit.
+ """
+
+ use TainaWeb, :controller
+
+ alias Taina.Maraca
+
+ def update(conn, %{"token" => token, "account" => params}) do
+ case Maraca.reset_password(token, params["password"], params["password_confirmation"]) do
+ {:ok, ava} ->
+ conn
+ |> TainaWeb.Auth.log_in(ava)
+ |> put_flash(:info, gettext("Senha redefinida. Você já está dentro."))
+ |> redirect(to: ~p"/")
+
+ {:error, :invalid_token} ->
+ conn
+ |> put_flash(:error, gettext("Este link expirou ou já foi usado. Peça um novo a quem cuida da comunidade."))
+ |> redirect(to: ~p"/login")
+
+ {:error, _changeset} ->
+ conn
+ |> put_flash(:error, gettext("Não foi possível redefinir a senha. Revise os dados e tente de novo."))
+ |> redirect(to: ~p"/redefinir/#{token}")
+ end
+ end
+end
diff --git a/lib/taina_web/controllers/session_controller.ex b/lib/taina_web/controllers/session_controller.ex
new file mode 100644
index 0000000..6a57c38
--- /dev/null
+++ b/lib/taina_web/controllers/session_controller.ex
@@ -0,0 +1,43 @@
+defmodule TainaWeb.SessionController do
+ @moduledoc """
+ Cria e destrói a sessão (cookie). LiveView não escreve cookie, então o login
+ é um POST tradicional vindo do form do `LoginLive`; o resto da navegação
+ segue live.
+ """
+
+ use TainaWeb, :controller
+
+ alias Taina.Maraca
+
+ def create(conn, %{"username" => username, "password" => password}) do
+ with {:ok, tekoa} <- Maraca.get_tekoa(),
+ {:ok, ava} <- Maraca.authenticate(username, password, tekoa) do
+ conn
+ |> TainaWeb.Auth.log_in(ava)
+ |> redirect(to: ~p"/")
+ else
+ {:error, :not_bootstrapped} ->
+ redirect(conn, to: ~p"/setup")
+
+ {:error, _reason} ->
+ conn
+ |> put_flash(:error, gettext("Nome ou senha incorretos."))
+ |> redirect(to: ~p"/login")
+ end
+ end
+
+ # POST sem os campos esperados (form adulterado, bot): trata como credencial
+ # invalida em vez de estourar FunctionClauseError (500).
+ def create(conn, _params) do
+ conn
+ |> put_flash(:error, gettext("Nome ou senha incorretos."))
+ |> redirect(to: ~p"/login")
+ end
+
+ def delete(conn, _params) do
+ conn
+ |> Maraca.destroy_session()
+ |> put_flash(:info, gettext("Até logo!"))
+ |> redirect(to: ~p"/login")
+ end
+end
diff --git a/lib/taina_web/controllers/setup_controller.ex b/lib/taina_web/controllers/setup_controller.ex
new file mode 100644
index 0000000..300ea52
--- /dev/null
+++ b/lib/taina_web/controllers/setup_controller.ex
@@ -0,0 +1,65 @@
+defmodule TainaWeb.SetupController do
+ @moduledoc """
+ Passo final do wizard de primeiro boot: o `SetupLive` coleta os dados nos
+ três passos e o submit final chega aqui como POST tradicional, `bootstrap/2`
+ + login na mesma requisição (LiveView não escreve cookie de sessão).
+ """
+
+ use TainaWeb, :controller
+
+ alias Taina.Maraca
+
+ # Cota inicial = espaço livre do disco do storage (melhor esforço); sem leitura
+ # possível, cai num padrão de 256 GB. O zelador reajusta depois em /armazenamento.
+ @default_quota_bytes 256 * 1024 * 1024 * 1024
+
+ def create(conn, %{"setup" => params}) do
+ tekoa_attrs = %{name: params["community_name"], storage_quota_bytes: initial_quota_bytes()}
+
+ zelador_attrs = %{
+ username: params["username"],
+ display_name: params["display_name"],
+ password: params["password"],
+ password_confirmation: params["password_confirmation"]
+ }
+
+ case Maraca.bootstrap(tekoa_attrs, zelador_attrs) do
+ {:ok, %{ava: ava}} ->
+ conn
+ |> TainaWeb.Auth.log_in(ava)
+ |> put_flash(:info, gettext("Sua nuvem está pronta. Boas-vindas!"))
+ |> redirect(to: ~p"/")
+
+ {:error, :already_bootstrapped} ->
+ redirect(conn, to: ~p"/login")
+
+ {:error, _changeset} ->
+ conn
+ |> put_flash(:error, gettext("Não foi possível criar a comunidade. Revise os dados e tente de novo."))
+ |> redirect(to: ~p"/setup")
+ end
+ end
+
+ # POST sem o mapa "setup" (form adulterado, bot): pede revisao dos dados em
+ # vez de estourar FunctionClauseError (500).
+ def create(conn, _params) do
+ conn
+ |> put_flash(:error, gettext("Não foi possível criar a comunidade. Revise os dados e tente de novo."))
+ |> redirect(to: ~p"/setup")
+ end
+
+ defp initial_quota_bytes do
+ storage_root = Application.fetch_env!(:taina, :storage_root)
+
+ with {out, 0} <- System.cmd("df", ["-k", storage_root], stderr_to_stdout: true),
+ [_header, line | _rest] <- String.split(out, "\n"),
+ [_fs, _blocks, _used, avail | _] <- String.split(line, ~r/\s+/, trim: true),
+ {kbytes, ""} <- Integer.parse(avail) do
+ kbytes * 1024
+ else
+ _ -> @default_quota_bytes
+ end
+ rescue
+ _e in ErlangError -> @default_quota_bytes
+ end
+end
diff --git a/lib/taina_web/gettext.ex b/lib/taina_web/gettext.ex
new file mode 100644
index 0000000..5ae4a03
--- /dev/null
+++ b/lib/taina_web/gettext.ex
@@ -0,0 +1,9 @@
+defmodule TainaWeb.Gettext do
+ @moduledoc """
+ Backend de i18n da UI. O idioma padrão é pt-BR (RFC 002, comunidade-first,
+ pt-BR primeiro); os textos no código são as `msgid` em português e novos
+ idiomas entram como `.po` em `priv/gettext/`.
+ """
+
+ use Gettext.Backend, otp_app: :taina
+end
diff --git a/lib/taina_web/hooks.ex b/lib/taina_web/hooks.ex
new file mode 100644
index 0000000..2376812
--- /dev/null
+++ b/lib/taina_web/hooks.ex
@@ -0,0 +1,26 @@
+defmodule TainaWeb.Hooks do
+ @moduledoc """
+ `on_mount` compartilhado do shell autenticado: estatísticas de armazenamento
+ para o mini-card da sidebar, calculadas uma vez por mount (`assign_new` evita
+ reconsulta na passagem HTTP -> WebSocket).
+ """
+
+ import Phoenix.Component, only: [assign_new: 3]
+
+ alias Taina.Ybira
+
+ def on_mount(:shell, _params, _session, socket) do
+ socket = assign_new(socket, :storage_stats, fn -> load_storage_stats(socket.assigns.current_scope) end)
+
+ {:cont, socket}
+ end
+
+ defp load_storage_stats(nil), do: nil
+
+ defp load_storage_stats(scope) do
+ case Ybira.storage_stats(scope) do
+ {:ok, stats} -> stats
+ _error -> nil
+ end
+ end
+end
diff --git a/lib/taina_web/live/account_live.ex b/lib/taina_web/live/account_live.ex
new file mode 100644
index 0000000..56253f2
--- /dev/null
+++ b/lib/taina_web/live/account_live.ex
@@ -0,0 +1,77 @@
+defmodule TainaWeb.AccountLive do
+ @moduledoc """
+ Hub "Conta" (aba do bottom-nav mobile): cartão da pessoa logada e atalhos
+ para membros, convites, armazenamento, lixeira e sair. Não tem board próprio
+ no Penpot, composto com os componentes de linha do design system.
+ """
+
+ use TainaWeb, :live_view
+
+ alias Taina.Maraca
+ alias TainaWeb.Layouts
+
+ @impl true
+ def mount(_params, _session, socket) do
+ {:ok, assign(socket, :page_title, gettext("Conta"))}
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ {gettext("Conta")}
+
+
+ <.card class="row gap-4">
+ <.avatar name={@current_scope.ava.display_name || @current_scope.ava.username} />
+
+
{@current_scope.ava.display_name || @current_scope.ava.username}
+
{"@" <> @current_scope.ava.username}
+
+ <.badge :if={Maraca.zelador?(@current_scope.ava)} variant="zelador">{gettext("Zelador(a)")}
+
+
+
+ <.list_row title={gettext("Membros")} meta={gettext("quem faz parte da comunidade")} navigate={~p"/membros"}>
+ <:leading><.service_square service="nhaman" icon="user" />
+ <:actions><.icon name="chevron-right" size={18} class="text-faint" />
+
+
+ <.list_row
+ :if={Maraca.zelador?(@current_scope.ava)}
+ title={gettext("Convidar pessoas")}
+ meta={gettext("link + QR code")}
+ navigate={~p"/membros/convidar"}
+ >
+ <:leading><.service_square service="ybira" icon="qr" />
+ <:actions><.icon name="chevron-right" size={18} class="text-faint" />
+
+
+ <.list_row
+ title={gettext("Armazenamento")}
+ meta={gettext("uso do disco e cota")}
+ navigate={~p"/armazenamento"}
+ >
+ <:leading><.service_square service="jaci" icon="disk" />
+ <:actions><.icon name="chevron-right" size={18} class="text-faint" />
+
+
+ <.list_row title={gettext("Lixeira")} meta={gettext("itens dos últimos 30 dias")} navigate={~p"/arquivos/lixeira"}>
+ <:leading><.service_square service="neutral" icon="trash" />
+ <:actions><.icon name="chevron-right" size={18} class="text-faint" />
+
+
+
+ <.link href={~p"/logout"} method="delete" class="btn btn--danger btn--md w-full">
+ <.icon name="logout" size={18} /> {gettext("Sair")}
+
+
+
+ """
+ end
+end
diff --git a/lib/taina_web/live/file_preview_live.ex b/lib/taina_web/live/file_preview_live.ex
new file mode 100644
index 0000000..aa23402
--- /dev/null
+++ b/lib/taina_web/live/file_preview_live.ex
@@ -0,0 +1,217 @@
+defmodule TainaWeb.FilePreviewLive do
+ @moduledoc """
+ Detalhe/preview de um arquivo (tela "Ybira - Preview"): mídia inline
+ (imagem/vídeo/áudio/PDF via `/files/:id`, que já fala `Range`), metadados e
+ ações: baixar, copiar link, detalhes, excluir (lixeira).
+ """
+
+ use TainaWeb, :live_view
+
+ alias Taina.Ybira
+ alias TainaWeb.Layouts
+
+ @impl true
+ def mount(%{"id" => public_id}, _session, socket) do
+ case Ybira.get_file(socket.assigns.current_scope, public_id) do
+ {:ok, file} ->
+ {:ok,
+ socket
+ |> assign(:page_title, file.original_filename)
+ |> assign(:file, file)
+ |> assign(:text_preview, load_text_preview(file))
+ |> assign(:show_details, false)
+ |> assign(:show_confirm, false)}
+
+ {:error, :not_found} ->
+ {:ok,
+ socket
+ |> Phoenix.LiveView.put_flash(:error, gettext("Arquivo não encontrado."))
+ |> Phoenix.LiveView.redirect(to: ~p"/arquivos")}
+
+ {:error, _reason} ->
+ {:ok,
+ socket
+ |> Phoenix.LiveView.put_flash(:error, gettext("Não foi possível abrir o arquivo agora."))
+ |> Phoenix.LiveView.redirect(to: ~p"/arquivos")}
+ end
+ end
+
+ @impl true
+ def handle_event("copied", _params, socket) do
+ {:noreply, Phoenix.LiveView.put_flash(socket, :info, gettext("Link copiado!"))}
+ end
+
+ def handle_event("toggle-details", _params, socket) do
+ {:noreply, assign(socket, :show_details, !socket.assigns.show_details)}
+ end
+
+ def handle_event("ask-delete", _params, socket) do
+ {:noreply, assign(socket, :show_confirm, true)}
+ end
+
+ def handle_event("close-confirm", _params, socket) do
+ {:noreply, assign(socket, :show_confirm, false)}
+ end
+
+ def handle_event("confirm-delete", _params, socket) do
+ case Ybira.delete_file(socket.assigns.current_scope, socket.assigns.file.public_id) do
+ {:ok, _file} ->
+ {:noreply,
+ socket
+ |> Phoenix.LiveView.put_flash(:info, gettext("Arquivo movido para a lixeira."))
+ |> Phoenix.LiveView.push_navigate(to: ~p"/arquivos")}
+
+ {:error, _reason} ->
+ {:noreply, Phoenix.LiveView.put_flash(socket, :error, gettext("Não foi possível excluir o arquivo."))}
+ end
+ end
+
+ defp media_kind("image/" <> _rest), do: :image
+ defp media_kind("video/" <> _rest), do: :video
+ defp media_kind("audio/" <> _rest), do: :audio
+ defp media_kind("application/pdf"), do: :pdf
+ defp media_kind("text/" <> _rest), do: :text
+ defp media_kind(_mime), do: :other
+
+ # Pré-visualização de texto: lê até 256 KB do disco (o suficiente para a tela;
+ # arquivos maiores mostram o começo). Falha de leitura: sem preview, cai no
+ # ícone genérico.
+ @text_preview_limit 256 * 1024
+
+ defp load_text_preview(%{mime_type: "text/" <> _} = file) do
+ case File.open(file.filepath, [:read, :binary], &IO.binread(&1, @text_preview_limit)) do
+ {:ok, content} when is_binary(content) -> content
+ _ -> nil
+ end
+ end
+
+ defp load_text_preview(_file), do: nil
+
+ defp file_meta(file) do
+ dimensions =
+ case file.metadata do
+ %{"width" => w, "height" => h} -> ", #{w}x#{h}"
+ _ -> ""
+ end
+
+ "#{file_kind(file.mime_type)}, #{format_bytes(file.file_size_bytes)}#{dimensions}, " <>
+ gettext("enviado %{when}", when: relative_time(file.inserted_at))
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+ <:action>
+ <.icon_button name="download" label={gettext("Baixar")} href={~p"/files/#{@file.public_id}"} download />
+
+
+
+
+
+
+ <.card>
+
{@file.original_filename}
+
{file_meta(@file)}
+
+
+
+
+
+ <.modal id="file-details" show={@show_details} on_cancel={JS.push("toggle-details")}>
+ {gettext("Detalhes")}
+
+
+
{gettext("Nome original")}
+
{@file.original_filename}
+
+
+
{gettext("Tipo")}
+
{@file.mime_type}
+
+
+
{gettext("Tamanho")}
+
{format_bytes(@file.file_size_bytes)}
+
+
+
{gettext("SHA-256")}
+
{@file.file_hash}
+
+
+
{gettext("Enviado em")}
+
{Calendar.strftime(@file.inserted_at, "%d/%m/%Y %H:%M")}
+
+
+ <.button variant="secondary" class="w-full mt-6" phx-click="toggle-details">
+ {gettext("Fechar")}
+
+
+
+ <.confirm_dialog
+ id="confirm-delete"
+ show={@show_confirm}
+ title={gettext("Excluir \"%{name}\"?", name: @file.original_filename)}
+ message={gettext("Vai para a lixeira. Dá para restaurar em até 30 dias.")}
+ on_confirm="confirm-delete"
+ on_cancel={JS.push("close-confirm")}
+ />
+
+ """
+ end
+end
diff --git a/lib/taina_web/live/files_live.ex b/lib/taina_web/live/files_live.ex
new file mode 100644
index 0000000..a22827d
--- /dev/null
+++ b/lib/taina_web/live/files_live.ex
@@ -0,0 +1,565 @@
+defmodule TainaWeb.FilesLive do
+ @moduledoc """
+ Navegador de arquivos do Ybira: pastas + arquivos da pasta atual, breadcrumb
+ raso (Início / pasta), ordenação (nome/data/tamanho), alternância
+ lista/grade, e ações por item: renomear, mover (seletor ou arrastar-e-soltar)
+ e excluir (com confirmação). Telas "Ybira - Arquivos" e "Desktop - Arquivos".
+
+ Ordenação e visão moram na URL (`?sort=name-asc&view=grid`), então sobrevivem
+ a navegação, recarga e compartilhamento de link. Paginação por offset
+ (`list_folder_contents`), o que permite qualquer ordem.
+ """
+
+ use TainaWeb, :live_view
+
+ alias Taina.Ybira
+ alias TainaWeb.Layouts
+
+ @impl true
+ def handle_params(params, _uri, socket) do
+ folder_public_id = params["folder_id"]
+ sort = parse_sort(params["sort"])
+ view = if params["view"] == "grid", do: :grid, else: :list
+ scope = socket.assigns.current_scope
+
+ case load_folder(scope, folder_public_id) do
+ {:ok, folder} ->
+ case Ybira.list_folder_contents(scope, folder_public_id, sort: sort) do
+ {:ok, %{folders: folders, files: files, next_cursor: next_cursor}} ->
+ {:noreply,
+ socket
+ |> assign(:page_title, (folder && folder.name) || gettext("Arquivos"))
+ |> assign(:folder, folder)
+ |> assign(:folder_public_id, folder_public_id)
+ |> assign(:folders, folders)
+ |> assign(:next_cursor, next_cursor)
+ |> assign(:item_count, length(folders) + length(files))
+ |> assign(:sort, sort)
+ |> assign(:view, view)
+ |> assign(:modal, nil)
+ |> assign(:rename_target, nil)
+ |> assign(:move_target, nil)
+ |> assign(:all_folders, [])
+ |> assign(:confirm, nil)
+ |> stream(:files, files, reset: true)}
+
+ {:error, _reason} ->
+ {:noreply,
+ socket
+ |> Phoenix.LiveView.put_flash(:error, gettext("Não foi possível carregar a pasta."))
+ |> Phoenix.LiveView.push_navigate(to: ~p"/arquivos")}
+ end
+
+ {:error, :not_found} ->
+ {:noreply,
+ socket
+ |> Phoenix.LiveView.put_flash(:error, gettext("Pasta não encontrada."))
+ |> Phoenix.LiveView.push_navigate(to: ~p"/arquivos")}
+ end
+ end
+
+ defp load_folder(_scope, nil), do: {:ok, nil}
+ defp load_folder(scope, public_id), do: Ybira.get_folder(scope, public_id)
+
+ @impl true
+ def handle_event("load-more", _params, socket) do
+ %{next_cursor: offset, folder_public_id: folder_public_id, sort: sort} = socket.assigns
+
+ if offset do
+ case Ybira.list_folder_contents(socket.assigns.current_scope, folder_public_id, sort: sort, offset: offset) do
+ {:ok, %{files: files, next_cursor: next_cursor}} ->
+ {:noreply,
+ socket
+ |> assign(:next_cursor, next_cursor)
+ |> assign(:item_count, socket.assigns.item_count + length(files))
+ |> stream(:files, files)}
+
+ {:error, _reason} ->
+ {:noreply, Phoenix.LiveView.put_flash(socket, :error, gettext("Não foi possível carregar mais itens."))}
+ end
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_event("open-modal", %{"modal" => modal}, socket) do
+ {:noreply, assign(socket, :modal, modal)}
+ end
+
+ def handle_event("open-rename", %{"kind" => kind, "id" => id, "name" => name}, socket) do
+ {:noreply, socket |> assign(:modal, "rename") |> assign(:rename_target, %{kind: kind, id: id, name: name})}
+ end
+
+ def handle_event("open-move", %{"kind" => kind, "id" => id, "name" => name}, socket) do
+ case Ybira.list_folders(socket.assigns.current_scope) do
+ {:ok, folders} ->
+ {:noreply,
+ socket
+ |> assign(:modal, "move")
+ |> assign(:move_target, %{kind: kind, id: id, name: name})
+ |> assign(:all_folders, folders)}
+
+ {:error, _reason} ->
+ {:noreply, Phoenix.LiveView.put_flash(socket, :error, gettext("Não foi possível abrir as pastas."))}
+ end
+ end
+
+ def handle_event("close-modal", _params, socket) do
+ {:noreply,
+ socket
+ |> assign(:modal, nil)
+ |> assign(:rename_target, nil)
+ |> assign(:move_target, nil)
+ |> assign(:confirm, nil)}
+ end
+
+ def handle_event("create-folder", %{"folder" => %{"name" => name}}, socket) do
+ case Ybira.create_folder(socket.assigns.current_scope, %{
+ name: name,
+ parent_public_id: socket.assigns.folder_public_id
+ }) do
+ {:ok, _folder} ->
+ {:noreply, socket |> Phoenix.LiveView.put_flash(:info, gettext("Pasta criada.")) |> refresh()}
+
+ {:error, _reason} ->
+ {:noreply, Phoenix.LiveView.put_flash(socket, :error, gettext("Não foi possível criar a pasta."))}
+ end
+ end
+
+ def handle_event("rename-item", %{"folder" => %{"name" => name}}, socket) do
+ %{kind: kind, id: id} = socket.assigns.rename_target
+
+ result =
+ case kind do
+ "folder" -> Ybira.rename_folder(socket.assigns.current_scope, id, name)
+ "file" -> Ybira.rename_file(socket.assigns.current_scope, id, name)
+ _ -> {:error, :invalid_kind}
+ end
+
+ case result do
+ {:ok, _} -> {:noreply, refresh(socket)}
+ {:error, _} -> {:noreply, Phoenix.LiveView.put_flash(socket, :error, gettext("Não foi possível renomear."))}
+ end
+ end
+
+ # Mover por seletor (botões do modal) ou por arrastar-e-soltar (hook DnD): os
+ # dois caem aqui. `target` vazio = raiz.
+ def handle_event("move-item", %{"kind" => kind, "id" => id} = params, socket) do
+ target = normalize_target(params["target"])
+
+ result =
+ case kind do
+ "file" -> Ybira.move_file(socket.assigns.current_scope, id, target)
+ "folder" -> Ybira.move_folder(socket.assigns.current_scope, id, target)
+ _ -> {:error, :invalid_kind}
+ end
+
+ case result do
+ {:ok, _} ->
+ {:noreply, socket |> Phoenix.LiveView.put_flash(:info, gettext("Movido.")) |> refresh()}
+
+ {:error, :circular_reference} ->
+ {:noreply,
+ socket
+ |> assign(:modal, nil)
+ |> Phoenix.LiveView.put_flash(:error, gettext("Não dá para mover uma pasta para dentro dela mesma."))}
+
+ {:error, _} ->
+ {:noreply,
+ socket |> assign(:modal, nil) |> Phoenix.LiveView.put_flash(:error, gettext("Não foi possível mover."))}
+ end
+ end
+
+ def handle_event("ask-delete", %{"kind" => kind, "id" => id, "name" => name}, socket) do
+ {:noreply, assign(socket, :confirm, %{kind: kind, id: id, name: name})}
+ end
+
+ def handle_event("confirm-delete", _params, %{assigns: %{confirm: %{kind: "folder", id: id}}} = socket) do
+ case Ybira.delete_folder(socket.assigns.current_scope, id) do
+ {:ok, :deleted} ->
+ {:noreply,
+ socket
+ |> assign(:confirm, nil)
+ |> Phoenix.LiveView.put_flash(:info, gettext("Pasta movida para a lixeira."))
+ |> refresh()}
+
+ {:error, _reason} ->
+ {:noreply,
+ socket
+ |> assign(:confirm, nil)
+ |> Phoenix.LiveView.put_flash(:error, gettext("Não foi possível excluir a pasta."))}
+ end
+ end
+
+ def handle_event("confirm-delete", _params, %{assigns: %{confirm: %{kind: "file", id: id}}} = socket) do
+ case Ybira.delete_file(socket.assigns.current_scope, id) do
+ {:ok, _file} ->
+ {:noreply,
+ socket
+ |> assign(:confirm, nil)
+ |> Phoenix.LiveView.put_flash(:info, gettext("Arquivo movido para a lixeira."))
+ |> refresh()}
+
+ {:error, _reason} ->
+ {:noreply,
+ socket
+ |> assign(:confirm, nil)
+ |> Phoenix.LiveView.put_flash(:error, gettext("Não foi possível excluir o arquivo."))}
+ end
+ end
+
+ def handle_event("confirm-delete", _params, socket) do
+ {:noreply, assign(socket, :confirm, nil)}
+ end
+
+ # Recarrega a pasta atual reaproveitando o handle_params, preservando ordem/visão.
+ defp refresh(socket) do
+ Phoenix.LiveView.push_patch(socket,
+ to: browse_path(socket.assigns.folder_public_id, socket.assigns.sort, socket.assigns.view)
+ )
+ end
+
+ # --- ordenação / visão / caminhos ---
+
+ defp parse_sort("name-asc"), do: {:name, :asc}
+ defp parse_sort("name-desc"), do: {:name, :desc}
+ defp parse_sort("date-asc"), do: {:date, :asc}
+ defp parse_sort("size-asc"), do: {:size, :asc}
+ defp parse_sort("size-desc"), do: {:size, :desc}
+ defp parse_sort(_default), do: {:date, :desc}
+
+ defp sort_param({field, dir}), do: "#{field}-#{dir}"
+
+ # Clicar no campo já ativo inverte a direção; em outro campo, começa crescente.
+ defp toggle_sort(field, {field, :asc}), do: {field, :desc}
+ defp toggle_sort(field, {field, :desc}), do: {field, :asc}
+ defp toggle_sort(field, _other), do: {field, :asc}
+
+ defp sort_label({:name, _}), do: gettext("Nome")
+ defp sort_label({:date, _}), do: gettext("Data")
+ defp sort_label({:size, _}), do: gettext("Tamanho")
+
+ defp sort_arrow_icon({_field, :asc}), do: "chevron-up"
+ defp sort_arrow_icon({_field, :desc}), do: "chevron-down"
+
+ defp browse_path(folder_public_id, sort, view) do
+ params = %{sort: sort_param(sort)}
+ params = if view == :grid, do: Map.put(params, :view, "grid"), else: params
+
+ case folder_public_id do
+ nil -> ~p"/arquivos?#{params}"
+ id -> ~p"/arquivos/pasta/#{id}?#{params}"
+ end
+ end
+
+ defp normalize_target(target) when target in [nil, ""], do: nil
+ defp normalize_target(target), do: target
+
+ defp upload_path(nil), do: ~p"/arquivos/enviar"
+ defp upload_path(folder_id), do: ~p"/arquivos/enviar?#{[pasta: folder_id]}"
+
+ defp file_meta(file) do
+ "#{file_kind(file.mime_type)}, #{format_bytes(file.file_size_bytes)}"
+ end
+
+ defp storage_percent(%{used_bytes: used, quota_bytes: quota}) when is_integer(quota) and quota > 0 do
+ round(used / quota * 100)
+ end
+
+ defp storage_percent(_stats), do: 0
+
+ defp storage_free(%{used_bytes: used, quota_bytes: quota}) when is_integer(quota), do: max(quota - used, 0)
+ defp storage_free(_stats), do: 0
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+
{gettext("Arquivos")}
+
+ <.icon_button
+ name="info"
+ label={gettext("Espaço da comunidade")}
+ phx-click="open-modal"
+ phx-value-modal="storage-info"
+ />
+ <.icon_button name="trash" label={gettext("Lixeira")} navigate={~p"/arquivos/lixeira"} />
+ <.icon_button
+ name="folder-plus"
+ label={gettext("Nova pasta")}
+ phx-click="open-modal"
+ phx-value-modal="new-folder"
+ />
+ <.button variant="primary" size="md" navigate={upload_path(@folder_public_id)} class="hide-mobile">
+ <.icon name="upload" size={18} /> {gettext("Enviar")}
+
+
+
+
+
+ <.menu id="sort-menu" icon="list" label={gettext("Ordenar")}>
+ <:item click={JS.patch(browse_path(@folder_public_id, toggle_sort(:name, @sort), @view))}>
+ {gettext("Nome")}
+
+ <:item click={JS.patch(browse_path(@folder_public_id, toggle_sort(:date, @sort), @view))}>
+ {gettext("Data")}
+
+ <:item click={JS.patch(browse_path(@folder_public_id, toggle_sort(:size, @sort), @view))}>
+ {gettext("Tamanho")}
+
+
+
+
+
+ {sort_label(@sort)} <.icon name={sort_arrow_icon(@sort)} size={14} />
+
+ <.icon_button
+ name="grid"
+ label={gettext("Alternar grade/lista")}
+ phx-click={JS.patch(browse_path(@folder_public_id, @sort, if(@view == :grid, do: :list, else: :grid)))}
+ />
+
+
+
+
+ <.link navigate={~p"/arquivos"} data-drop-folder="">{gettext("Início")}
+ <.icon name="chevron-right" size={14} />
+ {@folder.name}
+
+
+
+ {ngettext("%{count} item", "%{count} itens", @item_count)}
+
+
+
+ <%= if @view == :grid do %>
+
+ <.link
+ :for={folder <- @folders}
+ navigate={~p"/arquivos/pasta/#{folder.public_id}"}
+ class="tile"
+ data-drop-folder={folder.public_id}
+ >
+ <.service_square service="ybira" icon="folder" size="lg" />
+
{folder.name}
+
{gettext("Pasta")}
+
+
+
+ <.link
+ :for={{dom_id, file} <- @streams.files}
+ id={dom_id}
+ navigate={~p"/arquivos/#{file.public_id}"}
+ class="tile"
+ >
+ <% {service, icon} = file_visual(file.mime_type) %>
+ <.service_square service={service} icon={icon} size="lg" />
+
{file.original_filename}
+
{file_meta(file)}
+
+
+
+ <% else %>
+
+ <.list_row
+ :for={folder <- @folders}
+ title={folder.name}
+ meta={gettext("Pasta")}
+ navigate={~p"/arquivos/pasta/#{folder.public_id}"}
+ draggable="true"
+ data-drag-id={folder.public_id}
+ data-drag-kind="folder"
+ data-drop-folder={folder.public_id}
+ >
+ <:leading>
+ <.service_square service="ybira" icon="folder" />
+
+ <:actions>
+ <.item_menu kind="folder" id={folder.public_id} name={folder.name} />
+
+
+
+
+ <.list_row
+ :for={{dom_id, file} <- @streams.files}
+ id={dom_id}
+ title={file.original_filename}
+ meta={file_meta(file)}
+ navigate={~p"/arquivos/#{file.public_id}"}
+ draggable="true"
+ data-drag-id={file.public_id}
+ data-drag-kind="file"
+ >
+ <:leading>
+ <% {service, icon} = file_visual(file.mime_type) %>
+ <.service_square service={service} icon={icon} />
+
+ <:actions>
+ <.item_menu kind="file" id={file.public_id} name={file.original_filename} />
+
+
+
+
+ <% end %>
+
+
+
+
{gettext("Carregando mais...")}
+
+
+ <.empty_state
+ :if={@item_count == 0}
+ icon="folder"
+ title={gettext("Pasta vazia")}
+ hint={gettext("Envie arquivos ou crie uma pasta para organizar.")}
+ >
+ <.button variant="primary" size="md" navigate={upload_path(@folder_public_id)}>
+ {gettext("Enviar arquivos")}
+
+
+
+ <.button variant="fab" navigate={upload_path(@folder_public_id)} aria-label={gettext("Enviar arquivos")}>
+ <.icon name="plus" />
+
+
+ <.modal id="new-folder-modal" show={@modal == "new-folder"} on_cancel={JS.push("close-modal")}>
+ {gettext("Nova pasta")}
+
+
+
+ <.modal id="rename-modal" show={@modal == "rename"} on_cancel={JS.push("close-modal")}>
+ {gettext("Renomear")}
+
+
+
+ <.modal id="move-modal" show={@modal == "move"} on_cancel={JS.push("close-modal")}>
+ {gettext("Mover para...")}
+ {@move_target.name}
+
+
+ <.service_square service="ybira" icon="home" />
+ {gettext("Início (raiz)")}
+
+
+ <.service_square service="ybira" icon="folder" />
+ {folder.name}
+
+
+
+
+ <.modal id="storage-info-modal" show={@modal == "storage-info"} on_cancel={JS.push("close-modal")}>
+ {gettext("Espaço da comunidade")}
+
+ {gettext("Tudo o que vocês enviam divide o mesmo espaço. É da comunidade inteira.")}
+
+
+ <%= if @storage_stats do %>
+ <.progress value={storage_percent(@storage_stats)} />
+
+
+ {gettext("%{used} de %{total} usados",
+ used: format_bytes(@storage_stats.used_bytes),
+ total: format_bytes(@storage_stats.quota_bytes)
+ )}
+
+
+ {gettext("%{free} livres", free: format_bytes(storage_free(@storage_stats)))}
+
+
+
+ {gettext("Quando o espaço acabar, quem cuida da comunidade pode aumentar o limite.")}
+
+ <.button variant="secondary" size="md" navigate={~p"/armazenamento"} class="w-full mt-4">
+ {gettext("Ver detalhes")}
+
+ <% else %>
+ {gettext("Não foi possível carregar o uso agora.")}
+ <% end %>
+
+
+ <.confirm_dialog
+ id="confirm-delete"
+ show={@confirm != nil}
+ title={confirm_title(@confirm)}
+ message={gettext("Vai para a lixeira. Dá para restaurar em até 30 dias.")}
+ on_confirm="confirm-delete"
+ on_cancel={JS.push("close-modal")}
+ />
+
+ """
+ end
+
+ # Menu de ações compartilhado entre lista e grade.
+ attr :kind, :string, required: true
+ attr :id, :string, required: true
+ attr :name, :string, required: true
+
+ defp item_menu(assigns) do
+ ~H"""
+ <.menu id={"item-menu-#{@id}"} label={gettext("Mais opções")}>
+ <:item icon="pencil" click={JS.push("open-rename", value: %{kind: @kind, id: @id, name: @name})}>
+ {gettext("Renomear")}
+
+ <:item icon="move" click={JS.push("open-move", value: %{kind: @kind, id: @id, name: @name})}>
+ {gettext("Mover")}
+
+ <:item icon="trash" danger click={JS.push("ask-delete", value: %{kind: @kind, id: @id, name: @name})}>
+ {gettext("Excluir")}
+
+
+ """
+ end
+
+ defp confirm_title(%{kind: "folder", name: name}), do: gettext("Excluir a pasta \"%{name}\"?", name: name)
+ defp confirm_title(%{kind: "file", name: name}), do: gettext("Excluir \"%{name}\"?", name: name)
+ defp confirm_title(_confirm), do: gettext("Excluir?")
+end
diff --git a/lib/taina_web/live/gallery_live.ex b/lib/taina_web/live/gallery_live.ex
new file mode 100644
index 0000000..fb5210b
--- /dev/null
+++ b/lib/taina_web/live/gallery_live.ex
@@ -0,0 +1,426 @@
+defmodule TainaWeb.GalleryLive do
+ @moduledoc """
+ Jaci-lite (telas "Jaci - Grade", "Jaci - Linha do tempo" e "Jaci -
+ Visualizador"): grade 3/6 colunas com scroll infinito (`phx-viewport-bottom`
+ + cursor keyset do Jaci), linha do tempo agrupada por dia e visualizador
+ fullscreen com navegação por teclado/swipe (hook `ViewerNav`).
+
+ Thumbnails: `/files/:id/thumbnail/sm` (grade) e `md` (visualizador), o
+ worker `Rendition` gera depois do upload; enquanto não há thumbnail, o
+ controller responde 404 e o quadradinho fica com o fundo de superfície.
+ """
+
+ use TainaWeb, :live_view
+
+ alias Taina.Jaci
+ alias Taina.Jaci.Timeline
+ alias Taina.Ybira
+ alias TainaWeb.Layouts
+
+ @impl true
+ def mount(_params, _session, socket) do
+ {:ok,
+ socket
+ |> assign(:page_title, gettext("Fotos"))
+ |> assign(:photo_ids, [])
+ |> assign(:next_cursor, nil)
+ |> assign(:groups, [])
+ |> assign(:timeline_cursor, nil)
+ |> assign(:current, nil)
+ |> assign(:loaded, false)
+ |> assign(:show_confirm, false)
+ |> assign(:show_details, false)
+ |> stream(:photos, [])}
+ end
+
+ @impl true
+ def handle_params(params, _uri, socket) do
+ case socket.assigns.live_action do
+ :grid -> {:noreply, ensure_grid(socket)}
+ :timeline -> {:noreply, ensure_timeline(socket)}
+ :viewer -> {:noreply, open_viewer(socket, params["id"])}
+ end
+ end
+
+ # Sempre recarrega ao entrar na grade: o container do stream (`phx-update`)
+ # é desmontado ao trocar de visão/visualizador, então um stream "lembrado"
+ # voltaria vazio. Recarregar do zero garante render e reseta o scroll.
+ defp ensure_grid(socket) do
+ {:ok, %{items: photos, next_cursor: cursor}} = Jaci.list_photos(socket.assigns.current_scope)
+
+ socket
+ |> assign(:loaded, true)
+ |> assign(:current, nil)
+ |> assign(:next_cursor, cursor)
+ |> assign(:photo_ids, Enum.map(photos, & &1.public_id))
+ |> stream(:photos, photos, reset: true)
+ end
+
+ defp ensure_timeline(socket) do
+ {:ok, %{groups: groups, next_cursor: cursor}} = Jaci.timeline(socket.assigns.current_scope)
+
+ socket
+ |> assign(:current, nil)
+ |> assign(:groups, groups)
+ |> assign(:timeline_cursor, cursor)
+ |> assign(:photo_ids, timeline_photo_ids(groups))
+ end
+
+ defp timeline_photo_ids(groups) do
+ for group <- groups, photo <- group.photos, do: photo.public_id
+ end
+
+ defp open_viewer(socket, public_id) do
+ case Ybira.get_file(socket.assigns.current_scope, public_id) do
+ {:ok, file} ->
+ socket
+ |> assign(:current, file)
+ |> assign_new_ids(public_id)
+
+ {:error, :not_found} ->
+ socket
+ |> Phoenix.LiveView.put_flash(:error, gettext("Foto não encontrada."))
+ |> Phoenix.LiveView.push_patch(to: ~p"/fotos")
+ end
+ end
+
+ # Link direto para /fotos/:id (sem grade carregada): navega só esta foto.
+ defp assign_new_ids(%{assigns: %{photo_ids: []}} = socket, public_id) do
+ assign(socket, :photo_ids, [public_id])
+ end
+
+ defp assign_new_ids(socket, _public_id), do: socket
+
+ @impl true
+ def handle_event("load-more", _params, socket) do
+ case {socket.assigns.live_action, socket.assigns.next_cursor, socket.assigns.timeline_cursor} do
+ {:grid, cursor, _t} when is_binary(cursor) ->
+ {:ok, %{items: photos, next_cursor: next_cursor}} =
+ Jaci.list_photos(socket.assigns.current_scope, after_cursor: cursor)
+
+ {:noreply,
+ socket
+ |> assign(:next_cursor, next_cursor)
+ |> assign(:photo_ids, socket.assigns.photo_ids ++ Enum.map(photos, & &1.public_id))
+ |> stream(:photos, photos)}
+
+ {:timeline, _g, cursor} when is_binary(cursor) ->
+ {:ok, %{groups: groups, next_cursor: next_cursor}} =
+ Jaci.timeline(socket.assigns.current_scope, after_cursor: cursor)
+
+ {:noreply,
+ socket
+ |> assign(:timeline_cursor, next_cursor)
+ |> assign(:photo_ids, socket.assigns.photo_ids ++ timeline_photo_ids(groups))
+ |> assign(:groups, merge_groups(socket.assigns.groups, groups))}
+
+ _other ->
+ {:noreply, socket}
+ end
+ end
+
+ def handle_event("prev", _params, socket), do: {:noreply, step(socket, -1)}
+ def handle_event("next", _params, socket), do: {:noreply, step(socket, +1)}
+
+ def handle_event("close", _params, socket) do
+ {:noreply, Phoenix.LiveView.push_patch(socket, to: ~p"/fotos")}
+ end
+
+ def handle_event("copied", _params, socket) do
+ {:noreply, Phoenix.LiveView.put_flash(socket, :info, gettext("Link copiado!"))}
+ end
+
+ def handle_event("ask-delete", _params, socket) do
+ {:noreply, assign(socket, :show_confirm, true)}
+ end
+
+ def handle_event("close-confirm", _params, socket) do
+ {:noreply, assign(socket, :show_confirm, false)}
+ end
+
+ def handle_event("toggle-details", _params, socket) do
+ {:noreply, assign(socket, :show_details, !socket.assigns.show_details)}
+ end
+
+ def handle_event("confirm-delete", _params, %{assigns: %{current: %{public_id: public_id}}} = socket) do
+ case Ybira.delete_file(socket.assigns.current_scope, public_id) do
+ {:ok, _file} ->
+ {:noreply,
+ socket
+ |> assign(:show_confirm, false)
+ |> Phoenix.LiveView.put_flash(:info, gettext("Foto movida para a lixeira."))
+ |> reset_gallery()
+ |> Phoenix.LiveView.push_patch(to: ~p"/fotos")}
+
+ {:error, _reason} ->
+ {:noreply,
+ socket
+ |> assign(:show_confirm, false)
+ |> Phoenix.LiveView.put_flash(:error, gettext("Não foi possível excluir a foto."))}
+ end
+ end
+
+ def handle_event("confirm-delete", _params, socket) do
+ {:noreply, assign(socket, :show_confirm, false)}
+ end
+
+ defp reset_gallery(socket) do
+ socket
+ |> assign(:loaded, false)
+ |> assign(:groups, [])
+ |> assign(:photo_ids, [])
+ end
+
+ defp step(socket, delta) do
+ ids = socket.assigns.photo_ids
+ current_id = socket.assigns.current && socket.assigns.current.public_id
+
+ with index when is_integer(index) <- current_id && Enum.find_index(ids, &(&1 == current_id)),
+ next_index = index + delta,
+ true <- next_index >= 0 and next_index < length(ids) do
+ Phoenix.LiveView.push_patch(socket, to: ~p"/fotos/#{Enum.at(ids, next_index)}")
+ else
+ _out_of_range -> socket
+ end
+ end
+
+ # Paginação pode partir um dia entre páginas; funde grupos adjacentes de
+ # mesma data (ver `Taina.Jaci.Timeline.group_by_date/1`).
+ defp merge_groups(groups, []), do: groups
+ defp merge_groups([], new_groups), do: new_groups
+
+ defp merge_groups(groups, [first | rest] = new_groups) do
+ {head, [last]} = Enum.split(groups, -1)
+
+ if last.date == first.date do
+ head ++ [%{last | photos: last.photos ++ first.photos} | rest]
+ else
+ groups ++ new_groups
+ end
+ end
+
+ defp group_title(date) do
+ case Date.diff(Date.utc_today(), date) do
+ 0 -> gettext("Hoje")
+ 1 -> gettext("Ontem")
+ _days -> Calendar.strftime(date, "%d/%m/%Y")
+ end
+ end
+
+ defp viewer_index(ids, %{public_id: id}), do: (Enum.find_index(ids, &(&1 == id)) || 0) + 1
+
+ defp video?(%{mime_type: mime}), do: String.starts_with?(mime, "video/")
+
+ defp photo_date(file) do
+ file
+ |> Timeline.effective_datetime()
+ |> NaiveDateTime.to_date()
+ |> Calendar.strftime("%d/%m/%Y")
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ {gettext("Fotos")}
+
+ <.segmented>
+ <:option patch={~p"/fotos"} current={@live_action == :grid}>{gettext("Grade")}
+ <:option patch={~p"/fotos/linha-do-tempo"} current={@live_action == :timeline}>
+ {gettext("Linha do tempo")}
+
+
+
+
+
+ <.link
+ :for={{dom_id, photo} <- @streams.photos}
+ id={dom_id}
+ patch={~p"/fotos/#{photo.public_id}"}
+ class="photo-grid__item"
+ aria-label={photo.original_filename}
+ >
+
+ <.icon name="play" size={28} />
+
+
+
+
+
+
+
{gettext("Carregando mais...")}
+
+
+ <.empty_state
+ :if={@loaded && @photo_ids == []}
+ icon="image"
+ title={gettext("Nenhuma foto ainda")}
+ hint={gettext("As imagens que a comunidade enviar aparecem aqui.")}
+ >
+ <.button variant="primary" size="md" navigate={~p"/arquivos/enviar"}>
+ {gettext("Enviar fotos")}
+
+
+
+
+
+
+
+ {group_title(group.date)}, {ngettext("%{count} foto", "%{count} fotos", length(group.photos))}
+
+
+ <.link
+ :for={photo <- group.photos}
+ patch={~p"/fotos/#{photo.public_id}"}
+ class="photo-grid__item"
+ aria-label={photo.original_filename}
+ >
+
+ <.icon name="play" size={28} />
+
+
+
+
+
+
+
+
{gettext("Carregando mais...")}
+
+
+ <.empty_state
+ :if={@groups == []}
+ icon="image"
+ title={gettext("Nenhuma foto ainda")}
+ hint={gettext("As imagens que a comunidade enviar aparecem aqui.")}
+ />
+
+
+
+
+
+
+
+
+
+ <.icon name="chevron-left" size={28} />
+
+
+ <.icon name="chevron-right" size={28} />
+
+
+
+
{@current.original_filename}
+
+
+
+
+ <.confirm_dialog
+ :if={@current}
+ id="confirm-delete-photo"
+ show={@show_confirm}
+ title={gettext("Excluir \"%{name}\"?", name: @current.original_filename)}
+ message={gettext("Vai para a lixeira. Dá para restaurar em até 30 dias.")}
+ on_confirm="confirm-delete"
+ on_cancel={JS.push("close-confirm")}
+ />
+
+ <.modal :if={@current} id="photo-details" show={@show_details} on_cancel={JS.push("toggle-details")}>
+ {gettext("Detalhes")}
+
+
+
{gettext("Nome original")}
+
{@current.original_filename}
+
+
+
{gettext("Tipo")}
+
{@current.mime_type}
+
+
+
{gettext("Tamanho")}
+
{format_bytes(@current.file_size_bytes)}
+
+
+
{gettext("SHA-256")}
+
{@current.file_hash}
+
+
+
{gettext("Enviado em")}
+
{Calendar.strftime(@current.inserted_at, "%d/%m/%Y %H:%M")}
+
+
+ <.button variant="secondary" class="w-full mt-6" phx-click="toggle-details">
+ {gettext("Fechar")}
+
+
+
+ """
+ end
+end
diff --git a/lib/taina_web/live/home_live.ex b/lib/taina_web/live/home_live.ex
new file mode 100644
index 0000000..224ef7c
--- /dev/null
+++ b/lib/taina_web/live/home_live.ex
@@ -0,0 +1,137 @@
+defmodule TainaWeb.HomeLive do
+ @moduledoc """
+ Home "superapp": saudação, cartão de armazenamento, cards de serviço
+ (Arquivos/Fotos/Membros e Mensagens "em breve") e os envios recentes.
+ Tela "Home - Superapp" (mobile) e "Desktop - Home" do Penpot.
+ """
+
+ use TainaWeb, :live_view
+
+ alias Taina.Ybira
+ alias TainaWeb.Layouts
+
+ @impl true
+ def mount(_params, _session, socket) do
+ scope = socket.assigns.current_scope
+
+ # A home degrada em vez de cair: se o Ybira falhar, avisamos por flash e
+ # mostramos a tela com as listas vazias (o empty_state assume o lugar) em
+ # vez de derrubar a LiveView.
+ {recent, ok?} = unwrap(Ybira.list_recent(scope, limit: 4), [])
+ {by_kind, ok?} = unwrap(Ybira.storage_stats_by_kind(scope), [], ok?)
+
+ socket =
+ socket
+ |> assign(:page_title, gettext("Início"))
+ |> assign(:recent, recent)
+ |> assign(:by_kind, by_kind)
+
+ {:ok, maybe_warn(socket, ok?)}
+ end
+
+ defp unwrap(result, default, ok? \\ true)
+ defp unwrap({:ok, value}, _default, ok?), do: {value, ok?}
+ defp unwrap(_error, default, _ok?), do: {default, false}
+
+ defp maybe_warn(socket, true), do: socket
+
+ defp maybe_warn(socket, false) do
+ Phoenix.LiveView.put_flash(
+ socket,
+ :error,
+ gettext("Não foi possível carregar tudo agora. Tente recarregar em instantes.")
+ )
+ end
+
+ # Saudação pelo relógio da caixa, o servidor mora na casa da comunidade,
+ # então o horário local do sistema é o horário local de quem usa.
+ defp greeting do
+ {_date, {hour, _min, _sec}} = :calendar.local_time()
+
+ cond do
+ hour in 5..11 -> gettext("Bom dia,")
+ hour in 12..17 -> gettext("Boa tarde,")
+ true -> gettext("Boa noite,")
+ end
+ end
+
+ defp segments(by_kind, %{used_bytes: used}) when used > 0 do
+ for {kind, bytes} <- Enum.sort_by(by_kind, fn {_k, b} -> -b end), bytes > 0 do
+ {Atom.to_string(kind), bytes / used}
+ end
+ end
+
+ defp segments(_by_kind, _stats), do: []
+
+ defp recent_meta(file) do
+ "#{file_kind(file.mime_type)}, #{format_bytes(file.file_size_bytes)}, #{relative_time(file.inserted_at)}"
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+
+
{greeting()}
+
{@current_scope.tekoa.name}
+
+ <.link navigate={~p"/conta"} aria-label={gettext("Sua conta")}>
+ <.avatar name={@current_scope.ava.display_name || @current_scope.ava.username} />
+
+
+
+ <.card :if={@storage_stats} class="mb-6">
+
+
{gettext("Armazenamento")}
+
+ {format_bytes(@storage_stats.quota_bytes - @storage_stats.used_bytes)} {gettext("livres")}
+
+
+ <.progress :if={segments(@by_kind, @storage_stats) != []} segments={segments(@by_kind, @storage_stats)} />
+ <.progress :if={segments(@by_kind, @storage_stats) == []} value={0} />
+
+ {gettext("%{used} de %{total} usados",
+ used: format_bytes(@storage_stats.used_bytes),
+ total: format_bytes(@storage_stats.quota_bytes)
+ )}
+
+
+
+ {gettext("Recentes")}
+
+ <.list_row
+ :for={file <- @recent}
+ title={file.original_filename}
+ meta={recent_meta(file)}
+ navigate={~p"/arquivos/#{file.public_id}"}
+ >
+ <:leading>
+ <% {service, icon} = file_visual(file.mime_type) %>
+ <.service_square service={service} icon={icon} />
+
+ <:actions>
+ <.icon name="chevron-right" size={18} class="text-faint" />
+
+
+
+
+ <.empty_state
+ :if={@recent == []}
+ icon="upload"
+ title={gettext("Nada por aqui ainda")}
+ hint={gettext("Envie o primeiro arquivo da comunidade.")}
+ >
+ <.button variant="primary" size="md" navigate={~p"/arquivos/enviar"}>
+ {gettext("Enviar arquivos")}
+
+
+
+ """
+ end
+end
diff --git a/lib/taina_web/live/invite_accept_live.ex b/lib/taina_web/live/invite_accept_live.ex
new file mode 100644
index 0000000..25a54fe
--- /dev/null
+++ b/lib/taina_web/live/invite_accept_live.ex
@@ -0,0 +1,81 @@
+defmodule TainaWeb.InviteAcceptLive do
+ @moduledoc """
+ Aceite de convite: a pessoa convidada escolhe nome e senha. O submit é um
+ POST tradicional para `InviteController.accept` (`accept_invite/2` + sessão
+ na mesma requisição). O token só é validado de verdade no submit, aqui ele
+ apenas viaja na URL.
+ """
+
+ use TainaWeb, :live_view
+
+ alias TainaWeb.Layouts
+
+ @impl true
+ def mount(%{"token" => token}, _session, socket) do
+ {:ok,
+ socket
+ |> assign(:page_title, gettext("Aceitar convite"))
+ |> assign(:token, token)}
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+ <.icon name="spark" size={40} class="spark" />
+
{gettext("Você foi convidado!")}
+
+ {gettext("Crie sua conta para entrar na nuvem da comunidade.")}
+
+
+
+
+
+ """
+ end
+end
diff --git a/lib/taina_web/live/invite_live.ex b/lib/taina_web/live/invite_live.ex
new file mode 100644
index 0000000..b742227
--- /dev/null
+++ b/lib/taina_web/live/invite_live.ex
@@ -0,0 +1,145 @@
+defmodule TainaWeb.InviteLive do
+ @moduledoc """
+ Convite por link + QR (RFC 002 D6, RFC_003 seção 4): o zelador escolhe o papel
+ e recebe a URL com token; QR renderizado no servidor (`eqrcode`, SVG inline,
+ zero JS) e botões de copiar/compartilhar. O convite não pede e-mail: o token
+ viaja no link e a pessoa escolhe nome e senha ao aceitar.
+ """
+
+ use TainaWeb, :live_view
+
+ alias Taina.Maraca
+ alias TainaWeb.Layouts
+
+ @impl true
+ def mount(_params, _session, socket) do
+ scope = socket.assigns.current_scope
+
+ if Maraca.zelador?(scope.ava) do
+ {:ok,
+ socket
+ |> assign(:page_title, gettext("Convidar pessoas"))
+ |> assign(:role, "morador")
+ |> assign(:invite_url, nil)}
+ else
+ {:ok,
+ socket
+ |> Phoenix.LiveView.put_flash(:error, gettext("Só quem cuida da comunidade pode convidar pessoas."))
+ |> Phoenix.LiveView.redirect(to: ~p"/")}
+ end
+ end
+
+ @impl true
+ def handle_event("set-role", %{"role" => role}, socket) when role in ~w(morador zelador) do
+ {:noreply, assign(socket, :role, role)}
+ end
+
+ def handle_event("set-role", _params, socket) do
+ {:noreply, socket}
+ end
+
+ def handle_event("generate", _params, socket) do
+ scope = socket.assigns.current_scope
+ role = String.to_existing_atom(socket.assigns.role)
+
+ case Maraca.invite_user(scope.ava, scope.tekoa, role: role) do
+ {:ok, ava} ->
+ {:noreply, assign(socket, :invite_url, url(~p"/convite/#{ava.invite_token}"))}
+
+ {:error, _reason} ->
+ {:noreply,
+ Phoenix.LiveView.put_flash(socket, :error, gettext("Não foi possível gerar o convite. Tente de novo."))}
+ end
+ end
+
+ def handle_event("copied", _params, socket) do
+ {:noreply, Phoenix.LiveView.put_flash(socket, :info, gettext("Link copiado!"))}
+ end
+
+ def handle_event("reset", _params, socket) do
+ {:noreply, assign(socket, :invite_url, nil)}
+ end
+
+ defp qr_svg(invite_url) do
+ invite_url
+ |> EQRCode.encode()
+ |> EQRCode.svg(viewbox: true)
+ |> Phoenix.HTML.raw()
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+
+
+ <%= if @invite_url do %>
+
{qr_svg(@invite_url)}
+
+ {gettext("Aponte a câmera ou compartilhe o link abaixo")}
+
+
+
+
{gettext("Link do convite")}
+
+ {@invite_url}
+ <.icon name="link" size={18} class="text-brand" />
+
+
+
+ <.button
+ id="copy-invite"
+ variant="primary"
+ class="w-full"
+ phx-hook="Clipboard"
+ data-copy={@invite_url}
+ >
+ {gettext("Copiar link")}
+
+ <.button
+ id="share-invite"
+ variant="secondary"
+ class="w-full"
+ phx-hook="Share"
+ data-url={@invite_url}
+ data-title={gettext("Convite para a nuvem da comunidade")}
+ >
+ {gettext("Compartilhar convite")}
+
+
+ {gettext("Convidar outra pessoa")}
+
+
+ {gettext("Este convite expira em 7 dias, revogável a qualquer momento.")}
+
+ <% else %>
+
+ <% end %>
+
+
+ """
+ end
+end
diff --git a/lib/taina_web/live/login_live.ex b/lib/taina_web/live/login_live.ex
new file mode 100644
index 0000000..b80298a
--- /dev/null
+++ b/lib/taina_web/live/login_live.ex
@@ -0,0 +1,110 @@
+defmodule TainaWeb.LoginLive do
+ @moduledoc """
+ Tela de login. O form é um POST tradicional para `SessionController.create`
+ (cookie nasce lá); o LiveView cuida só da apresentação e dos atalhos
+ ("esqueci minha senha", "tenho um convite").
+ """
+
+ use TainaWeb, :live_view
+
+ alias Taina.Maraca
+ alias TainaWeb.Layouts
+
+ @impl true
+ def mount(_params, _session, socket) do
+ cond do
+ not Maraca.bootstrapped?() ->
+ {:ok, Phoenix.LiveView.redirect(socket, to: ~p"/setup")}
+
+ socket.assigns.current_scope ->
+ {:ok, Phoenix.LiveView.redirect(socket, to: ~p"/")}
+
+ true ->
+ {:ok,
+ socket
+ |> assign(:page_title, gettext("Entrar"))
+ |> assign(:show_invite_help, false)}
+ end
+ end
+
+ @impl true
+ def handle_event("forgot-password", _params, socket) do
+ # Sem e-mail (RFC_003, seção 4): a recuperação é mediada pelo zelador, que
+ # gera um link de redefinição e entrega pelo mesmo canal dos convites.
+ {:noreply,
+ Phoenix.LiveView.put_flash(
+ socket,
+ :info,
+ gettext("Fale com quem cuida da sua comunidade. Ela gera um link para você criar uma senha nova.")
+ )}
+ end
+
+ def handle_event("toggle-invite-help", _params, socket) do
+ {:noreply, assign(socket, :show_invite_help, !socket.assigns.show_invite_help)}
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+ <.icon name="spark" size={40} class="spark" />
+
Tainá
+
{gettext("Bem-vindo de volta")}
+
{gettext("Entre com seu nome na nuvem da sua comunidade.")}
+
+
+
+
+
+
+ {gettext("ou")}
+
+
+
+ <.button variant="secondary" class="w-full" phx-click="toggle-invite-help">
+ {gettext("Tenho um convite")}
+
+
+ <.modal id="invite-help" show={@show_invite_help} on_cancel={JS.push("toggle-invite-help")}>
+ {gettext("Como entrar com um convite")}
+
+ {gettext(
+ "Quem administra a comunidade te manda um link (ou um QR code). É só abrir o link neste aparelho. A criação da conta acontece lá."
+ )}
+
+ <.button variant="secondary" class="w-full" phx-click="toggle-invite-help">
+ {gettext("Entendi")}
+
+
+
+ """
+ end
+end
diff --git a/lib/taina_web/live/members_live.ex b/lib/taina_web/live/members_live.ex
new file mode 100644
index 0000000..b240291
--- /dev/null
+++ b/lib/taina_web/live/members_live.ex
@@ -0,0 +1,172 @@
+defmodule TainaWeb.MembersLive do
+ @moduledoc """
+ Moradores da comunidade: lista com papel e estado de cada conta, busca local
+ (a lista inteira já está na memória, 50 pessoas é o teto do produto) e atalho
+ para convidar. Visível para qualquer morador; convidar e gerar link de
+ redefinição é só quem cuida (zelador).
+ """
+
+ use TainaWeb, :live_view
+
+ alias Taina.Maraca
+ alias TainaWeb.Layouts
+
+ @impl true
+ def mount(_params, _session, socket) do
+ {:ok, members} = Maraca.list_members(socket.assigns.current_scope)
+
+ {:ok,
+ socket
+ |> assign(:page_title, gettext("Moradores"))
+ |> assign(:members, members)
+ |> assign(:query, "")
+ |> assign(:reset_link, nil)
+ |> assign(:reset_member, nil)}
+ end
+
+ @impl true
+ def handle_event("search", %{"query" => query}, socket) do
+ {:noreply, assign(socket, :query, query)}
+ end
+
+ def handle_event("reset-link", %{"id" => public_id}, socket) do
+ scope = socket.assigns.current_scope
+
+ with %{} = member <- Enum.find(socket.assigns.members, &(&1.public_id == public_id)),
+ {:ok, ava} <- Maraca.mint_reset_link(scope, member) do
+ {:noreply,
+ socket
+ |> assign(:reset_link, url(~p"/redefinir/#{ava.reset_token}"))
+ |> assign(:reset_member, display_name(member))}
+ else
+ _ ->
+ {:noreply, Phoenix.LiveView.put_flash(socket, :error, gettext("Não foi possível gerar o link. Tente de novo."))}
+ end
+ end
+
+ def handle_event("close-reset", _params, socket) do
+ {:noreply, socket |> assign(:reset_link, nil) |> assign(:reset_member, nil)}
+ end
+
+ def handle_event("copied", _params, socket) do
+ {:noreply, Phoenix.LiveView.put_flash(socket, :info, gettext("Link copiado!"))}
+ end
+
+ defp filtered(members, ""), do: members
+
+ defp filtered(members, query) do
+ needle = String.downcase(query)
+
+ Enum.filter(members, fn member ->
+ String.contains?(String.downcase(display_name(member)), needle)
+ end)
+ end
+
+ defp display_name(member) do
+ member.display_name || member.username || gettext("Convite pendente")
+ end
+
+ defp member_meta(member, scope) do
+ cond do
+ member.id == scope.ava.id -> gettext("Você")
+ is_nil(member.activated_at) -> gettext("Convite pendente")
+ member.role == :zelador -> gettext("Quem cuida da máquina")
+ true -> gettext("Entrou %{when}", when: relative_time(member.inserted_at))
+ end
+ end
+
+ defp can_reset?(member, scope) do
+ Maraca.zelador?(scope.ava) and member.id != scope.ava.id and not is_nil(member.activated_at)
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+ <:action :if={Maraca.zelador?(@current_scope.ava)}>
+ <.icon_button name="plus" label={gettext("Convidar pessoas")} navigate={~p"/membros/convidar"} />
+
+
+
+
+
+
+
+
+ {ngettext("%{count} pessoa", "%{count} pessoas", length(@members))}
+
+ <.link
+ :if={Maraca.zelador?(@current_scope.ava)}
+ navigate={~p"/membros/convidar"}
+ class="type-label text-brand"
+ >
+ {gettext("Convidar")}
+
+
+
+
+ <.list_row
+ :for={member <- filtered(@members, @query)}
+ title={display_name(member)}
+ meta={member_meta(member, @current_scope)}
+ >
+ <:leading>
+ <.avatar name={display_name(member)} inactive={is_nil(member.activated_at)} />
+
+ <:actions>
+ <.badge :if={member.role == :zelador} variant="zelador">{gettext("Zelador(a)")}
+ <.icon_button
+ :if={can_reset?(member, @current_scope)}
+ name="link"
+ label={gettext("Gerar link de redefinição de senha")}
+ phx-click="reset-link"
+ phx-value-id={member.public_id}
+ />
+
+
+
+
+ <.empty_state
+ :if={filtered(@members, @query) == []}
+ icon="user"
+ title={gettext("Ninguém por aqui")}
+ hint={gettext("Nenhuma pessoa corresponde à busca.")}
+ />
+
+
+ <.modal id="reset-link-modal" show={@reset_link != nil} on_cancel={JS.push("close-reset")}>
+ {gettext("Link de redefinição de senha")}
+
+ {gettext(
+ "Entregue este link para %{name} pelo mesmo canal do convite. Com ele, a pessoa cria uma senha nova. O link expira em 1 hora.",
+ name: @reset_member
+ )}
+
+
+ {@reset_link}
+ <.icon name="link" size={18} class="text-brand" />
+
+ <.button id="copy-reset" variant="primary" class="w-full" phx-hook="Clipboard" data-copy={@reset_link}>
+ {gettext("Copiar link")}
+
+
+
+ """
+ end
+end
diff --git a/lib/taina_web/live/reset_password_live.ex b/lib/taina_web/live/reset_password_live.ex
new file mode 100644
index 0000000..e8aa420
--- /dev/null
+++ b/lib/taina_web/live/reset_password_live.ex
@@ -0,0 +1,62 @@
+defmodule TainaWeb.ResetPasswordLive do
+ @moduledoc """
+ Redefinição de senha pelo link que o zelador gerou (recuperação mediada, sem
+ e-mail). O submit é um POST tradicional para `PasswordController.update` (a
+ sessão nasce lá); aqui o LiveView cuida só da apresentação. O token viaja na
+ URL e só é validado de verdade no submit.
+ """
+
+ use TainaWeb, :live_view
+
+ alias TainaWeb.Layouts
+
+ @impl true
+ def mount(%{"token" => token}, _session, socket) do
+ {:ok,
+ socket
+ |> assign(:page_title, gettext("Redefinir senha"))
+ |> assign(:token, token)}
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+ <.icon name="shield" size={40} class="spark" />
+
{gettext("Redefinir senha")}
+
+ {gettext("Crie uma senha nova para voltar a entrar.")}
+
+
+
+
+
+ """
+ end
+end
diff --git a/lib/taina_web/live/setup_live.ex b/lib/taina_web/live/setup_live.ex
new file mode 100644
index 0000000..da332fe
--- /dev/null
+++ b/lib/taina_web/live/setup_live.ex
@@ -0,0 +1,230 @@
+defmodule TainaWeb.SetupLive do
+ @moduledoc """
+ Wizard de primeiro boot (3 passos): nome da comunidade, conta de quem cuida
+ (zelador) e armazenamento. Os passos vivem aqui (validação ao vivo);
+ o submit final é um POST tradicional para `SetupController`, que faz
+ `Maraca.bootstrap/2` e abre a sessão na mesma requisição.
+
+ Deriva da RFC 002 (D2): só roda em instância virgem, se já existe Tekoa,
+ redireciona para o login. A conta criada é a do zelador (quem cuida da
+ máquina); identidade por nome de usuário, sem e-mail (RFC_003, seção 4).
+ """
+
+ use TainaWeb, :live_view
+
+ alias Taina.Maraca
+ alias TainaWeb.Layouts
+
+ @impl true
+ def mount(_params, _session, socket) do
+ if Maraca.bootstrapped?() do
+ {:ok, Phoenix.LiveView.redirect(socket, to: ~p"/login")}
+ else
+ {:ok,
+ socket
+ |> assign(:page_title, gettext("Primeiros passos"))
+ |> assign(:step, 1)
+ |> assign(:data, %{"community_name" => "", "username" => "", "display_name" => "", "password" => ""})
+ |> assign(:errors, %{})
+ |> assign(:storage_root, Application.fetch_env!(:taina, :storage_root))
+ |> assign(:free_space, free_space())}
+ end
+ end
+
+ @impl true
+ def handle_event("validate", %{"setup" => params}, socket) do
+ data = Map.merge(socket.assigns.data, params)
+ {:noreply, socket |> assign(:data, data) |> assign(:errors, Map.drop(socket.assigns.errors, Map.keys(params)))}
+ end
+
+ def handle_event("next", %{"setup" => params}, socket) do
+ data = Map.merge(socket.assigns.data, params)
+
+ case validate_step(socket.assigns.step, data) do
+ %{} = errors when map_size(errors) == 0 ->
+ next_step = min(socket.assigns.step + 1, 3)
+ {:noreply, socket |> assign(:data, data) |> assign(:errors, %{}) |> assign(:step, next_step)}
+
+ errors ->
+ {:noreply, socket |> assign(:data, data) |> assign(:errors, errors)}
+ end
+ end
+
+ def handle_event("back", _params, socket) do
+ {:noreply, assign(socket, :step, max(socket.assigns.step - 1, 1))}
+ end
+
+ defp validate_step(1, data) do
+ if String.trim(data["community_name"]) == "",
+ do: %{"community_name" => gettext("dê um nome à comunidade")},
+ else: %{}
+ end
+
+ defp validate_step(2, data) do
+ %{}
+ |> validate_presence(data, "username", gettext("escolha um nome de usuário"))
+ |> validate_password(data)
+ end
+
+ defp validate_step(_step, _data), do: %{}
+
+ defp validate_presence(errors, data, key, message) do
+ if String.trim(data[key] || "") == "", do: Map.put(errors, key, message), else: errors
+ end
+
+ defp validate_password(errors, data) do
+ if String.length(data["password"] || "") < 8,
+ do: Map.put(errors, "password", gettext("a senha precisa de pelo menos 8 caracteres")),
+ else: errors
+ end
+
+ # Espaço livre no volume do storage, melhor esforço (`df` POSIX). Falhou?
+ # Mostramos só o caminho: o número é conforto, não requisito.
+ defp free_space do
+ storage_root = Application.fetch_env!(:taina, :storage_root)
+
+ with {out, 0} <- System.cmd("df", ["-k", storage_root], stderr_to_stdout: true),
+ [_header, line | _rest] <- String.split(out, "\n"),
+ [_fs, _blocks, _used, avail | _] <- String.split(line, ~r/\s+/, trim: true),
+ {kbytes, ""} <- Integer.parse(avail) do
+ kbytes * 1024
+ else
+ _ -> nil
+ end
+ rescue
+ _e in ErlangError -> nil
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+
+ <.icon name="spark" size={48} class="spark" />
+
Tainá
+
+ {gettext("A estrela da manhã da sua comunidade. Arquivos e fotos, na sua casa, sob seu controle.")}
+
+ <.steps current={1} />
+
{gettext("Passo 1 de 3: sua comunidade")}
+
+
+
+
+
+
+ <.icon_button name="chevron-left" label={gettext("Voltar")} phx-click="back" />
+
+
{gettext("Crie a conta de quem cuida")}
+
+ {gettext("Você é o primeiro zelador desta comunidade. Guarde bem este acesso. Ele cuida da máquina.")}
+
+ <.steps current={2} />
+
{gettext("Passo 2 de 3: quem cuida")}
+
+
+
+
+
+
+ <.icon_button name="chevron-left" label={gettext("Voltar")} phx-click="back" />
+
+
{gettext("Onde guardar os arquivos")}
+
+ {gettext("Escolha o disco onde a comunidade vai morar. Dá pra mudar depois nas configurações.")}
+
+ <.steps current={3} />
+
{gettext("Passo 3 de 3: armazenamento")}
+
+
+
+
+ <.icon name="shield" size={22} class="text-success" />
+
+
+ {gettext("Disco interno")}{if @free_space, do: ", #{format_bytes(@free_space)} #{gettext("livres")}"}
+
+
{@storage_root} ({gettext("recomendado")})
+
+
+
+
+
+ <.icon name="download" size={22} class="text-muted" />
+
+
{gettext("Disco USB externo")}
+
{gettext("Em breve. Conecte e monte o dispositivo")}
+
+
+
+
+ <.card class="mt-2">
+
{gettext("Resumo")}
+
+ {@data["community_name"]}, {gettext("zelador(a)")} {@data["username"]}, {gettext("disco interno")}
+
+
+
+
+
+
+
+
+
+ """
+ end
+end
diff --git a/lib/taina_web/live/storage_live.ex b/lib/taina_web/live/storage_live.ex
new file mode 100644
index 0000000..b0733cf
--- /dev/null
+++ b/lib/taina_web/live/storage_live.ex
@@ -0,0 +1,162 @@
+defmodule TainaWeb.StorageLive do
+ @moduledoc """
+ Armazenamento (tela "Ybira - Armazenamento"): uso total, barra segmentada
+ por tipo e cota da comunidade (zelador edita via `update_tekoa_quota/2`).
+
+ Nota de drift (design vs. backend): o Penpot mostra "cota por membro", mas a
+ cota do Maraca é da Tekoa inteira, a UI mostra e edita a cota da
+ comunidade até existir cota por pessoa.
+ """
+
+ use TainaWeb, :live_view
+
+ alias Taina.Maraca
+ alias Taina.Ybira
+ alias TainaWeb.Layouts
+
+ @impl true
+ def mount(_params, _session, socket) do
+ {:ok,
+ socket
+ |> assign(:page_title, gettext("Armazenamento"))
+ |> assign(:show_quota_modal, false)
+ |> load_stats()}
+ end
+
+ # Carrega as estatisticas sem derrubar a tela: se o dominio falhar, avisamos
+ # por flash e marcamos `load_error` para o render cair no empty_state em vez
+ # de quebrar no match.
+ defp load_stats(socket) do
+ scope = socket.assigns.current_scope
+
+ with {:ok, stats} <- Ybira.storage_stats(scope),
+ {:ok, by_kind} <- Ybira.storage_stats_by_kind(scope) do
+ socket
+ |> assign(:stats, stats)
+ |> assign(:by_kind, by_kind)
+ |> assign(:load_error, false)
+ else
+ _error ->
+ socket
+ |> assign(:stats, nil)
+ |> assign(:by_kind, [])
+ |> assign(:load_error, true)
+ |> Phoenix.LiveView.put_flash(:error, gettext("Não foi possível carregar o armazenamento agora."))
+ end
+ end
+
+ @impl true
+ def handle_event("toggle-quota-modal", _params, socket) do
+ {:noreply, assign(socket, :show_quota_modal, !socket.assigns.show_quota_modal)}
+ end
+
+ def handle_event("update-quota", %{"quota" => %{"gigabytes" => gb}}, socket) do
+ with {gigabytes, ""} when gigabytes > 0 <- Integer.parse(gb),
+ {:ok, _tekoa} <-
+ Maraca.update_tekoa_quota(socket.assigns.current_scope, gigabytes * 1024 ** 3) do
+ {:noreply,
+ socket
+ |> assign(:show_quota_modal, false)
+ |> Phoenix.LiveView.put_flash(:info, gettext("Cota atualizada."))
+ |> load_stats()}
+ else
+ _error ->
+ {:noreply, Phoenix.LiveView.put_flash(socket, :error, gettext("Não foi possível atualizar a cota."))}
+ end
+ end
+
+ defp segments(by_kind, %{used_bytes: used}) when used > 0 do
+ for {kind, bytes} <- Enum.sort_by(by_kind, fn {_k, b} -> -b end), bytes > 0 do
+ {Atom.to_string(kind), bytes / used}
+ end
+ end
+
+ defp segments(_by_kind, _stats), do: []
+
+ defp kind_label(:photos), do: gettext("Fotos")
+ defp kind_label(:videos), do: gettext("Vídeos")
+ defp kind_label(:documents), do: gettext("Documentos")
+ defp kind_label(:others), do: gettext("Outros")
+
+ defp quota_gb(%{quota_bytes: quota}) when is_integer(quota), do: div(quota, 1024 ** 3)
+ defp quota_gb(_stats), do: nil
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+
+ <.empty_state
+ :if={@load_error}
+ icon="alert"
+ title={gettext("Não foi possível carregar")}
+ hint={gettext("O uso de armazenamento não respondeu agora. Tente recarregar em instantes.")}
+ />
+
+
+ <.card raised class="p-6">
+
{format_bytes(@stats.used_bytes)}
+
+
+ {gettext("de %{total} usados", total: format_bytes(@stats.quota_bytes))}
+
+
+ {format_bytes(@stats.quota_bytes - @stats.used_bytes)} {gettext("livres")}
+
+
+ <.progress :if={segments(@by_kind, @stats) != []} segments={segments(@by_kind, @stats)} />
+ <.progress :if={segments(@by_kind, @stats) == []} value={0} />
+
{gettext("Uso por tipo")}
+
+
+
+
-b end)} class="list-row">
+
+
+
{format_bytes(bytes)}
+
+
+
+
+
{gettext("Cota da comunidade")}
+
+ {gettext("Limite total")}
+
+ {format_bytes(@stats.quota_bytes)} <.icon name="chevron-right" size={16} />
+
+
+
+
+
+ <.modal id="quota-modal" show={@show_quota_modal} on_cancel={JS.push("toggle-quota-modal")}>
+ {gettext("Cota da comunidade")}
+
+
+
+ """
+ end
+end
diff --git a/lib/taina_web/live/trash_live.ex b/lib/taina_web/live/trash_live.ex
new file mode 100644
index 0000000..2faae05
--- /dev/null
+++ b/lib/taina_web/live/trash_live.ex
@@ -0,0 +1,142 @@
+defmodule TainaWeb.TrashLive do
+ @moduledoc """
+ Lixeira (tela "Ybira - Lixeira"): itens apagados nos últimos 30 dias, com
+ restauração. A purga definitiva é automática (`PurgeTrash`, Oban, 30 dias),
+ o design mostra exclusão definitiva por item, mas o Ybira ainda não expõe
+ essa operação; fica para quando o context ganhar `purge_file/2`.
+ """
+
+ use TainaWeb, :live_view
+
+ alias Taina.Ybira
+ alias TainaWeb.Layouts
+
+ @retention_days 30
+ @warning_days 5
+
+ @impl true
+ def mount(_params, _session, socket) do
+ {:ok, socket |> assign(:page_title, gettext("Lixeira")) |> load_trash()}
+ end
+
+ defp load_trash(socket) do
+ {:ok, %{items: items, next_cursor: next_cursor}} = Ybira.list_trash(socket.assigns.current_scope)
+
+ socket
+ |> assign(:items, items)
+ |> assign(:next_cursor, next_cursor)
+ end
+
+ @impl true
+ def handle_event("restore", %{"id" => public_id}, socket) do
+ case Ybira.restore_file(socket.assigns.current_scope, public_id) do
+ {:ok, _file} ->
+ {:noreply,
+ socket
+ |> Phoenix.LiveView.put_flash(:info, gettext("Arquivo restaurado."))
+ |> load_trash()}
+
+ {:error, _reason} ->
+ {:noreply, Phoenix.LiveView.put_flash(socket, :error, gettext("Não foi possível restaurar o arquivo."))}
+ end
+ end
+
+ def handle_event("load-more", _params, socket) do
+ if cursor = socket.assigns.next_cursor do
+ {:ok, %{items: items, next_cursor: next_cursor}} =
+ Ybira.list_trash(socket.assigns.current_scope, after_cursor: cursor)
+
+ {:noreply,
+ socket
+ |> assign(:items, socket.assigns.items ++ items)
+ |> assign(:next_cursor, next_cursor)}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ defp days_left(file) do
+ deleted_days_ago = Date.diff(Date.utc_today(), DateTime.to_date(file.deleted_at))
+ max(@retention_days - deleted_days_ago, 0)
+ end
+
+ defp trash_meta(file) do
+ left = days_left(file)
+ deleted_days = Date.diff(Date.utc_today(), DateTime.to_date(file.deleted_at))
+
+ deleted_label =
+ case deleted_days do
+ 0 -> gettext("Apagado hoje")
+ 1 -> gettext("Apagado ontem")
+ days -> gettext("Apagado há %{count} dias", count: days)
+ end
+
+ if left <= @warning_days do
+ ngettext("Resta %{count} dia, apaga em breve", "Restam %{count} dias, apaga em breve", left)
+ else
+ deleted_label <> ", " <> ngettext("resta %{count} dia", "restam %{count} dias", left)
+ end
+ end
+
+ defp warning?(file), do: days_left(file) <= @warning_days
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+
+
+ <.card class="row gap-3">
+ <.icon name="clock" size={20} class="text-warning" />
+
+ {gettext("Itens são apagados para sempre após %{count} dias na lixeira.", count: 30)}
+
+
+
+
+ {ngettext("%{count} item", "%{count} itens", length(@items))}
+
+
+
+ <.list_row
+ :for={file <- @items}
+ title={file.original_filename}
+ meta={trash_meta(file)}
+ meta_class={warning?(file) && "text-warning"}
+ >
+ <:leading>
+ <% {_service, icon} = file_visual(file.mime_type) %>
+ <.service_square service="neutral" icon={icon} />
+
+ <:actions>
+ <.icon_button
+ name="restore"
+ label={gettext("Restaurar")}
+ phx-click="restore"
+ phx-value-id={file.public_id}
+ />
+
+
+
+
+
+
{gettext("Carregando mais...")}
+
+
+ <.empty_state
+ :if={@items == []}
+ icon="trash"
+ title={gettext("Lixeira vazia")}
+ hint={gettext("Nada esperando para ser apagado.")}
+ />
+
+
+ """
+ end
+end
diff --git a/lib/taina_web/live/upload_live.ex b/lib/taina_web/live/upload_live.ex
new file mode 100644
index 0000000..eb4e4f9
--- /dev/null
+++ b/lib/taina_web/live/upload_live.ex
@@ -0,0 +1,203 @@
+defmodule TainaWeb.UploadLive do
+ @moduledoc """
+ Envio de arquivos (tela "Ybira - Upload"): dropzone tracejada, progresso por
+ arquivo (ENVIANDO/CONCLUÍDO), cancelamento individual e total. LiveView
+ uploads com `auto_upload`; cada arquivo concluído vai direto para
+ `Ybira.upload/3` (validação por magic bytes e cota acontecem lá).
+
+ `?pasta=` define a pasta de destino.
+ """
+
+ use TainaWeb, :live_view
+
+ alias Taina.Ybira
+ alias TainaWeb.Layouts
+
+ @max_file_size 2 * 1024 * 1024 * 1024
+
+ @impl true
+ def mount(params, _session, socket) do
+ folder = resolve_folder(socket.assigns.current_scope, params["pasta"])
+
+ {:ok,
+ socket
+ |> assign(:page_title, gettext("Enviar arquivos"))
+ |> assign(:folder, folder)
+ |> assign(:done, [])
+ |> assign(:failed, [])
+ |> allow_upload(:files,
+ accept: :any,
+ max_entries: 10,
+ max_file_size: @max_file_size,
+ auto_upload: true,
+ progress: &handle_progress/3
+ )}
+ end
+
+ defp resolve_folder(_scope, nil), do: nil
+
+ defp resolve_folder(scope, public_id) do
+ case Ybira.get_folder(scope, public_id) do
+ {:ok, folder} -> folder
+ {:error, :not_found} -> nil
+ end
+ end
+
+ # Consome cada arquivo assim que termina de subir; o resultado vai para a
+ # lista CONCLUÍDO (ou para os erros, sem derrubar os demais envios).
+ defp handle_progress(:files, entry, socket) do
+ if entry.done?, do: finish_upload(entry, socket), else: {:noreply, socket}
+ end
+
+ defp finish_upload(entry, socket) do
+ scope = socket.assigns.current_scope
+
+ result =
+ consume_uploaded_entry(socket, entry, fn %{path: path} ->
+ {:ok, Ybira.upload(scope, path, upload_opts(entry, socket.assigns.folder))}
+ end)
+
+ case result do
+ {:ok, file} ->
+ {:noreply, update(socket, :done, &[file | &1])}
+
+ {:error, reason} ->
+ {:noreply, update(socket, :failed, &[{entry.client_name, upload_error(reason)} | &1])}
+ end
+ end
+
+ defp upload_opts(entry, nil), do: [filename: entry.client_name]
+ defp upload_opts(entry, folder), do: [filename: entry.client_name, folder_id: folder.id]
+
+ defp upload_error(:mime_not_allowed), do: gettext("tipo de arquivo não permitido")
+ defp upload_error(:storage_quota_exceeded), do: gettext("a cota de armazenamento acabou")
+ defp upload_error(_reason), do: gettext("falha no envio")
+
+ @impl true
+ def handle_event("validate", _params, socket), do: {:noreply, socket}
+
+ def handle_event("cancel", %{"ref" => ref}, socket) do
+ {:noreply, cancel_upload(socket, :files, ref)}
+ end
+
+ def handle_event("cancel-all", _params, socket) do
+ socket =
+ Enum.reduce(socket.assigns.uploads.files.entries, socket, fn entry, socket ->
+ cancel_upload(socket, :files, entry.ref)
+ end)
+
+ {:noreply, socket}
+ end
+
+ defp back_path(nil), do: ~p"/arquivos"
+ defp back_path(folder), do: ~p"/arquivos/pasta/#{folder.public_id}"
+
+ defp uploading?(uploads), do: uploads.files.entries != []
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+
+
+
+
+
+
+ {gettext("Enviando (%{count})", count: length(@uploads.files.entries))}
+
+
+ <.list_row :for={entry <- @uploads.files.entries} title={entry.client_name}>
+ <:leading>
+ <% {service, icon} = file_visual(entry.client_type || "") %>
+ <.service_square service={service} icon={icon} />
+
+ <:actions>
+ {entry.progress}%
+ <.icon_button name="close" label={gettext("Cancelar envio")} phx-click="cancel" phx-value-ref={entry.ref} />
+
+
+
+
+ <.progress value={entry.progress} />
+
+
+ {upload_config_error(err)}
+
+
+
+
+
{gettext("Falharam (%{count})", count: length(@failed))}
+
+ <.list_row :for={{name, reason} <- @failed} title={name} meta={reason} meta_class="text-error">
+ <:leading>
+ <.service_square service="neutral" icon="alert" />
+
+
+
+
+
+
+
{gettext("Concluído (%{count})", count: length(@done))}
+
+ <.list_row
+ :for={file <- @done}
+ title={file.original_filename}
+ meta={gettext("Concluído")}
+ meta_class="text-success"
+ >
+ <:leading>
+ <% {service, icon} = file_visual(file.mime_type) %>
+ <.service_square service={service} icon={icon} />
+
+ <:actions>
+ <.icon name="check" size={20} class="text-success" />
+
+
+
+ <.progress value={100} color="success" />
+
+
+
+ <.button variant="primary" class="w-full" navigate={back_path(@folder)}>
+ {gettext("Concluir")}
+
+
+ {gettext("Cancelar tudo")}
+
+
+
+
+ """
+ end
+
+ defp upload_config_error(:too_large), do: gettext("arquivo maior que 2 GB")
+ defp upload_config_error(:too_many_files), do: gettext("no máximo 10 arquivos por vez")
+ defp upload_config_error(_err), do: gettext("falha no envio")
+end
diff --git a/lib/taina_web/router.ex b/lib/taina_web/router.ex
index 4addddb..f1c69e5 100644
--- a/lib/taina_web/router.ex
+++ b/lib/taina_web/router.ex
@@ -22,6 +22,7 @@ defmodule TainaWeb.Router do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
+ plug :put_root_layout, html: {TainaWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_scope
@@ -39,4 +40,47 @@ defmodule TainaWeb.Router do
get "/files/:public_id", FileController, :download
get "/files/:public_id/thumbnail/:size", FileController, :thumbnail
end
+
+ # Telas públicas: wizard de primeiro boot, login e aceite de convite. Os
+ # POSTs tradicionais existem porque cookie de sessão só nasce em controller.
+ scope "/", TainaWeb do
+ pipe_through :browser
+
+ live_session :public, on_mount: [{TainaWeb.Auth, :mount_current_scope}] do
+ live "/setup", SetupLive
+ live "/login", LoginLive
+ live "/convite/:token", InviteAcceptLive
+ live "/redefinir/:token", ResetPasswordLive
+ end
+
+ post "/setup", SetupController, :create
+ post "/login", SessionController, :create
+ post "/convite/:token", InviteController, :accept
+ post "/redefinir/:token", PasswordController, :update
+ delete "/logout", SessionController, :delete
+ end
+
+ scope "/", TainaWeb do
+ pipe_through :browser
+
+ live_session :authenticated,
+ on_mount: [{TainaWeb.Auth, :require_authenticated}, {TainaWeb.Hooks, :shell}] do
+ live "/", HomeLive
+
+ live "/arquivos", FilesLive, :index
+ live "/arquivos/pasta/:folder_id", FilesLive, :folder
+ live "/arquivos/enviar", UploadLive
+ live "/arquivos/lixeira", TrashLive
+ live "/arquivos/:id", FilePreviewLive
+
+ live "/fotos", GalleryLive, :grid
+ live "/fotos/linha-do-tempo", GalleryLive, :timeline
+ live "/fotos/:id", GalleryLive, :viewer
+
+ live "/membros", MembersLive
+ live "/membros/convidar", InviteLive
+ live "/armazenamento", StorageLive
+ live "/conta", AccountLive
+ end
+ end
end
diff --git a/mix.exs b/mix.exs
index ff2b817..96aeca4 100644
--- a/mix.exs
+++ b/mix.exs
@@ -5,7 +5,7 @@ defmodule Taina.MixProject do
[
app: :taina,
version: "0.1.0",
- elixir: "~> 1.16",
+ elixir: "~> 1.20",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
aliases: aliases(),
@@ -46,6 +46,8 @@ defmodule Taina.MixProject do
[
{:phoenix, "~> 1.8.1"},
{:phoenix_live_view, "~> 1.1"},
+ {:gettext, "~> 0.26"},
+ {:eqrcode, "~> 0.2"},
{:phoenix_ecto, "~> 4.5"},
{:ecto_sql, "~> 3.13"},
{:postgrex, ">= 0.0.0"},
@@ -58,8 +60,11 @@ defmodule Taina.MixProject do
{:image, "~> 0.68"},
{:bcrypt_elixir, "~> 3.3"},
{:oban, "~> 2.23"},
+ {:lazy_html, "~> 0.1.11", only: :test, override: true},
# dev
{:tidewave, "~> 0.5", only: :dev},
+ {:phoenix_live_reload, "~> 1.6", only: :dev},
+ {:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
{:styler, "~> 1.5", only: [:dev, :test], runtime: false}
@@ -74,10 +79,13 @@ defmodule Taina.MixProject do
# See the documentation for `Mix` for more info on aliases.
defp aliases do
[
- setup: ["deps.get", "ecto.setup"],
+ setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
+ "assets.setup": ["esbuild.install --if-missing"],
+ "assets.build": ["esbuild taina"],
+ "assets.deploy": ["compile", "esbuild taina --minify", "phx.digest"],
precommit: ["compile --warning-as-errors", "deps.unlock --unused", "format", "test"]
]
end
diff --git a/mix.lock b/mix.lock
index a245dcd..db9d1a9 100644
--- a/mix.lock
+++ b/mix.lock
@@ -13,22 +13,29 @@
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"ecto": {:hex, :ecto, "3.14.0", "2fa64521eebfcb2670d907a86e4ad947290e9933706bb315e6fb5c21b172cb26", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "130d69ffb4285f9ce4792b65dfbb994fd13ea4cbc3cbea2524b199aa3de84af3"},
"ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"},
- "elixir_make": {:hex, :elixir_make, "0.10.0", "16577e2583a79bb79237bbff349619ef5d80afffc07eac6e4faf0d00e2ddaf7d", [:mix], [], "hexpm", "dc1f09fb7fa68866b886abd5f0f3c83553b1a19a52359a899e92af1bb3b31982"},
+ "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
+ "eqrcode": {:hex, :eqrcode, "0.2.1", "d12838813e8fc87b8940cc05f9baadb189031f6009facdc56ff074375ec73b6e", [:mix], [], "hexpm", "d5828a222b904c68360e7dc2a40c3ef33a1328b7c074583898040f389f928025"},
"erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"},
+ "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
+ "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"},
+ "fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
+ "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"image": {:hex, :image, "0.68.0", "fbb937d7b0b19fb2198c0fc7511aa963017dbfd5feab58f92884a8e8b638a1bb", [:mix], [{:color, "~> 0.13", [hex: :color, repo: "hexpm", optional: false]}, {:evision, "~> 0.1.33 or ~> 0.2", [hex: :evision, repo: "hexpm", optional: true]}, {:exla, "~> 0.12", [hex: :exla, repo: "hexpm", optional: true]}, {:kino, "~> 0.13", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.12", [hex: :nx, repo: "hexpm", optional: true]}, {:nx_image, "~> 0.1", [hex: :nx_image, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.1 or ~> 3.2 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:rustler, "> 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:scholar, "~> 0.3", [hex: :scholar, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}, {:vix, "~> 0.33", [hex: :vix, repo: "hexpm", optional: false]}, {:xav, "~> 0.10", [hex: :xav, repo: "hexpm", optional: true]}], "hexpm", "0c1437f3e20001be80bee83e8f55b9d8d01696d57d86b8c2f46c7d6cccc0ab67"},
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
+ "lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"},
"nanoid": {:hex, :nanoid, "2.1.0", "d192a5bf1d774258bc49762b480fca0e3128178fa6d35a464af2a738526607fd", [:mix], [], "hexpm", "ebc7a342d02d213534a7f93a091d569b9fea7f26fcd3a638dc655060fc1f76ac"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"oban": {:hex, :oban, "2.23.0", "1867d0fa4e8c7685217b02cc2632e3ee86c93da770e9029ff71304d9e62e53d7", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8e5f0cec5abecce78dd08cb14dc5438db90ec3884987b44773ce76fe60dd3f81"},
- "phoenix": {:hex, :phoenix, "1.8.7", "d8d755b4ff4b449f610223dd706b4ae64155cb720d3dc09c706c079ecea189e4", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "47352f72d6ab31009ef77516b1b3a14745be97b54061fd458031b9d8294869d5"},
+ "phoenix": {:hex, :phoenix, "1.8.8", "ada3d761359274178180c0e992ef0c2b536bd7c3bd75ebba94acbf39ab4347fe", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "f0c843037bd2e7012fc1d1ec9574dfa6972b7e3d09e9b77fd23aa283af0aa994"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
+ "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.2.0", "e2295af6677a85944f1a9e31db3eac150d8c322522542ff73bfe6c81ec970087", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e540d7c98dbb1c9714c204af690a71e389093efe66cdcd1c18764878219e16d7"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
new file mode 100644
index 0000000..11b7dc0
--- /dev/null
+++ b/priv/gettext/default.pot
@@ -0,0 +1,1325 @@
+## This file is a PO Template file.
+##
+## "msgid"s here are often extracted from source code.
+## Add new messages manually only if they're dynamic
+## messages that can't be statically extracted.
+##
+## Run "mix gettext.extract" to bring this file up to
+## date. Leave "msgstr"s empty as changing them here has no
+## effect: edit them in PO (.po) files instead.
+#
+msgid ""
+msgstr ""
+
+#: lib/taina_web/live/gallery_live.ex:267
+#, elixir-autogen, elixir-format
+msgid "%{count} foto"
+msgid_plural "%{count} fotos"
+msgstr[0] ""
+msgstr[1] ""
+
+#: lib/taina_web/live/files_live.ex:318
+#: lib/taina_web/live/trash_live.ex:103
+#, elixir-autogen, elixir-format
+msgid "%{count} item"
+msgid_plural "%{count} itens"
+msgstr[0] ""
+msgstr[1] ""
+
+#: lib/taina_web/live/members_live.ex:112
+#, elixir-autogen, elixir-format
+msgid "%{count} pessoa"
+msgid_plural "%{count} pessoas"
+msgstr[0] ""
+msgstr[1] ""
+
+#: lib/taina_web/live/files_live.ex:496
+#, elixir-autogen, elixir-format
+msgid "%{free} livres"
+msgstr ""
+
+#: lib/taina_web/live/gallery_live.ex:302
+#, elixir-autogen, elixir-format
+msgid "%{index} de %{total}"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:490
+#: lib/taina_web/live/home_live.ex:78
+#, elixir-autogen, elixir-format
+msgid "%{used} de %{total} usados"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:104
+#, elixir-autogen, elixir-format
+msgid "A estrela da manhã da sua comunidade. Arquivos e fotos, na sua casa, sob seu controle."
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:17
+#, elixir-autogen, elixir-format
+msgid "Aceitar convite"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:305
+#, elixir-autogen, elixir-format
+msgid "Alternar grade/lista"
+msgstr ""
+
+#: lib/taina_web/live/trash_live.ex:69
+#, elixir-autogen, elixir-format
+msgid "Apagado hoje"
+msgstr ""
+
+#: lib/taina_web/live/trash_live.ex:71
+#, elixir-autogen, elixir-format
+msgid "Apagado há %{count} dias"
+msgstr ""
+
+#: lib/taina_web/live/trash_live.ex:70
+#, elixir-autogen, elixir-format
+msgid "Apagado ontem"
+msgstr ""
+
+#: lib/taina_web/live/invite_live.ex:81
+#, elixir-autogen, elixir-format
+msgid "Aponte a câmera ou compartilhe o link abaixo"
+msgstr ""
+
+#: lib/taina_web/components/layouts.ex:58
+#: lib/taina_web/live/account_live.ex:56
+#: lib/taina_web/live/home_live.ex:70
+#: lib/taina_web/live/storage_live.ex:21
+#: lib/taina_web/live/storage_live.ex:81
+#, elixir-autogen, elixir-format
+msgid "Armazenamento"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:535
+#, elixir-autogen, elixir-format
+msgid "Arquivo"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:55
+#: lib/taina_web/live/files_live.ex:185
+#, elixir-autogen, elixir-format
+msgid "Arquivo movido para a lixeira."
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:28
+#, elixir-autogen, elixir-format
+msgid "Arquivo não encontrado."
+msgstr ""
+
+#: lib/taina_web/live/trash_live.ex:36
+#, elixir-autogen, elixir-format
+msgid "Arquivo restaurado."
+msgstr ""
+
+#: lib/taina_web/components/layouts.ex:41
+#: lib/taina_web/components/layouts.ex:81
+#: lib/taina_web/live/files_live.ex:32
+#: lib/taina_web/live/files_live.ex:265
+#, elixir-autogen, elixir-format
+msgid "Arquivos"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:116
+#, elixir-autogen, elixir-format
+msgid "Arraste arquivos aqui"
+msgstr ""
+
+#: lib/taina_web/live/gallery_live.ex:256
+#: lib/taina_web/live/gallery_live.ex:292
+#, elixir-autogen, elixir-format
+msgid "As imagens que a comunidade enviar aparecem aqui."
+msgstr ""
+
+#: lib/taina_web/controllers/session_controller.ex:32
+#, elixir-autogen, elixir-format
+msgid "Até logo!"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:106
+#: lib/taina_web/live/file_preview_live.ex:148
+#: lib/taina_web/live/gallery_live.ex:345
+#, elixir-autogen, elixir-format
+msgid "Baixar"
+msgstr ""
+
+#: lib/taina_web/live/login_live.ex:53
+#, elixir-autogen, elixir-format
+msgid "Bem-vindo de volta"
+msgstr ""
+
+#: lib/taina_web/live/home_live.ex:35
+#, elixir-autogen, elixir-format
+msgid "Boa noite,"
+msgstr ""
+
+#: lib/taina_web/live/home_live.ex:34
+#, elixir-autogen, elixir-format
+msgid "Boa tarde,"
+msgstr ""
+
+#: lib/taina_web/live/home_live.ex:33
+#, elixir-autogen, elixir-format
+msgid "Bom dia,"
+msgstr ""
+
+#: lib/taina_web/live/members_live.ex:104
+#, elixir-autogen, elixir-format
+msgid "Buscar pessoa"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:451
+#: lib/taina_web/live/files_live.ex:426
+#: lib/taina_web/live/files_live.ex:446
+#: lib/taina_web/live/storage_live.ex:134
+#, elixir-autogen, elixir-format
+msgid "Cancelar"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:137
+#, elixir-autogen, elixir-format
+msgid "Cancelar envio"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:191
+#, elixir-autogen, elixir-format
+msgid "Cancelar tudo"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:394
+#: lib/taina_web/live/gallery_live.ex:249
+#: lib/taina_web/live/gallery_live.ex:285
+#: lib/taina_web/live/trash_live.ex:129
+#, elixir-autogen, elixir-format
+msgid "Carregando mais..."
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:122
+#, elixir-autogen, elixir-format
+msgid "Começar"
+msgstr ""
+
+#: lib/taina_web/live/login_live.ex:97
+#, elixir-autogen, elixir-format
+msgid "Como entrar com um convite"
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:52
+#: lib/taina_web/live/setup_live.ex:154
+#, elixir-autogen, elixir-format
+msgid "Como seu nome aparece para a comunidade."
+msgstr ""
+
+#: lib/taina_web/live/gallery_live.ex:341
+#, elixir-autogen, elixir-format
+msgid "Compartilhar"
+msgstr ""
+
+#: lib/taina_web/live/invite_live.ex:109
+#, elixir-autogen, elixir-format
+msgid "Compartilhar convite"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:183
+#, elixir-autogen, elixir-format
+msgid "Concluir"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:166
+#, elixir-autogen, elixir-format
+msgid "Concluído"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:161
+#, elixir-autogen, elixir-format
+msgid "Concluído (%{count})"
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:67
+#: lib/taina_web/live/reset_password_live.ex:48
+#, elixir-autogen, elixir-format
+msgid "Confirme a senha"
+msgstr ""
+
+#: lib/taina_web/components/layouts.ex:54
+#: lib/taina_web/components/layouts.ex:88
+#: lib/taina_web/live/account_live.ex:15
+#: lib/taina_web/live/account_live.ex:27
+#, elixir-autogen, elixir-format
+msgid "Conta"
+msgstr ""
+
+#: lib/taina_web/controllers/invite_controller.ex:17
+#, elixir-autogen, elixir-format
+msgid "Conta criada. Boas-vindas à comunidade!"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:168
+#, elixir-autogen, elixir-format
+msgid "Continuar"
+msgstr ""
+
+#: lib/taina_web/live/members_live.ex:119
+#, elixir-autogen, elixir-format
+msgid "Convidar"
+msgstr ""
+
+#: lib/taina_web/live/invite_live.ex:112
+#, elixir-autogen, elixir-format
+msgid "Convidar outra pessoa"
+msgstr ""
+
+#: lib/taina_web/live/account_live.ex:47
+#: lib/taina_web/live/invite_live.ex:21
+#: lib/taina_web/live/invite_live.ex:75
+#: lib/taina_web/live/members_live.ex:93
+#, elixir-autogen, elixir-format
+msgid "Convidar pessoas"
+msgstr ""
+
+#: lib/taina_web/live/invite_live.ex:107
+#, elixir-autogen, elixir-format
+msgid "Convite para a nuvem da comunidade"
+msgstr ""
+
+#: lib/taina_web/live/members_live.ex:66
+#: lib/taina_web/live/members_live.ex:72
+#, elixir-autogen, elixir-format
+msgid "Convite pendente"
+msgstr ""
+
+#: lib/taina_web/live/gallery_live.ex:307
+#: lib/taina_web/live/invite_live.ex:99
+#: lib/taina_web/live/members_live.ex:166
+#, elixir-autogen, elixir-format
+msgid "Copiar link"
+msgstr ""
+
+#: lib/taina_web/live/storage_live.ex:48
+#, elixir-autogen, elixir-format
+msgid "Cota atualizada."
+msgstr ""
+
+#: lib/taina_web/live/storage_live.ex:110
+#: lib/taina_web/live/storage_live.ex:121
+#, elixir-autogen, elixir-format
+msgid "Cota da comunidade"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:428
+#, elixir-autogen, elixir-format
+msgid "Criar"
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:76
+#, elixir-autogen, elixir-format
+msgid "Criar conta"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:220
+#, elixir-autogen, elixir-format
+msgid "Criar minha nuvem"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:129
+#, elixir-autogen, elixir-format
+msgid "Crie a conta de quem cuida"
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:29
+#, elixir-autogen, elixir-format
+msgid "Crie sua conta para entrar na nuvem da comunidade."
+msgstr ""
+
+#: lib/taina_web/live/reset_password_live.ex:29
+#, elixir-autogen, elixir-format
+msgid "Crie uma senha nova para voltar a entrar."
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:220
+#: lib/taina_web/live/files_live.ex:292
+#, elixir-autogen, elixir-format
+msgid "Data"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:166
+#: lib/taina_web/live/file_preview_live.ex:173
+#: lib/taina_web/live/gallery_live.ex:349
+#: lib/taina_web/live/gallery_live.ex:369
+#, elixir-autogen, elixir-format
+msgid "Detalhes"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:198
+#, elixir-autogen, elixir-format
+msgid "Disco USB externo"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:188
+#, elixir-autogen, elixir-format
+msgid "Disco interno"
+msgstr ""
+
+#: lib/taina_web/live/storage_live.ex:66
+#, elixir-autogen, elixir-format
+msgid "Documentos"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:199
+#, elixir-autogen, elixir-format
+msgid "Em breve. Conecte e monte o dispositivo"
+msgstr ""
+
+#: lib/taina_web/live/login_live.ex:104
+#, elixir-autogen, elixir-format
+msgid "Entendi"
+msgstr ""
+
+#: lib/taina_web/live/login_live.ex:25
+#: lib/taina_web/live/login_live.ex:83
+#, elixir-autogen, elixir-format
+msgid "Entrar"
+msgstr ""
+
+#: lib/taina_web/live/login_live.ex:54
+#, elixir-autogen, elixir-format
+msgid "Entre com seu nome na nuvem da sua comunidade."
+msgstr ""
+
+#: lib/taina_web/live/members_live.ex:156
+#, elixir-autogen, elixir-format
+msgid "Entregue este link para %{name} pelo mesmo canal do convite. Com ele, a pessoa cria uma senha nova. O link expira em 1 hora."
+msgstr ""
+
+#: lib/taina_web/live/members_live.ex:74
+#, elixir-autogen, elixir-format
+msgid "Entrou %{when}"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:192
+#: lib/taina_web/live/gallery_live.ex:388
+#, elixir-autogen, elixir-format
+msgid "Enviado em"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:127
+#, elixir-autogen, elixir-format
+msgid "Enviando (%{count})"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:281
+#, elixir-autogen, elixir-format
+msgid "Enviar"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:404
+#: lib/taina_web/live/files_live.ex:408
+#: lib/taina_web/live/home_live.ex:110
+#: lib/taina_web/live/upload_live.ex:24
+#: lib/taina_web/live/upload_live.ex:105
+#, elixir-autogen, elixir-format
+msgid "Enviar arquivos"
+msgstr ""
+
+#: lib/taina_web/live/gallery_live.ex:259
+#, elixir-autogen, elixir-format
+msgid "Enviar fotos"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:401
+#, elixir-autogen, elixir-format
+msgid "Envie arquivos ou crie uma pasta para organizar."
+msgstr ""
+
+#: lib/taina_web/live/home_live.ex:107
+#, elixir-autogen, elixir-format
+msgid "Envie o primeiro arquivo da comunidade."
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:177
+#, elixir-autogen, elixir-format
+msgid "Escolha o disco onde a comunidade vai morar. Dá pra mudar depois nas configurações."
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:269
+#: lib/taina_web/live/files_live.ex:481
+#, elixir-autogen, elixir-format
+msgid "Espaço da comunidade"
+msgstr ""
+
+#: lib/taina_web/live/login_live.ex:81
+#, elixir-autogen, elixir-format
+msgid "Esqueci minha senha"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:117
+#, elixir-autogen, elixir-format
+msgid "Esse nome aparece para todas as pessoas convidadas."
+msgstr ""
+
+#: lib/taina_web/live/invite_live.ex:115
+#, elixir-autogen, elixir-format
+msgid "Este convite expira em 7 dias, revogável a qualquer momento."
+msgstr ""
+
+#: lib/taina_web/controllers/invite_controller.ex:22
+#, elixir-autogen, elixir-format
+msgid "Este convite expirou ou já foi usado. Peça um novo link."
+msgstr ""
+
+#: lib/taina_web/controllers/password_controller.ex:22
+#, elixir-autogen, elixir-format
+msgid "Este link expirou ou já foi usado. Peça um novo a quem cuida da comunidade."
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:454
+#: lib/taina_web/live/file_preview_live.ex:162
+#: lib/taina_web/live/files_live.ex:537
+#: lib/taina_web/live/gallery_live.ex:353
+#, elixir-autogen, elixir-format
+msgid "Excluir"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:204
+#: lib/taina_web/live/files_live.ex:544
+#: lib/taina_web/live/gallery_live.ex:362
+#, elixir-autogen, elixir-format
+msgid "Excluir \"%{name}\"?"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:543
+#, elixir-autogen, elixir-format
+msgid "Excluir a pasta \"%{name}\"?"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:545
+#, elixir-autogen, elixir-format
+msgid "Excluir?"
+msgstr ""
+
+#: lib/taina_web/live/login_live.ex:38
+#, elixir-autogen, elixir-format
+msgid "Fale com quem cuida da sua comunidade. Ela gera um link para você criar uma senha nova."
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:150
+#, elixir-autogen, elixir-format
+msgid "Falharam (%{count})"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:197
+#: lib/taina_web/live/gallery_live.ex:298
+#: lib/taina_web/live/gallery_live.ex:393
+#, elixir-autogen, elixir-format
+msgid "Fechar"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:486
+#, elixir-autogen, elixir-format
+msgid "Fechar aviso"
+msgstr ""
+
+#: lib/taina_web/live/gallery_live.ex:144
+#, elixir-autogen, elixir-format
+msgid "Foto movida para a lixeira."
+msgstr ""
+
+#: lib/taina_web/live/gallery_live.ex:76
+#, elixir-autogen, elixir-format
+msgid "Foto não encontrada."
+msgstr ""
+
+#: lib/taina_web/components/layouts.ex:43
+#: lib/taina_web/components/layouts.ex:83
+#: lib/taina_web/live/gallery_live.ex:23
+#: lib/taina_web/live/gallery_live.ex:219
+#: lib/taina_web/live/storage_live.ex:64
+#, elixir-autogen, elixir-format
+msgid "Fotos"
+msgstr ""
+
+#: lib/taina_web/live/invite_live.ex:134
+#, elixir-autogen, elixir-format
+msgid "Gerar convite"
+msgstr ""
+
+#: lib/taina_web/live/members_live.ex:137
+#, elixir-autogen, elixir-format
+msgid "Gerar link de redefinição de senha"
+msgstr ""
+
+#: lib/taina_web/live/gallery_live.ex:222
+#, elixir-autogen, elixir-format
+msgid "Grade"
+msgstr ""
+
+#: lib/taina_web/live/gallery_live.ex:193
+#, elixir-autogen, elixir-format
+msgid "Hoje"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:529
+#, elixir-autogen, elixir-format
+msgid "Imagem"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:119
+#, elixir-autogen, elixir-format
+msgid "Imagens, PDFs e vídeos. Até 2 GB por arquivo"
+msgstr ""
+
+#: lib/taina_web/components/layouts.ex:36
+#: lib/taina_web/components/layouts.ex:76
+#: lib/taina_web/live/files_live.ex:312
+#: lib/taina_web/live/home_live.ex:22
+#, elixir-autogen, elixir-format
+msgid "Início"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:463
+#, elixir-autogen, elixir-format
+msgid "Início (raiz)"
+msgstr ""
+
+#: lib/taina_web/live/trash_live.ex:98
+#, elixir-autogen, elixir-format
+msgid "Itens são apagados para sempre após %{count} dias na lixeira."
+msgstr ""
+
+#: lib/taina_web/live/storage_live.ex:112
+#, elixir-autogen, elixir-format
+msgid "Limite total"
+msgstr ""
+
+#: lib/taina_web/live/storage_live.ex:124
+#, elixir-autogen, elixir-format
+msgid "Limite total (GB)"
+msgstr ""
+
+#: lib/taina_web/live/gallery_live.ex:224
+#, elixir-autogen, elixir-format
+msgid "Linha do tempo"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:158
+#, elixir-autogen, elixir-format
+msgid "Link"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:35
+#: lib/taina_web/live/gallery_live.ex:123
+#: lib/taina_web/live/invite_live.ex:52
+#: lib/taina_web/live/members_live.ex:52
+#, elixir-autogen, elixir-format
+msgid "Link copiado!"
+msgstr ""
+
+#: lib/taina_web/live/members_live.ex:154
+#, elixir-autogen, elixir-format
+msgid "Link de redefinição de senha"
+msgstr ""
+
+#: lib/taina_web/live/invite_live.ex:85
+#, elixir-autogen, elixir-format
+msgid "Link do convite"
+msgstr ""
+
+#: lib/taina_web/live/account_live.ex:64
+#: lib/taina_web/live/files_live.ex:273
+#: lib/taina_web/live/trash_live.ex:19
+#: lib/taina_web/live/trash_live.ex:92
+#, elixir-autogen, elixir-format
+msgid "Lixeira"
+msgstr ""
+
+#: lib/taina_web/live/trash_live.ex:135
+#, elixir-autogen, elixir-format
+msgid "Lixeira vazia"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:529
+#, elixir-autogen, elixir-format
+msgid "Mais opções"
+msgstr ""
+
+#: lib/taina_web/components/layouts.ex:48
+#: lib/taina_web/live/account_live.ex:40
+#, elixir-autogen, elixir-format
+msgid "Membros"
+msgstr ""
+
+#: lib/taina_web/live/invite_live.ex:130
+#, elixir-autogen, elixir-format
+msgid "Morador participa da comunidade. Zelador também cuida da máquina."
+msgstr ""
+
+#: lib/taina_web/live/invite_live.ex:123
+#, elixir-autogen, elixir-format
+msgid "Morador(a)"
+msgstr ""
+
+#: lib/taina_web/live/members_live.ex:20
+#: lib/taina_web/live/members_live.ex:91
+#, elixir-autogen, elixir-format
+msgid "Moradores"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:534
+#, elixir-autogen, elixir-format
+msgid "Mover"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:454
+#, elixir-autogen, elixir-format
+msgid "Mover para..."
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:144
+#, elixir-autogen, elixir-format
+msgid "Movido."
+msgstr ""
+
+#: lib/taina_web/live/trash_live.ex:136
+#, elixir-autogen, elixir-format
+msgid "Nada esperando para ser apagado."
+msgstr ""
+
+#: lib/taina_web/live/home_live.ex:106
+#, elixir-autogen, elixir-format
+msgid "Nada por aqui ainda"
+msgstr ""
+
+#: lib/taina_web/live/gallery_live.ex:255
+#: lib/taina_web/live/gallery_live.ex:291
+#, elixir-autogen, elixir-format
+msgid "Nenhuma foto ainda"
+msgstr ""
+
+#: lib/taina_web/live/members_live.ex:149
+#, elixir-autogen, elixir-format
+msgid "Nenhuma pessoa corresponde à busca."
+msgstr ""
+
+#: lib/taina_web/live/members_live.ex:148
+#, elixir-autogen, elixir-format
+msgid "Ninguém por aqui"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:219
+#: lib/taina_web/live/files_live.ex:289
+#, elixir-autogen, elixir-format
+msgid "Nome"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:112
+#, elixir-autogen, elixir-format
+msgid "Nome da comunidade"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:416
+#, elixir-autogen, elixir-format
+msgid "Nome da pasta"
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:47
+#: lib/taina_web/live/setup_live.ex:149
+#, elixir-autogen, elixir-format
+msgid "Nome de exibição (opcional)"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:176
+#: lib/taina_web/live/gallery_live.ex:372
+#, elixir-autogen, elixir-format
+msgid "Nome original"
+msgstr ""
+
+#: lib/taina_web/controllers/session_controller.ex:24
+#, elixir-autogen, elixir-format
+msgid "Nome ou senha incorretos."
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:276
+#: lib/taina_web/live/files_live.ex:413
+#, elixir-autogen, elixir-format
+msgid "Nova pasta"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:437
+#, elixir-autogen, elixir-format
+msgid "Novo nome"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:150
+#, elixir-autogen, elixir-format
+msgid "Não dá para mover uma pasta para dentro dela mesma."
+msgstr ""
+
+#: lib/taina_web/live/storage_live.ex:52
+#, elixir-autogen, elixir-format
+msgid "Não foi possível atualizar a cota."
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:506
+#, elixir-autogen, elixir-format
+msgid "Não foi possível carregar o uso agora."
+msgstr ""
+
+#: lib/taina_web/controllers/setup_controller.ex:38
+#, elixir-autogen, elixir-format
+msgid "Não foi possível criar a comunidade. Revise os dados e tente de novo."
+msgstr ""
+
+#: lib/taina_web/controllers/invite_controller.ex:27
+#, elixir-autogen, elixir-format
+msgid "Não foi possível criar a conta. Revise os dados e tente de novo."
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:112
+#, elixir-autogen, elixir-format
+msgid "Não foi possível criar a pasta."
+msgstr ""
+
+#: lib/taina_web/live/gallery_live.ex:152
+#, elixir-autogen, elixir-format
+msgid "Não foi possível excluir a foto."
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:175
+#, elixir-autogen, elixir-format
+msgid "Não foi possível excluir a pasta."
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:59
+#: lib/taina_web/live/files_live.ex:192
+#, elixir-autogen, elixir-format
+msgid "Não foi possível excluir o arquivo."
+msgstr ""
+
+#: lib/taina_web/live/invite_live.ex:47
+#, elixir-autogen, elixir-format
+msgid "Não foi possível gerar o convite. Tente de novo."
+msgstr ""
+
+#: lib/taina_web/live/members_live.ex:43
+#, elixir-autogen, elixir-format
+msgid "Não foi possível gerar o link. Tente de novo."
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:154
+#, elixir-autogen, elixir-format
+msgid "Não foi possível mover."
+msgstr ""
+
+#: lib/taina_web/controllers/password_controller.ex:27
+#, elixir-autogen, elixir-format
+msgid "Não foi possível redefinir a senha. Revise os dados e tente de novo."
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:127
+#, elixir-autogen, elixir-format
+msgid "Não foi possível renomear."
+msgstr ""
+
+#: lib/taina_web/live/trash_live.ex:40
+#, elixir-autogen, elixir-format
+msgid "Não foi possível restaurar o arquivo."
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:175
+#, elixir-autogen, elixir-format
+msgid "Onde guardar os arquivos"
+msgstr ""
+
+#: lib/taina_web/live/gallery_live.ex:194
+#, elixir-autogen, elixir-format
+msgid "Ontem"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:287
+#, elixir-autogen, elixir-format
+msgid "Ordenar"
+msgstr ""
+
+#: lib/taina_web/live/storage_live.ex:67
+#, elixir-autogen, elixir-format
+msgid "Outros"
+msgstr ""
+
+#: lib/taina_web/live/invite_live.ex:120
+#, elixir-autogen, elixir-format
+msgid "Papel da pessoa convidada"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:107
+#, elixir-autogen, elixir-format
+msgid "Passo 1 de 3: sua comunidade"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:134
+#, elixir-autogen, elixir-format
+msgid "Passo 2 de 3: quem cuida"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:180
+#, elixir-autogen, elixir-format
+msgid "Passo 3 de 3: armazenamento"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:332
+#: lib/taina_web/live/files_live.ex:354
+#, elixir-autogen, elixir-format
+msgid "Pasta"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:109
+#, elixir-autogen, elixir-format
+msgid "Pasta criada."
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:168
+#, elixir-autogen, elixir-format
+msgid "Pasta movida para a lixeira."
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:50
+#, elixir-autogen, elixir-format
+msgid "Pasta não encontrada."
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:400
+#, elixir-autogen, elixir-format
+msgid "Pasta vazia"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:25
+#, elixir-autogen, elixir-format
+msgid "Primeiros passos"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:500
+#, elixir-autogen, elixir-format
+msgid "Quando o espaço acabar, quem cuida da comunidade pode aumentar o limite."
+msgstr ""
+
+#: lib/taina_web/live/login_live.ex:99
+#, elixir-autogen, elixir-format
+msgid "Quem administra a comunidade te manda um link (ou um QR code). É só abrir o link neste aparelho. A criação da conta acontece lá."
+msgstr ""
+
+#: lib/taina_web/live/members_live.ex:73
+#, elixir-autogen, elixir-format
+msgid "Quem cuida da máquina"
+msgstr ""
+
+#: lib/taina_web/live/home_live.ex:85
+#, elixir-autogen, elixir-format
+msgid "Recentes"
+msgstr ""
+
+#: lib/taina_web/live/reset_password_live.ex:17
+#: lib/taina_web/live/reset_password_live.ex:27
+#, elixir-autogen, elixir-format
+msgid "Redefinir senha"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:434
+#: lib/taina_web/live/files_live.ex:448
+#: lib/taina_web/live/files_live.ex:531
+#, elixir-autogen, elixir-format
+msgid "Renomear"
+msgstr ""
+
+#: lib/taina_web/live/trash_live.ex:75
+#, elixir-autogen, elixir-format
+msgid "Resta %{count} dia, apaga em breve"
+msgid_plural "Restam %{count} dias, apaga em breve"
+msgstr[0] ""
+msgstr[1] ""
+
+#: lib/taina_web/live/trash_live.ex:120
+#, elixir-autogen, elixir-format
+msgid "Restaurar"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:205
+#, elixir-autogen, elixir-format
+msgid "Resumo"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:188
+#: lib/taina_web/live/gallery_live.ex:384
+#, elixir-autogen, elixir-format
+msgid "SHA-256"
+msgstr ""
+
+#: lib/taina_web/live/account_live.ex:71
+#, elixir-autogen, elixir-format
+msgid "Sair"
+msgstr ""
+
+#: lib/taina_web/live/storage_live.ex:136
+#, elixir-autogen, elixir-format
+msgid "Salvar"
+msgstr ""
+
+#: lib/taina_web/live/reset_password_live.ex:57
+#, elixir-autogen, elixir-format
+msgid "Salvar nova senha"
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:41
+#, elixir-autogen, elixir-format
+msgid "Sem espaços. É com ele que você entra na comunidade."
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:144
+#, elixir-autogen, elixir-format
+msgid "Sem espaços. É com ele que você entra."
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:55
+#: lib/taina_web/live/login_live.ex:71
+#: lib/taina_web/live/setup_live.ex:157
+#, elixir-autogen, elixir-format
+msgid "Senha"
+msgstr ""
+
+#: lib/taina_web/live/reset_password_live.ex:36
+#, elixir-autogen, elixir-format
+msgid "Senha nova"
+msgstr ""
+
+#: lib/taina_web/controllers/password_controller.ex:17
+#, elixir-autogen, elixir-format
+msgid "Senha redefinida. Você já está dentro."
+msgstr ""
+
+#: lib/taina_web/live/login_live.ex:60
+#, elixir-autogen, elixir-format
+msgid "Seu nome"
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:36
+#: lib/taina_web/live/setup_live.ex:139
+#, elixir-autogen, elixir-format
+msgid "Seu nome de usuário"
+msgstr ""
+
+#: lib/taina_web/components/layouts.ex:143
+#, elixir-autogen, elixir-format
+msgid "Software livre, AGPL, seus dados ficam aqui"
+msgstr ""
+
+#: lib/taina_web/controllers/setup_controller.ex:30
+#, elixir-autogen, elixir-format
+msgid "Sua nuvem está pronta. Boas-vindas!"
+msgstr ""
+
+#: lib/taina_web/live/invite_live.ex:27
+#, elixir-autogen, elixir-format
+msgid "Só quem cuida da comunidade pode convidar pessoas."
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:184
+#: lib/taina_web/live/files_live.ex:221
+#: lib/taina_web/live/files_live.ex:295
+#: lib/taina_web/live/gallery_live.ex:380
+#, elixir-autogen, elixir-format
+msgid "Tamanho"
+msgstr ""
+
+#: lib/taina_web/live/login_live.ex:93
+#, elixir-autogen, elixir-format
+msgid "Tenho um convite"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:534
+#, elixir-autogen, elixir-format
+msgid "Texto"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:180
+#: lib/taina_web/live/gallery_live.ex:376
+#, elixir-autogen, elixir-format
+msgid "Tipo"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:483
+#, elixir-autogen, elixir-format
+msgid "Tudo o que vocês enviam divide o mesmo espaço. É da comunidade inteira."
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:62
+#: lib/taina_web/live/reset_password_live.ex:43
+#: lib/taina_web/live/setup_live.ex:164
+#, elixir-autogen, elixir-format
+msgid "Use uma frase longa que só você lembra."
+msgstr ""
+
+#: lib/taina_web/live/storage_live.ex:96
+#, elixir-autogen, elixir-format
+msgid "Uso por tipo"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:205
+#: lib/taina_web/live/files_live.ex:514
+#: lib/taina_web/live/gallery_live.ex:363
+#, elixir-autogen, elixir-format
+msgid "Vai para a lixeira. Dá para restaurar em até 30 dias."
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:503
+#, elixir-autogen, elixir-format
+msgid "Ver detalhes"
+msgstr ""
+
+#: lib/taina_web/live/members_live.ex:71
+#, elixir-autogen, elixir-format
+msgid "Você"
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:27
+#, elixir-autogen, elixir-format
+msgid "Você foi convidado!"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:131
+#, elixir-autogen, elixir-format
+msgid "Você é o primeiro zelador desta comunidade. Guarde bem este acesso. Ele cuida da máquina."
+msgstr ""
+
+#: lib/taina_web/components/layouts.ex:159
+#: lib/taina_web/live/setup_live.ex:127
+#: lib/taina_web/live/setup_live.ex:173
+#, elixir-autogen, elixir-format
+msgid "Voltar"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:530
+#, elixir-autogen, elixir-format
+msgid "Vídeo"
+msgstr ""
+
+#: lib/taina_web/live/storage_live.ex:65
+#, elixir-autogen, elixir-format
+msgid "Vídeos"
+msgstr ""
+
+#: lib/taina_web/live/account_live.ex:36
+#: lib/taina_web/live/invite_live.ex:126
+#: lib/taina_web/live/members_live.ex:133
+#, elixir-autogen, elixir-format
+msgid "Zelador(a)"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:72
+#, elixir-autogen, elixir-format
+msgid "a cota de armazenamento acabou"
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:72
+#: lib/taina_web/live/reset_password_live.ex:53
+#, elixir-autogen, elixir-format
+msgid "a mesma senha"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:74
+#, elixir-autogen, elixir-format
+msgid "a senha precisa de pelo menos 8 caracteres"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:199
+#, elixir-autogen, elixir-format
+msgid "arquivo maior que 2 GB"
+msgstr ""
+
+#: lib/taina_web/live/storage_live.ex:88
+#, elixir-autogen, elixir-format
+msgid "de %{total} usados"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:207
+#, elixir-autogen, elixir-format
+msgid "disco interno"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:58
+#, elixir-autogen, elixir-format
+msgid "dê um nome à comunidade"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:92
+#, elixir-autogen, elixir-format
+msgid "enviado %{when}"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:64
+#, elixir-autogen, elixir-format
+msgid "escolha um nome de usuário"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:153
+#, elixir-autogen, elixir-format
+msgid "ex.: Ana Oliveira"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:420
+#, elixir-autogen, elixir-format
+msgid "ex.: Documentos"
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:51
+#, elixir-autogen, elixir-format
+msgid "ex.: João Mendes"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:116
+#, elixir-autogen, elixir-format
+msgid "ex.: Quilombo do Café"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:143
+#, elixir-autogen, elixir-format
+msgid "ex.: ana"
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:40
+#, elixir-autogen, elixir-format
+msgid "ex.: joao"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:73
+#: lib/taina_web/live/upload_live.ex:201
+#, elixir-autogen, elixir-format
+msgid "falha no envio"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:569
+#, elixir-autogen, elixir-format
+msgid "hoje"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:571
+#, elixir-autogen, elixir-format
+msgid "há %{count} dias"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:575
+#, elixir-autogen, elixir-format
+msgid "há %{count} meses"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:573
+#, elixir-autogen, elixir-format
+msgid "há %{count} semanas"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:574
+#, elixir-autogen, elixir-format
+msgid "há 1 mês"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:572
+#, elixir-autogen, elixir-format
+msgid "há 1 semana"
+msgstr ""
+
+#: lib/taina_web/live/account_live.ex:64
+#, elixir-autogen, elixir-format
+msgid "itens dos últimos 30 dias"
+msgstr ""
+
+#: lib/taina_web/live/account_live.ex:48
+#, elixir-autogen, elixir-format
+msgid "link + QR code"
+msgstr ""
+
+#: lib/taina_web/components/layouts.ex:64
+#: lib/taina_web/live/home_live.ex:72
+#: lib/taina_web/live/setup_live.ex:188
+#: lib/taina_web/live/storage_live.ex:91
+#, elixir-autogen, elixir-format
+msgid "livres"
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:60
+#: lib/taina_web/live/reset_password_live.ex:41
+#: lib/taina_web/live/setup_live.ex:162
+#, elixir-autogen, elixir-format
+msgid "mínimo 8 caracteres"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:200
+#, elixir-autogen, elixir-format
+msgid "no máximo 10 arquivos por vez"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:570
+#, elixir-autogen, elixir-format
+msgid "ontem"
+msgstr ""
+
+#: lib/taina_web/live/login_live.ex:88
+#, elixir-autogen, elixir-format
+msgid "ou"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:117
+#, elixir-autogen, elixir-format
+msgid "ou toque para escolher"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:131
+#: lib/taina_web/live/file_preview_live.ex:136
+#, elixir-autogen, elixir-format
+msgid "pré-visualização indisponível"
+msgstr ""
+
+#: lib/taina_web/live/account_live.ex:40
+#, elixir-autogen, elixir-format
+msgid "quem faz parte da comunidade"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:190
+#, elixir-autogen, elixir-format
+msgid "recomendado"
+msgstr ""
+
+#: lib/taina_web/live/trash_live.ex:77
+#, elixir-autogen, elixir-format
+msgid "resta %{count} dia"
+msgid_plural "restam %{count} dias"
+msgstr[0] ""
+msgstr[1] ""
+
+#: lib/taina_web/live/login_live.ex:65
+#, elixir-autogen, elixir-format
+msgid "seu nome de usuário"
+msgstr ""
+
+#: lib/taina_web/live/login_live.ex:76
+#, elixir-autogen, elixir-format
+msgid "sua senha"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:71
+#, elixir-autogen, elixir-format
+msgid "tipo de arquivo não permitido"
+msgstr ""
+
+#: lib/taina_web/live/account_live.ex:57
+#, elixir-autogen, elixir-format
+msgid "uso do disco e cota"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:207
+#, elixir-autogen, elixir-format
+msgid "zelador(a)"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:531
+#, elixir-autogen, elixir-format
+msgid "Áudio"
+msgstr ""
diff --git a/priv/gettext/pt_BR/LC_MESSAGES/default.po b/priv/gettext/pt_BR/LC_MESSAGES/default.po
new file mode 100644
index 0000000..a4ff27f
--- /dev/null
+++ b/priv/gettext/pt_BR/LC_MESSAGES/default.po
@@ -0,0 +1,1320 @@
+## Os msgids do domínio default já são escritos em pt-BR no código,
+## este arquivo existe para o fluxo do Gettext (extract/merge) e para
+## servir de base quando outros idiomas chegarem.
+msgid ""
+msgstr ""
+"Language: pt_BR\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
+
+#: lib/taina_web/live/gallery_live.ex:267
+#, elixir-autogen, elixir-format
+msgid "%{count} foto"
+msgid_plural "%{count} fotos"
+msgstr[0] ""
+msgstr[1] ""
+
+#: lib/taina_web/live/files_live.ex:318
+#: lib/taina_web/live/trash_live.ex:103
+#, elixir-autogen, elixir-format
+msgid "%{count} item"
+msgid_plural "%{count} itens"
+msgstr[0] ""
+msgstr[1] ""
+
+#: lib/taina_web/live/members_live.ex:112
+#, elixir-autogen, elixir-format
+msgid "%{count} pessoa"
+msgid_plural "%{count} pessoas"
+msgstr[0] ""
+msgstr[1] ""
+
+#: lib/taina_web/live/files_live.ex:496
+#, elixir-autogen, elixir-format
+msgid "%{free} livres"
+msgstr ""
+
+#: lib/taina_web/live/gallery_live.ex:302
+#, elixir-autogen, elixir-format
+msgid "%{index} de %{total}"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:490
+#: lib/taina_web/live/home_live.ex:78
+#, elixir-autogen, elixir-format
+msgid "%{used} de %{total} usados"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:104
+#, elixir-autogen, elixir-format
+msgid "A estrela da manhã da sua comunidade. Arquivos e fotos, na sua casa, sob seu controle."
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:17
+#, elixir-autogen, elixir-format
+msgid "Aceitar convite"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:305
+#, elixir-autogen, elixir-format
+msgid "Alternar grade/lista"
+msgstr ""
+
+#: lib/taina_web/live/trash_live.ex:69
+#, elixir-autogen, elixir-format
+msgid "Apagado hoje"
+msgstr ""
+
+#: lib/taina_web/live/trash_live.ex:71
+#, elixir-autogen, elixir-format
+msgid "Apagado há %{count} dias"
+msgstr ""
+
+#: lib/taina_web/live/trash_live.ex:70
+#, elixir-autogen, elixir-format
+msgid "Apagado ontem"
+msgstr ""
+
+#: lib/taina_web/live/invite_live.ex:81
+#, elixir-autogen, elixir-format
+msgid "Aponte a câmera ou compartilhe o link abaixo"
+msgstr ""
+
+#: lib/taina_web/components/layouts.ex:58
+#: lib/taina_web/live/account_live.ex:56
+#: lib/taina_web/live/home_live.ex:70
+#: lib/taina_web/live/storage_live.ex:21
+#: lib/taina_web/live/storage_live.ex:81
+#, elixir-autogen, elixir-format
+msgid "Armazenamento"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:535
+#, elixir-autogen, elixir-format
+msgid "Arquivo"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:55
+#: lib/taina_web/live/files_live.ex:185
+#, elixir-autogen, elixir-format
+msgid "Arquivo movido para a lixeira."
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:28
+#, elixir-autogen, elixir-format
+msgid "Arquivo não encontrado."
+msgstr ""
+
+#: lib/taina_web/live/trash_live.ex:36
+#, elixir-autogen, elixir-format
+msgid "Arquivo restaurado."
+msgstr ""
+
+#: lib/taina_web/components/layouts.ex:41
+#: lib/taina_web/components/layouts.ex:81
+#: lib/taina_web/live/files_live.ex:32
+#: lib/taina_web/live/files_live.ex:265
+#, elixir-autogen, elixir-format
+msgid "Arquivos"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:116
+#, elixir-autogen, elixir-format
+msgid "Arraste arquivos aqui"
+msgstr ""
+
+#: lib/taina_web/live/gallery_live.ex:256
+#: lib/taina_web/live/gallery_live.ex:292
+#, elixir-autogen, elixir-format
+msgid "As imagens que a comunidade enviar aparecem aqui."
+msgstr ""
+
+#: lib/taina_web/controllers/session_controller.ex:32
+#, elixir-autogen, elixir-format
+msgid "Até logo!"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:106
+#: lib/taina_web/live/file_preview_live.ex:148
+#: lib/taina_web/live/gallery_live.ex:345
+#, elixir-autogen, elixir-format
+msgid "Baixar"
+msgstr ""
+
+#: lib/taina_web/live/login_live.ex:53
+#, elixir-autogen, elixir-format
+msgid "Bem-vindo de volta"
+msgstr ""
+
+#: lib/taina_web/live/home_live.ex:35
+#, elixir-autogen, elixir-format
+msgid "Boa noite,"
+msgstr ""
+
+#: lib/taina_web/live/home_live.ex:34
+#, elixir-autogen, elixir-format
+msgid "Boa tarde,"
+msgstr ""
+
+#: lib/taina_web/live/home_live.ex:33
+#, elixir-autogen, elixir-format
+msgid "Bom dia,"
+msgstr ""
+
+#: lib/taina_web/live/members_live.ex:104
+#, elixir-autogen, elixir-format
+msgid "Buscar pessoa"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:451
+#: lib/taina_web/live/files_live.ex:426
+#: lib/taina_web/live/files_live.ex:446
+#: lib/taina_web/live/storage_live.ex:134
+#, elixir-autogen, elixir-format
+msgid "Cancelar"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:137
+#, elixir-autogen, elixir-format
+msgid "Cancelar envio"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:191
+#, elixir-autogen, elixir-format
+msgid "Cancelar tudo"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:394
+#: lib/taina_web/live/gallery_live.ex:249
+#: lib/taina_web/live/gallery_live.ex:285
+#: lib/taina_web/live/trash_live.ex:129
+#, elixir-autogen, elixir-format
+msgid "Carregando mais..."
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:122
+#, elixir-autogen, elixir-format
+msgid "Começar"
+msgstr ""
+
+#: lib/taina_web/live/login_live.ex:97
+#, elixir-autogen, elixir-format
+msgid "Como entrar com um convite"
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:52
+#: lib/taina_web/live/setup_live.ex:154
+#, elixir-autogen, elixir-format
+msgid "Como seu nome aparece para a comunidade."
+msgstr ""
+
+#: lib/taina_web/live/gallery_live.ex:341
+#, elixir-autogen, elixir-format
+msgid "Compartilhar"
+msgstr ""
+
+#: lib/taina_web/live/invite_live.ex:109
+#, elixir-autogen, elixir-format
+msgid "Compartilhar convite"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:183
+#, elixir-autogen, elixir-format
+msgid "Concluir"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:166
+#, elixir-autogen, elixir-format
+msgid "Concluído"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:161
+#, elixir-autogen, elixir-format
+msgid "Concluído (%{count})"
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:67
+#: lib/taina_web/live/reset_password_live.ex:48
+#, elixir-autogen, elixir-format
+msgid "Confirme a senha"
+msgstr ""
+
+#: lib/taina_web/components/layouts.ex:54
+#: lib/taina_web/components/layouts.ex:88
+#: lib/taina_web/live/account_live.ex:15
+#: lib/taina_web/live/account_live.ex:27
+#, elixir-autogen, elixir-format
+msgid "Conta"
+msgstr ""
+
+#: lib/taina_web/controllers/invite_controller.ex:17
+#, elixir-autogen, elixir-format
+msgid "Conta criada. Boas-vindas à comunidade!"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:168
+#, elixir-autogen, elixir-format
+msgid "Continuar"
+msgstr ""
+
+#: lib/taina_web/live/members_live.ex:119
+#, elixir-autogen, elixir-format
+msgid "Convidar"
+msgstr ""
+
+#: lib/taina_web/live/invite_live.ex:112
+#, elixir-autogen, elixir-format
+msgid "Convidar outra pessoa"
+msgstr ""
+
+#: lib/taina_web/live/account_live.ex:47
+#: lib/taina_web/live/invite_live.ex:21
+#: lib/taina_web/live/invite_live.ex:75
+#: lib/taina_web/live/members_live.ex:93
+#, elixir-autogen, elixir-format
+msgid "Convidar pessoas"
+msgstr ""
+
+#: lib/taina_web/live/invite_live.ex:107
+#, elixir-autogen, elixir-format
+msgid "Convite para a nuvem da comunidade"
+msgstr ""
+
+#: lib/taina_web/live/members_live.ex:66
+#: lib/taina_web/live/members_live.ex:72
+#, elixir-autogen, elixir-format
+msgid "Convite pendente"
+msgstr ""
+
+#: lib/taina_web/live/gallery_live.ex:307
+#: lib/taina_web/live/invite_live.ex:99
+#: lib/taina_web/live/members_live.ex:166
+#, elixir-autogen, elixir-format
+msgid "Copiar link"
+msgstr ""
+
+#: lib/taina_web/live/storage_live.ex:48
+#, elixir-autogen, elixir-format
+msgid "Cota atualizada."
+msgstr ""
+
+#: lib/taina_web/live/storage_live.ex:110
+#: lib/taina_web/live/storage_live.ex:121
+#, elixir-autogen, elixir-format
+msgid "Cota da comunidade"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:428
+#, elixir-autogen, elixir-format
+msgid "Criar"
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:76
+#, elixir-autogen, elixir-format
+msgid "Criar conta"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:220
+#, elixir-autogen, elixir-format
+msgid "Criar minha nuvem"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:129
+#, elixir-autogen, elixir-format
+msgid "Crie a conta de quem cuida"
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:29
+#, elixir-autogen, elixir-format
+msgid "Crie sua conta para entrar na nuvem da comunidade."
+msgstr ""
+
+#: lib/taina_web/live/reset_password_live.ex:29
+#, elixir-autogen, elixir-format
+msgid "Crie uma senha nova para voltar a entrar."
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:220
+#: lib/taina_web/live/files_live.ex:292
+#, elixir-autogen, elixir-format
+msgid "Data"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:166
+#: lib/taina_web/live/file_preview_live.ex:173
+#: lib/taina_web/live/gallery_live.ex:349
+#: lib/taina_web/live/gallery_live.ex:369
+#, elixir-autogen, elixir-format
+msgid "Detalhes"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:198
+#, elixir-autogen, elixir-format
+msgid "Disco USB externo"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:188
+#, elixir-autogen, elixir-format
+msgid "Disco interno"
+msgstr ""
+
+#: lib/taina_web/live/storage_live.ex:66
+#, elixir-autogen, elixir-format
+msgid "Documentos"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:199
+#, elixir-autogen, elixir-format
+msgid "Em breve. Conecte e monte o dispositivo"
+msgstr ""
+
+#: lib/taina_web/live/login_live.ex:104
+#, elixir-autogen, elixir-format
+msgid "Entendi"
+msgstr ""
+
+#: lib/taina_web/live/login_live.ex:25
+#: lib/taina_web/live/login_live.ex:83
+#, elixir-autogen, elixir-format
+msgid "Entrar"
+msgstr ""
+
+#: lib/taina_web/live/login_live.ex:54
+#, elixir-autogen, elixir-format
+msgid "Entre com seu nome na nuvem da sua comunidade."
+msgstr ""
+
+#: lib/taina_web/live/members_live.ex:156
+#, elixir-autogen, elixir-format
+msgid "Entregue este link para %{name} pelo mesmo canal do convite. Com ele, a pessoa cria uma senha nova. O link expira em 1 hora."
+msgstr ""
+
+#: lib/taina_web/live/members_live.ex:74
+#, elixir-autogen, elixir-format
+msgid "Entrou %{when}"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:192
+#: lib/taina_web/live/gallery_live.ex:388
+#, elixir-autogen, elixir-format
+msgid "Enviado em"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:127
+#, elixir-autogen, elixir-format
+msgid "Enviando (%{count})"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:281
+#, elixir-autogen, elixir-format
+msgid "Enviar"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:404
+#: lib/taina_web/live/files_live.ex:408
+#: lib/taina_web/live/home_live.ex:110
+#: lib/taina_web/live/upload_live.ex:24
+#: lib/taina_web/live/upload_live.ex:105
+#, elixir-autogen, elixir-format
+msgid "Enviar arquivos"
+msgstr ""
+
+#: lib/taina_web/live/gallery_live.ex:259
+#, elixir-autogen, elixir-format
+msgid "Enviar fotos"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:401
+#, elixir-autogen, elixir-format
+msgid "Envie arquivos ou crie uma pasta para organizar."
+msgstr ""
+
+#: lib/taina_web/live/home_live.ex:107
+#, elixir-autogen, elixir-format
+msgid "Envie o primeiro arquivo da comunidade."
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:177
+#, elixir-autogen, elixir-format
+msgid "Escolha o disco onde a comunidade vai morar. Dá pra mudar depois nas configurações."
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:269
+#: lib/taina_web/live/files_live.ex:481
+#, elixir-autogen, elixir-format
+msgid "Espaço da comunidade"
+msgstr ""
+
+#: lib/taina_web/live/login_live.ex:81
+#, elixir-autogen, elixir-format
+msgid "Esqueci minha senha"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:117
+#, elixir-autogen, elixir-format
+msgid "Esse nome aparece para todas as pessoas convidadas."
+msgstr ""
+
+#: lib/taina_web/live/invite_live.ex:115
+#, elixir-autogen, elixir-format
+msgid "Este convite expira em 7 dias, revogável a qualquer momento."
+msgstr ""
+
+#: lib/taina_web/controllers/invite_controller.ex:22
+#, elixir-autogen, elixir-format
+msgid "Este convite expirou ou já foi usado. Peça um novo link."
+msgstr ""
+
+#: lib/taina_web/controllers/password_controller.ex:22
+#, elixir-autogen, elixir-format
+msgid "Este link expirou ou já foi usado. Peça um novo a quem cuida da comunidade."
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:454
+#: lib/taina_web/live/file_preview_live.ex:162
+#: lib/taina_web/live/files_live.ex:537
+#: lib/taina_web/live/gallery_live.ex:353
+#, elixir-autogen, elixir-format
+msgid "Excluir"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:204
+#: lib/taina_web/live/files_live.ex:544
+#: lib/taina_web/live/gallery_live.ex:362
+#, elixir-autogen, elixir-format
+msgid "Excluir \"%{name}\"?"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:543
+#, elixir-autogen, elixir-format
+msgid "Excluir a pasta \"%{name}\"?"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:545
+#, elixir-autogen, elixir-format
+msgid "Excluir?"
+msgstr ""
+
+#: lib/taina_web/live/login_live.ex:38
+#, elixir-autogen, elixir-format
+msgid "Fale com quem cuida da sua comunidade. Ela gera um link para você criar uma senha nova."
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:150
+#, elixir-autogen, elixir-format
+msgid "Falharam (%{count})"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:197
+#: lib/taina_web/live/gallery_live.ex:298
+#: lib/taina_web/live/gallery_live.ex:393
+#, elixir-autogen, elixir-format
+msgid "Fechar"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:486
+#, elixir-autogen, elixir-format
+msgid "Fechar aviso"
+msgstr ""
+
+#: lib/taina_web/live/gallery_live.ex:144
+#, elixir-autogen, elixir-format
+msgid "Foto movida para a lixeira."
+msgstr ""
+
+#: lib/taina_web/live/gallery_live.ex:76
+#, elixir-autogen, elixir-format
+msgid "Foto não encontrada."
+msgstr ""
+
+#: lib/taina_web/components/layouts.ex:43
+#: lib/taina_web/components/layouts.ex:83
+#: lib/taina_web/live/gallery_live.ex:23
+#: lib/taina_web/live/gallery_live.ex:219
+#: lib/taina_web/live/storage_live.ex:64
+#, elixir-autogen, elixir-format
+msgid "Fotos"
+msgstr ""
+
+#: lib/taina_web/live/invite_live.ex:134
+#, elixir-autogen, elixir-format
+msgid "Gerar convite"
+msgstr ""
+
+#: lib/taina_web/live/members_live.ex:137
+#, elixir-autogen, elixir-format
+msgid "Gerar link de redefinição de senha"
+msgstr ""
+
+#: lib/taina_web/live/gallery_live.ex:222
+#, elixir-autogen, elixir-format
+msgid "Grade"
+msgstr ""
+
+#: lib/taina_web/live/gallery_live.ex:193
+#, elixir-autogen, elixir-format
+msgid "Hoje"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:529
+#, elixir-autogen, elixir-format
+msgid "Imagem"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:119
+#, elixir-autogen, elixir-format
+msgid "Imagens, PDFs e vídeos. Até 2 GB por arquivo"
+msgstr ""
+
+#: lib/taina_web/components/layouts.ex:36
+#: lib/taina_web/components/layouts.ex:76
+#: lib/taina_web/live/files_live.ex:312
+#: lib/taina_web/live/home_live.ex:22
+#, elixir-autogen, elixir-format
+msgid "Início"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:463
+#, elixir-autogen, elixir-format
+msgid "Início (raiz)"
+msgstr ""
+
+#: lib/taina_web/live/trash_live.ex:98
+#, elixir-autogen, elixir-format
+msgid "Itens são apagados para sempre após %{count} dias na lixeira."
+msgstr ""
+
+#: lib/taina_web/live/storage_live.ex:112
+#, elixir-autogen, elixir-format
+msgid "Limite total"
+msgstr ""
+
+#: lib/taina_web/live/storage_live.ex:124
+#, elixir-autogen, elixir-format
+msgid "Limite total (GB)"
+msgstr ""
+
+#: lib/taina_web/live/gallery_live.ex:224
+#, elixir-autogen, elixir-format
+msgid "Linha do tempo"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:158
+#, elixir-autogen, elixir-format
+msgid "Link"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:35
+#: lib/taina_web/live/gallery_live.ex:123
+#: lib/taina_web/live/invite_live.ex:52
+#: lib/taina_web/live/members_live.ex:52
+#, elixir-autogen, elixir-format
+msgid "Link copiado!"
+msgstr ""
+
+#: lib/taina_web/live/members_live.ex:154
+#, elixir-autogen, elixir-format
+msgid "Link de redefinição de senha"
+msgstr ""
+
+#: lib/taina_web/live/invite_live.ex:85
+#, elixir-autogen, elixir-format
+msgid "Link do convite"
+msgstr ""
+
+#: lib/taina_web/live/account_live.ex:64
+#: lib/taina_web/live/files_live.ex:273
+#: lib/taina_web/live/trash_live.ex:19
+#: lib/taina_web/live/trash_live.ex:92
+#, elixir-autogen, elixir-format
+msgid "Lixeira"
+msgstr ""
+
+#: lib/taina_web/live/trash_live.ex:135
+#, elixir-autogen, elixir-format
+msgid "Lixeira vazia"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:529
+#, elixir-autogen, elixir-format
+msgid "Mais opções"
+msgstr ""
+
+#: lib/taina_web/components/layouts.ex:48
+#: lib/taina_web/live/account_live.ex:40
+#, elixir-autogen, elixir-format
+msgid "Membros"
+msgstr ""
+
+#: lib/taina_web/live/invite_live.ex:130
+#, elixir-autogen, elixir-format
+msgid "Morador participa da comunidade. Zelador também cuida da máquina."
+msgstr ""
+
+#: lib/taina_web/live/invite_live.ex:123
+#, elixir-autogen, elixir-format
+msgid "Morador(a)"
+msgstr ""
+
+#: lib/taina_web/live/members_live.ex:20
+#: lib/taina_web/live/members_live.ex:91
+#, elixir-autogen, elixir-format
+msgid "Moradores"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:534
+#, elixir-autogen, elixir-format
+msgid "Mover"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:454
+#, elixir-autogen, elixir-format
+msgid "Mover para..."
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:144
+#, elixir-autogen, elixir-format
+msgid "Movido."
+msgstr ""
+
+#: lib/taina_web/live/trash_live.ex:136
+#, elixir-autogen, elixir-format
+msgid "Nada esperando para ser apagado."
+msgstr ""
+
+#: lib/taina_web/live/home_live.ex:106
+#, elixir-autogen, elixir-format
+msgid "Nada por aqui ainda"
+msgstr ""
+
+#: lib/taina_web/live/gallery_live.ex:255
+#: lib/taina_web/live/gallery_live.ex:291
+#, elixir-autogen, elixir-format
+msgid "Nenhuma foto ainda"
+msgstr ""
+
+#: lib/taina_web/live/members_live.ex:149
+#, elixir-autogen, elixir-format
+msgid "Nenhuma pessoa corresponde à busca."
+msgstr ""
+
+#: lib/taina_web/live/members_live.ex:148
+#, elixir-autogen, elixir-format
+msgid "Ninguém por aqui"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:219
+#: lib/taina_web/live/files_live.ex:289
+#, elixir-autogen, elixir-format
+msgid "Nome"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:112
+#, elixir-autogen, elixir-format
+msgid "Nome da comunidade"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:416
+#, elixir-autogen, elixir-format
+msgid "Nome da pasta"
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:47
+#: lib/taina_web/live/setup_live.ex:149
+#, elixir-autogen, elixir-format
+msgid "Nome de exibição (opcional)"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:176
+#: lib/taina_web/live/gallery_live.ex:372
+#, elixir-autogen, elixir-format
+msgid "Nome original"
+msgstr ""
+
+#: lib/taina_web/controllers/session_controller.ex:24
+#, elixir-autogen, elixir-format
+msgid "Nome ou senha incorretos."
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:276
+#: lib/taina_web/live/files_live.ex:413
+#, elixir-autogen, elixir-format
+msgid "Nova pasta"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:437
+#, elixir-autogen, elixir-format
+msgid "Novo nome"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:150
+#, elixir-autogen, elixir-format
+msgid "Não dá para mover uma pasta para dentro dela mesma."
+msgstr ""
+
+#: lib/taina_web/live/storage_live.ex:52
+#, elixir-autogen, elixir-format
+msgid "Não foi possível atualizar a cota."
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:506
+#, elixir-autogen, elixir-format
+msgid "Não foi possível carregar o uso agora."
+msgstr ""
+
+#: lib/taina_web/controllers/setup_controller.ex:38
+#, elixir-autogen, elixir-format
+msgid "Não foi possível criar a comunidade. Revise os dados e tente de novo."
+msgstr ""
+
+#: lib/taina_web/controllers/invite_controller.ex:27
+#, elixir-autogen, elixir-format
+msgid "Não foi possível criar a conta. Revise os dados e tente de novo."
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:112
+#, elixir-autogen, elixir-format
+msgid "Não foi possível criar a pasta."
+msgstr ""
+
+#: lib/taina_web/live/gallery_live.ex:152
+#, elixir-autogen, elixir-format
+msgid "Não foi possível excluir a foto."
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:175
+#, elixir-autogen, elixir-format
+msgid "Não foi possível excluir a pasta."
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:59
+#: lib/taina_web/live/files_live.ex:192
+#, elixir-autogen, elixir-format
+msgid "Não foi possível excluir o arquivo."
+msgstr ""
+
+#: lib/taina_web/live/invite_live.ex:47
+#, elixir-autogen, elixir-format
+msgid "Não foi possível gerar o convite. Tente de novo."
+msgstr ""
+
+#: lib/taina_web/live/members_live.ex:43
+#, elixir-autogen, elixir-format
+msgid "Não foi possível gerar o link. Tente de novo."
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:154
+#, elixir-autogen, elixir-format
+msgid "Não foi possível mover."
+msgstr ""
+
+#: lib/taina_web/controllers/password_controller.ex:27
+#, elixir-autogen, elixir-format
+msgid "Não foi possível redefinir a senha. Revise os dados e tente de novo."
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:127
+#, elixir-autogen, elixir-format
+msgid "Não foi possível renomear."
+msgstr ""
+
+#: lib/taina_web/live/trash_live.ex:40
+#, elixir-autogen, elixir-format
+msgid "Não foi possível restaurar o arquivo."
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:175
+#, elixir-autogen, elixir-format
+msgid "Onde guardar os arquivos"
+msgstr ""
+
+#: lib/taina_web/live/gallery_live.ex:194
+#, elixir-autogen, elixir-format
+msgid "Ontem"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:287
+#, elixir-autogen, elixir-format
+msgid "Ordenar"
+msgstr ""
+
+#: lib/taina_web/live/storage_live.ex:67
+#, elixir-autogen, elixir-format
+msgid "Outros"
+msgstr ""
+
+#: lib/taina_web/live/invite_live.ex:120
+#, elixir-autogen, elixir-format
+msgid "Papel da pessoa convidada"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:107
+#, elixir-autogen, elixir-format
+msgid "Passo 1 de 3: sua comunidade"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:134
+#, elixir-autogen, elixir-format
+msgid "Passo 2 de 3: quem cuida"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:180
+#, elixir-autogen, elixir-format
+msgid "Passo 3 de 3: armazenamento"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:332
+#: lib/taina_web/live/files_live.ex:354
+#, elixir-autogen, elixir-format
+msgid "Pasta"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:109
+#, elixir-autogen, elixir-format
+msgid "Pasta criada."
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:168
+#, elixir-autogen, elixir-format
+msgid "Pasta movida para a lixeira."
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:50
+#, elixir-autogen, elixir-format
+msgid "Pasta não encontrada."
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:400
+#, elixir-autogen, elixir-format
+msgid "Pasta vazia"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:25
+#, elixir-autogen, elixir-format
+msgid "Primeiros passos"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:500
+#, elixir-autogen, elixir-format
+msgid "Quando o espaço acabar, quem cuida da comunidade pode aumentar o limite."
+msgstr ""
+
+#: lib/taina_web/live/login_live.ex:99
+#, elixir-autogen, elixir-format
+msgid "Quem administra a comunidade te manda um link (ou um QR code). É só abrir o link neste aparelho. A criação da conta acontece lá."
+msgstr ""
+
+#: lib/taina_web/live/members_live.ex:73
+#, elixir-autogen, elixir-format
+msgid "Quem cuida da máquina"
+msgstr ""
+
+#: lib/taina_web/live/home_live.ex:85
+#, elixir-autogen, elixir-format
+msgid "Recentes"
+msgstr ""
+
+#: lib/taina_web/live/reset_password_live.ex:17
+#: lib/taina_web/live/reset_password_live.ex:27
+#, elixir-autogen, elixir-format
+msgid "Redefinir senha"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:434
+#: lib/taina_web/live/files_live.ex:448
+#: lib/taina_web/live/files_live.ex:531
+#, elixir-autogen, elixir-format
+msgid "Renomear"
+msgstr ""
+
+#: lib/taina_web/live/trash_live.ex:75
+#, elixir-autogen, elixir-format
+msgid "Resta %{count} dia, apaga em breve"
+msgid_plural "Restam %{count} dias, apaga em breve"
+msgstr[0] ""
+msgstr[1] ""
+
+#: lib/taina_web/live/trash_live.ex:120
+#, elixir-autogen, elixir-format
+msgid "Restaurar"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:205
+#, elixir-autogen, elixir-format
+msgid "Resumo"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:188
+#: lib/taina_web/live/gallery_live.ex:384
+#, elixir-autogen, elixir-format
+msgid "SHA-256"
+msgstr ""
+
+#: lib/taina_web/live/account_live.ex:71
+#, elixir-autogen, elixir-format
+msgid "Sair"
+msgstr ""
+
+#: lib/taina_web/live/storage_live.ex:136
+#, elixir-autogen, elixir-format
+msgid "Salvar"
+msgstr ""
+
+#: lib/taina_web/live/reset_password_live.ex:57
+#, elixir-autogen, elixir-format
+msgid "Salvar nova senha"
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:41
+#, elixir-autogen, elixir-format
+msgid "Sem espaços. É com ele que você entra na comunidade."
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:144
+#, elixir-autogen, elixir-format
+msgid "Sem espaços. É com ele que você entra."
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:55
+#: lib/taina_web/live/login_live.ex:71
+#: lib/taina_web/live/setup_live.ex:157
+#, elixir-autogen, elixir-format
+msgid "Senha"
+msgstr ""
+
+#: lib/taina_web/live/reset_password_live.ex:36
+#, elixir-autogen, elixir-format
+msgid "Senha nova"
+msgstr ""
+
+#: lib/taina_web/controllers/password_controller.ex:17
+#, elixir-autogen, elixir-format
+msgid "Senha redefinida. Você já está dentro."
+msgstr ""
+
+#: lib/taina_web/live/login_live.ex:60
+#, elixir-autogen, elixir-format
+msgid "Seu nome"
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:36
+#: lib/taina_web/live/setup_live.ex:139
+#, elixir-autogen, elixir-format
+msgid "Seu nome de usuário"
+msgstr ""
+
+#: lib/taina_web/components/layouts.ex:143
+#, elixir-autogen, elixir-format
+msgid "Software livre, AGPL, seus dados ficam aqui"
+msgstr ""
+
+#: lib/taina_web/controllers/setup_controller.ex:30
+#, elixir-autogen, elixir-format
+msgid "Sua nuvem está pronta. Boas-vindas!"
+msgstr ""
+
+#: lib/taina_web/live/invite_live.ex:27
+#, elixir-autogen, elixir-format
+msgid "Só quem cuida da comunidade pode convidar pessoas."
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:184
+#: lib/taina_web/live/files_live.ex:221
+#: lib/taina_web/live/files_live.ex:295
+#: lib/taina_web/live/gallery_live.ex:380
+#, elixir-autogen, elixir-format
+msgid "Tamanho"
+msgstr ""
+
+#: lib/taina_web/live/login_live.ex:93
+#, elixir-autogen, elixir-format
+msgid "Tenho um convite"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:534
+#, elixir-autogen, elixir-format
+msgid "Texto"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:180
+#: lib/taina_web/live/gallery_live.ex:376
+#, elixir-autogen, elixir-format
+msgid "Tipo"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:483
+#, elixir-autogen, elixir-format
+msgid "Tudo o que vocês enviam divide o mesmo espaço. É da comunidade inteira."
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:62
+#: lib/taina_web/live/reset_password_live.ex:43
+#: lib/taina_web/live/setup_live.ex:164
+#, elixir-autogen, elixir-format
+msgid "Use uma frase longa que só você lembra."
+msgstr ""
+
+#: lib/taina_web/live/storage_live.ex:96
+#, elixir-autogen, elixir-format
+msgid "Uso por tipo"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:205
+#: lib/taina_web/live/files_live.ex:514
+#: lib/taina_web/live/gallery_live.ex:363
+#, elixir-autogen, elixir-format
+msgid "Vai para a lixeira. Dá para restaurar em até 30 dias."
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:503
+#, elixir-autogen, elixir-format
+msgid "Ver detalhes"
+msgstr ""
+
+#: lib/taina_web/live/members_live.ex:71
+#, elixir-autogen, elixir-format
+msgid "Você"
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:27
+#, elixir-autogen, elixir-format
+msgid "Você foi convidado!"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:131
+#, elixir-autogen, elixir-format
+msgid "Você é o primeiro zelador desta comunidade. Guarde bem este acesso. Ele cuida da máquina."
+msgstr ""
+
+#: lib/taina_web/components/layouts.ex:159
+#: lib/taina_web/live/setup_live.ex:127
+#: lib/taina_web/live/setup_live.ex:173
+#, elixir-autogen, elixir-format
+msgid "Voltar"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:530
+#, elixir-autogen, elixir-format
+msgid "Vídeo"
+msgstr ""
+
+#: lib/taina_web/live/storage_live.ex:65
+#, elixir-autogen, elixir-format
+msgid "Vídeos"
+msgstr ""
+
+#: lib/taina_web/live/account_live.ex:36
+#: lib/taina_web/live/invite_live.ex:126
+#: lib/taina_web/live/members_live.ex:133
+#, elixir-autogen, elixir-format
+msgid "Zelador(a)"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:72
+#, elixir-autogen, elixir-format
+msgid "a cota de armazenamento acabou"
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:72
+#: lib/taina_web/live/reset_password_live.ex:53
+#, elixir-autogen, elixir-format
+msgid "a mesma senha"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:74
+#, elixir-autogen, elixir-format
+msgid "a senha precisa de pelo menos 8 caracteres"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:199
+#, elixir-autogen, elixir-format
+msgid "arquivo maior que 2 GB"
+msgstr ""
+
+#: lib/taina_web/live/storage_live.ex:88
+#, elixir-autogen, elixir-format
+msgid "de %{total} usados"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:207
+#, elixir-autogen, elixir-format
+msgid "disco interno"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:58
+#, elixir-autogen, elixir-format
+msgid "dê um nome à comunidade"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:92
+#, elixir-autogen, elixir-format
+msgid "enviado %{when}"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:64
+#, elixir-autogen, elixir-format
+msgid "escolha um nome de usuário"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:153
+#, elixir-autogen, elixir-format
+msgid "ex.: Ana Oliveira"
+msgstr ""
+
+#: lib/taina_web/live/files_live.ex:420
+#, elixir-autogen, elixir-format
+msgid "ex.: Documentos"
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:51
+#, elixir-autogen, elixir-format
+msgid "ex.: João Mendes"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:116
+#, elixir-autogen, elixir-format
+msgid "ex.: Quilombo do Café"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:143
+#, elixir-autogen, elixir-format
+msgid "ex.: ana"
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:40
+#, elixir-autogen, elixir-format
+msgid "ex.: joao"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:73
+#: lib/taina_web/live/upload_live.ex:201
+#, elixir-autogen, elixir-format
+msgid "falha no envio"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:569
+#, elixir-autogen, elixir-format
+msgid "hoje"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:571
+#, elixir-autogen, elixir-format
+msgid "há %{count} dias"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:575
+#, elixir-autogen, elixir-format
+msgid "há %{count} meses"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:573
+#, elixir-autogen, elixir-format
+msgid "há %{count} semanas"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:574
+#, elixir-autogen, elixir-format
+msgid "há 1 mês"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:572
+#, elixir-autogen, elixir-format
+msgid "há 1 semana"
+msgstr ""
+
+#: lib/taina_web/live/account_live.ex:64
+#, elixir-autogen, elixir-format
+msgid "itens dos últimos 30 dias"
+msgstr ""
+
+#: lib/taina_web/live/account_live.ex:48
+#, elixir-autogen, elixir-format
+msgid "link + QR code"
+msgstr ""
+
+#: lib/taina_web/components/layouts.ex:64
+#: lib/taina_web/live/home_live.ex:72
+#: lib/taina_web/live/setup_live.ex:188
+#: lib/taina_web/live/storage_live.ex:91
+#, elixir-autogen, elixir-format
+msgid "livres"
+msgstr ""
+
+#: lib/taina_web/live/invite_accept_live.ex:60
+#: lib/taina_web/live/reset_password_live.ex:41
+#: lib/taina_web/live/setup_live.ex:162
+#, elixir-autogen, elixir-format
+msgid "mínimo 8 caracteres"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:200
+#, elixir-autogen, elixir-format
+msgid "no máximo 10 arquivos por vez"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:570
+#, elixir-autogen, elixir-format
+msgid "ontem"
+msgstr ""
+
+#: lib/taina_web/live/login_live.ex:88
+#, elixir-autogen, elixir-format
+msgid "ou"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:117
+#, elixir-autogen, elixir-format
+msgid "ou toque para escolher"
+msgstr ""
+
+#: lib/taina_web/live/file_preview_live.ex:131
+#: lib/taina_web/live/file_preview_live.ex:136
+#, elixir-autogen, elixir-format
+msgid "pré-visualização indisponível"
+msgstr ""
+
+#: lib/taina_web/live/account_live.ex:40
+#, elixir-autogen, elixir-format
+msgid "quem faz parte da comunidade"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:190
+#, elixir-autogen, elixir-format
+msgid "recomendado"
+msgstr ""
+
+#: lib/taina_web/live/trash_live.ex:77
+#, elixir-autogen, elixir-format
+msgid "resta %{count} dia"
+msgid_plural "restam %{count} dias"
+msgstr[0] ""
+msgstr[1] ""
+
+#: lib/taina_web/live/login_live.ex:65
+#, elixir-autogen, elixir-format
+msgid "seu nome de usuário"
+msgstr ""
+
+#: lib/taina_web/live/login_live.ex:76
+#, elixir-autogen, elixir-format
+msgid "sua senha"
+msgstr ""
+
+#: lib/taina_web/live/upload_live.ex:71
+#, elixir-autogen, elixir-format
+msgid "tipo de arquivo não permitido"
+msgstr ""
+
+#: lib/taina_web/live/account_live.ex:57
+#, elixir-autogen, elixir-format
+msgid "uso do disco e cota"
+msgstr ""
+
+#: lib/taina_web/live/setup_live.ex:207
+#, elixir-autogen, elixir-format
+msgid "zelador(a)"
+msgstr ""
+
+#: lib/taina_web/components/core_components.ex:531
+#, elixir-autogen, elixir-format
+msgid "Áudio"
+msgstr ""
diff --git a/priv/gettext/pt_BR/LC_MESSAGES/errors.po b/priv/gettext/pt_BR/LC_MESSAGES/errors.po
new file mode 100644
index 0000000..5f44732
--- /dev/null
+++ b/priv/gettext/pt_BR/LC_MESSAGES/errors.po
@@ -0,0 +1,81 @@
+## "msgid"s in this file come from POT (.pot) files.
+## Mensagens de erro do Ecto/changesets traduzidas para pt-BR.
+msgid ""
+msgstr ""
+"Language: pt_BR\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
+
+msgid "can't be blank"
+msgstr "não pode ficar em branco"
+
+msgid "has already been taken"
+msgstr "já está em uso"
+
+msgid "is invalid"
+msgstr "não é válido"
+
+msgid "must be accepted"
+msgstr "precisa ser aceito"
+
+msgid "has invalid format"
+msgstr "tem formato inválido"
+
+msgid "has an invalid entry"
+msgstr "tem uma entrada inválida"
+
+msgid "is reserved"
+msgstr "está reservado"
+
+msgid "does not match confirmation"
+msgstr "não confere com a confirmação"
+
+msgid "is still associated with this entry"
+msgstr "ainda está associado a este registro"
+
+msgid "are still associated with this entry"
+msgstr "ainda estão associados a este registro"
+
+msgid "should be %{count} character(s)"
+msgid_plural "should be %{count} character(s)"
+msgstr[0] "deve ter %{count} caractere"
+msgstr[1] "deve ter %{count} caracteres"
+
+msgid "should have %{count} item(s)"
+msgid_plural "should have %{count} item(s)"
+msgstr[0] "deve ter %{count} item"
+msgstr[1] "deve ter %{count} itens"
+
+msgid "should be at least %{count} character(s)"
+msgid_plural "should be at least %{count} character(s)"
+msgstr[0] "deve ter no mínimo %{count} caractere"
+msgstr[1] "deve ter no mínimo %{count} caracteres"
+
+msgid "should have at least %{count} item(s)"
+msgid_plural "should have at least %{count} item(s)"
+msgstr[0] "deve ter no mínimo %{count} item"
+msgstr[1] "deve ter no mínimo %{count} itens"
+
+msgid "should be at most %{count} character(s)"
+msgid_plural "should be at most %{count} character(s)"
+msgstr[0] "deve ter no máximo %{count} caractere"
+msgstr[1] "deve ter no máximo %{count} caracteres"
+
+msgid "should have at most %{count} item(s)"
+msgid_plural "should have at most %{count} item(s)"
+msgstr[0] "deve ter no máximo %{count} item"
+msgstr[1] "deve ter no máximo %{count} itens"
+
+msgid "must be less than %{number}"
+msgstr "deve ser menor que %{number}"
+
+msgid "must be greater than %{number}"
+msgstr "deve ser maior que %{number}"
+
+msgid "must be less than or equal to %{number}"
+msgstr "deve ser menor ou igual a %{number}"
+
+msgid "must be greater than or equal to %{number}"
+msgstr "deve ser maior ou igual a %{number}"
+
+msgid "must be equal to %{number}"
+msgstr "deve ser igual a %{number}"
diff --git a/priv/repo/migrations/20260616120000_identity_username_first.exs b/priv/repo/migrations/20260616120000_identity_username_first.exs
new file mode 100644
index 0000000..3542e85
--- /dev/null
+++ b/priv/repo/migrations/20260616120000_identity_username_first.exs
@@ -0,0 +1,82 @@
+defmodule Taina.Repo.Migrations.IdentityUsernameFirst do
+ @moduledoc """
+ RFC_003 seção 4: identidade nome-primeiro, e-mail descartado.
+
+ - Renomeia os papéis no banco: admin -> zelador, member -> morador (D3). O
+ enum é um `Ecto.Enum` sobre coluna `:string`, então basta um UPDATE.
+ - Descarta a coluna `:email` e o índice único de e-mail.
+ - Reaproveita o token de confirmação de e-mail como token de convite
+ (`email_confirmation_*` -> `invite_*`); a mesma infra serve o convite e o
+ link de redefinição do zelador.
+ - `confirmed_at` -> `activated_at`: marca o aceite do convite, não a
+ confirmação de e-mail.
+ - Adiciona `:display_name` (nome de exibição, opcional).
+
+ Pré-alpha (RFC_002/D2, RFC_003/D3): migração limpa, sem ressalva de
+ compatibilidade de sessão.
+ """
+
+ use Ecto.Migration
+
+ def up do
+ # Papéis: admin -> zelador, member -> morador.
+ execute "UPDATE maraca.avas SET role = 'zelador' WHERE role = 'admin'"
+ execute "UPDATE maraca.avas SET role = 'morador' WHERE role = 'member'"
+
+ # E-mail descartado.
+ drop unique_index(:avas, [:tekoa_id, :email],
+ prefix: "maraca",
+ name: :avas_tekoa_id_email_index
+ )
+
+ alter table(:avas, prefix: "maraca") do
+ remove :email
+ end
+
+ # Confirmação de e-mail -> token de convite (reaproveita a infra).
+ drop unique_index(:avas, [:email_confirmation_token_hash], prefix: "maraca")
+
+ rename table(:avas, prefix: "maraca"), :email_confirmation_token_hash, to: :invite_token_hash
+ rename table(:avas, prefix: "maraca"), :email_confirmation_sent_at, to: :invite_sent_at
+
+ create unique_index(:avas, [:invite_token_hash],
+ prefix: "maraca",
+ where: "invite_token_hash IS NOT NULL"
+ )
+
+ # Aceite do convite, não confirmação de e-mail.
+ rename table(:avas, prefix: "maraca"), :confirmed_at, to: :activated_at
+
+ # Nome de exibição (opcional): o rosto social, separado da chave de acesso.
+ alter table(:avas, prefix: "maraca") do
+ add :display_name, :string
+ end
+ end
+
+ def down do
+ alter table(:avas, prefix: "maraca") do
+ remove :display_name
+ end
+
+ rename table(:avas, prefix: "maraca"), :activated_at, to: :confirmed_at
+
+ drop unique_index(:avas, [:invite_token_hash], prefix: "maraca")
+
+ rename table(:avas, prefix: "maraca"), :invite_token_hash, to: :email_confirmation_token_hash
+ rename table(:avas, prefix: "maraca"), :invite_sent_at, to: :email_confirmation_sent_at
+
+ create unique_index(:avas, [:email_confirmation_token_hash], prefix: "maraca")
+
+ alter table(:avas, prefix: "maraca") do
+ add :email, :string
+ end
+
+ create unique_index(:avas, [:tekoa_id, :email],
+ prefix: "maraca",
+ name: :avas_tekoa_id_email_index
+ )
+
+ execute "UPDATE maraca.avas SET role = 'admin' WHERE role = 'zelador'"
+ execute "UPDATE maraca.avas SET role = 'member' WHERE role = 'morador'"
+ end
+end
diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs
index cda4c70..b304bcd 100644
--- a/priv/repo/seeds.exs
+++ b/priv/repo/seeds.exs
@@ -1,11 +1,162 @@
-# Script for populating the database. You can run it as:
+# Seeds, popula uma instância de desenvolvimento com uma comunidade plausível:
+# uma Tekoa, um zelador, dois moradores (mais um convite pendente), pastas e
+# alguns arquivos reais (fotos, documento, PDF). Tudo passa pelos contexts
+# públicos (Maraca/Ybira), então respeita RLS, cota, detecção de MIME por magic
+# bytes e dispara os jobs de thumbnail, exatamente como em produção.
#
# mix run priv/repo/seeds.exs
#
-# Inside the script, you can read and write to any of your
-# repositories directly:
+# Identidade nome-primeiro, sem e-mail (RFC_003, seção 4): convite por link +
+# aceite (a pessoa escolhe nome de usuário e senha), login por nome.
#
-# Taina.Repo.insert!(%Taina.SomeSchema{})
-#
-# We recommend using the bang functions (`insert!`, `update!`
-# and so on) as they will fail if something goes wrong.
+# Idempotente: se já houver uma Tekoa (RFC 002, D2, uma por instância), não faz
+# nada. Use `mix ecto.reset` para recomeçar do zero.
+
+alias Taina.Maraca
+alias Taina.Scope
+alias Taina.Ybira
+
+if Maraca.bootstrapped?() do
+ IO.puts("\nJá existe uma comunidade, seeds ignorados. Use `mix ecto.reset` para recomeçar.\n")
+else
+ # 256 GB de cota, como nas telas do design.
+ quota_bytes = 256 * 1024 * 1024 * 1024
+ zelador_password = "semente-da-manha"
+
+ {:ok, %{tekoa: tekoa, ava: zelador}} =
+ Maraca.bootstrap(
+ %{name: "Quilombo do Café", storage_quota_bytes: quota_bytes},
+ %{
+ username: "ana",
+ display_name: "Ana Oliveira",
+ password: zelador_password,
+ password_confirmation: zelador_password
+ }
+ )
+
+ scope = Scope.new(zelador, tekoa)
+
+ # --- Moradores: convite por link + aceite, o fluxo real (sem e-mail) ---
+
+ convidar_e_aceitar = fn username, display_name ->
+ {:ok, convidado} = Maraca.invite_user(zelador, tekoa, role: :morador)
+ senha = "morador-#{username}-123"
+
+ {:ok, _ava} =
+ Maraca.accept_invite(convidado.invite_token, %{
+ "username" => username,
+ "display_name" => display_name,
+ "password" => senha,
+ "password_confirmation" => senha
+ })
+ end
+
+ convidar_e_aceitar.("joao", "João Mendes")
+ convidar_e_aceitar.("maria", "Maria Silva")
+
+ # Convite gerado mas ainda não aceito (aparece como pendente nos Moradores).
+ {:ok, _pendente} = Maraca.invite_user(zelador, tekoa, role: :morador)
+
+ # --- Pastas ---
+
+ {:ok, documentos} = Ybira.create_folder(scope, %{name: "Documentos"})
+ {:ok, fotos} = Ybira.create_folder(scope, %{name: "Fotos da comunidade"})
+ {:ok, _videos} = Ybira.create_folder(scope, %{name: "Vídeos"})
+
+ # --- Geração de arquivos reais num diretório temporário ---
+ #
+ # As fotos são PNGs sólidos (a paleta do grid do design) gerados via libvips,
+ # magic bytes válidos, então passam pela allowlist e disparam o worker de
+ # thumbnail de verdade.
+
+ tmp = Path.join(System.tmp_dir!(), "taina_seeds_#{System.unique_integer([:positive])}")
+ File.mkdir_p!(tmp)
+
+ # try/after: o diretorio temporario some mesmo se um upload falhar no meio.
+ try do
+ gera_foto = fn nome, [r, g, b] ->
+ caminho = Path.join(tmp, nome)
+ img = Image.new!(800, 600, color: [r, g, b])
+ Image.write!(img, caminho)
+ caminho
+ end
+
+ gera_texto = fn nome, conteudo ->
+ caminho = Path.join(tmp, nome)
+ File.write!(caminho, conteudo)
+ caminho
+ end
+
+ gera_pdf = fn nome ->
+ caminho = Path.join(tmp, nome)
+ # PDF mínimo válido, só precisamos dos magic bytes "%PDF" e estrutura básica.
+ File.write!(caminho, """
+ %PDF-1.4
+ 1 0 obj<>endobj
+ 2 0 obj<>endobj
+ 3 0 obj<>endobj
+ trailer<>
+ %%EOF
+ """)
+
+ caminho
+ end
+
+ # Paleta de fotos (forest/moon/sky/ember/guara, ver tokens do Penpot).
+ paleta = [
+ {"festa-junina-01.png", [63, 161, 113]},
+ {"festa-junina-02.png", [227, 188, 102]},
+ {"assembleia-01.png", [91, 163, 245]},
+ {"assembleia-02.png", [226, 103, 74]},
+ {"quintal-01.png", [130, 203, 164]},
+ {"quintal-02.png", [222, 91, 63]},
+ {"horta-01.png", [196, 154, 72]},
+ {"horta-02.png", [47, 129, 89]}
+ ]
+
+ # Sobe as fotos para a pasta "Fotos da comunidade".
+ Enum.each(paleta, fn {nome, rgb} ->
+ caminho = gera_foto.(nome, rgb)
+ {:ok, _} = Ybira.upload(scope, caminho, filename: nome, folder_id: fotos.id)
+ end)
+
+ # Documentos.
+ ata =
+ gera_texto.(
+ "ata-da-assembleia.txt",
+ "Ata da assembleia comunitária\n\nPauta: uso do espaço, mutirão de limpeza, festa junina.\n"
+ )
+
+ {:ok, _} = Ybira.upload(scope, ata, filename: "Ata da assembleia.txt", folder_id: documentos.id)
+
+ orcamento =
+ gera_texto.("orcamento-2025.txt", "Orçamento 2025\n\nReceitas: rifas, doações.\nDespesas: materiais, manutenção.\n")
+
+ {:ok, _} = Ybira.upload(scope, orcamento, filename: "Orçamento 2025.txt", folder_id: documentos.id)
+
+ estatuto = gera_pdf.("estatuto.pdf")
+ {:ok, _} = Ybira.upload(scope, estatuto, filename: "Estatuto.pdf", folder_id: documentos.id)
+
+ # Um arquivo na raiz (logo), para a Home ter "recentes" variados.
+ logo = gera_foto.("logo-da-radio.png", [255, 217, 163])
+ {:ok, _} = Ybira.upload(scope, logo, filename: "Logo da rádio.png")
+ after
+ File.rm_rf!(tmp)
+ end
+
+ IO.puts("""
+
+ Tainá, comunidade de exemplo criada
+
+ Comunidade: #{tekoa.name}
+ Zelador: ana / senha: #{zelador_password}
+ Moradores: joao / senha: morador-joao-123
+ maria / senha: morador-maria-123
+ Pendente: 1 convite de morador gerado, ainda não aceito
+
+ Pastas: Documentos, Fotos da comunidade, Vídeos
+ Arquivos: 8 fotos + 3 documentos + 1 logo (thumbnails em segundo plano)
+
+ Entre em http://localhost:4000/login com o nome "ana"
+ """)
+end
diff --git a/priv/static/fonts/bricolage-grotesque-latin-wght.woff2 b/priv/static/fonts/bricolage-grotesque-latin-wght.woff2
new file mode 100644
index 0000000..82152a4
Binary files /dev/null and b/priv/static/fonts/bricolage-grotesque-latin-wght.woff2 differ
diff --git a/priv/static/fonts/jetbrains-mono-latin-wght.woff2 b/priv/static/fonts/jetbrains-mono-latin-wght.woff2
new file mode 100644
index 0000000..cd5102a
Binary files /dev/null and b/priv/static/fonts/jetbrains-mono-latin-wght.woff2 differ
diff --git a/priv/static/fonts/schibsted-grotesk-latin-wght.woff2 b/priv/static/fonts/schibsted-grotesk-latin-wght.woff2
new file mode 100644
index 0000000..ea0f36f
Binary files /dev/null and b/priv/static/fonts/schibsted-grotesk-latin-wght.woff2 differ
diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex
index 25f93a7..3cff395 100644
--- a/test/support/conn_case.ex
+++ b/test/support/conn_case.ex
@@ -38,7 +38,7 @@ defmodule TainaWeb.ConnCase do
@doc """
Loga um Ava na sessão de teste (cookie), do jeito que a pipeline
- `:authenticated` espera — `Maraca.get_session_user/1` lê `:ava_id`.
+ `:authenticated` espera, `Maraca.get_session_user/1` lê `:ava_id`.
"""
def log_in(conn, ava) do
Plug.Test.init_test_session(conn, %{ava_id: ava.public_id})
diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex
index 18af990..5bac530 100644
--- a/test/support/fixtures.ex
+++ b/test/support/fixtures.ex
@@ -25,13 +25,13 @@ defmodule Taina.Fixtures do
|> Repo.insert!()
end
+ # Convite pendente: tem nome mas ainda sem senha (conta não ativada).
def ava_fixture(%Tekoa{} = tekoa, attrs \\ %{}) do
n = System.unique_integer([:positive])
attrs =
Enum.into(attrs, %{
username: "ava#{n}",
- email: "ava#{n}@example.com",
tekoa_id: tekoa.id
})
@@ -40,29 +40,31 @@ defmodule Taina.Fixtures do
|> Repo.insert!()
end
- def confirmed_ava_fixture(%Tekoa{} = tekoa, attrs \\ %{}) do
+ # Conta ativa: nome de usuário + senha definidos (convite aceito).
+ def active_ava_fixture(%Tekoa{} = tekoa, attrs \\ %{}) do
n = System.unique_integer([:positive])
password = Map.get(attrs, :password, "senhasegura123")
base = %{
username: Map.get(attrs, :username, "ava#{n}"),
- email: Map.get(attrs, :email, "ava#{n}@example.com"),
- role: Map.get(attrs, :role, :member),
+ display_name: Map.get(attrs, :display_name),
+ role: Map.get(attrs, :role, :morador),
tekoa_id: tekoa.id
}
%Ava{}
|> Ava.changeset(base)
- |> Ava.confirmation_changeset(%{
+ |> Ava.accept_invite_changeset(%{
username: base.username,
+ display_name: base.display_name,
password: password,
password_confirmation: password
})
|> Repo.insert!()
end
- def admin_fixture(%Tekoa{} = tekoa, attrs \\ %{}) do
- confirmed_ava_fixture(tekoa, Map.put(attrs, :role, :admin))
+ def zelador_fixture(%Tekoa{} = tekoa, attrs \\ %{}) do
+ active_ava_fixture(tekoa, Map.put(attrs, :role, :zelador))
end
def scope_fixture(attrs \\ %{}) do
@@ -81,7 +83,7 @@ defmodule Taina.Fixtures do
@doc """
Grava uma imagem JPEG de verdade num caminho temporário (via libvips), para
- exercitar o pipeline de renditions do Ybira/Jaci. Sem EXIF — `taken_at` cai no
+ exercitar o pipeline de renditions do Ybira/Jaci. Sem EXIF, `taken_at` cai no
fallback de upload, como num arquivo sem metadados de câmera.
"""
def tmp_image_fixture(opts \\ []) do
@@ -97,4 +99,13 @@ defmodule Taina.Fixtures do
{:ok, _} = Image.write(image, path)
path
end
+
+ @doc """
+ Grava um arquivo com os magic bytes de um contêiner ISO-BMFF `ftyp/mp42`, o
+ bastante para o `MimeDetector` classificar como `video/mp4`. Sem faixas reais,
+ só exercita a allowlist e o filtro image/video do Jaci/Ybira.
+ """
+ def tmp_video_fixture(filename \\ "v.mp4") do
+ tmp_upload_fixture(<<0, 0, 0, 0x18, "ftyp", "mp42", 0, 0, 0, 0>>, filename)
+ end
end
diff --git a/test/taina/jaci_test.exs b/test/taina/jaci_test.exs
index 51f1199..ec394e2 100644
--- a/test/taina/jaci_test.exs
+++ b/test/taina/jaci_test.exs
@@ -20,6 +20,19 @@ defmodule Taina.JaciTest do
assert Enum.map(items, & &1.public_id) == [p2.public_id, p1.public_id]
end
+ test "inclui vídeos e exclui tipos fora de image/video" do
+ scope = scope_fixture()
+ {:ok, _doc} = Ybira.upload(scope, tmp_upload_fixture("texto", "a.txt"))
+ {:ok, img} = Ybira.upload(scope, tmp_image_fixture(filename: "foto.jpg"))
+ {:ok, vid} = Ybira.upload(scope, tmp_video_fixture("clipe.mp4"))
+
+ {:ok, %{items: items}} = Jaci.list_photos(scope)
+ ids = Enum.map(items, & &1.public_id)
+ assert img.public_id in ids
+ assert vid.public_id in ids
+ assert length(ids) == 2
+ end
+
test "paginates by opaque cursor" do
scope = scope_fixture()
for i <- 1..3, do: {:ok, _} = Ybira.upload(scope, tmp_image_fixture(filename: "#{i}.jpg"))
@@ -39,7 +52,7 @@ defmodule Taina.JaciTest do
{:ok, other} = Ybira.upload(scope, tmp_image_fixture(filename: "o.jpg"))
{:ok, recent} = Ybira.upload(scope, tmp_image_fixture(filename: "r.jpg"))
- # enviada por último, mas tirada há anos → deve ir para o fim da timeline,
+ # enviada por último, mas tirada há anos -> deve ir para o fim da timeline,
# contrariando a ordem de upload (senão o teste passa mesmo ignorando EXIF)
set_taken_at(recent.id, ~N[2024-01-01 10:00:00])
@@ -63,7 +76,7 @@ defmodule Taina.JaciTest do
end
# Sobrescreve o metadata para simular EXIF (a imagem gerada não tem). Operação
- # de sistema → `skip_tekoa_id`.
+ # de sistema -> `skip_tekoa_id`.
defp set_taken_at(file_id, %NaiveDateTime{} = dt) do
Repo.update_all(
from(f in YbiraFile, where: f.id == ^file_id),
diff --git a/test/taina/maraca_members_test.exs b/test/taina/maraca_members_test.exs
new file mode 100644
index 0000000..a8f7bcd
--- /dev/null
+++ b/test/taina/maraca_members_test.exs
@@ -0,0 +1,57 @@
+defmodule Taina.MaracaMembersTest do
+ use Taina.DataCase, async: true
+
+ import Taina.Fixtures
+
+ alias Taina.Maraca
+ alias Taina.Scope
+
+ describe "bootstrapped?/0 e get_tekoa/0" do
+ test "instância virgem não está bootstrapped" do
+ refute Maraca.bootstrapped?()
+ assert {:error, :not_bootstrapped} = Maraca.get_tekoa()
+ end
+
+ test "com Tekoa criada, devolve a Tekoa única" do
+ tekoa = tekoa_fixture()
+
+ assert Maraca.bootstrapped?()
+ assert {:ok, found} = Maraca.get_tekoa()
+ assert found.id == tekoa.id
+ end
+ end
+
+ describe "list_members/1" do
+ test "lista zeladores primeiro, depois moradores por ordem de entrada" do
+ tekoa = tekoa_fixture()
+ morador = active_ava_fixture(tekoa)
+ zelador = zelador_fixture(tekoa)
+ scope = Scope.new(zelador, tekoa)
+
+ assert {:ok, [first, second]} = Maraca.list_members(scope)
+ assert first.id == zelador.id
+ assert second.id == morador.id
+ end
+
+ test "inclui convites pendentes (sem nome ainda)" do
+ tekoa = tekoa_fixture()
+ zelador = zelador_fixture(tekoa)
+ pending = ava_fixture(tekoa)
+ scope = Scope.new(zelador, tekoa)
+
+ assert {:ok, members} = Maraca.list_members(scope)
+ assert Enum.any?(members, &(&1.id == pending.id and is_nil(&1.activated_at)))
+ end
+ end
+
+ describe "count_members/1" do
+ test "conta todas as contas da tekoa" do
+ tekoa = tekoa_fixture()
+ zelador = zelador_fixture(tekoa)
+ _morador = active_ava_fixture(tekoa)
+ _pending = ava_fixture(tekoa)
+
+ assert {:ok, 3} = Maraca.count_members(Scope.new(zelador, tekoa))
+ end
+ end
+end
diff --git a/test/taina/maraca_test.exs b/test/taina/maraca_test.exs
index c0bf41a..1df95f0 100644
--- a/test/taina/maraca_test.exs
+++ b/test/taina/maraca_test.exs
@@ -12,22 +12,24 @@ defmodule Taina.MaracaTest do
alias Taina.Ybira
describe "bootstrap/2" do
- test "creates the single tekoa and a confirmed admin" do
- assert {:ok, %{tekoa: tekoa, ava: admin}} =
+ test "creates the single tekoa and an active zelador" do
+ assert {:ok, %{tekoa: tekoa, ava: zelador}} =
Maraca.bootstrap(
%{name: "Aldeia Inicial", storage_quota_bytes: 1024},
%{
username: "fundadora",
- email: "fundadora@example.com",
+ display_name: "Fundadora",
password: "senhasegura123",
password_confirmation: "senhasegura123"
}
)
assert tekoa.name == "Aldeia Inicial"
- assert admin.role == :admin
- assert Maraca.email_confirmed?(admin)
- assert admin.password_hash
+ assert zelador.role == :zelador
+ assert zelador.username == "fundadora"
+ assert zelador.display_name == "Fundadora"
+ assert Maraca.activated?(zelador)
+ assert zelador.password_hash
end
test "refuses when a tekoa already exists" do
@@ -38,82 +40,104 @@ defmodule Taina.MaracaTest do
end
end
- describe "invite_user/4 and confirm_email/4" do
+ describe "invite_user/3 and accept_invite/2" do
setup do
tekoa = tekoa_fixture()
- %{tekoa: tekoa, admin: admin_fixture(tekoa)}
+ %{tekoa: tekoa, zelador: zelador_fixture(tekoa)}
end
- test "admin invites, raw token confirms the account", %{tekoa: tekoa, admin: admin} do
- assert {:ok, invited} = Maraca.invite_user(admin, tekoa, "nova@example.com")
- assert invited.email_confirmation_token
- refute Maraca.email_confirmed?(invited)
+ test "invite carries a token (no e-mail) and accept creates the account", %{tekoa: tekoa, zelador: zelador} do
+ assert {:ok, invited} = Maraca.invite_user(zelador, tekoa)
+ assert invited.invite_token
+ assert invited.role == :morador
+ refute Maraca.activated?(invited)
+ assert is_nil(invited.username)
- assert {:ok, confirmed} =
- Maraca.confirm_email(
- invited.email_confirmation_token,
- "senhasegura123",
- "senhasegura123",
- "maria"
- )
+ assert {:ok, accepted} =
+ Maraca.accept_invite(invited.invite_token, %{
+ "username" => "maria",
+ "display_name" => "Maria Silva",
+ "password" => "senhasegura123",
+ "password_confirmation" => "senhasegura123"
+ })
+
+ assert accepted.username == "maria"
+ assert accepted.display_name == "Maria Silva"
+ assert Maraca.activated?(accepted)
+ assert is_nil(accepted.invite_token_hash)
+ end
- assert confirmed.username == "maria"
- assert Maraca.email_confirmed?(confirmed)
- assert is_nil(confirmed.email_confirmation_token_hash)
+ test "the username is normalized to a handle on accept", %{tekoa: tekoa, zelador: zelador} do
+ {:ok, invited} = Maraca.invite_user(zelador, tekoa)
+
+ assert {:ok, accepted} =
+ Maraca.accept_invite(invited.invite_token, %{
+ "username" => " Maria ",
+ "password" => "senhasegura123",
+ "password_confirmation" => "senhasegura123"
+ })
+
+ assert accepted.username == "maria"
end
- test "members cannot invite", %{tekoa: tekoa} do
- member = confirmed_ava_fixture(tekoa)
+ test "a morador cannot invite", %{tekoa: tekoa} do
+ morador = active_ava_fixture(tekoa)
- assert {:error, :not_admin} = Maraca.invite_user(member, tekoa, "x@example.com")
+ assert {:error, :not_zelador} = Maraca.invite_user(morador, tekoa)
end
test "unknown token is rejected" do
assert {:error, :invalid_token} =
- Maraca.confirm_email("token_invalido", "senha1234", "senha1234", "alguem")
+ Maraca.accept_invite("token_invalido", %{
+ "username" => "alguem",
+ "password" => "senha1234",
+ "password_confirmation" => "senha1234"
+ })
end
- test "expired invitation token is rejected", %{tekoa: tekoa, admin: admin} do
- {:ok, invited} = Maraca.invite_user(admin, tekoa, "tarde@example.com")
+ test "expired invite token is rejected", %{tekoa: tekoa, zelador: zelador} do
+ {:ok, invited} = Maraca.invite_user(zelador, tekoa)
eight_days_ago = DateTime.add(DateTime.utc_now(), -8, :day)
- invited
- |> Ecto.Changeset.change(email_confirmation_sent_at: eight_days_ago)
- |> Repo.update!()
+ Repo.with_tekoa(tekoa.public_id, fn ->
+ {:ok,
+ invited
+ |> Ecto.Changeset.change(invite_sent_at: eight_days_ago)
+ |> Repo.update!()}
+ end)
assert {:error, :invalid_token} =
- Maraca.confirm_email(
- invited.email_confirmation_token,
- "senhasegura123",
- "senhasegura123",
- "tarde"
- )
+ Maraca.accept_invite(invited.invite_token, %{
+ "username" => "tarde",
+ "password" => "senhasegura123",
+ "password_confirmation" => "senhasegura123"
+ })
end
end
describe "update_tekoa_quota/2" do
- test "admin updates the storage quota" do
+ test "a zelador updates the storage quota" do
tekoa = tekoa_fixture()
- admin = admin_fixture(tekoa)
- scope = Scope.new(admin, tekoa)
+ zelador = zelador_fixture(tekoa)
+ scope = Scope.new(zelador, tekoa)
assert {:ok, updated} = Maraca.update_tekoa_quota(scope, 5_000)
assert updated.storage_quota_bytes == 5_000
end
- test "members are rejected" do
+ test "moradores are rejected" do
tekoa = tekoa_fixture()
- member = confirmed_ava_fixture(tekoa)
- scope = Scope.new(member, tekoa)
+ morador = active_ava_fixture(tekoa)
+ scope = Scope.new(morador, tekoa)
assert {:error, :unauthorized} = Maraca.update_tekoa_quota(scope, 5_000)
end
test "a non-positive quota fails validation" do
tekoa = tekoa_fixture()
- admin = admin_fixture(tekoa)
- scope = Scope.new(admin, tekoa)
+ zelador = zelador_fixture(tekoa)
+ scope = Scope.new(zelador, tekoa)
assert {:error, changeset} = Maraca.update_tekoa_quota(scope, 0)
assert "must be greater than 0" in errors_on(changeset).storage_quota_bytes
@@ -123,35 +147,39 @@ defmodule Taina.MaracaTest do
describe "authenticate/3" do
setup do
tekoa = tekoa_fixture()
- ava = confirmed_ava_fixture(tekoa, %{email: "login@example.com"})
+ ava = active_ava_fixture(tekoa, %{username: "maria"})
%{tekoa: tekoa, ava: ava}
end
- test "valid credentials authenticate", %{tekoa: tekoa, ava: ava} do
- assert {:ok, authenticated} = Maraca.authenticate("login@example.com", "senhasegura123", tekoa)
+ test "valid credentials authenticate by username", %{tekoa: tekoa, ava: ava} do
+ assert {:ok, authenticated} = Maraca.authenticate("maria", "senhasegura123", tekoa)
+ assert authenticated.id == ava.id
+ end
+
+ test "login is case-insensitive on the username", %{tekoa: tekoa, ava: ava} do
+ assert {:ok, authenticated} = Maraca.authenticate(" MARIA ", "senhasegura123", tekoa)
assert authenticated.id == ava.id
end
test "wrong password is rejected", %{tekoa: tekoa} do
- assert {:error, :invalid_credentials} = Maraca.authenticate("login@example.com", "errada123", tekoa)
+ assert {:error, :invalid_credentials} = Maraca.authenticate("maria", "errada123", tekoa)
end
- test "unknown email is rejected", %{tekoa: tekoa} do
- assert {:error, :invalid_credentials} = Maraca.authenticate("nao@existe.com", "qualquer123", tekoa)
+ test "unknown username is rejected", %{tekoa: tekoa} do
+ assert {:error, :invalid_credentials} = Maraca.authenticate("ninguem", "qualquer123", tekoa)
end
- test "unconfirmed account cannot log in", %{tekoa: tekoa} do
- ava_fixture(tekoa, %{email: "pendente@example.com"})
+ test "a pending invite (no password) cannot log in", %{tekoa: tekoa} do
+ ava_fixture(tekoa, %{username: "pendente"})
- assert {:error, :email_not_confirmed} =
- Maraca.authenticate("pendente@example.com", "qualquer123", tekoa)
+ assert {:error, :invalid_credentials} = Maraca.authenticate("pendente", "qualquer123", tekoa)
end
end
describe "sessions" do
setup do
tekoa = tekoa_fixture()
- %{tekoa: tekoa, ava: confirmed_ava_fixture(tekoa)}
+ %{tekoa: tekoa, ava: active_ava_fixture(tekoa)}
end
test "create_session/1 exposes only public ids", %{tekoa: tekoa, ava: ava} do
@@ -159,7 +187,7 @@ defmodule Taina.MaracaTest do
assert session.ava_id == ava.public_id
assert session.tekoa_id == tekoa.public_id
- assert session.role == :member
+ assert session.role == :morador
end
test "get_session_user/1 loads the ava with tekoa", %{ava: ava} do
@@ -185,32 +213,43 @@ defmodule Taina.MaracaTest do
end
end
- describe "password reset" do
+ describe "mint_reset_link/2 (recuperação mediada pelo zelador)" do
setup do
tekoa = tekoa_fixture()
- %{tekoa: tekoa, ava: confirmed_ava_fixture(tekoa, %{email: "reset@example.com"})}
+ zelador = zelador_fixture(tekoa)
+ member = active_ava_fixture(tekoa, %{username: "maria"})
+ %{tekoa: tekoa, zelador: zelador, member: member}
end
- test "full reset flow", %{tekoa: tekoa} do
- assert {:ok, %Ava{} = with_token} = Maraca.request_password_reset("reset@example.com", tekoa)
+ test "zelador mints a link and the member sets a new password", %{tekoa: tekoa, zelador: zelador, member: member} do
+ scope = Scope.new(zelador, tekoa)
+
+ assert {:ok, %Ava{} = with_token} = Maraca.mint_reset_link(scope, member)
assert with_token.reset_token
assert {:ok, _} = Maraca.reset_password(with_token.reset_token, "novasenha123", "novasenha123")
- assert {:ok, _} = Maraca.authenticate("reset@example.com", "novasenha123", tekoa)
+ assert {:ok, _} = Maraca.authenticate("maria", "novasenha123", tekoa)
end
- test "unknown email gets the same shaped reply", %{tekoa: tekoa} do
- assert {:ok, :email_sent} = Maraca.request_password_reset("ghost@example.com", tekoa)
+ test "a morador cannot mint a reset link", %{tekoa: tekoa, member: member} do
+ morador = active_ava_fixture(tekoa)
+ scope = Scope.new(morador, tekoa)
+
+ assert {:error, :unauthorized} = Maraca.mint_reset_link(scope, member)
end
- test "expired reset token is rejected", %{tekoa: tekoa} do
- {:ok, with_token} = Maraca.request_password_reset("reset@example.com", tekoa)
+ test "expired reset token is rejected", %{tekoa: tekoa, zelador: zelador, member: member} do
+ scope = Scope.new(zelador, tekoa)
+ {:ok, with_token} = Maraca.mint_reset_link(scope, member)
two_hours_ago = DateTime.add(DateTime.utc_now(), -2, :hour)
- with_token
- |> Ecto.Changeset.change(reset_token_sent_at: two_hours_ago)
- |> Repo.update!()
+ Repo.with_tekoa(tekoa.public_id, fn ->
+ {:ok,
+ with_token
+ |> Ecto.Changeset.change(reset_token_sent_at: two_hours_ago)
+ |> Repo.update!()}
+ end)
assert {:error, :invalid_token} =
Maraca.reset_password(with_token.reset_token, "novasenha123", "novasenha123")
@@ -220,8 +259,8 @@ defmodule Taina.MaracaTest do
describe "authorize?/4 and permissions" do
setup do
tekoa = tekoa_fixture()
- owner = confirmed_ava_fixture(tekoa)
- other = confirmed_ava_fixture(tekoa)
+ owner = active_ava_fixture(tekoa)
+ other = active_ava_fixture(tekoa)
{:ok, ybira_file} = Ybira.upload(Scope.new(owner, tekoa), tmp_upload_fixture())
%{tekoa: tekoa, owner: owner, other: other, ybira_file: ybira_file}
end
@@ -231,11 +270,11 @@ defmodule Taina.MaracaTest do
assert Maraca.authorize?(owner, :delete, "ybira_file", ybira_file.public_id)
end
- test "others are denied by default — including admins", %{tekoa: tekoa, other: other, ybira_file: ybira_file} do
- admin = admin_fixture(tekoa)
+ test "others are denied by default, including zeladores", %{tekoa: tekoa, other: other, ybira_file: ybira_file} do
+ zelador = zelador_fixture(tekoa)
refute Maraca.authorize?(other, :read, "ybira_file", ybira_file.public_id)
- refute Maraca.authorize?(admin, :read, "ybira_file", ybira_file.public_id)
+ refute Maraca.authorize?(zelador, :read, "ybira_file", ybira_file.public_id)
end
test "explicit permission grants access", %{owner: owner, other: other, ybira_file: ybira_file} do
@@ -273,7 +312,7 @@ defmodule Taina.MaracaTest do
end
test "third parties cannot revoke", %{tekoa: tekoa, owner: owner, other: other, ybira_file: ybira_file} do
- stranger = confirmed_ava_fixture(tekoa)
+ stranger = active_ava_fixture(tekoa)
{:ok, _} = Maraca.grant_permission(owner, other, :read, "ybira_file", ybira_file.public_id)
assert {:error, :not_authorized} =
@@ -292,60 +331,60 @@ defmodule Taina.MaracaTest do
describe "access requests" do
setup do
tekoa = tekoa_fixture()
- owner = confirmed_ava_fixture(tekoa)
- admin = admin_fixture(tekoa)
+ owner = active_ava_fixture(tekoa)
+ zelador = zelador_fixture(tekoa)
{:ok, ybira_file} = Ybira.upload(Scope.new(owner, tekoa), tmp_upload_fixture())
- %{tekoa: tekoa, owner: owner, admin: admin, ybira_file: ybira_file}
+ %{tekoa: tekoa, owner: owner, zelador: zelador, ybira_file: ybira_file}
end
- test "admin requests, owner approves, read permission appears", %{
+ test "zelador requests, owner approves, read permission appears", %{
owner: owner,
- admin: admin,
+ zelador: zelador,
ybira_file: ybira_file
} do
assert {:ok, %AccessRequest{status: :pending} = request} =
- Maraca.request_access(admin, owner, "ybira_file", ybira_file.public_id, "Ticket #123")
+ Maraca.request_access(zelador, owner, "ybira_file", ybira_file.public_id, "Ticket #123")
assert {:ok, %Permission{action: :read}} = Maraca.approve_access_request(owner, request.id)
- assert Maraca.authorize?(admin, :read, "ybira_file", ybira_file.public_id)
+ assert Maraca.authorize?(zelador, :read, "ybira_file", ybira_file.public_id)
end
- test "owner can deny; request stays for audit", %{owner: owner, admin: admin, ybira_file: ybira_file} do
- {:ok, request} = Maraca.request_access(admin, owner, "ybira_file", ybira_file.public_id, "Auditoria")
+ test "owner can deny; request stays for audit", %{owner: owner, zelador: zelador, ybira_file: ybira_file} do
+ {:ok, request} = Maraca.request_access(zelador, owner, "ybira_file", ybira_file.public_id, "Auditoria")
assert {:ok, %AccessRequest{status: :denied}} = Maraca.deny_access_request(owner, request.id)
- refute Maraca.authorize?(admin, :read, "ybira_file", ybira_file.public_id)
+ refute Maraca.authorize?(zelador, :read, "ybira_file", ybira_file.public_id)
assert {:error, :invalid_status} = Maraca.deny_access_request(owner, request.id)
end
- test "members cannot request access", %{tekoa: tekoa, owner: owner, ybira_file: ybira_file} do
- member = confirmed_ava_fixture(tekoa)
+ test "moradores cannot request access", %{tekoa: tekoa, owner: owner, ybira_file: ybira_file} do
+ morador = active_ava_fixture(tekoa)
- assert {:error, :not_admin} =
- Maraca.request_access(member, owner, "ybira_file", ybira_file.public_id, "Por favor")
+ assert {:error, :not_zelador} =
+ Maraca.request_access(morador, owner, "ybira_file", ybira_file.public_id, "Por favor")
end
- test "admin with existing permission cannot re-request", %{owner: owner, admin: admin, ybira_file: ybira_file} do
- {:ok, _} = Maraca.grant_permission(owner, admin, :read, "ybira_file", ybira_file.public_id)
+ test "zelador with existing permission cannot re-request", %{owner: owner, zelador: zelador, ybira_file: ybira_file} do
+ {:ok, _} = Maraca.grant_permission(owner, zelador, :read, "ybira_file", ybira_file.public_id)
assert {:error, :already_has_access} =
- Maraca.request_access(admin, owner, "ybira_file", ybira_file.public_id, "De novo")
+ Maraca.request_access(zelador, owner, "ybira_file", ybira_file.public_id, "De novo")
end
- test "only the owner decides", %{tekoa: tekoa, owner: owner, admin: admin, ybira_file: ybira_file} do
- stranger = confirmed_ava_fixture(tekoa)
- {:ok, request} = Maraca.request_access(admin, owner, "ybira_file", ybira_file.public_id, "Ticket")
+ test "only the owner decides", %{tekoa: tekoa, owner: owner, zelador: zelador, ybira_file: ybira_file} do
+ stranger = active_ava_fixture(tekoa)
+ {:ok, request} = Maraca.request_access(zelador, owner, "ybira_file", ybira_file.public_id, "Ticket")
assert {:error, :not_owner} = Maraca.approve_access_request(stranger, request.id)
assert {:error, :not_found} = Maraca.approve_access_request(owner, 0)
end
- test "list_access_requests/1 shows pending for the owner", %{owner: owner, admin: admin, ybira_file: ybira_file} do
- {:ok, request} = Maraca.request_access(admin, owner, "ybira_file", ybira_file.public_id, "Ticket")
+ test "list_access_requests/1 shows pending for the owner", %{owner: owner, zelador: zelador, ybira_file: ybira_file} do
+ {:ok, request} = Maraca.request_access(zelador, owner, "ybira_file", ybira_file.public_id, "Ticket")
assert [pending] = Maraca.list_access_requests(owner)
assert pending.id == request.id
- assert pending.requester.id == admin.id
+ assert pending.requester.id == zelador.id
end
end
end
diff --git a/test/taina/rls_isolation_test.exs b/test/taina/rls_isolation_test.exs
index 833cb98..204f5ad 100644
--- a/test/taina/rls_isolation_test.exs
+++ b/test/taina/rls_isolation_test.exs
@@ -3,20 +3,19 @@ defmodule Taina.RlsIsolationTest do
Prova que o isolamento RLS funciona de verdade no banco.
O usuário de teste padrão (`postgres`) é superuser e **ignora RLS por
- definição** — por isso este teste cria uma role restrita (`taina_rls_probe`)
+ definição**, por isso este teste cria uma role restrita (`taina_rls_probe`)
e conecta via Postgrex puro, fora do sandbox do Ecto. Os dados são
comitados de verdade e limpos ao final; o módulo precisa ser `async: false`
porque o índice `single_tekoa_enforcement` permite apenas uma Tekoa viva.
Com modo single-tekoa não há segunda Tekoa para testar vazamento cruzado;
- o que se valida aqui é o mecanismo: sem contexto → zero linhas; contexto
- correto → linhas da Tekoa; contexto errado → zero linhas.
+ o que se valida aqui é o mecanismo: sem contexto -> zero linhas; contexto
+ correto -> linhas da Tekoa; contexto errado -> zero linhas.
"""
use ExUnit.Case, async: false
@probe_role "taina_rls_probe"
- @probe_email "rls@probe.test"
setup_all do
admin_opts = conn_opts()
@@ -57,10 +56,10 @@ defmodule Taina.RlsIsolationTest do
Postgrex.query!(
admin,
"""
- INSERT INTO maraca.avas (tekoa_id, username, email, inserted_at, updated_at)
- VALUES ($1, 'rlsprobe', $2, now(), now())
+ INSERT INTO maraca.avas (tekoa_id, username, inserted_at, updated_at)
+ VALUES ($1, 'rlsprobe', now(), now())
""",
- [tekoa_id, @probe_email]
+ [tekoa_id]
)
probe_opts =
@@ -74,7 +73,7 @@ defmodule Taina.RlsIsolationTest do
on_exit(fn ->
{:ok, cleaner} = Postgrex.start_link(admin_opts)
- Postgrex.query!(cleaner, "DELETE FROM maraca.avas WHERE email = $1", [@probe_email])
+ Postgrex.query!(cleaner, "DELETE FROM maraca.avas WHERE username = 'rlsprobe'", [])
Postgrex.query!(cleaner, "DELETE FROM maraca.tekoas WHERE public_id = $1", [tekoa_public_id])
GenServer.stop(cleaner)
end)
@@ -116,7 +115,7 @@ defmodule Taina.RlsIsolationTest do
assert count == 0
end
- test "superuser bypasses RLS — production must not connect as superuser", %{
+ test "superuser bypasses RLS, production must not connect as superuser", %{
admin: admin,
tekoa_public_id: tekoa_public_id
} do
diff --git a/test/taina/ybira/rendition_test.exs b/test/taina/ybira/rendition_test.exs
index bc85ac1..e797623 100644
--- a/test/taina/ybira/rendition_test.exs
+++ b/test/taina/ybira/rendition_test.exs
@@ -8,7 +8,7 @@ defmodule Taina.Ybira.Workers.RenditionTest do
@moduletag capture_log: true
# Oban roda `:inline` em teste, então o job de rendition acontece durante o
- # `upload/3` — daí podermos reler o arquivo logo em seguida.
+ # `upload/3`, daí podermos reler o arquivo logo em seguida.
test "image upload fills dimensions, EXIF slot and thumbnail paths" do
scope = scope_fixture()
@@ -18,7 +18,7 @@ defmodule Taina.Ybira.Workers.RenditionTest do
assert photo.metadata["width"] == 50
assert photo.metadata["height"] == 40
- # imagem gerada não tem EXIF → taken_at fica nulo (fallback é o upload)
+ # imagem gerada não tem EXIF -> taken_at fica nulo (fallback é o upload)
assert Map.has_key?(photo.metadata, "taken_at")
assert is_nil(photo.metadata["taken_at"])
assert %{"sm" => sm, "md" => md} = photo.metadata["thumbnails"]
diff --git a/test/taina/ybira_stats_test.exs b/test/taina/ybira_stats_test.exs
new file mode 100644
index 0000000..7b8cbb5
--- /dev/null
+++ b/test/taina/ybira_stats_test.exs
@@ -0,0 +1,54 @@
+defmodule Taina.YbiraStatsTest do
+ use Taina.DataCase, async: true
+
+ import Taina.Fixtures
+
+ alias Taina.Ybira
+
+ setup do
+ %{scope: scope_fixture()}
+ end
+
+ describe "list_recent/2" do
+ test "devolve os últimos envios, mais novos primeiro, fora da lixeira", %{scope: scope} do
+ {:ok, old} = Ybira.upload(scope, tmp_upload_fixture("um", "a.txt"))
+ {:ok, deleted} = Ybira.upload(scope, tmp_upload_fixture("dois", "b.txt"))
+ {:ok, newest} = Ybira.upload(scope, tmp_upload_fixture("três", "c.txt"))
+ {:ok, _} = Ybira.delete_file(scope, deleted.public_id)
+
+ assert {:ok, [first, second]} = Ybira.list_recent(scope, limit: 2)
+ assert first.id == newest.id
+ assert second.id == old.id
+ end
+ end
+
+ describe "count_files/1 e count_photos/1" do
+ test "contam ativos; fotos só image/*, vídeo não conta como foto", %{scope: scope} do
+ {:ok, _doc} = Ybira.upload(scope, tmp_upload_fixture())
+ {:ok, _img} = Ybira.upload(scope, tmp_image_fixture())
+ {:ok, _vid} = Ybira.upload(scope, tmp_video_fixture())
+ {:ok, trashed} = Ybira.upload(scope, tmp_upload_fixture("x", "x.txt"))
+ {:ok, _} = Ybira.delete_file(scope, trashed.public_id)
+
+ assert {:ok, 3} = Ybira.count_files(scope)
+ assert {:ok, 1} = Ybira.count_photos(scope)
+ end
+ end
+
+ describe "storage_stats_by_kind/1" do
+ test "agrupa bytes por categoria de mídia", %{scope: scope} do
+ {:ok, doc} = Ybira.upload(scope, tmp_upload_fixture("documento", "doc.txt"))
+ {:ok, img} = Ybira.upload(scope, tmp_image_fixture())
+
+ assert {:ok, by_kind} = Ybira.storage_stats_by_kind(scope)
+ assert by_kind.photos == img.file_size_bytes
+ assert by_kind.documents == doc.file_size_bytes
+ assert by_kind.videos == 0
+ assert by_kind.others == 0
+ end
+
+ test "tudo zerado sem arquivos", %{scope: scope} do
+ assert {:ok, %{photos: 0, videos: 0, documents: 0, others: 0}} = Ybira.storage_stats_by_kind(scope)
+ end
+ end
+end
diff --git a/test/taina/ybira_test.exs b/test/taina/ybira_test.exs
index 0799d1b..6837abc 100644
--- a/test/taina/ybira_test.exs
+++ b/test/taina/ybira_test.exs
@@ -264,10 +264,11 @@ defmodule Taina.YbiraTest do
assert folder.name == "uma pasta"
assert length(files) == 2
- assert is_binary(cursor)
+ # Paginação por offset: next_cursor é o próximo deslocamento (inteiro).
+ assert cursor == 2
assert {:ok, %{files: [_last], next_cursor: nil}} =
- Ybira.list_folder_contents(scope, nil, limit: 2, after_cursor: cursor)
+ Ybira.list_folder_contents(scope, nil, limit: 2, offset: cursor)
end
end
diff --git a/test/taina_web/controllers/session_controller_test.exs b/test/taina_web/controllers/session_controller_test.exs
new file mode 100644
index 0000000..123ff50
--- /dev/null
+++ b/test/taina_web/controllers/session_controller_test.exs
@@ -0,0 +1,47 @@
+defmodule TainaWeb.SessionControllerTest do
+ use TainaWeb.ConnCase, async: true
+
+ import Taina.Fixtures
+
+ @password "frase-longa-que-so-eu-lembro"
+
+ describe "com instância inicializada" do
+ setup do
+ tekoa = tekoa_fixture()
+ ava = active_ava_fixture(tekoa, %{username: "maria", password: @password})
+ %{tekoa: tekoa, ava: ava}
+ end
+
+ test "login por nome de usuário cria sessão e vai para a home", %{conn: conn, ava: ava} do
+ conn = post(conn, ~p"/login", %{"username" => ava.username, "password" => @password})
+
+ assert redirected_to(conn) == ~p"/"
+ assert get_session(conn, :ava_id) == ava.public_id
+ end
+
+ test "senha errada volta para o login com aviso", %{conn: conn, ava: ava} do
+ conn = post(conn, ~p"/login", %{"username" => ava.username, "password" => "senha-errada-123"})
+
+ assert redirected_to(conn) == ~p"/login"
+ assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "incorretos"
+ refute get_session(conn, :ava_id)
+ end
+
+ test "logout derruba a sessão", %{conn: conn, ava: ava} do
+ conn =
+ conn
+ |> log_in(ava)
+ |> delete(~p"/logout")
+
+ assert redirected_to(conn) == ~p"/login"
+ refute get_session(conn, :ava_id)
+ end
+ end
+
+ test "login sem comunidade ainda criada redireciona para o setup", %{conn: conn} do
+ conn = post(conn, ~p"/login", %{"username" => "ninguem", "password" => @password})
+
+ assert redirected_to(conn) == ~p"/setup"
+ refute get_session(conn, :ava_id)
+ end
+end
diff --git a/test/taina_web/controllers/setup_controller_test.exs b/test/taina_web/controllers/setup_controller_test.exs
new file mode 100644
index 0000000..99a4c26
--- /dev/null
+++ b/test/taina_web/controllers/setup_controller_test.exs
@@ -0,0 +1,43 @@
+defmodule TainaWeb.SetupControllerTest do
+ use TainaWeb.ConnCase, async: true
+
+ import Taina.Fixtures
+
+ alias Taina.Maraca
+
+ @params %{
+ "setup" => %{
+ "community_name" => "Quilombo do Café",
+ "username" => "ana",
+ "display_name" => "Ana Oliveira",
+ "password" => "frase-longa-segura",
+ "password_confirmation" => "frase-longa-segura"
+ }
+ }
+
+ test "bootstrap cria a Tekoa + zelador e já entra logado", %{conn: conn} do
+ conn = post(conn, ~p"/setup", @params)
+
+ assert redirected_to(conn) == ~p"/"
+ assert get_session(conn, :ava_id)
+ assert Maraca.bootstrapped?()
+ assert {:ok, tekoa} = Maraca.get_tekoa()
+ assert tekoa.name == "Quilombo do Café"
+ end
+
+ test "instância já inicializada manda para o login", %{conn: conn} do
+ tekoa_fixture()
+
+ conn = post(conn, ~p"/setup", @params)
+ assert redirected_to(conn) == ~p"/login"
+ end
+
+ test "dados inválidos voltam para o wizard com aviso", %{conn: conn} do
+ params = put_in(@params, ["setup", "password"], "curta")
+ conn = post(conn, ~p"/setup", params)
+
+ assert redirected_to(conn) == ~p"/setup"
+ assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Revise"
+ refute Maraca.bootstrapped?()
+ end
+end
diff --git a/test/taina_web/live/files_live_test.exs b/test/taina_web/live/files_live_test.exs
new file mode 100644
index 0000000..5f705b2
--- /dev/null
+++ b/test/taina_web/live/files_live_test.exs
@@ -0,0 +1,74 @@
+defmodule TainaWeb.FilesLiveTest do
+ use TainaWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+ import Taina.Fixtures
+
+ alias Taina.Scope
+ alias Taina.Ybira
+
+ setup do
+ tekoa = tekoa_fixture()
+ ava = active_ava_fixture(tekoa)
+ %{scope: Scope.new(ava, tekoa), ava: ava}
+ end
+
+ test "lista pastas e arquivos da raiz", %{conn: conn, scope: scope, ava: ava} do
+ {:ok, folder} = Ybira.create_folder(scope, %{name: "Documentos"})
+ {:ok, file} = Ybira.upload(scope, tmp_upload_fixture("oi", "estatuto.txt"))
+
+ {:ok, _lv, html} = conn |> log_in(ava) |> live(~p"/arquivos")
+
+ assert html =~ folder.name
+ assert html =~ file.original_filename
+ assert html =~ "2 itens"
+ end
+
+ test "navega para dentro de uma pasta", %{conn: conn, scope: scope, ava: ava} do
+ {:ok, folder} = Ybira.create_folder(scope, %{name: "Fotos da comunidade"})
+
+ {:ok, _lv, html} = conn |> log_in(ava) |> live(~p"/arquivos/pasta/#{folder.public_id}")
+
+ assert html =~ "Fotos da comunidade"
+ assert html =~ "Pasta vazia"
+ end
+
+ test "cria pasta pelo modal", %{conn: conn, ava: ava} do
+ {:ok, lv, _html} = conn |> log_in(ava) |> live(~p"/arquivos")
+
+ lv |> element("button[phx-value-modal=new-folder]") |> render_click()
+ lv |> form("#new-folder-modal form") |> render_submit(%{"folder" => %{"name" => "Vídeos"}})
+
+ assert render(lv) =~ "Vídeos"
+ end
+
+ test "renomeia pasta", %{conn: conn, scope: scope, ava: ava} do
+ {:ok, folder} = Ybira.create_folder(scope, %{name: "Rascunho"})
+ {:ok, lv, _html} = conn |> log_in(ava) |> live(~p"/arquivos")
+
+ lv
+ |> element(~s(button[phx-click*="open-rename"][phx-click*="#{folder.public_id}"]))
+ |> render_click()
+
+ lv |> form("#rename-modal form") |> render_submit(%{"folder" => %{"name" => "Definitivo"}})
+
+ html = render(lv)
+ assert html =~ "Definitivo"
+ refute html =~ "Rascunho"
+ end
+
+ test "exclui arquivo para a lixeira", %{conn: conn, scope: scope, ava: ava} do
+ {:ok, file} = Ybira.upload(scope, tmp_upload_fixture("oi", "velho.txt"))
+ {:ok, lv, _html} = conn |> log_in(ava) |> live(~p"/arquivos")
+
+ lv
+ |> element(~s(button[phx-click*="ask-delete"][phx-click*="#{file.public_id}"]))
+ |> render_click()
+
+ lv |> element(~s(button[phx-click="confirm-delete"])) |> render_click()
+
+ refute render(lv) =~ "velho.txt"
+ assert {:ok, %{items: [trashed]}} = Ybira.list_trash(scope)
+ assert trashed.id == file.id
+ end
+end
diff --git a/test/taina_web/live/gallery_live_test.exs b/test/taina_web/live/gallery_live_test.exs
new file mode 100644
index 0000000..87708a8
--- /dev/null
+++ b/test/taina_web/live/gallery_live_test.exs
@@ -0,0 +1,62 @@
+defmodule TainaWeb.GalleryLiveTest do
+ use TainaWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+ import Taina.Fixtures
+
+ alias Taina.Scope
+ alias Taina.Ybira
+
+ setup do
+ tekoa = tekoa_fixture()
+ ava = active_ava_fixture(tekoa)
+ scope = Scope.new(ava, tekoa)
+
+ {:ok, first} = Ybira.upload(scope, tmp_image_fixture(filename: "primeira.jpg"))
+ {:ok, second} = Ybira.upload(scope, tmp_image_fixture(filename: "segunda.jpg"))
+
+ %{scope: scope, ava: ava, first: first, second: second}
+ end
+
+ test "grade lista thumbnails das fotos", %{conn: conn, ava: ava, first: first, second: second} do
+ {:ok, _lv, html} = conn |> log_in(ava) |> live(~p"/fotos")
+
+ assert html =~ "/files/#{first.public_id}/thumbnail/sm"
+ assert html =~ "/files/#{second.public_id}/thumbnail/sm"
+ end
+
+ test "linha do tempo agrupa por dia", %{conn: conn, ava: ava} do
+ {:ok, _lv, html} = conn |> log_in(ava) |> live(~p"/fotos/linha-do-tempo")
+
+ assert html =~ "Hoje"
+ assert html =~ "2 fotos"
+ end
+
+ test "visualizador navega entre fotos", %{conn: conn, ava: ava, first: first, second: second} do
+ {:ok, lv, _html} = conn |> log_in(ava) |> live(~p"/fotos")
+
+ # abre a mais recente (segunda) a partir da grade
+ html = lv |> element(~s(a[href="/fotos/#{second.public_id}"])) |> render_click()
+ assert html =~ "1 de 2"
+ assert html =~ "segunda.jpg"
+
+ # próxima = mais antiga
+ lv |> element("button.viewer__nav--next") |> render_click()
+ assert_patch(lv, ~p"/fotos/#{first.public_id}")
+ assert render(lv) =~ "primeira.jpg"
+
+ # fechar volta para a grade
+ lv |> element("button[phx-click=close]") |> render_click()
+ assert_patch(lv, ~p"/fotos")
+ end
+
+ test "excluir manda a foto para a lixeira e volta para a grade", %{conn: conn, ava: ava, scope: scope, second: second} do
+ {:ok, lv, _html} = conn |> log_in(ava) |> live(~p"/fotos/#{second.public_id}")
+
+ lv |> element("button[phx-click=ask-delete]") |> render_click()
+ lv |> element("button[phx-click=confirm-delete]") |> render_click()
+ assert_patch(lv, ~p"/fotos")
+
+ assert {:error, :not_found} = Ybira.get_file(scope, second.public_id)
+ end
+end
diff --git a/test/taina_web/live/home_live_test.exs b/test/taina_web/live/home_live_test.exs
new file mode 100644
index 0000000..4a36a50
--- /dev/null
+++ b/test/taina_web/live/home_live_test.exs
@@ -0,0 +1,27 @@
+defmodule TainaWeb.HomeLiveTest do
+ use TainaWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+ import Taina.Fixtures
+
+ alias Taina.Scope
+ alias Taina.Ybira
+
+ test "sem sessão, redireciona para o login", %{conn: conn} do
+ assert {:error, {:redirect, %{to: "/login"}}} = live(conn, ~p"/")
+ end
+
+ test "mostra comunidade, armazenamento e recentes", %{conn: conn} do
+ tekoa = tekoa_fixture(%{name: "Quilombo do Café"})
+ ava = active_ava_fixture(tekoa)
+ scope = Scope.new(ava, tekoa)
+ {:ok, file} = Ybira.upload(scope, tmp_upload_fixture("oi", "ata.txt"))
+
+ {:ok, _lv, html} = conn |> log_in(ava) |> live(~p"/")
+
+ assert html =~ "Quilombo do Café"
+ assert html =~ "Armazenamento"
+ assert html =~ "Recentes"
+ assert html =~ file.original_filename
+ end
+end
diff --git a/test/taina_web/live/invite_flow_test.exs b/test/taina_web/live/invite_flow_test.exs
new file mode 100644
index 0000000..65424fa
--- /dev/null
+++ b/test/taina_web/live/invite_flow_test.exs
@@ -0,0 +1,67 @@
+defmodule TainaWeb.InviteFlowTest do
+ use TainaWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+ import Taina.Fixtures
+
+ alias Taina.Maraca
+
+ setup do
+ tekoa = tekoa_fixture()
+ zelador = zelador_fixture(tekoa)
+ %{tekoa: tekoa, zelador: zelador}
+ end
+
+ test "morador comum não acessa a tela de convite", %{conn: conn, tekoa: tekoa} do
+ morador = active_ava_fixture(tekoa)
+
+ assert {:error, {:redirect, %{to: "/"}}} = conn |> log_in(morador) |> live(~p"/membros/convidar")
+ end
+
+ test "zelador gera convite com link + QR, sem e-mail", %{conn: conn, zelador: zelador} do
+ {:ok, lv, _html} = conn |> log_in(zelador) |> live(~p"/membros/convidar")
+
+ html = lv |> element("form") |> render_submit(%{})
+
+ assert html =~ "/convite/"
+ assert html =~ " %{
+ "username" => "joao",
+ "display_name" => "João Mendes",
+ "password" => "frase-longa-segura",
+ "password_confirmation" => "frase-longa-segura"
+ }
+ })
+
+ assert redirected_to(conn) == ~p"/"
+ assert get_session(conn, :ava_id)
+ end
+
+ test "token inválido no aceite volta para o login com aviso", %{conn: conn} do
+ conn =
+ post(conn, ~p"/convite/token-invalido", %{
+ "account" => %{
+ "username" => "joao",
+ "password" => "frase-longa-segura",
+ "password_confirmation" => "frase-longa-segura"
+ }
+ })
+
+ assert redirected_to(conn) == ~p"/login"
+ assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "expirou"
+ end
+end
diff --git a/test/taina_web/live/login_live_test.exs b/test/taina_web/live/login_live_test.exs
new file mode 100644
index 0000000..1a27364
--- /dev/null
+++ b/test/taina_web/live/login_live_test.exs
@@ -0,0 +1,28 @@
+defmodule TainaWeb.LoginLiveTest do
+ use TainaWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+ import Taina.Fixtures
+
+ test "instância virgem redireciona para o setup", %{conn: conn} do
+ assert {:error, {:redirect, %{to: "/setup"}}} = live(conn, ~p"/login")
+ end
+
+ test "quem já está logado vai direto para a home", %{conn: conn} do
+ tekoa = tekoa_fixture()
+ ava = active_ava_fixture(tekoa)
+
+ assert {:error, {:redirect, %{to: "/"}}} = conn |> log_in(ava) |> live(~p"/login")
+ end
+
+ test "renderiza o form de login apontando para o POST /login", %{conn: conn} do
+ tekoa_fixture()
+
+ {:ok, lv, html} = live(conn, ~p"/login")
+
+ assert html =~ "Bem-vindo de volta"
+ assert has_element?(lv, ~s(form[action="/login"]))
+ assert has_element?(lv, ~s(input[name="username"]))
+ assert has_element?(lv, ~s(input[name="password"]))
+ end
+end
diff --git a/test/taina_web/live/members_live_test.exs b/test/taina_web/live/members_live_test.exs
new file mode 100644
index 0000000..4809dee
--- /dev/null
+++ b/test/taina_web/live/members_live_test.exs
@@ -0,0 +1,46 @@
+defmodule TainaWeb.MembersLiveTest do
+ use TainaWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+ import Taina.Fixtures
+
+ setup do
+ tekoa = tekoa_fixture()
+ zelador = zelador_fixture(tekoa, %{username: "ana", display_name: "Ana Oliveira"})
+ member = active_ava_fixture(tekoa, %{username: "joao", display_name: "João Mendes"})
+ %{tekoa: tekoa, zelador: zelador, member: member}
+ end
+
+ test "lista moradores com papel e estado", %{conn: conn, zelador: zelador} do
+ {:ok, _lv, html} = conn |> log_in(zelador) |> live(~p"/membros")
+
+ assert html =~ "Ana Oliveira"
+ assert html =~ "João Mendes"
+ assert html =~ "Você"
+ assert html =~ "2 pessoas"
+ end
+
+ test "busca filtra a lista", %{conn: conn, zelador: zelador} do
+ {:ok, lv, _html} = conn |> log_in(zelador) |> live(~p"/membros")
+
+ html = lv |> element("form.search") |> render_change(%{"query" => "joão"})
+
+ assert html =~ "João Mendes"
+ refute html =~ "Ana Oliveira"
+ end
+
+ test "zelador gera link de redefinição para um morador", %{conn: conn, zelador: zelador} do
+ {:ok, lv, _html} = conn |> log_in(zelador) |> live(~p"/membros")
+
+ html = lv |> element(~s([phx-click="reset-link"][phx-value-id])) |> render_click()
+
+ assert html =~ "Link de redefinição de senha"
+ assert html =~ "/redefinir/"
+ end
+
+ test "morador comum não vê atalho de convite", %{conn: conn, member: member} do
+ {:ok, _lv, html} = conn |> log_in(member) |> live(~p"/membros")
+
+ refute html =~ "/membros/convidar"
+ end
+end
diff --git a/test/taina_web/live/setup_live_test.exs b/test/taina_web/live/setup_live_test.exs
new file mode 100644
index 0000000..440b5a4
--- /dev/null
+++ b/test/taina_web/live/setup_live_test.exs
@@ -0,0 +1,50 @@
+defmodule TainaWeb.SetupLiveTest do
+ use TainaWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+ import Taina.Fixtures
+
+ test "instância já inicializada redireciona para o login", %{conn: conn} do
+ tekoa_fixture()
+
+ assert {:error, {:redirect, %{to: "/login"}}} = live(conn, ~p"/setup")
+ end
+
+ test "wizard percorre os três passos validando cada um", %{conn: conn} do
+ {:ok, lv, html} = live(conn, ~p"/setup")
+
+ assert html =~ "Tainá"
+ assert html =~ "Passo 1 de 3"
+
+ # passo 1: nome vazio trava
+ html = lv |> element("form") |> render_submit(%{"setup" => %{"community_name" => " "}})
+ assert html =~ "dê um nome à comunidade"
+
+ html = lv |> element("form") |> render_submit(%{"setup" => %{"community_name" => "Quilombo do Café"}})
+ assert html =~ "Crie a conta de quem cuida"
+
+ # passo 2: senha curta trava
+ html = lv |> element("form") |> render_submit(%{"setup" => %{"username" => "ana", "password" => "curta"}})
+ assert html =~ "pelo menos 8 caracteres"
+
+ html =
+ lv
+ |> element("form")
+ |> render_submit(%{"setup" => %{"username" => "ana", "password" => "frase-longa-segura"}})
+
+ # passo 3: resumo + form final apontando para o POST /setup
+ assert html =~ "Onde guardar os arquivos"
+ assert html =~ "Quilombo do Café, zelador(a) ana, disco interno"
+ assert has_element?(lv, ~s(form[action="/setup"]))
+ assert has_element?(lv, ~s(input[name="setup[password_confirmation]"]))
+ end
+
+ test "voltar regride o passo", %{conn: conn} do
+ {:ok, lv, _html} = live(conn, ~p"/setup")
+
+ lv |> element("form") |> render_submit(%{"setup" => %{"community_name" => "Aldeia"}})
+ html = lv |> element("button[phx-click=back]") |> render_click()
+
+ assert html =~ "Passo 1 de 3"
+ end
+end
diff --git a/test/taina_web/live/storage_live_test.exs b/test/taina_web/live/storage_live_test.exs
new file mode 100644
index 0000000..67feeaf
--- /dev/null
+++ b/test/taina_web/live/storage_live_test.exs
@@ -0,0 +1,32 @@
+defmodule TainaWeb.StorageLiveTest do
+ use TainaWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+ import Taina.Fixtures
+
+ setup do
+ tekoa = tekoa_fixture()
+ %{tekoa: tekoa, admin: zelador_fixture(tekoa), member: active_ava_fixture(tekoa)}
+ end
+
+ test "mostra uso e legenda por tipo", %{conn: conn, member: member} do
+ {:ok, _lv, html} = conn |> log_in(member) |> live(~p"/armazenamento")
+
+ assert html =~ "Uso por tipo"
+ assert html =~ "Fotos"
+ assert html =~ "Documentos"
+ # membro comum não edita cota
+ refute html =~ "Cota da comunidade"
+ end
+
+ test "admin atualiza a cota da comunidade", %{conn: conn, admin: admin} do
+ {:ok, lv, html} = conn |> log_in(admin) |> live(~p"/armazenamento")
+
+ assert html =~ "Cota da comunidade"
+
+ lv |> element("button[phx-click=toggle-quota-modal]") |> render_click()
+ lv |> form("#quota-modal form") |> render_submit(%{"quota" => %{"gigabytes" => "10"}})
+
+ assert render(lv) =~ "10 GB"
+ end
+end
diff --git a/test/taina_web/live/trash_live_test.exs b/test/taina_web/live/trash_live_test.exs
new file mode 100644
index 0000000..90600d3
--- /dev/null
+++ b/test/taina_web/live/trash_live_test.exs
@@ -0,0 +1,37 @@
+defmodule TainaWeb.TrashLiveTest do
+ use TainaWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+ import Taina.Fixtures
+
+ alias Taina.Scope
+ alias Taina.Ybira
+
+ setup do
+ tekoa = tekoa_fixture()
+ ava = active_ava_fixture(tekoa)
+ scope = Scope.new(ava, tekoa)
+ {:ok, file} = Ybira.upload(scope, tmp_upload_fixture("oi", "antiga.txt"))
+ {:ok, _} = Ybira.delete_file(scope, file.public_id)
+ # `:file` é chave reservada do contexto ExUnit (caminho do arquivo de teste).
+ %{scope: scope, ava: ava, trashed_file: file}
+ end
+
+ test "lista itens com prazo de purga", %{conn: conn, ava: ava, trashed_file: file} do
+ {:ok, _lv, html} = conn |> log_in(ava) |> live(~p"/arquivos/lixeira")
+
+ assert html =~ file.original_filename
+ assert html =~ "Apagado hoje"
+ assert html =~ "30 dias"
+ end
+
+ test "restaura um arquivo", %{conn: conn, scope: scope, ava: ava, trashed_file: file} do
+ {:ok, lv, _html} = conn |> log_in(ava) |> live(~p"/arquivos/lixeira")
+
+ lv |> element(~s(button[phx-value-id="#{file.public_id}"])) |> render_click()
+
+ assert render(lv) =~ "Lixeira vazia"
+ assert {:ok, restored} = Ybira.get_file(scope, file.public_id)
+ assert is_nil(restored.deleted_at)
+ end
+end
diff --git a/test/taina_web/live/upload_live_test.exs b/test/taina_web/live/upload_live_test.exs
new file mode 100644
index 0000000..581da9e
--- /dev/null
+++ b/test/taina_web/live/upload_live_test.exs
@@ -0,0 +1,67 @@
+defmodule TainaWeb.UploadLiveTest do
+ use TainaWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+ import Taina.Fixtures
+
+ alias Taina.Scope
+ alias Taina.Ybira
+
+ setup do
+ tekoa = tekoa_fixture()
+ ava = active_ava_fixture(tekoa)
+ %{scope: Scope.new(ava, tekoa), ava: ava}
+ end
+
+ test "envia arquivo e mostra no grupo concluído", %{conn: conn, scope: scope, ava: ava} do
+ {:ok, lv, _html} = conn |> log_in(ava) |> live(~p"/arquivos/enviar")
+
+ input =
+ file_input(lv, "#dropzone", :files, [
+ %{name: "ata.txt", content: "ata da assembleia", type: "text/plain"}
+ ])
+
+ render_upload(input, "ata.txt")
+
+ html = render(lv)
+ assert html =~ "Concluído"
+ assert html =~ "ata.txt"
+
+ assert {:ok, %{items: [file]}} = Ybira.list_files(scope)
+ assert file.original_filename == "ata.txt"
+ end
+
+ test "envia para a pasta indicada em ?pasta=", %{conn: conn, scope: scope, ava: ava} do
+ {:ok, folder} = Ybira.create_folder(scope, %{name: "Atas"})
+ {:ok, lv, _html} = conn |> log_in(ava) |> live(~p"/arquivos/enviar?#{[pasta: folder.public_id]}")
+
+ input =
+ file_input(lv, "#dropzone", :files, [
+ %{name: "junho.txt", content: "ata de junho", type: "text/plain"}
+ ])
+
+ render_upload(input, "junho.txt")
+
+ assert {:ok, %{files: [file]}} = Ybira.list_folder_contents(scope, folder.public_id)
+ assert file.original_filename == "junho.txt"
+ end
+
+ test "arquivo rejeitado pelo Ybira aparece como falha", %{conn: conn, scope: scope, ava: ava} do
+ {:ok, lv, _html} = conn |> log_in(ava) |> live(~p"/arquivos/enviar")
+
+ # ELF magic bytes, executável, rejeitado pela allowlist de MIME
+ input =
+ file_input(lv, "#dropzone", :files, [
+ %{name: "virus.bin", content: <<0x7F, ?E, ?L, ?F, 0, 1, 2, 3>>, type: "application/octet-stream"}
+ ])
+
+ render_upload(input, "virus.bin")
+
+ html = render(lv)
+ assert html =~ "Falharam"
+ assert html =~ "tipo de arquivo não permitido"
+
+ # rejeição vale no domínio, não só na UI: nada foi persistido
+ assert {:ok, %{items: []}} = Ybira.list_files(scope)
+ end
+end