From 1bcb969f72496df571b7c3272413adfcd2f9ddad Mon Sep 17 00:00:00 2001 From: Leonardo Date: Sun, 26 Apr 2026 08:10:53 -0300 Subject: [PATCH] =?UTF-8?q?Layout=20Melissa=20(Dire=C3=A7=C3=A3o=20B):=20p?= =?UTF-8?q?review,=20/profile,=20Agenda,=20dock,=20cadastro?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sandbox completo do novo layout Win11 lockscreen-style. Não troca o AppLayout atual — Fase 5 (router wire-up) fica pra sessão dedicada. Estrutura - src/layout/melissa/ — MelissaLayout (bg+ψ+overlays), MelissaCronometro, MelissaAgenda (fullscreen), MelissaCard, MelissaMenu, MelissaBusca - composables/useMelissaEventos.js — semana real do FC + range mensal pros dots do mini-cal - composables/useMelissaPacientes.js — agora retorna created_at p/ "novo" - melissaToques.js — toques Web Audio do término Rota e persistência - /preview/melissa (sem auth, sem AppLayout) - /account/profile ganha 3º card "Melissa" com badge "Em construção" - bootstrapUserSettings + layout composable aceitam variant='melissa' - Migration: CHECK constraint user_settings.layout_variant aceita 'melissa' Light mode - Gradiente Bloom flipa via CSS vars (--bloom-c1/c2/base-1/base-2) Dark: 400/300/950 · Light: 200/100/0 - Cronômetro/Personalização: color: white → var(--m-text) - Pílula psi-kbd ganha tokens --m-kbd-bg/--m-kbd-text - Override mapeia text-X-200/300/400 → text-X-600 (17 cores Tailwind) Agenda fullscreen - Mini-cal funcional: click pula FC, range visível destacado, dots reais - Feriados nacional/municipal/personalizado (rose/amber/violet) - Dias fechados (workRules) cinza apagado, mutex feriado vence - Card "Hoje" (stats+sessões) mesclado e movido pra sidebar esquerda - ProximosFeriadosCard reaproveitado entre mini-cal e Hoje - Avatar paciente: bg --m-accent-strong → --m-accent (saturado em light) - Cores light: 12 substituições color:white → var(--m-text) Dock taskbar Win11-style - .melissa-dock 76px fixed bottom (CSS global, não scoped — Vue static hoisting perderia data-v-{hash}) - ψ centralizado vertical na faixa (bottom:10px) - Chip cronômetro teleportado pro dock + animação minimize macOS (dialog encolhe + voa pro canto bottom-left, 340ms cubic-bezier) - transform-origin: 96px calc(100% - 38px) (posição do chip no dock) Pacientes na sidebar - Botão fake "+" no topo abre PatientCreatePopover (rápido/completo/link) - Reaproveita PatientCadastroDialog + ComponentCadastroRapido - Pacientes criados nos últimos 7d sobem pro topo + badge "novo" Dock contextual (ações do paciente selecionado) - Avatar + nome + count + 5 ações (sessões/whatsapp/prontuário/editar/fechar) - Teleportado pro .melissa-dock quando há paciente selecionado - Em mobile, ações vivem em kebab por linha - Pattern obrigatório (NUNCA o contrário) pra evitar comment placeholder + emitsOptions:null no reconciler Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 3 + ...1_user_settings_layout_variant_melissa.sql | 22 + src/app/bootstrapUserSettings.js | 8 +- src/layout/composables/layout.js | 8 +- src/layout/melissa/MelissaAgenda.vue | 1862 +++++++++++++++ src/layout/melissa/MelissaBusca.vue | 427 ++++ src/layout/melissa/MelissaCard.vue | 175 ++ src/layout/melissa/MelissaCronometro.vue | 620 +++++ src/layout/melissa/MelissaLayout.vue | 2106 +++++++++++++++++ src/layout/melissa/MelissaMenu.vue | 1042 ++++++++ .../melissa/composables/useMelissaEventos.js | 212 ++ .../composables/useMelissaPacientes.js | 107 + src/layout/melissa/melissaToques.js | 116 + src/router/routes.misc.js | 8 + src/views/pages/account/ProfilePage.vue | 120 +- 15 files changed, 6828 insertions(+), 8 deletions(-) create mode 100644 database-novo/migrations/20260425000001_user_settings_layout_variant_melissa.sql create mode 100644 src/layout/melissa/MelissaAgenda.vue create mode 100644 src/layout/melissa/MelissaBusca.vue create mode 100644 src/layout/melissa/MelissaCard.vue create mode 100644 src/layout/melissa/MelissaCronometro.vue create mode 100644 src/layout/melissa/MelissaLayout.vue create mode 100644 src/layout/melissa/MelissaMenu.vue create mode 100644 src/layout/melissa/composables/useMelissaEventos.js create mode 100644 src/layout/melissa/composables/useMelissaPacientes.js create mode 100644 src/layout/melissa/melissaToques.js diff --git a/.gitignore b/.gitignore index 6901089..1c7424c 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ evolution-api/ # Backups locais do banco — não comitar (regeneráveis via db.cjs backup) database-novo/backups/ +# Rascunhos de design locais (Melissa Direção A, etc) +layout-scratchs/ + # Outputs do Playwright test-results/ playwright-report/ diff --git a/database-novo/migrations/20260425000001_user_settings_layout_variant_melissa.sql b/database-novo/migrations/20260425000001_user_settings_layout_variant_melissa.sql new file mode 100644 index 0000000..22c672e --- /dev/null +++ b/database-novo/migrations/20260425000001_user_settings_layout_variant_melissa.sql @@ -0,0 +1,22 @@ +-- ========================================================================== +-- Agencia PSI — Migracao: layout_variant aceita 'melissa' +-- ========================================================================== +-- O CHECK constraint user_settings_layout_variant_check restringia o valor +-- a ('classic', 'rail'). Com a chegada do Layout Melissa (Direção B do +-- redesign — wrapper estilo Win11 lockscreen), precisamos aceitar o valor +-- 'melissa' tambem. +-- +-- Wire-up real do router (troca do AppLayout pelo MelissaLayout) ainda nao +-- foi feito (Fase 5 do roadmap Melissa) — mas a preferencia ja precisa +-- persistir desde agora pra UI do /profile funcionar. +-- ========================================================================== + +ALTER TABLE public.user_settings + DROP CONSTRAINT IF EXISTS user_settings_layout_variant_check; + +ALTER TABLE public.user_settings + ADD CONSTRAINT user_settings_layout_variant_check + CHECK (layout_variant = ANY (ARRAY['classic'::text, 'rail'::text, 'melissa'::text])); + +COMMENT ON COLUMN public.user_settings.layout_variant + IS 'classic (sidebar) | rail (mini rail + painel) | melissa (Win11 lockscreen, Beta)'; diff --git a/src/app/bootstrapUserSettings.js b/src/app/bootstrapUserSettings.js index e504536..bc85ac3 100644 --- a/src/app/bootstrapUserSettings.js +++ b/src/app/bootstrapUserSettings.js @@ -105,7 +105,7 @@ export async function bootstrapUserSettings({ const _lsV = (() => { try { const v = localStorage.getItem('layout_variant'); - return v === 'rail' || v === 'classic' ? v : null; + return v === 'rail' || v === 'classic' || v === 'melissa' ? v : null; } catch { return null; } @@ -114,9 +114,9 @@ export async function bootstrapUserSettings({ if (_lsV !== null) { // localStorage já tem valor → aplica ele (garante coerência com layoutConfig) if (_lsV !== (settings.layout_variant ?? _lsV)) setVariant(_lsV); - } else if (settings.layout_variant === 'rail') { - // localStorage vazio + banco tem 'rail' → aplica e grava no localStorage - setVariant('rail'); + } else if (settings.layout_variant === 'rail' || settings.layout_variant === 'melissa') { + // localStorage vazio + banco tem 'rail'/'melissa' → aplica e grava no localStorage + setVariant(settings.layout_variant); } // localStorage vazio + banco tem 'classic' → mantém padrão 'rail' (não aplica) diff --git a/src/layout/composables/layout.js b/src/layout/composables/layout.js index 35ac4c3..f306545 100644 --- a/src/layout/composables/layout.js +++ b/src/layout/composables/layout.js @@ -20,10 +20,14 @@ import { computed, reactive, ref, onMounted, onBeforeUnmount } from 'vue'; const BREAKPOINT_XL = 1280; // ── resolve variant salvo no localStorage ─────────────────── +// 'classic' = Sidebar lateral +// 'rail' = Mini rail + painel +// 'melissa' = Layout Melissa (Direção B). Hoje só persiste a preferência — +// o switch real do app vem na Fase 5 (router wire-up). function _loadVariant() { try { const v = localStorage.getItem('layout_variant'); - if (v === 'rail' || v === 'classic') return v; + if (v === 'rail' || v === 'classic' || v === 'melissa') return v; } catch {} return 'rail'; } @@ -199,7 +203,7 @@ export function useLayout() { }; const setVariant = (v, { fromUser = true } = {}) => { - if (v !== 'classic' && v !== 'rail') return; + if (v !== 'classic' && v !== 'rail' && v !== 'melissa') return; layoutConfig.variant = v; try { localStorage.setItem('layout_variant', v); diff --git a/src/layout/melissa/MelissaAgenda.vue b/src/layout/melissa/MelissaAgenda.vue new file mode 100644 index 0000000..04a2b14 --- /dev/null +++ b/src/layout/melissa/MelissaAgenda.vue @@ -0,0 +1,1862 @@ + + + + + diff --git a/src/layout/melissa/MelissaBusca.vue b/src/layout/melissa/MelissaBusca.vue new file mode 100644 index 0000000..92bd0cd --- /dev/null +++ b/src/layout/melissa/MelissaBusca.vue @@ -0,0 +1,427 @@ + + + + + diff --git a/src/layout/melissa/MelissaCard.vue b/src/layout/melissa/MelissaCard.vue new file mode 100644 index 0000000..e3e3d63 --- /dev/null +++ b/src/layout/melissa/MelissaCard.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/src/layout/melissa/MelissaCronometro.vue b/src/layout/melissa/MelissaCronometro.vue new file mode 100644 index 0000000..847c991 --- /dev/null +++ b/src/layout/melissa/MelissaCronometro.vue @@ -0,0 +1,620 @@ + + + + + diff --git a/src/layout/melissa/MelissaLayout.vue b/src/layout/melissa/MelissaLayout.vue new file mode 100644 index 0000000..3b48926 --- /dev/null +++ b/src/layout/melissa/MelissaLayout.vue @@ -0,0 +1,2106 @@ + + + + + + + + diff --git a/src/layout/melissa/MelissaMenu.vue b/src/layout/melissa/MelissaMenu.vue new file mode 100644 index 0000000..115b8d9 --- /dev/null +++ b/src/layout/melissa/MelissaMenu.vue @@ -0,0 +1,1042 @@ + + + + + diff --git a/src/layout/melissa/composables/useMelissaEventos.js b/src/layout/melissa/composables/useMelissaEventos.js new file mode 100644 index 0000000..e7d853a --- /dev/null +++ b/src/layout/melissa/composables/useMelissaEventos.js @@ -0,0 +1,212 @@ +/* + * useMelissaEventos — composables que carregam eventos reais da agenda + * -------------------------------------------------- + * Pattern espelhado de `src/features/agenda/pages/AgendaTerapeutaPage.vue` + * (loadMonthSearchRows ~ linha 539). + * + * Exporta dois composables: + * - useMelissaEventosSemana(refDateRef) — semana de refDate (ref) + * - useMelissaEventosHoje() — apenas o dia atual + * + * Forma normalizada do evento: + * { + * id, tipo, status, titulo, + * pacienteNome, modalidade, descricao, + * color, label, + * inicio_em, fim_em, + * startH, endH, // decimais (9.5 = 09:30) — usado pelo layout + * dateKey // 'YYYY-MM-DD' pra agrupar por dia + * } + * + * Sem auth/tenant: retorna [] silencioso (UI segue funcional). + * + * NÃO inclui ocorrências virtuais de recorrência (loadAndExpand) pra simplificar + * o preview. Adicionar quando promover Melissa pra produção. + */ +import { ref, watch, onMounted, computed } from 'vue'; +import { supabase } from '@/lib/supabase/client'; +import { useTenantStore } from '@/stores/tenantStore'; + +// ── Cores por tipo/status (consistente com o resto do Melissa) ── +function pickColor(tipo, status) { + const s = String(status || '').toLowerCase(); + if (s === 'realizado' || s === 'realizada') return '#10b981'; + if (s === 'faltou') return '#ef4444'; + if (s === 'cancelado' || s === 'cancelada') return '#94a3b8'; + + const t = String(tipo || '').toLowerCase(); + if (t === 'supervisao' || t === 'supervisão') return '#a855f7'; + if (t === 'reuniao' || t === 'reunião') return '#0ea5e9'; + if (t === 'bloqueio') return '#64748b'; + return '#6366f1'; // sessao default +} + +function isoToDecimalHour(iso) { + const d = new Date(iso); + return d.getHours() + d.getMinutes() / 60; +} + +function normalizeEvent(r) { + const pacNome = r.patients?.nome_completo || ''; + return { + id: r.id, + tipo: r.tipo || 'sessao', + status: r.status || '', + titulo: r.titulo || '', + pacienteNome: pacNome, + modalidade: r.modalidade || '', + descricao: r.observacoes || '', + color: pickColor(r.tipo, r.status), + label: pacNome || r.titulo || '—', + inicio_em: r.inicio_em, + fim_em: r.fim_em, + startH: isoToDecimalHour(r.inicio_em), + endH: isoToDecimalHour(r.fim_em), + dateKey: String(r.inicio_em || '').slice(0, 10) + }; +} + +// ── Helper interno: garante uid + tenant + faz a query ── +async function _fetchRange(start, end) { + const tenantStore = useTenantStore(); + const { data: userData } = await supabase.auth.getUser(); + const userId = userData?.user?.id || null; + if (typeof tenantStore.ensureLoaded === 'function') { + await tenantStore.ensureLoaded(); + } + const tid = tenantStore.activeTenantId || tenantStore.tenantId || null; + + if (!userId || !tid) return []; + + const { data, error } = await supabase + .from('agenda_eventos') + .select('id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patients!agenda_eventos_patient_id_fkey(nome_completo, status)') + .eq('owner_id', userId) + .is('mirror_of_event_id', null) + .gte('inicio_em', start.toISOString()) + .lt('inicio_em', end.toISOString()) + .order('inicio_em', { ascending: true }); + + if (error) throw error; + return (data || []).map(normalizeEvent); +} + +// ── Range helpers ────────────────────────────────────────────── +function rangeSemana(refDate) { + const ref = new Date(refDate); + const dow = ref.getDay(); // 0=dom, 1=seg + const diff = dow === 0 ? -6 : 1 - dow; + const segunda = new Date(ref); + segunda.setDate(ref.getDate() + diff); + segunda.setHours(0, 0, 0, 0); + + // domingo final → usa dia seguinte 00:00 com `lt` pra incluir tudo até 23:59:59.999 + const apósDomingo = new Date(segunda); + apósDomingo.setDate(segunda.getDate() + 7); + return { start: segunda, end: apósDomingo }; +} + +function rangeHoje() { + const inicio = new Date(); + inicio.setHours(0, 0, 0, 0); + const fim = new Date(inicio); + fim.setDate(inicio.getDate() + 1); // amanhã 00:00 + return { start: inicio, end: fim }; +} + +// ── COMPOSABLE 1: semana visível (MelissaAgenda) ────────────── +export function useMelissaEventosSemana(refDateRef) { + const eventos = ref([]); + const loading = ref(false); + const error = ref(null); + + async function fetch() { + loading.value = true; + error.value = null; + try { + const { start, end } = rangeSemana(refDateRef.value); + eventos.value = await _fetchRange(start, end); + } catch (e) { + error.value = e?.message || 'Erro ao carregar agenda'; + eventos.value = []; + // eslint-disable-next-line no-console + console.warn('[useMelissaEventosSemana]', e); + } finally { + loading.value = false; + } + } + + onMounted(fetch); + watch(refDateRef, fetch); + + // Helper computado: agrupa por dateKey ('YYYY-MM-DD') + const eventosPorDia = computed(() => { + const map = {}; + for (const ev of eventos.value) { + (map[ev.dateKey] ||= []).push(ev); + } + return map; + }); + + return { eventos, eventosPorDia, loading, error, refetch: fetch }; +} + +// ── COMPOSABLE 3: range arbitrário (FullCalendar passa via datesSet) ── +// Usado pelo MelissaAgenda — refetcha sempre que start/end mudam (mudança +// de view, navegação prev/next/today). Cobre Day/Week/Month/List sem custo. +export function useMelissaEventosRange(startRef, endRef) { + const eventos = ref([]); + const loading = ref(false); + const error = ref(null); + + async function fetch() { + const s = startRef.value; + const e = endRef.value; + if (!s || !e) { eventos.value = []; return; } + + loading.value = true; + error.value = null; + try { + eventos.value = await _fetchRange(new Date(s), new Date(e)); + } catch (err) { + error.value = err?.message || 'Erro ao carregar agenda'; + eventos.value = []; + // eslint-disable-next-line no-console + console.warn('[useMelissaEventosRange]', err); + } finally { + loading.value = false; + } + } + + onMounted(fetch); + watch([startRef, endRef], fetch); + + return { eventos, loading, error, refetch: fetch }; +} + +// ── COMPOSABLE 2: apenas hoje (MelissaLayout) ────────────────── +export function useMelissaEventosHoje() { + const eventos = ref([]); + const loading = ref(false); + const error = ref(null); + + async function fetch() { + loading.value = true; + error.value = null; + try { + const { start, end } = rangeHoje(); + eventos.value = await _fetchRange(start, end); + } catch (e) { + error.value = e?.message || 'Erro ao carregar agenda'; + eventos.value = []; + // eslint-disable-next-line no-console + console.warn('[useMelissaEventosHoje]', e); + } finally { + loading.value = false; + } + } + + onMounted(fetch); + + return { eventos, loading, error, refetch: fetch }; +} diff --git a/src/layout/melissa/composables/useMelissaPacientes.js b/src/layout/melissa/composables/useMelissaPacientes.js new file mode 100644 index 0000000..bb92b44 --- /dev/null +++ b/src/layout/melissa/composables/useMelissaPacientes.js @@ -0,0 +1,107 @@ +/* + * useMelissaPacientes — composable que carrega pacientes ativos do terapeuta + * -------------------------------------------------- + * Pattern espelhado de `src/features/patients/PatientsListPage.vue`: + * - withOwnerFilter (apenas os pacientes do owner_id = uid do user logado) + * - withTenantFilter (defesa em profundidade — RLS cobre, mas blindamos no client) + * - normalizeStatus client-side (DB pode ter 'ativo'/'active'/null/etc.) + * + * Sem auth (sem uid ou tenant), retorna vazio sem erro — uso em /preview/melissa + * permite a página renderizar mesmo sem session. + * + * Forma normalizada do paciente: + * { id, nome, email, telefone, avatar_url, status, last_attended_at, created_at } + * + * Quando precisar fora do Melissa: promover pra src/composables/. + */ +import { ref, onMounted } from 'vue'; +import { supabase } from '@/lib/supabase/client'; +import { useTenantStore } from '@/stores/tenantStore'; + +// Mesma lógica da PatientsListPage.vue — DB pode ter valores variados +function normalizeStatus(s) { + const v = String(s || '').toLowerCase().trim(); + if (!v) return 'Ativo'; + if (v === 'active' || v === 'ativo') return 'Ativo'; + if (v === 'inactive' || v === 'inativo') return 'Inativo'; + return v.charAt(0).toUpperCase() + v.slice(1); +} + +export function useMelissaPacientes() { + const tenantStore = useTenantStore(); + + const pacientes = ref([]); + const loading = ref(false); + const error = ref(null); + const uid = ref(null); + + async function ensureUid() { + if (uid.value) return uid.value; + const { data, error: err } = await supabase.auth.getUser(); + if (err) return null; + uid.value = data?.user?.id || null; + return uid.value; + } + + async function fetchPacientes() { + const userId = await ensureUid(); + + // Garante que o tenantStore foi hidratado (preview misc não passa por + // guard de auth, então o store pode estar vazio mesmo com user logado) + if (typeof tenantStore.ensureLoaded === 'function') { + await tenantStore.ensureLoaded(); + } + const tid = tenantStore.activeTenantId || tenantStore.tenantId || null; + + if (!userId || !tid) { + pacientes.value = []; + return; + } + + loading.value = true; + error.value = null; + + try { + // Não filtra status no SQL — DB tem valores inconsistentes + // ('ativo', 'Ativo', 'active', null...). Normaliza e filtra no client. + const { data, error: err } = await supabase + .from('patients') + .select('id, nome_completo, email_principal, telefone, status, avatar_url, last_attended_at, created_at') + .eq('owner_id', userId) + .eq('tenant_id', tid) + .order('nome_completo', { ascending: true }) + .limit(1000); + + if (err) throw err; + + const todos = (data || []).map((r) => ({ + id: r.id, + nome: r.nome_completo || '', + email: r.email_principal || '', + telefone: r.telefone || '', + avatar_url: r.avatar_url || null, + status: normalizeStatus(r.status), + last_attended_at: r.last_attended_at || null, + created_at: r.created_at || null + })); + + pacientes.value = todos.filter((p) => p.status === 'Ativo'); + } catch (e) { + error.value = e?.message || 'Erro ao carregar pacientes'; + pacientes.value = []; + // eslint-disable-next-line no-console + console.warn('[useMelissaPacientes]', e); + } finally { + loading.value = false; + } + } + + onMounted(fetchPacientes); + + return { + pacientes, + loading, + error, + refetch: fetchPacientes + }; +} diff --git a/src/layout/melissa/melissaToques.js b/src/layout/melissa/melissaToques.js new file mode 100644 index 0000000..bc6c0fd --- /dev/null +++ b/src/layout/melissa/melissaToques.js @@ -0,0 +1,116 @@ +/* + * melissaToques — geração de toques de término via Web Audio API + * -------------------------------------------------------------- + * Não usa arquivos de áudio externos. Tudo gerado em runtime com + * osciladores. Mantém self-hosted, leve e sem build de assets. + * + * Uso: + * import { TOQUES, playToque } from './melissaToques'; + * playToque('sino'); + */ + +export const TOQUES = [ + { id: 'sino', label: 'Sino' }, + { id: 'acorde', label: 'Acorde' }, + { id: 'tic-tac', label: 'Tic-tac' }, + { id: 'suave', label: 'Suave' }, + { id: 'nenhum', label: 'Nenhum (silencioso)' } +]; + +function getCtx() { + const Ctx = window.AudioContext || window.webkitAudioContext; + return Ctx ? new Ctx() : null; +} + +// Sino: clássico ding com decaimento longo +function playSino(ctx) { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + osc.type = 'sine'; + osc.frequency.value = 880; // A5 + gain.gain.setValueAtTime(0.0001, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.25, ctx.currentTime + 0.01); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 1.6); + osc.start(); + osc.stop(ctx.currentTime + 1.7); + setTimeout(() => ctx.close(), 1900); +} + +// Acorde: C maior arpejado (C5 E5 G5) +function playAcorde(ctx) { + const freqs = [523.25, 659.25, 783.99]; + freqs.forEach((freq, i) => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + osc.type = 'sine'; + osc.frequency.value = freq; + const start = ctx.currentTime + i * 0.07; + gain.gain.setValueAtTime(0.0001, start); + gain.gain.exponentialRampToValueAtTime(0.18, start + 0.01); + gain.gain.exponentialRampToValueAtTime(0.001, start + 1.5); + osc.start(start); + osc.stop(start + 1.6); + }); + setTimeout(() => ctx.close(), 2100); +} + +// Tic-tac: dois cliques curtos e secos +function playTicTac(ctx) { + [0, 0.18].forEach((delay) => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + osc.type = 'square'; + osc.frequency.value = 1500; + const start = ctx.currentTime + delay; + gain.gain.setValueAtTime(0.0001, start); + gain.gain.exponentialRampToValueAtTime(0.12, start + 0.005); + gain.gain.exponentialRampToValueAtTime(0.001, start + 0.06); + osc.start(start); + osc.stop(start + 0.07); + }); + setTimeout(() => ctx.close(), 500); +} + +// Suave: fade-in/fade-out lento, quase respiração +function playSuave(ctx) { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + osc.type = 'sine'; + osc.frequency.value = 660; // E5 + gain.gain.setValueAtTime(0.0001, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.18, ctx.currentTime + 0.5); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 2.5); + osc.start(); + osc.stop(ctx.currentTime + 2.6); + setTimeout(() => ctx.close(), 2800); +} + +const PLAYERS = { + sino: playSino, + acorde: playAcorde, + 'tic-tac': playTicTac, + suave: playSuave, + nenhum: () => {} +}; + +export function playToque(id) { + if (id === 'nenhum') return; + const fn = PLAYERS[id] || PLAYERS.sino; + try { + const ctx = getCtx(); + if (!ctx) return; + // Web Audio em alguns browsers começa suspended até primeira interação + if (ctx.state === 'suspended') ctx.resume?.(); + fn(ctx); + } catch { + // Falha silenciosa: não há nada útil a fazer aqui + } +} diff --git a/src/router/routes.misc.js b/src/router/routes.misc.js index 80b8442..4d2ea03 100644 --- a/src/router/routes.misc.js +++ b/src/router/routes.misc.js @@ -23,6 +23,14 @@ export default { component: () => import('@/views/pages/Landing.vue') }, + // Sandbox do layout Melissa (Direção B — lockscreen-style) + // Standalone, sem auth, sem AppLayout. Promovido de /preview/dashboard-win11. + { + path: 'preview/melissa', + name: 'PreviewMelissa', + component: () => import('@/layout/melissa/MelissaLayout.vue') + }, + // 404 { path: 'pages/notfound', diff --git a/src/views/pages/account/ProfilePage.vue b/src/views/pages/account/ProfilePage.vue index 0e34815..6f7c85f 100644 --- a/src/views/pages/account/ProfilePage.vue +++ b/src/views/pages/account/ProfilePage.vue @@ -1366,7 +1366,7 @@ onBeforeUnmount(() => {
-
+
+ + +
- O layout Rail exibe ícones no canto esquerdo. Ao clicar em um ícone, o painel lateral expande com os itens de navegação. Disponível apenas no desktop. + Rail: ícones no canto esquerdo + painel expansível. Disponível apenas no desktop.
+ Melissa — em construção: layout fullscreen com resumo do dia, busca rápida e cronômetro de sessão. Selecionar aqui salva sua preferência, mas a navegação completa ainda não está integrada. Acesse o preview em + /preview/melissa.
@@ -1702,6 +1734,90 @@ onBeforeUnmount(() => { margin-top: 1px; } +/* ─── Card Melissa (Direção B) — preview Win11 lockscreen ──── */ +.lv-card--wip { + /* Listras suaves no fundo do card pra reforçar visualmente "em obras", + sem prejudicar o preview no topo. */ + background-image: repeating-linear-gradient( + 135deg, + transparent 0, + transparent 12px, + color-mix(in srgb, var(--primary-color) 4%, transparent) 12px, + color-mix(in srgb, var(--primary-color) 4%, transparent) 14px + ); +} +.lv-card__badge { + position: absolute; + top: 8px; + right: 8px; + z-index: 2; + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 3px 9px; + border-radius: 999px; + background: color-mix(in srgb, var(--primary-color) 22%, transparent); + color: var(--primary-color); + border: 1px solid color-mix(in srgb, var(--primary-color) 40%, transparent); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); +} +.lv-card__preview--melissa { + position: relative; + background: + radial-gradient(circle at 70% 30%, color-mix(in srgb, var(--primary-color) 35%, transparent) 0%, transparent 55%), + radial-gradient(circle at 25% 75%, color-mix(in srgb, var(--primary-color) 22%, transparent) 0%, transparent 50%), + linear-gradient(135deg, var(--surface-900, #0f172a) 0%, color-mix(in srgb, var(--primary-color) 50%, var(--surface-900, #0f172a)) 50%, var(--surface-900, #0f172a) 100%); + padding: 0; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 4px; +} +.lv-pm__clock { + font-family: 'Segoe UI', system-ui, sans-serif; + font-size: 1.4rem; + font-weight: 200; + color: rgba(255, 255, 255, 0.95); + letter-spacing: -0.04em; + line-height: 1; + text-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); +} +.lv-pm__sep { + opacity: 0.6; +} +.lv-pm__cards { + display: flex; + gap: 4px; + margin-top: 2px; +} +.lv-pm__card { + width: 14px; + height: 10px; + border-radius: 3px; + background: rgba(255, 255, 255, 0.18); + border: 1px solid rgba(255, 255, 255, 0.12); + backdrop-filter: blur(2px); +} +.lv-pm__psi { + position: absolute; + bottom: 6px; + left: 8px; + width: 14px; + height: 14px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.25); + color: rgba(255, 255, 255, 0.95); + font-family: 'Instrument Serif', Georgia, serif; + font-size: 0.7rem; + font-style: italic; + display: grid; + place-items: center; + line-height: 1; +} + /* ─── Animation ─────────────────────────────────────────── */ @keyframes prof-fadeUp { from {