From 3ae46918517ed6b3cc7397f6496faf2fc838286c Mon Sep 17 00:00:00 2001 From: Zoey Pessanha Date: Mon, 15 Jun 2026 19:46:08 -0300 Subject: [PATCH 1/4] feat(web): LiveView frontend (design system + Maraca/Ybira/Jaci screens) Server-rendered LiveView UI over the existing contexts, dark Tupi-Guarani vault aesthetic from Penpot ("Cofre da Comunidade"), mobile-first. - Design system: tokens.css (Penpot 1:1), core_components, icons, layouts (app shell + bottom-nav + auth), esbuild pipeline (no Tailwind), fonts. - Maraca: setup wizard, login, invite create/accept (controllers + LiveViews). - Ybira: file browser (list/grid, sort, rename/move/trash, DnD), upload, trash, storage usage + quota. - Jaci: photo gallery (grid + timeline + viewer). - Home, members, account screens. pt-BR via gettext. - Functional Core / Imperative Shell on LiveViews and JS hooks; tests across controllers and LiveViews. - Build output priv/static/assets now gitignored (regenerated by assets.build). --- .gitignore | 3 + AGENTS.md | 174 +++ CLAUDE.md | 42 + assets/css/app.css | 23 + assets/css/base.css | 178 +++ assets/css/components.css | 1157 +++++++++++++++++ assets/css/layouts.css | 297 +++++ assets/css/tokens.css | 153 +++ assets/css/utilities.css | 249 ++++ assets/js/app.js | 28 + assets/js/hooks.js | 137 ++ assets/vendor/topbar.js | 138 ++ config/config.exs | 13 + config/dev.exs | 14 +- config/prod.exs | 3 + config/test.exs | 2 +- lib/taina/jaci.ex | 10 +- lib/taina/jaci/behaviour.ex | 4 +- lib/taina/jaci/timeline.ex | 4 +- lib/taina/maraca.ex | 42 +- lib/taina/maraca/ava.ex | 2 +- lib/taina/maraca/behaviour.ex | 53 +- lib/taina/repo.ex | 12 +- lib/taina/scope.ex | 2 +- lib/taina/ybira.ex | 171 ++- lib/taina/ybira/behaviour.ex | 58 +- lib/taina/ybira/file.ex | 11 + lib/taina/ybira/media.ex | 8 +- lib/taina/ybira/mime_detector.ex | 6 +- lib/taina/ybira/workers/purge_trash.ex | 2 +- lib/taina/ybira/workers/rendition.ex | 6 +- lib/taina_web.ex | 38 +- lib/taina_web/auth.ex | 31 +- lib/taina_web/components/core_components.ex | 593 +++++++++ lib/taina_web/components/icons.ex | 70 + lib/taina_web/components/layouts.ex | 166 +++ .../components/layouts/root.html.heex | 18 + lib/taina_web/controllers/file_controller.ex | 2 +- .../controllers/invite_controller.ex | 31 + .../controllers/session_controller.ex | 35 + lib/taina_web/controllers/setup_controller.ex | 57 + lib/taina_web/gettext.ex | 9 + lib/taina_web/hooks.ex | 29 + lib/taina_web/live/account_live.ex | 77 ++ lib/taina_web/live/file_preview_live.ex | 212 +++ lib/taina_web/live/files_live.ex | 546 ++++++++ lib/taina_web/live/gallery_live.ex | 399 ++++++ lib/taina_web/live/home_live.ex | 116 ++ lib/taina_web/live/invite_accept_live.ex | 71 + lib/taina_web/live/invite_live.ex | 173 +++ lib/taina_web/live/login_live.ex | 107 ++ lib/taina_web/live/members_live.ex | 116 ++ lib/taina_web/live/setup_live.ex | 237 ++++ lib/taina_web/live/storage_live.ex | 143 ++ lib/taina_web/live/trash_live.ex | 142 ++ lib/taina_web/live/upload_live.ex | 202 +++ lib/taina_web/router.ex | 42 + mix.exs | 10 +- mix.lock | 11 +- priv/gettext/pt_BR/LC_MESSAGES/default.po | 7 + priv/gettext/pt_BR/LC_MESSAGES/errors.po | 81 ++ priv/repo/seeds.exs | 150 ++- .../bricolage-grotesque-latin-wght.woff2 | Bin 0 -> 41344 bytes .../fonts/jetbrains-mono-latin-wght.woff2 | Bin 0 -> 40404 bytes .../fonts/schibsted-grotesk-latin-wght.woff2 | Bin 0 -> 46752 bytes test/support/conn_case.ex | 2 +- test/support/fixtures.ex | 2 +- test/taina/jaci_test.exs | 4 +- test/taina/maraca_members_test.exs | 57 + test/taina/maraca_test.exs | 2 +- test/taina/rls_isolation_test.exs | 8 +- test/taina/ybira/rendition_test.exs | 4 +- test/taina/ybira_stats_test.exs | 53 + test/taina/ybira_test.exs | 5 +- .../controllers/session_controller_test.exs | 38 + .../controllers/setup_controller_test.exs | 43 + test/taina_web/live/files_live_test.exs | 74 ++ test/taina_web/live/gallery_live_test.exs | 62 + test/taina_web/live/home_live_test.exs | 27 + test/taina_web/live/invite_flow_test.exs | 66 + test/taina_web/live/login_live_test.exs | 28 + test/taina_web/live/members_live_test.exs | 37 + test/taina_web/live/setup_live_test.exs | 57 + test/taina_web/live/storage_live_test.exs | 32 + test/taina_web/live/trash_live_test.exs | 37 + test/taina_web/live/upload_live_test.exs | 64 + 86 files changed, 7520 insertions(+), 105 deletions(-) create mode 100644 CLAUDE.md create mode 100644 assets/css/app.css create mode 100644 assets/css/base.css create mode 100644 assets/css/components.css create mode 100644 assets/css/layouts.css create mode 100644 assets/css/tokens.css create mode 100644 assets/css/utilities.css create mode 100644 assets/js/app.js create mode 100644 assets/js/hooks.js create mode 100644 assets/vendor/topbar.js create mode 100644 lib/taina_web/components/core_components.ex create mode 100644 lib/taina_web/components/icons.ex create mode 100644 lib/taina_web/components/layouts.ex create mode 100644 lib/taina_web/components/layouts/root.html.heex create mode 100644 lib/taina_web/controllers/invite_controller.ex create mode 100644 lib/taina_web/controllers/session_controller.ex create mode 100644 lib/taina_web/controllers/setup_controller.ex create mode 100644 lib/taina_web/gettext.ex create mode 100644 lib/taina_web/hooks.ex create mode 100644 lib/taina_web/live/account_live.ex create mode 100644 lib/taina_web/live/file_preview_live.ex create mode 100644 lib/taina_web/live/files_live.ex create mode 100644 lib/taina_web/live/gallery_live.ex create mode 100644 lib/taina_web/live/home_live.ex create mode 100644 lib/taina_web/live/invite_accept_live.ex create mode 100644 lib/taina_web/live/invite_live.ex create mode 100644 lib/taina_web/live/login_live.ex create mode 100644 lib/taina_web/live/members_live.ex create mode 100644 lib/taina_web/live/setup_live.ex create mode 100644 lib/taina_web/live/storage_live.ex create mode 100644 lib/taina_web/live/trash_live.ex create mode 100644 lib/taina_web/live/upload_live.ex create mode 100644 priv/gettext/pt_BR/LC_MESSAGES/default.po create mode 100644 priv/gettext/pt_BR/LC_MESSAGES/errors.po create mode 100644 priv/static/fonts/bricolage-grotesque-latin-wght.woff2 create mode 100644 priv/static/fonts/jetbrains-mono-latin-wght.woff2 create mode 100644 priv/static/fonts/schibsted-grotesk-latin-wght.woff2 create mode 100644 test/taina/maraca_members_test.exs create mode 100644 test/taina/ybira_stats_test.exs create mode 100644 test/taina_web/controllers/session_controller_test.exs create mode 100644 test/taina_web/controllers/setup_controller_test.exs create mode 100644 test/taina_web/live/files_live_test.exs create mode 100644 test/taina_web/live/gallery_live_test.exs create mode 100644 test/taina_web/live/home_live_test.exs create mode 100644 test/taina_web/live/invite_flow_test.exs create mode 100644 test/taina_web/live/login_live_test.exs create mode 100644 test/taina_web/live/members_live_test.exs create mode 100644 test/taina_web/live/setup_live_test.exs create mode 100644 test/taina_web/live/storage_live_test.exs create mode 100644 test/taina_web/live/trash_live_test.exs create mode 100644 test/taina_web/live/upload_live_test.exs 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..0e18ea7 --- /dev/null +++ b/assets/css/base.css @@ -0,0 +1,178 @@ +/* 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%; +} + +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..0164461 --- /dev/null +++ b/assets/css/components.css @@ -0,0 +1,1157 @@ +/* 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: rgb(15 23 41 / 0.6); + 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--admin { + 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; + word-break: break-word; + text-align: left; +} + +/* ---------- 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..1bb8204 --- /dev/null +++ b/assets/css/layouts.css @@ -0,0 +1,297 @@ +/* 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(8px); + } + + 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: fixed; + 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; +} + +.auth-layout__footer { + position: relative; + z-index: 1; + margin-top: var(--space-8); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + color: var(--text-faint); + text-align: center; +} + +/* 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..cfd80da --- /dev/null +++ b/assets/css/tokens.css @@ -0,0 +1,153 @@ +/* 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); +} diff --git a/assets/css/utilities.css b/assets/css/utilities.css new file mode 100644 index 0000000..c4e81e5 --- /dev/null +++ b/assets/css/utilities.css @@ -0,0 +1,249 @@ +/* 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; +} + +.between { + justify-content: space-between; +} + +.wrap { + flex-wrap: wrap; +} + +.flex-1 { + flex: 1; + min-width: 0; +} + +.w-full { + width: 100%; +} + +.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..0ac37c2 --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,28 @@ +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"); + +const liveSocket = new LiveSocket("/live", Socket, { + longPollFallbackMs: 2500, + params: { _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..e434d74 --- /dev/null +++ b/assets/js/hooks.js @@ -0,0 +1,137 @@ +// 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; + + 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; + 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 }); + } + dragged = null; + }); + }, +}; + +// 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..05166a5 100644 --- a/lib/taina/jaci/behaviour.ex +++ b/lib/taina/jaci/behaviour.ex @@ -1,10 +1,10 @@ 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, + 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 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..9d20532 100644 --- a/lib/taina/maraca.ex +++ b/lib/taina/maraca.ex @@ -2,7 +2,7 @@ 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 @@ -15,7 +15,7 @@ defmodule Taina.Maraca do ## Convites sem e-mail 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 + 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. @@ -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 %{ @@ -85,6 +85,42 @@ defmodule Taina.Maraca do |> Repo.insert() end + @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 ? = 'admin' 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 confirmação @impl true diff --git a/lib/taina/maraca/ava.ex b/lib/taina/maraca/ava.ex index ef993a7..66a7ba3 100644 --- a/lib/taina/maraca/ava.ex +++ b/lib/taina/maraca/ava.ex @@ -161,7 +161,7 @@ defmodule Taina.Maraca.Ava do Changeset para convite de usuário por um administrador. Cria um novo Ava não confirmado com token de confirmação de email. - Usado no fluxo: Admin convida → Email enviado → Usuário confirma + Usado no fluxo: Admin convida -> Email enviado -> Usuário confirma ## Campos obrigatórios diff --git a/lib/taina/maraca/behaviour.ex b/lib/taina/maraca/behaviour.ex index 6846e72..9b8e8e0 100644 --- a/lib/taina/maraca/behaviour.ex +++ b/lib/taina/maraca/behaviour.ex @@ -162,7 +162,7 @@ defmodule Taina.Maraca.Behaviour do ## 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 @@ -632,4 +632,55 @@ defmodule Taina.Maraca.Behaviour do false """ @callback email_confirmed?(Ava.t()) :: boolean() + + @doc """ + Verifica se a instância já passou pelo setup (existe a Tekoa única). + + Consulta de sistema (`skip_tekoa_id: true`): roda antes de existir sessão, + no redirecionamento para o wizard de primeiro boot. + + ## Retorno + + * `true` - Setup concluído, instância pronta + * `false` - Instância virgem, redirecionar para `/setup` + """ + @callback bootstrapped?() :: boolean() + + @doc """ + Obtém a Tekoa única da instância (RFC 002, D2, modo single-tekoa). + + 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 (admins primeiro) + e data de entrada. + + ## 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 + + * `{:ok, [%Ava{}]}` - Membros da comunidade + """ + @callback list_members(Scope.t()) :: {:ok, [Ava.t()]} + + @doc """ + Conta os membros da Tekoa do scope (card "Membros" da home). + + ## Retorno + + * `{:ok, count}` - Total de membros + """ + @callback count_members(Scope.t()) :: {:ok, non_neg_integer()} end 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..a588539 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,37 @@ 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() + + rows = + folder_id + |> files_in() + |> apply_file_sort(sort) + |> limit(^(limit + 1)) + |> offset(^offset) + |> Repo.all() + + {files, next_cursor} = + if length(rows) > limit, do: {Enum.take(rows, limit), offset + limit}, else: {rows, nil} + {:ok, %{folders: folders, files: files, next_cursor: next_cursor}} end end) 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 +341,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/%") or like(f.mime_type, "video/%")) + ), + :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 +434,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 +445,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( @@ -438,7 +546,7 @@ defmodule Taina.Ybira do 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 + # "admins são facilitadores, não deuses"), quando precisarem, pedem via # `Maraca.request_access/5`. defp owner?(%Scope{ava: %Ava{id: id}}, owner_id), do: id == owner_id @@ -453,7 +561,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 +619,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..f99b6ef 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 @@ -76,6 +76,13 @@ defmodule Taina.Ybira.Behaviour do """ @callback list_trash(Scope.t(), keyword) :: {:ok, page(File.t())} + @doc """ + Renomeia um arquivo (muda só o `original_filename`; os bytes e o nome no disco + ficam). Dono ou admin. + """ + @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`). Dono ou admin. @@ -112,19 +119,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. Dono ou admin. 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 +165,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..c231cab --- /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[:email]} type="email" label="E-mail" help="Convites são por link/QR." /> + """ + 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[:email]" + 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(admin 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..5d8bc94 --- /dev/null +++ b/lib/taina_web/components/icons.ex @@ -0,0 +1,70 @@ +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(), + "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..2676869 --- /dev/null +++ b/lib/taina_web/components/layouts.ex @@ -0,0 +1,166 @@ +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(quota) and quota > 0 do + round(used / quota * 100) + end + + defp storage_percent(_stats), do: 0 + + @doc """ + Layout das telas públicas (setup, login, convite): céu noturno, estrelas e + horizonte aquecido; rodapé com a promessa do projeto. + """ + 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..b3aa04b --- /dev/null +++ b/lib/taina_web/components/layouts/root.html.heex @@ -0,0 +1,18 @@ + + + + + + + + <.live_title default="Tainá" suffix=" - 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..504eb4e --- /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, `confirm_email/4` ativa 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.confirm_email(token, params["password"], params["password_confirmation"], params["username"]) 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/session_controller.ex b/lib/taina_web/controllers/session_controller.ex new file mode 100644 index 0000000..4505ffa --- /dev/null +++ b/lib/taina_web/controllers/session_controller.ex @@ -0,0 +1,35 @@ +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, %{"email" => email, "password" => password}) do + with {:ok, tekoa} <- Maraca.get_tekoa(), + {:ok, ava} <- Maraca.authenticate(email, 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("E-mail ou senha incorretos.")) + |> redirect(to: ~p"/login") + end + 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..e516015 --- /dev/null +++ b/lib/taina_web/controllers/setup_controller.ex @@ -0,0 +1,57 @@ +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 admin 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()} + + admin_attrs = %{ + username: params["username"], + email: params["email"], + password: params["password"], + password_confirmation: params["password_confirmation"] + } + + case Maraca.bootstrap(tekoa_attrs, admin_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 + + 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..ff54a71 --- /dev/null +++ b/lib/taina_web/hooks.ex @@ -0,0 +1,29 @@ +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 -> + case socket.assigns.current_scope do + nil -> + nil + + scope -> + case Ybira.storage_stats(scope) do + {:ok, stats} -> stats + _error -> nil + end + end + end) + + {:cont, socket} + 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..40a63e5 --- /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.username || @current_scope.ava.email} /> +
+

{@current_scope.ava.username}

+

{@current_scope.ava.email}

+
+ <.badge :if={Maraca.admin?(@current_scope.ava)} variant="admin">{gettext("Admin")} + + +
+ <.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.admin?(@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..1b9db82 --- /dev/null +++ b/lib/taina_web/live/file_preview_live.ex @@ -0,0 +1,212 @@ +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")} + 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..67054b7 --- /dev/null +++ b/lib/taina_web/live/files_live.ex @@ -0,0 +1,546 @@ +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} -> + {:ok, %{folders: folders, files: files, next_cursor: next_cursor}} = + Ybira.list_folder_contents(scope, folder_public_id, sort: sort) + + {: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, :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 + {:ok, %{files: files, next_cursor: next_cursor}} = + Ybira.list_folder_contents(socket.assigns.current_scope, folder_public_id, sort: sort, offset: offset) + + {:noreply, + socket + |> assign(:next_cursor, next_cursor) + |> assign(:item_count, socket.assigns.item_count + length(files)) + |> stream(:files, files)} + 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 + {:ok, folders} = Ybira.list_folders(socket.assigns.current_scope) + + {:noreply, + socket + |> assign(:modal, "move") + |> assign(:move_target, %{kind: kind, id: id, name: name}) + |> assign(:all_folders, folders)} + 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) + 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) + 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 + + # 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({_field, :asc}), do: "↑" + defp sort_arrow({_field, :desc}), do: "↓" + + 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)} {sort_arrow(@sort)} + + <.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..5ece0fa --- /dev/null +++ b/lib/taina_web/live/gallery_live.ex @@ -0,0 +1,399 @@ +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.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) + 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(: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, socket) do + case Ybira.delete_file(socket.assigns.current_scope, socket.assigns.current.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 + + 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 + |> Taina.Jaci.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" + > +
+ <.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"> +
+ <.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..e9add5b --- /dev/null +++ b/lib/taina_web/live/home_live.ex @@ -0,0 +1,116 @@ +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 + + {:ok, recent} = Ybira.list_recent(scope, limit: 4) + {:ok, by_kind} = Ybira.storage_stats_by_kind(scope) + + {:ok, + socket + |> assign(:page_title, gettext("Início")) + |> assign(:recent, recent) + |> assign(:by_kind, by_kind)} + 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}

+
+ <.avatar name={@current_scope.ava.username || @current_scope.ava.email} /> +
+ + <.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..aa89adb --- /dev/null +++ b/lib/taina_web/live/invite_accept_live.ex @@ -0,0 +1,71 @@ +defmodule TainaWeb.InviteAcceptLive do + @moduledoc """ + Aceite de convite: a pessoa convidada escolhe nome e senha. O submit é um + POST tradicional para `InviteController.accept` (`confirm_email/4` + 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")} + name="account[username]" + id="account_username" + value="" + placeholder={gettext("ex.: João Mendes")} + required + minlength="3" + /> + <.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..88fd3b8 --- /dev/null +++ b/lib/taina_web/live/invite_live.ex @@ -0,0 +1,173 @@ +defmodule TainaWeb.InviteLive do + @moduledoc """ + Convite por link + QR (RFC 002, D6): admin informa o e-mail da pessoa, + escolhe o papel e recebe a URL com token, QR renderizado no servidor + (`eqrcode`, SVG inline, zero JS) e botões de copiar/compartilhar. + + Nota de drift (design vs. backend): o Penpot não pede e-mail, mas + `Maraca.invite_user/4` exige, o e-mail identifica a conta convidada. + """ + + 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.admin?(scope.ava) do + {:ok, + socket + |> assign(:page_title, gettext("Convidar pessoas")) + |> assign(:role, "member") + |> assign(:email, "") + |> assign(:invite_url, nil) + |> assign(:error, nil)} + else + {:ok, + socket + |> Phoenix.LiveView.put_flash(:error, gettext("Só a administração pode convidar pessoas.")) + |> Phoenix.LiveView.redirect(to: ~p"/")} + end + end + + @impl true + def handle_event("set-role", %{"role" => role}, socket) when role in ~w(member admin) do + {:noreply, assign(socket, :role, role)} + end + + def handle_event("generate", %{"invite" => %{"email" => email}}, socket) do + scope = socket.assigns.current_scope + role = String.to_existing_atom(socket.assigns.role) + + case Maraca.invite_user(scope.ava, scope.tekoa, email, role: role) do + {:ok, ava} -> + {:noreply, + socket + |> assign(:email, email) + |> assign(:error, nil) + |> assign(:invite_url, url(~p"/convite/#{ava.email_confirmation_token}"))} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, :error, invite_error(changeset))} + + {:error, :not_admin} -> + {:noreply, assign(socket, :error, gettext("Só a administração pode convidar pessoas."))} + 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, socket |> assign(:invite_url, nil) |> assign(:email, "")} + end + + defp invite_error(changeset) do + if Keyword.has_key?(changeset.errors, :email) do + {message, _opts} = changeset.errors[:email] + + case message do + "has already been taken" -> gettext("Já existe uma conta com esse e-mail nesta comunidade.") + _other -> gettext("Informe um e-mail válido.") + end + else + gettext("Não foi possível gerar o convite. Tente de novo.") + end + 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 %> +
+ <.input + label={gettext("E-mail da pessoa convidada")} + type="email" + name="invite[email]" + id="invite_email" + value={@email} + placeholder="pessoa@exemplo.org" + help={gettext("Identifica a conta. O convite em si vai por link/QR.")} + errors={List.wrap(@error)} + required + /> + +
+

{gettext("Papel da pessoa convidada")}

+
+ <.chip selected={@role == "member"} phx-click="set-role" phx-value-role="member"> + {gettext("Membro")} + + <.chip selected={@role == "admin"} phx-click="set-role" phx-value-role="admin"> + {gettext("Admin")} + +
+
+ + <.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..bee8316 --- /dev/null +++ b/lib/taina_web/live/login_live.ex @@ -0,0 +1,107 @@ +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 SMTP no MVP (RFC 002, D6): a redefinição passa pela administração. + {:noreply, + Phoenix.LiveView.put_flash( + socket, + :info, + gettext("Fale com quem administra sua comunidade para redefinir a senha.") + )} + 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 na nuvem da sua comunidade.")}

+
+ +
+ + <.input + label={gettext("E-mail")} + type="email" + name="email" + id="login_email" + value="" + placeholder="voce@exemplo.org" + 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..a14c841 --- /dev/null +++ b/lib/taina_web/live/members_live.ex @@ -0,0 +1,116 @@ +defmodule TainaWeb.MembersLive do + @moduledoc """ + Membros 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 membro; convidar é só admin. + """ + + 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("Membros")) + |> assign(:members, members) + |> assign(:query, "")} + end + + @impl true + def handle_event("search", %{"query" => query}, socket) do + {:noreply, assign(socket, :query, query)} + end + + defp filtered(members, ""), do: members + + defp filtered(members, query) do + needle = String.downcase(query) + + Enum.filter(members, fn member -> + String.contains?(String.downcase(member.username || member.email), needle) + end) + end + + defp display_name(member), do: member.username || member.email + + defp member_meta(member, scope) do + cond do + member.id == scope.ava.id -> gettext("Administração, você") + is_nil(member.confirmed_at) -> gettext("Convite pendente") + member.role == :admin -> gettext("Administração") + true -> gettext("Membro, entrou %{when}", when: relative_time(member.inserted_at)) + end + end + + @impl true + def render(assigns) do + ~H""" + + + <:action :if={Maraca.admin?(@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.admin?(@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.confirmed_at)} /> + + <:actions> + <.badge :if={member.role == :admin} variant="admin">{gettext("Admin")} + + +
+ + <.empty_state + :if={filtered(@members, @query) == []} + icon="user" + title={gettext("Ninguém por aqui")} + hint={gettext("Nenhuma pessoa corresponde à busca.")} + /> +
+
+ """ + 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..d4e61ab --- /dev/null +++ b/lib/taina_web/live/setup_live.ex @@ -0,0 +1,237 @@ +defmodule TainaWeb.SetupLive do + @moduledoc """ + Wizard de primeiro boot (3 passos): nome da comunidade, conta de + administração 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. + + Nota de drift (design vs. backend): o Penpot marca o e-mail do admin como + opcional, mas `Ava.changeset/2` exige e-mail; o campo aqui é obrigatório + até o Maraca suportar contas sem e-mail. + """ + + 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" => "", "email" => "", "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 -> + {:noreply, socket |> assign(:data, data) |> assign(:errors, %{}) |> assign(:step, socket.assigns.step + 1)} + + 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("como devemos te chamar?")) + |> validate_email(data) + |> validate_password(data) + end + + defp validate_presence(errors, data, key, message) do + if String.trim(data[key] || "") == "", do: Map.put(errors, key, message), else: errors + end + + defp validate_email(errors, data) do + if String.match?(data["email"] || "", ~r/^[^\s]+@[^\s]+\.[^\s]+$/), + do: errors, + else: Map.put(errors, "email", gettext("informe um e-mail válido")) + 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 administração")}

+

+ {gettext("Você é quem cuida desta comunidade. Guarde bem este acesso. Ele controla tudo.")} +

+ <.steps current={2} /> +

{gettext("Passo 2 de 3: administração")}

+
+ +
+ <.input + label={gettext("Seu nome")} + name="setup[username]" + id="setup_username" + value={@data["username"]} + placeholder={gettext("ex.: Ana Oliveira")} + errors={List.wrap(@errors["username"])} + /> + <.input + label={gettext("E-mail")} + type="email" + name="setup[email]" + id="setup_email" + value={@data["email"]} + placeholder="voce@exemplo.org" + help={gettext("Usado só para entrar e recuperar a conta. Nada sai daqui.")} + errors={List.wrap(@errors["email"])} + /> + <.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("admin")} {@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..302bcd2 --- /dev/null +++ b/lib/taina_web/live/storage_live.ex @@ -0,0 +1,143 @@ +defmodule TainaWeb.StorageLive do + @moduledoc """ + Armazenamento (tela "Ybira - Armazenamento"): uso total, barra segmentada + por tipo e cota da comunidade (admin 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 + + defp load_stats(socket) do + scope = socket.assigns.current_scope + {:ok, stats} = Ybira.storage_stats(scope) + {:ok, by_kind} = Ybira.storage_stats_by_kind(scope) + + socket + |> assign(:stats, stats) + |> assign(:by_kind, by_kind) + 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""" + + + +
+ <.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..37c7081 --- /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..eafcb38 --- /dev/null +++ b/lib/taina_web/live/upload_live.ex @@ -0,0 +1,202 @@ +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 + scope = socket.assigns.current_scope + folder = socket.assigns.folder + + result = + consume_uploaded_entry(socket, entry, fn %{path: path} -> + opts = [filename: entry.client_name] ++ if folder, do: [folder_id: folder.id], else: [] + {:ok, Ybira.upload(scope, path, opts)} + 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 + else + {:noreply, socket} + end + end + + 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..512fd3a 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,45 @@ 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 + end + + post "/setup", SetupController, :create + post "/login", SessionController, :create + post "/convite/:token", InviteController, :accept + delete "/logout", SessionController, :delete + end + + scope "/", TainaWeb do + pipe_through [:browser, :require_authenticated] + + 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..60a5219 100644 --- a/mix.exs +++ b/mix.exs @@ -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/pt_BR/LC_MESSAGES/default.po b/priv/gettext/pt_BR/LC_MESSAGES/default.po new file mode 100644 index 0000000..3007cb1 --- /dev/null +++ b/priv/gettext/pt_BR/LC_MESSAGES/default.po @@ -0,0 +1,7 @@ +## 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" 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/seeds.exs b/priv/repo/seeds.exs index cda4c70..63cbf77 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -1,11 +1,145 @@ -# 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 admin, dois membros, pastas e alguns arquivos reais (fotos, +# documento, planilha-texto). 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: -# -# 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("\n⚠️ Já 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 + admin_password = "semente-da-manha" + + {:ok, %{tekoa: tekoa, ava: admin}} = + Maraca.bootstrap( + %{name: "Quilombo do Café", storage_quota_bytes: quota_bytes}, + %{username: "ana", email: "ana@taina.local", password: admin_password, password_confirmation: admin_password} + ) + + scope = Scope.new(admin, tekoa) + + # --- Membros: convite por link + aceite, o fluxo real (sem e-mail) --- + + convidar_e_aceitar = fn email, username, role -> + {:ok, convidado} = Maraca.invite_user(admin, tekoa, email, role: role) + senha = "membro-#{username}-123" + {:ok, ava} = Maraca.confirm_email(convidado.email_confirmation_token, senha, senha, username) + {ava, senha} + end + + {_joao, _} = convidar_e_aceitar.("joao@taina.local", "joao", :member) + {_maria, _} = convidar_e_aceitar.("maria@taina.local", "maria", :member) + + # Pedro: convidado mas ainda não aceitou (aparece como pendente no admin). + {:ok, _pedro} = Maraca.invite_user(admin, tekoa, "pedro@taina.local", role: :member) + + # --- 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) + + 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) e uma foto na raiz, 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") + + File.rm_rf!(tmp) + + IO.puts(""" + + ✦ Tainá — comunidade de exemplo criada + + Comunidade: #{tekoa.name} + Admin: ana@taina.local / senha: #{admin_password} + Membros: joao@taina.local / senha: membro-joao-123 + maria@taina.local / senha: membro-maria-123 + Pendente: pedro@taina.local (convite não aceito) + + Pastas: Documentos, Fotos da comunidade, Vídeos + Arquivos: 8 fotos + 3 documentos + 1 logo (thumbnails geram em segundo plano) + + Entre em http://localhost:4000/login + """) +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 0000000000000000000000000000000000000000..82152a40802e0646b7a732744394a02c8c72772f GIT binary patch literal 41344 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 literal 0 HcmV?d00001 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..669bd96 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -81,7 +81,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 diff --git a/test/taina/jaci_test.exs b/test/taina/jaci_test.exs index 51f1199..a050c31 100644 --- a/test/taina/jaci_test.exs +++ b/test/taina/jaci_test.exs @@ -39,7 +39,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 +63,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..4491bf2 --- /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 admins primeiro, depois membros por ordem de entrada" do + tekoa = tekoa_fixture() + member = confirmed_ava_fixture(tekoa) + admin = admin_fixture(tekoa) + scope = Scope.new(admin, tekoa) + + assert {:ok, [first, second]} = Maraca.list_members(scope) + assert first.id == admin.id + assert second.id == member.id + end + + test "inclui convites pendentes (contas não confirmadas)" do + tekoa = tekoa_fixture() + admin = admin_fixture(tekoa) + pending = ava_fixture(tekoa) + scope = Scope.new(admin, tekoa) + + assert {:ok, members} = Maraca.list_members(scope) + assert Enum.any?(members, &(&1.id == pending.id and is_nil(&1.confirmed_at))) + end + end + + describe "count_members/1" do + test "conta todas as contas da tekoa" do + tekoa = tekoa_fixture() + admin = admin_fixture(tekoa) + _member = confirmed_ava_fixture(tekoa) + _pending = ava_fixture(tekoa) + + assert {:ok, 3} = Maraca.count_members(Scope.new(admin, tekoa)) + end + end +end diff --git a/test/taina/maraca_test.exs b/test/taina/maraca_test.exs index c0bf41a..6c30daa 100644 --- a/test/taina/maraca_test.exs +++ b/test/taina/maraca_test.exs @@ -231,7 +231,7 @@ 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 + test "others are denied by default, including admins", %{tekoa: tekoa, other: other, ybira_file: ybira_file} do admin = admin_fixture(tekoa) refute Maraca.authorize?(other, :read, "ybira_file", ybira_file.public_id) diff --git a/test/taina/rls_isolation_test.exs b/test/taina/rls_isolation_test.exs index 833cb98..7d398aa 100644 --- a/test/taina/rls_isolation_test.exs +++ b/test/taina/rls_isolation_test.exs @@ -3,14 +3,14 @@ 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 @@ -116,7 +116,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..96bfc93 --- /dev/null +++ b/test/taina/ybira_stats_test.exs @@ -0,0 +1,53 @@ +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/*", %{scope: scope} do + {:ok, _doc} = Ybira.upload(scope, tmp_upload_fixture()) + {:ok, _img} = Ybira.upload(scope, tmp_image_fixture()) + {:ok, trashed} = Ybira.upload(scope, tmp_upload_fixture("x", "x.txt")) + {:ok, _} = Ybira.delete_file(scope, trashed.public_id) + + assert {:ok, 2} = 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..4208377 --- /dev/null +++ b/test/taina_web/controllers/session_controller_test.exs @@ -0,0 +1,38 @@ +defmodule TainaWeb.SessionControllerTest do + use TainaWeb.ConnCase, async: true + + import Taina.Fixtures + + @password "frase-longa-que-so-eu-lembro" + + setup do + tekoa = tekoa_fixture() + ava = confirmed_ava_fixture(tekoa, %{password: @password}) + %{tekoa: tekoa, ava: ava} + end + + test "login com credenciais válidas cria sessão e vai para a home", %{conn: conn, ava: ava} do + conn = post(conn, ~p"/login", %{"email" => ava.email, "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", %{"email" => ava.email, "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 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..dca0131 --- /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 Oliveira", + "email" => "ana@exemplo.org", + "password" => "frase-longa-segura", + "password_confirmation" => "frase-longa-segura" + } + } + + test "bootstrap cria a Tekoa + admin 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..8ab3fe9 --- /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 = confirmed_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..b029626 --- /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 = confirmed_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 + html = lv |> element("button.viewer__nav--next") |> render_click() + assert_patch(lv, ~p"/fotos/#{first.public_id}") + assert html =~ "segunda.jpg" or 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..3d7aade --- /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 = confirmed_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..cf8c259 --- /dev/null +++ b/test/taina_web/live/invite_flow_test.exs @@ -0,0 +1,66 @@ +defmodule TainaWeb.InviteFlowTest do + use TainaWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Taina.Fixtures + + alias Taina.Maraca + + setup do + tekoa = tekoa_fixture() + admin = admin_fixture(tekoa) + %{tekoa: tekoa, admin: admin} + end + + test "membro comum não acessa a tela de convite", %{conn: conn, tekoa: tekoa} do + member = confirmed_ava_fixture(tekoa) + + assert {:error, {:redirect, %{to: "/"}}} = conn |> log_in(member) |> live(~p"/membros/convidar") + end + + test "admin gera convite com link + QR", %{conn: conn, admin: admin} do + {:ok, lv, _html} = conn |> log_in(admin) |> live(~p"/membros/convidar") + + html = lv |> element("form") |> render_submit(%{"invite" => %{"email" => "joao@exemplo.org"}}) + + assert html =~ "/convite/" + assert html =~ " %{ + "username" => "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" => "João", + "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..fc0bdce --- /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 = confirmed_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="email"])) + 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..8d7c88d --- /dev/null +++ b/test/taina_web/live/members_live_test.exs @@ -0,0 +1,37 @@ +defmodule TainaWeb.MembersLiveTest do + use TainaWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Taina.Fixtures + + setup do + tekoa = tekoa_fixture() + admin = admin_fixture(tekoa, %{username: "Ana Oliveira"}) + member = confirmed_ava_fixture(tekoa, %{username: "João Mendes"}) + %{tekoa: tekoa, admin: admin, member: member} + end + + test "lista membros com papel e estado", %{conn: conn, admin: admin} do + {:ok, _lv, html} = conn |> log_in(admin) |> live(~p"/membros") + + assert html =~ "Ana Oliveira" + assert html =~ "João Mendes" + assert html =~ "Administração, você" + assert html =~ "2 pessoas" + end + + test "busca filtra a lista", %{conn: conn, admin: admin} do + {:ok, lv, _html} = conn |> log_in(admin) |> live(~p"/membros") + + html = lv |> element("form.search") |> render_change(%{"query" => "joão"}) + + assert html =~ "João Mendes" + refute html =~ "Ana Oliveira" + end + + test "membro 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..93df37d --- /dev/null +++ b/test/taina_web/live/setup_live_test.exs @@ -0,0 +1,57 @@ +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 administração" + + # passo 2: validações de e-mail e senha + html = + lv + |> element("form") + |> render_submit(%{"setup" => %{"username" => "Ana", "email" => "invalido", "password" => "curta"}}) + + assert html =~ "informe um e-mail válido" + assert html =~ "pelo menos 8 caracteres" + + html = + lv + |> element("form") + |> render_submit(%{ + "setup" => %{"username" => "Ana", "email" => "ana@exemplo.org", "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é, admin 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..41e4701 --- /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: admin_fixture(tekoa), member: confirmed_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..23ed950 --- /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 = confirmed_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..3537630 --- /dev/null +++ b/test/taina_web/live/upload_live_test.exs @@ -0,0 +1,64 @@ +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 = confirmed_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, 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" + end +end From 74c48749d4e70f3df7a7a4036ad8001480e99c18 Mon Sep 17 00:00:00 2001 From: Zoey Pessanha Date: Tue, 16 Jun 2026 12:07:11 -0300 Subject: [PATCH 2/4] feat: dropa email, usa username como handle --- lib/taina/maraca.ex | 116 +- lib/taina/maraca/README.md | 14 +- lib/taina/maraca/access_request.ex | 17 +- lib/taina/maraca/ava.ex | 298 ++-- lib/taina/maraca/behaviour.ex | 175 +-- lib/taina_web/components/core_components.ex | 4 +- .../controllers/invite_controller.ex | 4 +- .../controllers/password_controller.ex | 31 + .../controllers/session_controller.ex | 6 +- lib/taina_web/controllers/setup_controller.ex | 6 +- lib/taina_web/live/account_live.ex | 10 +- lib/taina_web/live/home_live.ex | 2 +- lib/taina_web/live/invite_accept_live.ex | 16 +- lib/taina_web/live/invite_live.ex | 78 +- lib/taina_web/live/login_live.ex | 19 +- lib/taina_web/live/members_live.ex | 88 +- lib/taina_web/live/reset_password_live.ex | 62 + lib/taina_web/live/setup_live.ex | 52 +- lib/taina_web/live/storage_live.ex | 2 +- lib/taina_web/router.ex | 2 + priv/gettext/default.pot | 1325 +++++++++++++++++ priv/gettext/pt_BR/LC_MESSAGES/default.po | 1313 ++++++++++++++++ ...20260616120000_identity_username_first.exs | 82 + priv/repo/seeds.exs | 78 +- test/support/fixtures.ex | 16 +- test/taina/maraca_members_test.exs | 26 +- test/taina/maraca_test.exs | 219 +-- test/taina/rls_isolation_test.exs | 9 +- .../controllers/session_controller_test.exs | 8 +- .../controllers/setup_controller_test.exs | 6 +- test/taina_web/live/files_live_test.exs | 2 +- test/taina_web/live/gallery_live_test.exs | 2 +- test/taina_web/live/home_live_test.exs | 2 +- test/taina_web/live/invite_flow_test.exs | 27 +- test/taina_web/live/login_live_test.exs | 4 +- test/taina_web/live/members_live_test.exs | 27 +- test/taina_web/live/setup_live_test.exs | 17 +- test/taina_web/live/storage_live_test.exs | 2 +- test/taina_web/live/trash_live_test.exs | 2 +- test/taina_web/live/upload_live_test.exs | 2 +- 40 files changed, 3507 insertions(+), 664 deletions(-) create mode 100644 lib/taina_web/controllers/password_controller.ex create mode 100644 lib/taina_web/live/reset_password_live.ex create mode 100644 priv/gettext/default.pot create mode 100644 priv/repo/migrations/20260616120000_identity_username_first.exs diff --git a/lib/taina/maraca.ex b/lib/taina/maraca.ex index 9d20532..baf4ed0 100644 --- a/lib/taina/maraca.ex +++ b/lib/taina/maraca.ex @@ -7,17 +7,17 @@ defmodule Taina.Maraca do ## 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 @@ -56,32 +56,32 @@ 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 @@ -107,7 +107,7 @@ defmodule Taina.Maraca do Repo.all( from a in Ava, where: a.tekoa_id == ^scope.tekoa.id, - order_by: [asc: fragment("CASE WHEN ? = 'admin' THEN 0 ELSE 1 END", a.role), asc: a.inserted_at] + order_by: [asc: fragment("CASE WHEN ? = 'zelador' THEN 0 ELSE 1 END", a.role), asc: a.inserted_at] ) {:ok, members} @@ -121,34 +121,31 @@ defmodule Taina.Maraca do end) end - ## Convite e confirmação + ## 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} @@ -158,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) @@ -172,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 @@ -185,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} @@ -223,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) @@ -238,13 +242,17 @@ 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 -> + 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) + else + {:error, :unauthorized} + end end @impl true @@ -361,26 +369,26 @@ defmodule Taina.Maraca do ## Acesso administrativo (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} + 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) @@ -460,12 +468,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..3a1dccb 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 diff --git a/lib/taina/maraca/ava.ex b/lib/taina/maraca/ava.ex index 66a7ba3..1231d18 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,121 @@ 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 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, :activated_at, :public_id, :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 +246,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 +271,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 +287,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 +322,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 9b8e8e0..fc097f9 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() @@ -165,14 +163,14 @@ defmodule Taina.Maraca.Behaviour do - 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 @@ -239,7 +237,7 @@ defmodule Taina.Maraca.Behaviour do iex> update_tekoa_quota(admin_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 @@ -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,21 @@ 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, :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, :not_zelador | :already_has_access | Ecto.Changeset.t()} @doc """ Owner aprova solicitação de acesso. @@ -590,48 +593,24 @@ defmodule Taina.Maraca.Behaviour do @callback list_access_requests(Ava.t()) :: [AccessRequest.t()] @doc """ - Verifica se Ava é admin na sua Tekoa. - - ## Parâmetros - - * `ava` - Ava a verificar - - ## Retorno - - * `true` - É admin - * `false` - Não é admin - - ## Exemplos - - iex> admin?(admin_ava) - true - - iex> admin?(member_ava) - false + Verifica se o Ava é zelador(a) na sua Tekoa. """ - @callback admin?(Ava.t()) :: boolean() + @callback zelador?(Ava.t()) :: boolean() @doc """ - Verifica se email do Ava foi confirmado. - - ## Parâmetros + 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` - Email confirmado (confirmed_at não é nil) - * `false` - Email não confirmado - - ## Exemplos - - iex> email_confirmed?(confirmed_ava) - true - - iex> email_confirmed?(invited_ava) - false + * `true` - `activated_at` definido (convite aceito) + * `false` - Convite ainda pendente """ - @callback email_confirmed?(Ava.t()) :: boolean() + @callback activated?(Ava.t()) :: boolean() @doc """ Verifica se a instância já passou pelo setup (existe a Tekoa única). diff --git a/lib/taina_web/components/core_components.ex b/lib/taina_web/components/core_components.ex index c231cab..978df77 100644 --- a/lib/taina_web/components/core_components.ex +++ b/lib/taina_web/components/core_components.ex @@ -119,7 +119,7 @@ defmodule TainaWeb.CoreComponents do @doc """ Campo de formulário com rótulo, ajuda e erros. Aceita `Phoenix.HTML.FormField`. - <.input field={@form[:email]} type="email" label="E-mail" help="Convites são por link/QR." /> + <.input field={@form[:username]} type="text" label="Nome de usuário" /> """ attr :id, :any, default: nil attr :name, :any @@ -128,7 +128,7 @@ defmodule TainaWeb.CoreComponents do attr :type, :string, default: "text" attr :help, :string, default: nil attr :icon, :string, default: nil - attr :field, FormField, doc: "ex.: @form[:email]" + attr :field, FormField, doc: "ex.: @form[:username]" attr :errors, :list, default: [] attr :rest, :global, include: ~w(autocomplete placeholder required minlength maxlength inputmode autofocus) diff --git a/lib/taina_web/controllers/invite_controller.ex b/lib/taina_web/controllers/invite_controller.ex index 504eb4e..cab2a37 100644 --- a/lib/taina_web/controllers/invite_controller.ex +++ b/lib/taina_web/controllers/invite_controller.ex @@ -1,7 +1,7 @@ defmodule TainaWeb.InviteController do @moduledoc """ Aceite de convite: o `InviteAcceptLive` valida o formulário ao vivo e o - submit final chega como POST tradicional, `confirm_email/4` ativa a conta + submit final chega como POST tradicional, `accept_invite/2` cria a conta e a sessão nasce na mesma requisição. """ @@ -10,7 +10,7 @@ defmodule TainaWeb.InviteController do alias Taina.Maraca def accept(conn, %{"token" => token, "account" => params}) do - case Maraca.confirm_email(token, params["password"], params["password_confirmation"], params["username"]) do + case Maraca.accept_invite(token, params) do {:ok, ava} -> conn |> TainaWeb.Auth.log_in(ava) 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 index 4505ffa..ff04a75 100644 --- a/lib/taina_web/controllers/session_controller.ex +++ b/lib/taina_web/controllers/session_controller.ex @@ -9,9 +9,9 @@ defmodule TainaWeb.SessionController do alias Taina.Maraca - def create(conn, %{"email" => email, "password" => password}) do + def create(conn, %{"username" => username, "password" => password}) do with {:ok, tekoa} <- Maraca.get_tekoa(), - {:ok, ava} <- Maraca.authenticate(email, password, tekoa) do + {:ok, ava} <- Maraca.authenticate(username, password, tekoa) do conn |> TainaWeb.Auth.log_in(ava) |> redirect(to: ~p"/") @@ -21,7 +21,7 @@ defmodule TainaWeb.SessionController do {:error, _reason} -> conn - |> put_flash(:error, gettext("E-mail ou senha incorretos.")) + |> put_flash(:error, gettext("Nome ou senha incorretos.")) |> redirect(to: ~p"/login") end end diff --git a/lib/taina_web/controllers/setup_controller.ex b/lib/taina_web/controllers/setup_controller.ex index e516015..0b25a96 100644 --- a/lib/taina_web/controllers/setup_controller.ex +++ b/lib/taina_web/controllers/setup_controller.ex @@ -16,14 +16,14 @@ defmodule TainaWeb.SetupController do def create(conn, %{"setup" => params}) do tekoa_attrs = %{name: params["community_name"], storage_quota_bytes: initial_quota_bytes()} - admin_attrs = %{ + zelador_attrs = %{ username: params["username"], - email: params["email"], + display_name: params["display_name"], password: params["password"], password_confirmation: params["password_confirmation"] } - case Maraca.bootstrap(tekoa_attrs, admin_attrs) do + case Maraca.bootstrap(tekoa_attrs, zelador_attrs) do {:ok, %{ava: ava}} -> conn |> TainaWeb.Auth.log_in(ava) diff --git a/lib/taina_web/live/account_live.ex b/lib/taina_web/live/account_live.ex index 40a63e5..7e6bc4f 100644 --- a/lib/taina_web/live/account_live.ex +++ b/lib/taina_web/live/account_live.ex @@ -28,12 +28,12 @@ defmodule TainaWeb.AccountLive do

<.card class="row gap-4"> - <.avatar name={@current_scope.ava.username || @current_scope.ava.email} /> + <.avatar name={@current_scope.ava.display_name || @current_scope.ava.username} />
-

{@current_scope.ava.username}

-

{@current_scope.ava.email}

+

{@current_scope.ava.display_name || @current_scope.ava.username}

+

{"@" <> @current_scope.ava.username}

- <.badge :if={Maraca.admin?(@current_scope.ava)} variant="admin">{gettext("Admin")} + <.badge :if={Maraca.zelador?(@current_scope.ava)} variant="admin">{gettext("Zelador(a)")}
@@ -43,7 +43,7 @@ defmodule TainaWeb.AccountLive do <.list_row - :if={Maraca.admin?(@current_scope.ava)} + :if={Maraca.zelador?(@current_scope.ava)} title={gettext("Convidar pessoas")} meta={gettext("link + QR code")} navigate={~p"/membros/convidar"} diff --git a/lib/taina_web/live/home_live.ex b/lib/taina_web/live/home_live.ex index e9add5b..1cd8252 100644 --- a/lib/taina_web/live/home_live.ex +++ b/lib/taina_web/live/home_live.ex @@ -62,7 +62,7 @@ defmodule TainaWeb.HomeLive do

{greeting()}

{@current_scope.tekoa.name}

- <.avatar name={@current_scope.ava.username || @current_scope.ava.email} /> + <.avatar name={@current_scope.ava.display_name || @current_scope.ava.username} />
<.card :if={@storage_stats} class="mb-6"> diff --git a/lib/taina_web/live/invite_accept_live.ex b/lib/taina_web/live/invite_accept_live.ex index aa89adb..5e4ce53 100644 --- a/lib/taina_web/live/invite_accept_live.ex +++ b/lib/taina_web/live/invite_accept_live.ex @@ -1,7 +1,7 @@ defmodule TainaWeb.InviteAcceptLive do @moduledoc """ Aceite de convite: a pessoa convidada escolhe nome e senha. O submit é um - POST tradicional para `InviteController.accept` (`confirm_email/4` + sessão + 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. """ @@ -33,14 +33,24 @@ defmodule TainaWeb.InviteAcceptLive do
<.input - label={gettext("Seu nome")} + label={gettext("Seu nome de usuário")} name="account[username]" id="account_username" value="" - placeholder={gettext("ex.: João Mendes")} + 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" diff --git a/lib/taina_web/live/invite_live.ex b/lib/taina_web/live/invite_live.ex index 88fd3b8..24adba8 100644 --- a/lib/taina_web/live/invite_live.ex +++ b/lib/taina_web/live/invite_live.ex @@ -1,11 +1,9 @@ defmodule TainaWeb.InviteLive do @moduledoc """ - Convite por link + QR (RFC 002, D6): admin informa o e-mail da pessoa, - escolhe o papel e recebe a URL com token, QR renderizado no servidor - (`eqrcode`, SVG inline, zero JS) e botões de copiar/compartilhar. - - Nota de drift (design vs. backend): o Penpot não pede e-mail, mas - `Maraca.invite_user/4` exige, o e-mail identifica a conta convidada. + 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 @@ -17,44 +15,36 @@ defmodule TainaWeb.InviteLive do def mount(_params, _session, socket) do scope = socket.assigns.current_scope - if Maraca.admin?(scope.ava) do + if Maraca.zelador?(scope.ava) do {:ok, socket |> assign(:page_title, gettext("Convidar pessoas")) - |> assign(:role, "member") - |> assign(:email, "") - |> assign(:invite_url, nil) - |> assign(:error, nil)} + |> assign(:role, "morador") + |> assign(:invite_url, nil)} else {:ok, socket - |> Phoenix.LiveView.put_flash(:error, gettext("Só a administração pode convidar pessoas.")) + |> 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(member admin) do + def handle_event("set-role", %{"role" => role}, socket) when role in ~w(morador zelador) do {:noreply, assign(socket, :role, role)} end - def handle_event("generate", %{"invite" => %{"email" => email}}, socket) do + 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, email, role: role) do + case Maraca.invite_user(scope.ava, scope.tekoa, role: role) do {:ok, ava} -> - {:noreply, - socket - |> assign(:email, email) - |> assign(:error, nil) - |> assign(:invite_url, url(~p"/convite/#{ava.email_confirmation_token}"))} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, :error, invite_error(changeset))} + {:noreply, assign(socket, :invite_url, url(~p"/convite/#{ava.invite_token}"))} - {:error, :not_admin} -> - {:noreply, assign(socket, :error, gettext("Só a administração pode convidar pessoas."))} + {:error, _reason} -> + {:noreply, + Phoenix.LiveView.put_flash(socket, :error, gettext("Não foi possível gerar o convite. Tente de novo."))} end end @@ -63,20 +53,7 @@ defmodule TainaWeb.InviteLive do end def handle_event("reset", _params, socket) do - {:noreply, socket |> assign(:invite_url, nil) |> assign(:email, "")} - end - - defp invite_error(changeset) do - if Keyword.has_key?(changeset.errors, :email) do - {message, _opts} = changeset.errors[:email] - - case message do - "has already been taken" -> gettext("Já existe uma conta com esse e-mail nesta comunidade.") - _other -> gettext("Informe um e-mail válido.") - end - else - gettext("Não foi possível gerar o convite. Tente de novo.") - end + {:noreply, assign(socket, :invite_url, nil)} end defp qr_svg(invite_url) do @@ -139,28 +116,19 @@ defmodule TainaWeb.InviteLive do

<% else %> - <.input - label={gettext("E-mail da pessoa convidada")} - type="email" - name="invite[email]" - id="invite_email" - value={@email} - placeholder="pessoa@exemplo.org" - help={gettext("Identifica a conta. O convite em si vai por link/QR.")} - errors={List.wrap(@error)} - required - /> -

{gettext("Papel da pessoa convidada")}

- <.chip selected={@role == "member"} phx-click="set-role" phx-value-role="member"> - {gettext("Membro")} + <.chip selected={@role == "morador"} phx-click="set-role" phx-value-role="morador"> + {gettext("Morador(a)")} - <.chip selected={@role == "admin"} phx-click="set-role" phx-value-role="admin"> - {gettext("Admin")} + <.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")} diff --git a/lib/taina_web/live/login_live.ex b/lib/taina_web/live/login_live.ex index bee8316..ee405b4 100644 --- a/lib/taina_web/live/login_live.ex +++ b/lib/taina_web/live/login_live.ex @@ -29,12 +29,13 @@ defmodule TainaWeb.LoginLive do @impl true def handle_event("forgot-password", _params, socket) do - # Sem SMTP no MVP (RFC 002, D6): a redefinição passa pela administração. + # 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 administra sua comunidade para redefinir a senha.") + gettext("Fale com quem cuida da sua comunidade. Ela gera um link para você criar uma senha nova.") )} end @@ -50,18 +51,20 @@ defmodule TainaWeb.LoginLive do <.icon name="spark" size={40} class="spark" />

Tainá

{gettext("Bem-vindo de volta")}

-

{gettext("Entre na nuvem da sua comunidade.")}

+

{gettext("Entre com seu nome na nuvem da sua comunidade.")}

<.input - label={gettext("E-mail")} - type="email" - name="email" - id="login_email" + label={gettext("Seu nome")} + type="text" + name="username" + id="login_username" value="" - placeholder="voce@exemplo.org" + placeholder={gettext("seu nome de usuário")} + autocomplete="username" + autocapitalize="none" required /> <.input diff --git a/lib/taina_web/live/members_live.ex b/lib/taina_web/live/members_live.ex index a14c841..490994c 100644 --- a/lib/taina_web/live/members_live.ex +++ b/lib/taina_web/live/members_live.ex @@ -1,8 +1,9 @@ defmodule TainaWeb.MembersLive do @moduledoc """ - Membros 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 membro; convidar é só admin. + 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 @@ -16,9 +17,11 @@ defmodule TainaWeb.MembersLive do {:ok, socket - |> assign(:page_title, gettext("Membros")) + |> assign(:page_title, gettext("Moradores")) |> assign(:members, members) - |> assign(:query, "")} + |> assign(:query, "") + |> assign(:reset_link, nil) + |> assign(:reset_member, nil)} end @impl true @@ -26,27 +29,56 @@ defmodule TainaWeb.MembersLive 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(member.username || member.email), needle) + String.contains?(String.downcase(display_name(member)), needle) end) end - defp display_name(member), do: member.username || member.email + 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("Administração, você") - is_nil(member.confirmed_at) -> gettext("Convite pendente") - member.role == :admin -> gettext("Administração") - true -> gettext("Membro, entrou %{when}", when: relative_time(member.inserted_at)) + 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""" @@ -56,8 +88,8 @@ defmodule TainaWeb.MembersLive do active_tab={:members} storage_stats={assigns[:storage_stats]} > - - <:action :if={Maraca.admin?(@current_scope.ava)}> + + <:action :if={Maraca.zelador?(@current_scope.ava)}> <.icon_button name="plus" label={gettext("Convidar pessoas")} navigate={~p"/membros/convidar"} /> @@ -80,7 +112,7 @@ defmodule TainaWeb.MembersLive do {ngettext("%{count} pessoa", "%{count} pessoas", length(@members))}

<.link - :if={Maraca.admin?(@current_scope.ava)} + :if={Maraca.zelador?(@current_scope.ava)} navigate={~p"/membros/convidar"} class="type-label text-brand" > @@ -95,10 +127,17 @@ defmodule TainaWeb.MembersLive do meta={member_meta(member, @current_scope)} > <:leading> - <.avatar name={display_name(member)} inactive={is_nil(member.confirmed_at)} /> + <.avatar name={display_name(member)} inactive={is_nil(member.activated_at)} /> <:actions> - <.badge :if={member.role == :admin} variant="admin">{gettext("Admin")} + <.badge :if={member.role == :zelador} variant="admin">{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} + /> @@ -110,6 +149,23 @@ defmodule TainaWeb.MembersLive do 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 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..ed38a5d --- /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 index d4e61ab..d763fc0 100644 --- a/lib/taina_web/live/setup_live.ex +++ b/lib/taina_web/live/setup_live.ex @@ -1,16 +1,13 @@ defmodule TainaWeb.SetupLive do @moduledoc """ - Wizard de primeiro boot (3 passos): nome da comunidade, conta de - administração e armazenamento. Os passos vivem aqui (validação ao vivo); + 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. - - Nota de drift (design vs. backend): o Penpot marca o e-mail do admin como - opcional, mas `Ava.changeset/2` exige e-mail; o campo aqui é obrigatório - até o Maraca suportar contas sem e-mail. + 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 @@ -27,7 +24,7 @@ defmodule TainaWeb.SetupLive do socket |> assign(:page_title, gettext("Primeiros passos")) |> assign(:step, 1) - |> assign(:data, %{"community_name" => "", "username" => "", "email" => "", "password" => ""}) + |> assign(:data, %{"community_name" => "", "username" => "", "display_name" => "", "password" => ""}) |> assign(:errors, %{}) |> assign(:storage_root, Application.fetch_env!(:taina, :storage_root)) |> assign(:free_space, free_space())} @@ -64,8 +61,7 @@ defmodule TainaWeb.SetupLive do defp validate_step(2, data) do %{} - |> validate_presence(data, "username", gettext("como devemos te chamar?")) - |> validate_email(data) + |> validate_presence(data, "username", gettext("escolha um nome de usuário")) |> validate_password(data) end @@ -73,12 +69,6 @@ defmodule TainaWeb.SetupLive do if String.trim(data[key] || "") == "", do: Map.put(errors, key, message), else: errors end - defp validate_email(errors, data) do - if String.match?(data["email"] || "", ~r/^[^\s]+@[^\s]+\.[^\s]+$/), - do: errors, - else: Map.put(errors, "email", gettext("informe um e-mail válido")) - 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")), @@ -136,32 +126,32 @@ defmodule TainaWeb.SetupLive do
<.icon_button name="chevron-left" label={gettext("Voltar")} phx-click="back" />
-

{gettext("Crie a conta de administração")}

+

{gettext("Crie a conta de quem cuida")}

- {gettext("Você é quem cuida desta comunidade. Guarde bem este acesso. Ele controla tudo.")} + {gettext("Você é o primeiro zelador desta comunidade. Guarde bem este acesso. Ele cuida da máquina.")}

<.steps current={2} /> -

{gettext("Passo 2 de 3: administração")}

+

{gettext("Passo 2 de 3: quem cuida")}

<.input - label={gettext("Seu nome")} + label={gettext("Seu nome de usuário")} name="setup[username]" id="setup_username" value={@data["username"]} - placeholder={gettext("ex.: Ana Oliveira")} + placeholder={gettext("ex.: ana")} + help={gettext("Sem espaços. É com ele que você entra.")} + autocapitalize="none" errors={List.wrap(@errors["username"])} /> <.input - label={gettext("E-mail")} - type="email" - name="setup[email]" - id="setup_email" - value={@data["email"]} - placeholder="voce@exemplo.org" - help={gettext("Usado só para entrar e recuperar a conta. Nada sai daqui.")} - errors={List.wrap(@errors["email"])} + 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")} @@ -214,7 +204,7 @@ defmodule TainaWeb.SetupLive do <.card class="mt-2">

{gettext("Resumo")}

- {@data["community_name"]}, {gettext("admin")} {@data["username"]}, {gettext("disco interno")} + {@data["community_name"]}, {gettext("zelador(a)")} {@data["username"]}, {gettext("disco interno")}

@@ -224,7 +214,7 @@ defmodule TainaWeb.SetupLive do - + <.button type="submit" variant="service" class="w-full">{gettext("Criar minha nuvem")} diff --git a/lib/taina_web/live/storage_live.ex b/lib/taina_web/live/storage_live.ex index 302bcd2..0beeae6 100644 --- a/lib/taina_web/live/storage_live.ex +++ b/lib/taina_web/live/storage_live.ex @@ -106,7 +106,7 @@ defmodule TainaWeb.StorageLive do
-
+

{gettext("Cota da comunidade")}

-