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""" + + """ + 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""" + + """ + 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""" + + """ + 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""" +
+ +
+ + <.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""" + + """ + 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}

+ +
+

{@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""" + + """ + 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""" + + """ + 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""" + + """ + 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)} +
+
+ + +
+ """ + 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 /> + + + +
+
+ <%= case media_kind(@file.mime_type) do %> + <% :image -> %> + {@file.original_filename} + <% :video -> %> + + <% :audio -> %> + + <% :pdf -> %> + + <% :text -> %> +
{@text_preview}
+
+ <.icon name="file" size={40} /> +

{gettext("pré-visualização indisponível")}

+
+ <% :other -> %> +
+ <.icon name="file" size={40} /> +

{gettext("pré-visualização indisponível")}

+
+ <% end %> +
+ + <.card> +

{@file.original_filename}

+

{file_meta(@file)}

+
+
+ + <.icon name="download" size={20} /> + {gettext("Baixar")} + + + + +
+ +
+ + <.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)))} + /> +
+
+ + + +

+ {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")}

+
+ <.input + label={gettext("Nome da pasta")} + name="folder[name]" + id="new_folder_name" + value="" + placeholder={gettext("ex.: Documentos")} + required + autofocus + /> +
+ <.button variant="secondary" size="md" class="flex-1" phx-click="close-modal"> + {gettext("Cancelar")} + + <.button type="submit" variant="primary" size="md" class="flex-1">{gettext("Criar")} +
+
+ + + <.modal id="rename-modal" show={@modal == "rename"} on_cancel={JS.push("close-modal")}> +

{gettext("Renomear")}

+
+ <.input + label={gettext("Novo nome")} + name="folder[name]" + id="rename_name" + value={@rename_target.name} + required + autofocus + /> +
+ <.button variant="secondary" size="md" class="flex-1" phx-click="close-modal"> + {gettext("Cancelar")} + + <.button type="submit" variant="primary" size="md" class="flex-1">{gettext("Renomear")} +
+
+ + + <.modal id="move-modal" show={@modal == "move"} on_cancel={JS.push("close-modal")}> +

{gettext("Mover para...")}

+

{@move_target.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} /> +
+ {photo.original_filename} + +
+ +
+

{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} /> +
+ {photo.original_filename} + +
+
+ +
+

{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_button name="close" label={gettext("Fechar")} phx-click="close" /> +
+

{photo_date(@current)}

+

+ {gettext("%{index} de %{total}", index: viewer_index(@photo_ids, @current), total: length(@photo_ids))} +

+
+ <.icon_button + name="link" + label={gettext("Copiar link")} + id="viewer-copy-link" + phx-hook="Clipboard" + data-copy={url(~p"/files/#{@current.public_id}")} + /> +
+ +
+ + {@current.original_filename} + + +
+ +

{@current.original_filename}

+ +
+ + + <.icon name="download" size={20} /> + {gettext("Baixar")} + + + +
+
+ + <.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.")} +

+
+ +
+ + <.input + label={gettext("Seu nome de usuário")} + name="account[username]" + id="account_username" + value="" + placeholder={gettext("ex.: joao")} + help={gettext("Sem espaços. É com ele que você entra na comunidade.")} + autocapitalize="none" + required + minlength="3" + /> + <.input + label={gettext("Nome de exibição (opcional)")} + name="account[display_name]" + id="account_display_name" + value="" + placeholder={gettext("ex.: João Mendes")} + help={gettext("Como seu nome aparece para a comunidade.")} + /> + <.input + label={gettext("Senha")} + type="password" + name="account[password]" + id="account_password" + value="" + placeholder={gettext("mínimo 8 caracteres")} + icon="shield" + help={gettext("Use uma frase longa que só você lembra.")} + required + minlength="8" + /> + <.input + label={gettext("Confirme a senha")} + type="password" + name="account[password_confirmation]" + id="account_password_confirmation" + value="" + placeholder={gettext("a mesma senha")} + required + minlength="8" + /> + <.button type="submit" variant="primary" class="w-full mt-2">{gettext("Criar conta")} +
+
+ """ + 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("Este convite expira em 7 dias, revogável a qualquer momento.")} +

+ <% else %> +
+
+

{gettext("Papel da pessoa convidada")}

+
+ <.chip selected={@role == "morador"} phx-click="set-role" phx-value-role="morador"> + {gettext("Morador(a)")} + + <.chip selected={@role == "zelador"} phx-click="set-role" phx-value-role="zelador"> + {gettext("Zelador(a)")} + +
+

+ {gettext("Morador participa da comunidade. Zelador também cuida da máquina.")} +

+
+ + <.button type="submit" variant="primary" class="w-full">{gettext("Gerar convite")} +
+ <% 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.")}

+
+ +
+ + <.input + label={gettext("Seu nome")} + type="text" + name="username" + id="login_username" + value="" + placeholder={gettext("seu nome de usuário")} + autocomplete="username" + autocapitalize="none" + required + /> + <.input + label={gettext("Senha")} + type="password" + name="password" + id="login_password" + value="" + placeholder={gettext("sua senha")} + icon="shield" + required + /> + + <.button type="submit" variant="primary" class="w-full mt-2">{gettext("Entrar")} +
+ +
+
+ {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.")} +

+
+ +
+ + <.input + label={gettext("Senha nova")} + type="password" + name="account[password]" + id="reset_password" + value="" + placeholder={gettext("mínimo 8 caracteres")} + icon="shield" + help={gettext("Use uma frase longa que só você lembra.")} + required + minlength="8" + /> + <.input + label={gettext("Confirme a senha")} + type="password" + name="account[password_confirmation]" + id="reset_password_confirmation" + value="" + placeholder={gettext("a mesma senha")} + required + minlength="8" + /> + <.button type="submit" variant="primary" class="w-full mt-2">{gettext("Salvar nova senha")} +
+
+ """ + 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")}

+
+ +
+ <.input + label={gettext("Nome da comunidade")} + name="setup[community_name]" + id="setup_community_name" + value={@data["community_name"]} + placeholder={gettext("ex.: Quilombo do Café")} + help={gettext("Esse nome aparece para todas as pessoas convidadas.")} + errors={List.wrap(@errors["community_name"])} + autofocus + /> +
+ <.button type="submit" variant="primary" class="w-full">{gettext("Começar")} +
+
+ +
+ <.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")}

+
+ +
+ <.input + label={gettext("Seu nome de usuário")} + name="setup[username]" + id="setup_username" + value={@data["username"]} + placeholder={gettext("ex.: ana")} + help={gettext("Sem espaços. É com ele que você entra.")} + autocapitalize="none" + errors={List.wrap(@errors["username"])} + /> + <.input + label={gettext("Nome de exibição (opcional)")} + name="setup[display_name]" + id="setup_display_name" + value={@data["display_name"]} + placeholder={gettext("ex.: Ana Oliveira")} + help={gettext("Como seu nome aparece para a comunidade.")} + /> + <.input + label={gettext("Senha")} + type="password" + name="setup[password]" + id="setup_password" + value={@data["password"]} + placeholder={gettext("mínimo 8 caracteres")} + icon="shield" + help={gettext("Use uma frase longa que só você lembra.")} + errors={List.wrap(@errors["password"])} + /> +
+ <.button type="submit" variant="primary" class="w-full">{gettext("Continuar")} +
+
+ +
+ <.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")} +

+ + +
+ +
+ + + + + + + <.button type="submit" variant="service" class="w-full">{gettext("Criar minha nuvem")} +
+
+
+
+ """ + 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"> + +
+

{kind_label(kind)}

+
+

{format_bytes(bytes)}

+
+
+ +
+

{gettext("Cota da comunidade")}

+ +
+
+ + <.modal id="quota-modal" show={@show_quota_modal} on_cancel={JS.push("toggle-quota-modal")}> +

{gettext("Cota da comunidade")}

+
+ <.input + label={gettext("Limite total (GB)")} + type="number" + name="quota[gigabytes]" + id="quota_gigabytes" + value={quota_gb(@stats)} + inputmode="numeric" + required + /> +
+ <.button variant="secondary" size="md" class="flex-1" phx-click="toggle-quota-modal"> + {gettext("Cancelar")} + + <.button type="submit" variant="primary" size="md" class="flex-1">{gettext("Salvar")} +
+
+ +
+ """ + 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")} + + +
+
+
+ """ + 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