diff --git a/src/layout/melissa/MelissaHeroClock.vue b/src/layout/melissa/MelissaHeroClock.vue new file mode 100644 index 0000000..2e80235 --- /dev/null +++ b/src/layout/melissa/MelissaHeroClock.vue @@ -0,0 +1,131 @@ + + + + + + {{ hora }} + + + + + + + {{ dataExtenso }} + + + + {{ saudacao }}, {{ usuario }}. + + + + + Sua agenda está livre hoje. + + + Hoje há + + {{ p.text }}, e + . + + + + + + diff --git a/src/layout/melissa/MelissaLayout.vue b/src/layout/melissa/MelissaLayout.vue index 4e5af99..59108bb 100644 --- a/src/layout/melissa/MelissaLayout.vue +++ b/src/layout/melissa/MelissaLayout.vue @@ -19,12 +19,16 @@ import { ref, computed, watch, onMounted, onBeforeUnmount, provide, nextTick } f import { useRouter, useRoute } from 'vue-router'; import { useToast } from 'primevue/usetoast'; import { useLayout } from '@/layout/composables/layout'; -import { applyThemeEngine, surfaces as THEME_SURFACES } from '@/theme/theme.options'; +import { applyThemeEngine, surfaces as THEME_SURFACES, presetOptions as THEME_PRESETS } from '@/theme/theme.options'; import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence'; import MelissaCronometro from './MelissaCronometro.vue'; import MelissaCard from './MelissaCard.vue'; import MelissaBusca from './MelissaBusca.vue'; import MelissaMenu from './MelissaMenu.vue'; +import MelissaSettingsPanel from './MelissaSettingsPanel.vue'; +import MelissaHeroClock from './MelissaHeroClock.vue'; +import MelissaTimelineHoje from './MelissaTimelineHoje.vue'; +import { useMelissaWallpaper } from './composables/useMelissaWallpaper'; import MelissaAgenda from './MelissaAgenda.vue'; import MelissaPacientes from './MelissaPacientes.vue'; import MelissaCompromissos from './MelissaCompromissos.vue'; @@ -42,8 +46,9 @@ import MelissaBloqueios from './MelissaBloqueios.vue'; import MelissaAgendador from './MelissaAgendador.vue'; import MelissaAgendaConfig from './MelissaAgendaConfig.vue'; import MelissaPagamento from './MelissaPagamento.vue'; -import MelissaConfigSidebar from './MelissaConfigSidebar.vue'; -import { isMelissaConfigSlug } from './composables/melissaConfigGrupos.js'; +// Sidebar global de configs removido — substituido por botao + popover +// (MelissaConfigPopover) dentro de cada pagina de config. Resolveu lag +// de scroll que o overlay sempre visivel causava em mobile. import MelissaEmbed from './MelissaEmbed.vue'; import MelissaCadastrosRecebidos from './MelissaCadastrosRecebidos.vue'; import MelissaAgendamentosRecebidos from './MelissaAgendamentosRecebidos.vue'; @@ -56,7 +61,7 @@ import MelissaDocumentosTemplates from './MelissaDocumentosTemplates.vue'; import MelissaRelatorios from './MelissaRelatorios.vue'; import MelissaMedicos from './MelissaMedicos.vue'; import MelissaEventoPanel from './MelissaEventoPanel.vue'; -import { TOQUES, playToque } from './melissaToques'; +import { TOQUE_IDS, useMelissaToques } from './composables/useMelissaToques'; import { useMelissaPacientes } from './composables/useMelissaPacientes'; import { useMelissaEventosHoje } from './composables/useMelissaEventos'; import { useMelissaWhatsapp } from './composables/useMelissaWhatsapp'; @@ -250,16 +255,10 @@ const secaoAberta = computed(() => { return null; }); -// Mostra o sidebar lateral de configuracoes (MelissaConfigSidebar) -// quando a rota atual e um slug do catalogo de configs (any item em -// MELISSA_CONFIG_GRUPOS). -const showConfigSidebar = computed(() => isMelissaConfigSlug(secaoAberta.value)); - -// CSS var pras paginas nativas saberem que precisam abrir espaco a -// esquerda pro sidebar (296px = 280 width + 16 gap). -const configAsideLeftStyle = computed(() => ({ - '--m-config-aside-left': showConfigSidebar.value ? '296px' : '6px' -})); +// (Removido) showConfigSidebar / configAsideLeftStyle — o sidebar +// global de configs foi substituido pelo MelissaConfigPopover +// dentro de cada pagina, ancorado no botao "Configuracoes" no +// topo da .xxx-side. Sem mais CSS var de left dinamico nas pages. // Quando o usuário fecha a seção e volta pro resumo, garante que os // dados estão prontos (caso o idle callback ainda não tenha disparado @@ -355,7 +354,6 @@ function pinMeta(slug) { // Prefs de layout/UI (toque, fundo, opacidade, formato hora) // TODO: migrar pra configs do tenant — hoje só localStorage pra survive refresh const LAYOUT_STORAGE_KEY = 'melissa.layout.v1'; -const TOQUE_IDS = new Set(TOQUES.map((t) => t.id)); // ─────────────────────────────────────────────────────────────── // Relógio (atualiza a cada segundo) @@ -410,61 +408,20 @@ const saudacao = computed(() => { }); // ─────────────────────────────────────────────────────────────── -// Background customizável +// Background customizável (composable) // ─────────────────────────────────────────────────────────────── -const bgUrl = ref(''); // vazio = usa gradiente default -const overlayOpacity = ref(0.35); // 0–0.8 — escurecedor sobre o bg -const bgImageOpacity = ref(1); // 0.01–1 — transparência da foto custom -const fileInput = ref(null); - -// Limite de upload — protege quota do localStorage (~5MB) e evita data URL -// gigante atravessando a UI. JPG/PNG 1920×1080 cabe folgado nesse teto. -const MAX_BG_BYTES = 2 * 1024 * 1024; // 2 MB - -function pickFile() { - fileInput.value?.click(); -} -function onFileChange(e) { - const file = e.target.files?.[0]; - if (!file) return; - if (!file.type.startsWith('image/')) { - toast.add({ severity: 'warn', summary: 'Formato inválido', detail: 'Selecione um arquivo de imagem (JPG, PNG, WEBP).', life: 4000 }); - e.target.value = ''; - return; - } - if (file.size > MAX_BG_BYTES) { - toast.add({ - severity: 'warn', - summary: 'Imagem muito grande', - detail: 'Máximo 2 MB. Reduza a resolução ou compressão e tente novamente.', - life: 4500 - }); - e.target.value = ''; - return; - } - const reader = new FileReader(); - reader.onload = (ev) => (bgUrl.value = ev.target.result); - reader.readAsDataURL(file); -} -function clearBg() { - bgUrl.value = ''; -} - -// Gradiente default — sempre renderizado no .win11-root (atrás de tudo). -// Quando o user faz upload, .win11-photo aparece por cima com opacidade -// controlada pelo slider — permite blend natural com o gradiente abaixo. -// Cores vêm de CSS vars que flipam com dark/light AND seguem o preset -// (ver style global no fim do arquivo: --bloom-c1/c2/base-1/base-2). -const defaultBgStyle = { - backgroundImage: - 'radial-gradient(circle at 70% 30%, var(--bloom-c1) 0%, transparent 55%), radial-gradient(circle at 25% 75%, var(--bloom-c2) 0%, transparent 50%), linear-gradient(135deg, var(--bloom-base-1) 0%, var(--bloom-base-2) 50%, var(--bloom-base-1) 100%)', - backgroundSize: 'cover' -}; - -const photoStyle = computed(() => ({ - backgroundImage: bgUrl.value ? `url(${bgUrl.value})` : 'none', - opacity: bgImageOpacity.value -})); +// Refs (bgUrl/overlayOpacity/bgImageOpacity) sao criados pelo +// composable; persistencia continua aqui no pai (saveLayoutPrefs + +// applyPrefsPayload + watcher) pra agrupar com outras prefs. +const { + bgUrl, + overlayOpacity, + bgImageOpacity, + defaultBgStyle, + photoStyle, + onFileChange, + clearBg +} = useMelissaWallpaper(); // ─────────────────────────────────────────────────────────────── // Tema (dark/light + cor primária) — usa a infra existente do app @@ -524,6 +481,16 @@ function setSurface(name) { userSettings.queuePatch({ surface_color: name }); } +// Preset = engine de tokens do PrimeVue. Mantemos so Lara e Nora +// (Aura ficou de fora — decisao de design pra reduzir ruido visual). +const PRESETS = THEME_PRESETS.filter((p) => p === 'Lara' || p === 'Nora'); +function setPreset(name) { + if (!name || layoutConfig.preset === name) return; + layoutConfig.preset = name; + applyThemeEngine(layoutConfig); + userSettings.queuePatch({ preset: name }); +} + // ─────────────────────────────────────────────────────────────── // Settings popover (canto superior direito) @@ -531,164 +498,12 @@ function setSurface(name) { const settingsOpen = ref(false); // ─────────────────────────────────────────────────────────────── -// Timeline horizontal — range derivado de agenda_regras_semanais -// (regra do dia da semana atual). Fallback: agenda_configuracoes -// global → 08–18h. Range expande pra incluir eventos fora do -// expediente (sessão excepcional não some da timeline). +// Timeline horizontal — range/eco/posicoes/auto-scroll/cursor "Agora" // ─────────────────────────────────────────────────────────────── -function _timeStrToHour(s, fallback) { - const str = String(s || '').slice(0, 5); - const [h, m] = str.split(':').map(Number); - if (Number.isFinite(h) && Number.isFinite(m)) return h + m / 60; - return fallback; -} - -const todayRules = computed(() => { - const dow = new Date().getDay(); // 0=dom .. 6=sáb - return (agendaWorkRules.value || []).filter((r) => r.dia_semana === dow && r.ativo !== false); -}); - -const isFolga = computed(() => todayRules.value.length === 0); - -const todayFeriado = computed(() => { - const d = new Date(); - const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; - return (agendaFeriados.value || []).find((f) => f.data === k) || null; -}); - -function _baseRange() { - const rules = todayRules.value; - if (rules.length > 0) { - const starts = rules.map((r) => _timeStrToHour(r.hora_inicio, 8)); - const ends = rules.map((r) => _timeStrToHour(r.hora_fim, 18)); - return { start: Math.min(...starts), end: Math.max(...ends) }; - } - const s = agendaSettings.value; - const fbStart = (s?.usar_horario_admin_custom && s?.admin_inicio_visualizacao) || s?.agenda_custom_start || '08:00'; - const fbEnd = (s?.usar_horario_admin_custom && s?.admin_fim_visualizacao) || s?.agenda_custom_end || '18:00'; - return { start: _timeStrToHour(fbStart, 8), end: _timeStrToHour(fbEnd, 18) }; -} - -const HORA_INICIO = computed(() => { - const { start } = _baseRange(); - const eventos = eventosHojeReais.value || []; - const minEv = eventos.length ? Math.min(...eventos.map((e) => e.startH)) : Infinity; - return Math.max(0, Math.floor(Math.min(start, minEv))); -}); - -const HORA_FIM = computed(() => { - const { end } = _baseRange(); - const eventos = eventosHojeReais.value || []; - const maxEv = eventos.length ? Math.max(...eventos.map((e) => e.endH)) : -Infinity; - return Math.min(24, Math.ceil(Math.max(end, maxEv))); -}); - -const hoursRange = computed(() => { - const arr = []; - for (let h = HORA_INICIO.value; h <= HORA_FIM.value; h++) arr.push(h); - return arr; -}); - -// Auto-scroll horizontal: ao montar, centra "agora" na viewport pra -// jornada longa (ex: 02h–23h) não abrir com cursor off-screen. -// Só dispara uma vez — depois respeita o scroll manual. -// IMPORTANTE: o watch precisa rodar DEPOIS do destructure de M -// (agendaWorkRules etc.), senão TDZ explode. Daí registramos dentro -// de onMounted (mais abaixo no setup, depois das deps inicializarem). -const tlHScrollEl = ref(null); -let _tlAutoScrolled = false; -function _scrollTimelineToNow() { - const el = tlHScrollEl.value; - if (!el) return; - const d = new Date(); - const h = d.getHours() + d.getMinutes() / 60; - const total = HORA_FIM.value - HORA_INICIO.value; - if (total <= 0 || h < HORA_INICIO.value || h > HORA_FIM.value) return; - const inner = el.firstElementChild; - if (!inner) return; - const innerWidth = inner.scrollWidth || inner.offsetWidth; - const visibleWidth = el.clientWidth; - if (innerWidth <= visibleWidth) return; // sem overflow, nada a rolar - const ratio = (h - HORA_INICIO.value) / total; - el.scrollLeft = Math.max(0, ratio * innerWidth - visibleWidth / 2); -} - -// ── Eco lateral — tracinhos coloridos nas bordas indicando eventos -// fora da viewport. Posição vertical mapeada por tempo (mostra a -// "forma" do dia que tá off-screen). Click = scroll suave até o evento. -// Pulse sutil quando há algo não-visto. Padrão minimap consciente. -const tlScrollState = ref({ scrollL: 0, viewW: 0, innerW: 0 }); -function _updateTlScrollState() { - const el = tlHScrollEl.value; - if (!el) { - tlScrollState.value = { scrollL: 0, viewW: 0, innerW: 0 }; - return; - } - const inner = el.firstElementChild; - tlScrollState.value = { - scrollL: el.scrollLeft, - viewW: el.clientWidth, - innerW: inner ? (inner.scrollWidth || inner.offsetWidth) : 0 - }; -} -function onTimelineScroll() { - _updateTlScrollState(); -} -const tlEcoState = computed(() => { - const { scrollL, viewW, innerW } = tlScrollState.value; - const total = HORA_FIM.value - HORA_INICIO.value; - const empty = { left: [], right: [], vStart: HORA_INICIO.value, vEnd: HORA_FIM.value }; - if (total <= 0 || !innerW || !viewW || innerW <= viewW) return empty; - const vStart = HORA_INICIO.value + (scrollL / innerW) * total; - const vEnd = HORA_INICIO.value + ((scrollL + viewW) / innerW) * total; - const left = []; - const right = []; - for (const ev of eventosVisiveis.value) { - if (ev.endH <= vStart) left.push(ev); - else if (ev.startH >= vEnd) right.push(ev); - } - return { left, right, vStart, vEnd }; -}); -// Posição vertical (%) do tracinho dentro da faixa esquerda/direita. -// Esquerda: HORA_INICIO → vStart mapeado 0-100%. -// Direita: vEnd → HORA_FIM mapeado 0-100%. -function ecoTickStyle(ev, side) { - const { vStart, vEnd } = tlEcoState.value; - let topPct = 50; - if (side === 'left') { - const span = vStart - HORA_INICIO.value; - if (span > 0) topPct = ((ev.startH - HORA_INICIO.value) / span) * 100; - } else { - const span = HORA_FIM.value - vEnd; - if (span > 0) topPct = ((ev.startH - vEnd) / span) * 100; - } - return { - top: `${Math.max(0, Math.min(100, topPct))}%`, - backgroundColor: ev.color - }; -} -function scrollToEvent(ev) { - const el = tlHScrollEl.value; - if (!el) return; - const inner = el.firstElementChild; - if (!inner) return; - const innerWidth = inner.scrollWidth || inner.offsetWidth; - const visibleWidth = el.clientWidth; - const total = HORA_FIM.value - HORA_INICIO.value; - if (total <= 0) return; - const ratio = (ev.startH - HORA_INICIO.value) / total; - const target = Math.max(0, Math.min(innerWidth - visibleWidth, ratio * innerWidth - visibleWidth / 2)); - el.scrollTo({ left: target, behavior: 'smooth' }); -} - -// timelineEvents removido — agora usa `eventosHojeReais` (Supabase) -// Pra debug: ver useMelissaEventos.js em src/layout/melissa/composables/ - -function fmtHora(h) { - const horas = Math.floor(h); - const mins = Math.round((h - horas) * 60); - return `${String(horas).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; -} +// Tudo (HORA_INICIO/FIM, hoursRange, eventosVisiveis, isFolga, todayFeriado, +// tlHScrollEl, tlEcoState, etc.) migrou pro componente MelissaTimelineHoje. +// Pai so passa eventos brutos + workRules/settings/feriados via props, +// e recebe @evento (pra abrir dialog) + @clear-filter (pra limpar tipo). // Contagens por tipo + frase resumo do dia const contagensDia = computed(() => { @@ -912,7 +727,9 @@ function onRemarcar() { updateEventoStatus('remarcar', 'Marcada pra remarcar'); } -// Filtro da timeline por tipo (clicado a partir do resumo) +// Filtro da timeline por tipo (clicado no resumo do hero, lido pela +// timeline e pelo hero pra destacar chip ativo). Permanece aqui no +// pai porque eh state compartilhado entre dois filhos. const filtroTipo = ref(null); function toggleFiltro(tipo) { filtroTipo.value = filtroTipo.value === tipo ? null : tipo; @@ -921,85 +738,14 @@ function limparFiltro() { filtroTipo.value = null; } -const eventosVisiveis = computed(() => { - if (!filtroTipo.value) return eventosHojeReais.value; - return eventosHojeReais.value.filter((ev) => ev.tipo === filtroTipo.value); -}); - -const filtroLabel = computed(() => { - if (!filtroTipo.value) return ''; - const map = { sessao: 'atendimentos', supervisao: 'supervisões', reuniao: 'reuniões' }; - return map[filtroTipo.value] || ''; -}); - -function eventStyle(ev) { - const total = HORA_FIM.value - HORA_INICIO.value; - const left = ((ev.startH - HORA_INICIO.value) / total) * 100; - const width = ((ev.endH - ev.startH) / total) * 100; - return { - left: `${left}%`, - width: `${width}%`, - backgroundColor: ev.color, - // Expõe a cor pra CSS (glow/pulse usa color-mix com essa var). - '--ev-color': ev.color - }; +// Helper de formatacao de hora — usado pelo card "proximo paciente" +// abaixo. A timeline tem sua propria copia interna. +function fmtHora(h) { + const horas = Math.floor(h); + const mins = Math.round((h - horas) * 60); + return `${String(horas).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; } -// ── Status helpers — classes + ícones aplicados nas pílulas ── -function statusKey(ev) { - const s = String(ev?.status || '').toLowerCase(); - if (s === 'realizada') return 'realizado'; - if (s === 'cancelada') return 'cancelado'; - return s || 'agendado'; -} -function statusIcon(ev) { - const s = statusKey(ev); - if (s === 'realizado') return 'pi pi-check'; - if (s === 'faltou') return 'pi pi-times'; - if (s === 'cancelado') return 'pi pi-ban'; - if (s === 'remarcar') return 'pi pi-refresh'; - return null; -} -function isEvEmCurso(ev) { - const s = statusKey(ev); - if (s === 'realizado' || s === 'cancelado' || s === 'faltou') return false; - const d = now.value; - const h = d.getHours() + d.getMinutes() / 60; - return typeof ev?.startH === 'number' && typeof ev?.endH === 'number' - && h >= ev.startH && h < ev.endH; -} -function pillStatusClass(ev) { - const s = statusKey(ev); - return [ - `tl-pill--${s}`, - { 'tl-pill--em-curso': isEvEmCurso(ev) } - ]; -} - -const nowCursorLeft = computed(() => { - const d = now.value; - const h = d.getHours() + d.getMinutes() / 60; - if (h < HORA_INICIO.value || h > HORA_FIM.value) return '-100%'; - const total = HORA_FIM.value - HORA_INICIO.value; - return `${((h - HORA_INICIO.value) / total) * 100}%`; -}); - -// Visualização vertical (< lg) — tipo calendário "dia" -const VT_HOUR_PX = 48; // altura de cada slot de hora em px -function eventStyleVertical(ev) { - return { - top: `${(ev.startH - HORA_INICIO.value) * VT_HOUR_PX}px`, - height: `${(ev.endH - ev.startH) * VT_HOUR_PX}px`, - backgroundColor: ev.color - }; -} -const nowCursorTop = computed(() => { - const d = now.value; - const h = d.getHours() + d.getMinutes() / 60; - if (h < HORA_INICIO.value || h > HORA_FIM.value) return '-100%'; - return `${(h - HORA_INICIO.value) * VT_HOUR_PX}px`; -}); - // ─────────────────────────────────────────────────────────────── // Cards do resumo — proximo-paciente derivado de eventosHojeReais. // ─────────────────────────────────────────────────────────────── @@ -1123,7 +869,10 @@ const CONFIG_DURACAO_MIN = 50; const cronoRef = ref(null); const cronoVisible = ref(false); // sincronizado via @visible-change -const toqueTermino = ref('sino'); // TODO: persistir nas configs do tenant + +// Preferencia de toque + funcao de teste (composable). cronoRef/abrir/fechar +// continuam aqui — sao instance state do , nao da pref. +const { toqueTermino, testarToque } = useMelissaToques('sino'); function abrirCronometro() { cronoRef.value?.abrir(); @@ -1131,9 +880,6 @@ function abrirCronometro() { function fecharCronometro() { cronoRef.value?.fechar(); } -function testarToque() { - playToque(toqueTermino.value); -} // Provide das prefs/refs pro MelissaConfiguracoes (página interna de // configs). Posicionado aqui pra que TODAS as refs/funções referenciadas @@ -1146,8 +892,10 @@ provide('melissaSettings', { activeSurface, PRIMARY_COLORS, SURFACES, + PRESETS, setPrimary, setSurface, + setPreset, setDark, // bg + opacidades + handler do input bgUrl, @@ -1309,42 +1057,8 @@ onMounted(async () => { await loadDbPrefs(); // async: sobrescreve com valores autoritativos do DB }); -// Auto-scroll da timeline horizontal — registrado em onMounted pra -// garantir que agendaWorkRules / agendaSettings já saíram do TDZ. -// Watch dispara uma vez quando o range fica definido (rules carregam -// async). `stop()` no primeiro scroll válido pra não brigar com user. -// Também mantém o tlScrollState atualizado pra alimentar o eco lateral. -onMounted(() => { - const stop = watch( - [HORA_INICIO, HORA_FIM], - () => { - nextTick(() => { - if (!_tlAutoScrolled) { - _scrollTimelineToNow(); - const el = tlHScrollEl.value; - const inner = el?.firstElementChild; - if (inner && inner.scrollWidth > el.clientWidth) { - _tlAutoScrolled = true; - stop(); - } - } - // Sempre re-mede após mudança de range (innerWidth muda). - _updateTlScrollState(); - }); - }, - { immediate: true } - ); - - // ResizeObserver pra atualizar o eco quando a viewport muda - // (ex: user redimensiona janela, abre/fecha sidebar, etc). - const el = tlHScrollEl.value; - if (el && typeof ResizeObserver !== 'undefined') { - const ro = new ResizeObserver(() => _updateTlScrollState()); - ro.observe(el); - if (el.firstElementChild) ro.observe(el.firstElementChild); - onBeforeUnmount(() => ro.disconnect()); - } -}); +// Auto-scroll inicial + ResizeObserver da timeline migrou pro +// componente MelissaTimelineHoje (agora self-contained). // ─────────────────────────────────────────────────────────────── // Workspace overlay @@ -1401,7 +1115,7 @@ function onKeydown(e) { - + @@ -1466,181 +1180,25 @@ function onKeydown(e) { - - Personalização - - - - Trocar imagem de fundo - - - Recomendado: 1920×1080 (Full HD), JPG ou PNG. Tamanho máximo: 2 MB. - - - - Voltar ao padrão - - - - - - Transparência da imagem: {{ Math.round(bgImageOpacity * 100) }}% - - - - - - - Opacidade do fundo: {{ Math.round(overlayOpacity * 100) }}% - - - - - - Toque de término - - - - {{ t.label }} - - - - - - - - - - - Formato 24h - (relógio) - - - - - - - - Modo escuro - - - - - - - Cor primária - - - - - - - Surface - - - - - + @close="settingsOpen = false" + /> - - - - {{ horaFormatada }} - - - - - - {{ dataExtenso }} - - - {{ saudacao }}, Dr. Leonardo. - - - - Sua agenda está livre hoje. - - - Hoje há - - {{ p.text }}, e - . - - - + + - - - - - - Linha do tempo — Hoje - - - Feriado{{ todayFeriado.nome ? `: ${todayFeriado.nome}` : '' }} - - - - Folga - - - {{ filtroLabel }} - - - - - - Agora - - - - - - - - - - {{ h }}h - - - - - {{ ev.label }} - - - - - - - - - - - - - - - - - - - - - - - {{ h }}h - - - - - - - {{ fmtHora(ev.startH) }} – {{ fmtHora(ev.endH) }} - - {{ ev.label }} - - - - - - - - + + + + + - - - - - - - - " de cada pagina (acima dos cards contextuais). */ -.melissa-config-aside-host { - position: absolute; - top: 6px; - bottom: calc(var(--m-dock-h, 76px) + 6px); - left: 6px; - width: 280px; - z-index: 41; - pointer-events: auto; - animation: mcs-aside-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1); -} -@keyframes mcs-aside-enter { - from { opacity: 0; transform: translateX(-8px); } - to { opacity: 1; transform: translateX(0); } -} -@media (max-width: 1023px) { - .melissa-config-aside-host { display: none; } - /* Reseta a var pra paginas nao deixarem espaco vazio na esquerda */ - .win11-root { --m-config-aside-left: 6px !important; } -} +/* (Removido) .melissa-config-aside-host — o sidebar global foi + substituido pelo botao + MelissaConfigPopover em cada pagina de + config. CSS var --m-config-aside-left tambem nao e mais setada + aqui; as pages tem `inset: ... 6px` fixo de novo. */ -/* ─── Plano de trás (resumo) ───────────────────────────────── */ +/* ─── Plano de trás (resumo) ─────────────────────────────────── + Animacao composicao-pura (transform + opacity) — ambos sao free + no compositor (nao re-pintam, nao re-layout). O blur deixou de + estar AQUI (era filter no container = re-render de toda a arvore + a cada frame, com layer explosion via backdrop-filter dos + glass-panels filhos). Agora vem do .win11-blur-veil (irmao + abaixo) — 1 backdrop pass unico em vez de N×N. + + will-change + translateZ(0): promove pra layer dedicada ANTES + do transition disparar. Sem isso, o browser cria a layer no + primeiro frame e gera stutter visivel. + + backface-visibility: evita sub-pixel jitter no scale(0.98). +*/ .win11-summary { position: relative; z-index: 10; width: 100%; height: 100%; - transition: filter 320ms ease, transform 320ms ease; + transition: transform 320ms ease, opacity 320ms ease; + will-change: transform, opacity; + transform: translateZ(0); + backface-visibility: hidden; } .win11-summary.is-behind { - filter: blur(14px) brightness(0.7); - transform: scale(0.98); + transform: scale(0.98) translateZ(0); + opacity: 0.55; pointer-events: none; } + +/* ─── Veu de blur sobre o resumo ─────────────────────────────── + 1 unica camada com backdrop-filter cobrindo o resumo. Substitui + o filter:blur antigo aplicado no .win11-summary inteiro. Em vez + de blurar 5+ glass-panels filhos (cada um com seu proprio + backdrop-filter), e' UM blur unico que pinta o resultado + acumulado do resumo abaixo. + + z-index 11: acima do .win11-summary (10) e abaixo dos overlays + de seção/cronometro/menu (≥30). pointer-events: none deixa o + click atravessar pra o overlay aberto. + + contain: strict promove layer + isola repaints do veu do resto + do .win11-root (fix de "first-frame stutter"). will-change na + opacity prepara o compositor pro transition. + + Mobile: blur de 3px ainda eh caro em GPU mid-tier; reduz pra + 2px no media query abaixo. */ +.win11-blur-veil { + position: absolute; + inset: 0; + z-index: 11; + pointer-events: none; + opacity: 0; + transition: opacity 320ms ease; + backdrop-filter: blur(3px); + -webkit-backdrop-filter: blur(3px); + will-change: opacity; + contain: strict; +} +.win11-blur-veil.is-active { + opacity: 1; +} +@media (max-width: 768px) { + .win11-blur-veil { + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); + } +} .win11-summary__inner { width: 100%; height: 100%; @@ -2535,16 +1978,6 @@ function onKeydown(e) { } /* ─── Relógio ──────────────────────────────────────────────── */ -.clock-display { - font-size: clamp(5rem, 12vw, 9rem); - font-weight: 200; - line-height: 1; - letter-spacing: -0.04em; - font-variant-numeric: tabular-nums; - color: white; - text-shadow: 0 4px 24px rgba(0, 0, 0, 0.4); -} - /* ─── Glass primitives ─────────────────────────────────────── */ .glass-panel { background: var(--m-bg-soft); @@ -2799,107 +2232,7 @@ function onKeydown(e) { -webkit-backdrop-filter: blur(8px); } -/* ─── Settings popover: select + botão testar do toque ────── */ -.settings-select { - background: var(--m-bg-soft); - border: 1px solid var(--m-border-strong); - color: var(--m-text); - padding: 7px 10px; - border-radius: 8px; - font-size: 0.8rem; - outline: none; - appearance: none; - -webkit-appearance: none; - cursor: pointer; - min-width: 0; -} -.settings-select:hover { - background: var(--m-bg-soft-hover); -} -.settings-select option { - /* renderizado pelo OS — usa tokens semânticos pra acompanhar dark/light */ - background: var(--p-content-background); - color: var(--m-text); -} - -.settings-test-btn { - width: 32px; - height: 32px; - display: grid; - place-items: center; - background: var(--m-bg-soft); - border: 1px solid var(--m-border-strong); - color: var(--m-text); - border-radius: 8px; - cursor: pointer; - transition: background-color 140ms ease; -} -.settings-test-btn:hover { - background: var(--m-accent); - border-color: var(--m-accent); -} -.settings-test-btn:disabled { - background: var(--m-bg-soft); - border-color: var(--m-border); - color: var(--m-text-faint); - cursor: not-allowed; -} - -/* Sliders do painel de personalização — usa accent-color (pinta thumb + - parte preenchida na primary). Não sobrescrevemos ::-webkit-slider-track - pra preservar o alinhamento vertical nativo da bolinha (Chrome entra - em modo "custom" se a track for estilizada e a thumb desce). */ -.settings-range { - accent-color: var(--p-primary-color); - width: 100%; -} - -/* Toggle "ligado" usa a primary do preset escolhido (não emerald hardcoded - como antes — agora todo o painel reflete a paleta selecionada). */ -.settings-toggle.is-on { - background-color: var(--p-primary-color); -} - -/* Swatches de cor primária — círculos compactos com ring na ativa. */ -.settings-swatch { - width: 22px; - height: 22px; - border-radius: 9999px; - border: 1px solid rgba(255, 255, 255, 0.18); - cursor: pointer; - transition: transform 120ms ease, box-shadow 120ms ease; - padding: 0; -} -.settings-swatch:hover { - transform: scale(1.12); -} -.settings-swatch.is-active { - box-shadow: - 0 0 0 2px var(--m-bg-medium), - 0 0 0 4px var(--p-primary-color); -} - /* ─── Botão-trigger do cronômetro (irmão flex do relógio) ───── */ -.crono-icon-btn { - width: 48px; - height: 48px; - border-radius: 12px; - background: var(--m-bg-soft-hover); - backdrop-filter: blur(20px) saturate(160%); - -webkit-backdrop-filter: blur(20px) saturate(160%); - border: 1px solid var(--m-border-strong); - display: grid; - place-items: center; - cursor: pointer; - transition: all 200ms ease; - box-shadow: 0 6px 18px rgba(0, 0, 0, 0.2); - flex-shrink: 0; -} -.crono-icon-btn:hover { - background: var(--m-border-strong); - transform: scale(1.06); -} - /* ─── Seção: overlay grande (Pacientes, WhatsApp, etc.) ─────── */ .secao-layer { position: absolute; @@ -2994,373 +2327,6 @@ function onKeydown(e) { } } -/* ─── Links clicáveis dentro do resumo do dia ─────────────── */ -.resumo-link { - display: inline; - background: none; - border: none; - padding: 0; - margin: 0; - font: inherit; - color: var(--m-text); - cursor: pointer; - border-bottom: 1px dashed var(--m-border-strong); - transition: color 160ms ease, border-color 160ms ease; -} -.resumo-link:hover { - color: white; - border-bottom-color: var(--m-text-muted); -} -.resumo-link.is-active { - color: white; - font-weight: 500; - border-bottom: 1px solid var(--m-text); -} - -/* ─── Chip do filtro ativo (header da timeline) ────────────── */ -.filtro-chip { - display: inline-flex; - align-items: center; - gap: 6px; - margin-left: 6px; - padding: 2px 8px 2px 10px; - border-radius: 9999px; - background: var(--m-bg-soft-hover); - border: 1px solid var(--m-border-strong); - color: var(--m-text); - font-size: 0.7rem; - font-weight: 500; - cursor: pointer; - transition: background-color 140ms ease; -} -.filtro-chip:hover { - background: var(--m-border-strong); -} - -/* ─── Badge "Folga" / "Feriado" no header da timeline ────── - Sinaliza dia não-útil sem bloquear: sessões fora do expediente - continuam permitidas e visíveis na timeline. */ -.tl-day-badge { - display: inline-flex; - align-items: center; - gap: 4px; - margin-left: 6px; - padding: 2px 8px; - border-radius: 9999px; - font-size: 0.7rem; - font-weight: 500; - line-height: 1.2; - white-space: nowrap; -} -.tl-day-badge--folga { - background: var(--m-bg-soft-hover); - color: var(--m-text-muted); - border: 1px solid var(--m-border-strong); -} -.tl-day-badge--feriado { - background: color-mix(in srgb, rgb(245, 158, 11) 18%, transparent); - color: rgb(245, 158, 11); - border: 1px solid color-mix(in srgb, rgb(245, 158, 11) 38%, transparent); -} -html:not(.app-dark) .win11-root .tl-day-badge--feriado { - background: color-mix(in srgb, rgb(217, 119, 6) 12%, transparent); - color: rgb(180, 83, 9); /* amber-700 — legível em fundo claro */ - border-color: color-mix(in srgb, rgb(217, 119, 6) 32%, transparent); -} - -/* ─── Timeline vertical (< lg) — tipo calendário dia ─────── */ -.vt { - position: relative; - /* --m-vt-rows = HORA_FIM - HORA_INICIO (set inline; fallback 12 = 8→20) */ - height: calc(var(--m-vt-rows, 12) * 48px + 24px); - margin-top: 0.75rem; -} -.vt-row { - position: absolute; - left: 0; - right: 0; - height: 0; - display: flex; - align-items: center; -} -.vt-hour { - width: 36px; - text-align: right; - padding-right: 8px; - color: var(--m-text-muted); - font-size: 0.65rem; - font-weight: 500; - flex-shrink: 0; - /* O texto fica visualmente centrado na linha (transform shift up half) */ - transform: translateY(-50%); - background: transparent; - line-height: 1; -} -.vt-line { - flex: 1; - height: 1px; - background: var(--m-bg-soft); -} -.vt-event { - position: absolute; - left: 44px; - right: 4px; - padding: 4px 10px; - border-radius: 4px; - color: white; - cursor: pointer; - overflow: hidden; - z-index: 1; - display: flex; - flex-direction: column; - justify-content: center; - min-height: 24px; -} -.vt-event-time { - font-size: 0.62rem; - opacity: 0.85; - line-height: 1.1; -} -.vt-event-label { - font-size: 0.78rem; - font-weight: 600; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - line-height: 1.2; -} - -/* Timeline horizontal — label da pílula sempre branca (sobre cor saturada). - Em light, o override de `.text-white` no fim do arquivo flipa pra dark - e quebra contraste sobre indigo/verde/vermelho. Forçar branco aqui. */ -.tl-event-pill__label { - color: #fff; - flex: 1; - min-width: 0; -} - -/* ─── Status overlays nas pílulas (horizontal + vertical) ────── - Cada status ganha um tratamento visual específico — ícone no canto - + variação de opacidade/borda/hatch. A var --ev-color (setada no - inline style do eventStyle) alimenta o pulse "em curso" com a cor - do próprio evento. */ -.tl-event-pill { - /* já é position: absolute pelas Tailwind classes existentes */ - transition: filter 200ms ease, opacity 200ms ease, box-shadow 240ms ease; -} -.tl-event-pill__status, -.vt-event__status { - flex-shrink: 0; - display: inline-grid; - place-items: center; - width: 14px; - height: 14px; - border-radius: 9999px; - background: rgba(0, 0, 0, 0.28); - color: #fff; - font-size: 0.55rem; - margin-left: 6px; - line-height: 1; -} -.vt-event__status { - position: absolute; - top: 4px; - right: 4px; - width: 16px; - height: 16px; - font-size: 0.6rem; - margin-left: 0; -} -.vt-event { - position: absolute; /* já era; reforça pra absolute do __status */ -} - -/* Realizado: glow verde sutil (a cor do bg já é verde) — celebra o feito */ -.tl-pill--realizado { - box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.28), 0 4px 12px rgba(16, 185, 129, 0.18); -} - -/* Faltou: opacidade reduzida + label tachado */ -.tl-pill--faltou { - opacity: 0.78; -} -.tl-pill--faltou .tl-event-pill__label, -.tl-pill--faltou .vt-event-label { - text-decoration: line-through; - text-decoration-color: rgba(255, 255, 255, 0.55); - text-decoration-thickness: 1.5px; -} - -/* Cancelado: hatching diagonal + opacidade + label tachado. - Mantém o backgroundColor do ev.color (cinza por pickColor). */ -.tl-pill--cancelado { - opacity: 0.6; - background-image: repeating-linear-gradient( - 135deg, - rgba(255, 255, 255, 0.0) 0, - rgba(255, 255, 255, 0.0) 4px, - rgba(255, 255, 255, 0.22) 4px, - rgba(255, 255, 255, 0.22) 6px - ); -} -.tl-pill--cancelado .tl-event-pill__label, -.tl-pill--cancelado .vt-event-label { - text-decoration: line-through; - text-decoration-color: rgba(255, 255, 255, 0.5); -} - -/* Remarcar: ring âmbar puxa atenção (status transiente, precisa decisão) */ -.tl-pill--remarcar { - box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.6), 0 4px 14px rgba(245, 158, 11, 0.25); -} - -/* Em curso: pulse glow na cor do próprio evento via --ev-color. - Compute via color-mix pra herdar a hue. */ -.tl-pill--em-curso { - animation: tl-pill-em-curso 2.2s ease-in-out infinite; - z-index: 12; -} -@keyframes tl-pill-em-curso { - 0%, 100% { - box-shadow: - 0 0 0 0 color-mix(in srgb, var(--ev-color, #6366f1) 55%, transparent), - 0 0 0 1px rgba(255, 255, 255, 0.14); - } - 50% { - box-shadow: - 0 0 0 6px color-mix(in srgb, var(--ev-color, #6366f1) 0%, transparent), - 0 0 18px 2px color-mix(in srgb, var(--ev-color, #6366f1) 60%, transparent); - } -} - -/* ─── Timeline horizontal: scroll quando o range é grande ──── - --m-tl-cols (set inline) = HORA_FIM - HORA_INICIO - --m-tl-slot-w = largura mínima por hora (default 80px). - Inner stretch atinge (cols * slot-w). Container externo rola - horizontal quando passa do viewport. */ -.tl-h-scroll { - overflow-x: auto; - overflow-y: visible; - /* respiro pra pílulas e cursor "Agora" não cortarem na borda */ - padding-bottom: 4px; - /* scrollbar discreta — combina com o tom do panel */ - scrollbar-width: thin; - scrollbar-color: var(--m-border-strong) transparent; -} -.tl-h-scroll::-webkit-scrollbar { - height: 6px; -} -.tl-h-scroll::-webkit-scrollbar-track { - background: transparent; -} -.tl-h-scroll::-webkit-scrollbar-thumb { - background: var(--m-border-strong); - border-radius: 9999px; -} -.tl-h-inner { - min-width: calc(var(--m-tl-cols, 12) * var(--m-tl-slot-w, 80px)); -} - -/* ─── Eco lateral: minimap pulsante de cores nas bordas ──────── - Faixas verticais de 8px coladas nas bordas do scroll, mostrando - tracinhos coloridos (cor do status) pra cada evento off-screen. - Posição vertical de cada tracinho é proporcional ao tempo do - evento dentro da janela invisível — você lê a "forma" do dia - off-screen. Pulse sutil 2.4s só quando há algo escondido. */ -.tl-h-frame { - position: relative; -} -.tl-eco { - position: absolute; - top: 16px; /* alinha com o topo da barra (descontando linha de horas) */ - bottom: 8px; /* respiro do scrollbar */ - width: 8px; - z-index: 6; - pointer-events: auto; - border-radius: 4px; - background: color-mix(in srgb, var(--m-bg-soft) 70%, transparent); - border: 1px solid var(--m-border); - box-shadow: 0 0 0 0 transparent; - animation: tl-eco-pulse 2400ms ease-in-out infinite; - /* Transição entre estados — evita flicker quando some/aparece */ - transition: opacity 180ms ease; -} -.tl-eco--left { left: -2px; } -.tl-eco--right { right: -2px; } - -.tl-eco__tick { - position: absolute; - left: 1px; - right: 1px; - height: 4px; - transform: translateY(-50%); - border: 0; - padding: 0; - border-radius: 2px; - cursor: pointer; - opacity: 0.85; - box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15); - transition: opacity 140ms ease, transform 140ms ease, height 140ms ease; -} -.tl-eco__tick:hover { - opacity: 1; - height: 6px; - /* Espelha pra fora pra "estourar" da faixa quando hover */ - transform: translateY(-50%) scaleX(2.2); - z-index: 1; -} -.tl-eco--left .tl-eco__tick:hover { transform-origin: left center; } -.tl-eco--right .tl-eco__tick:hover { transform-origin: right center; } - -@keyframes tl-eco-pulse { - 0%, 100% { - box-shadow: 0 0 0 0 color-mix(in srgb, var(--p-primary-color) 0%, transparent); - } - 50% { - box-shadow: 0 0 10px 1px color-mix(in srgb, var(--p-primary-color) 35%, transparent); - } -} - -/* Light mode: faixa precisa contrastar mais com bloom claro */ -html:not(.app-dark) .win11-root .tl-eco { - background: color-mix(in srgb, var(--m-bg-soft-hover) 90%, transparent); - border-color: var(--m-border-strong); -} -.vt-now { - position: absolute; - left: 32px; - right: 0; - z-index: 2; - pointer-events: none; - display: flex; - align-items: center; - transform: translateY(-50%); /* centro do cursor sobre a linha do horário */ -} -.vt-now-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: rgb(239, 68, 68); - flex-shrink: 0; - animation: pulse 1.6s ease-in-out infinite; -} -.vt-now-line { - flex: 1; - height: 2px; - background: rgb(239, 68, 68); - box-shadow: 0 0 6px rgba(239, 68, 68, 0.5); - margin-left: 2px; -} - -/* ─── Pulse no "Agora" ─────────────────────────────────────── */ -.pulse-dot { - animation: pulse 1.6s ease-in-out infinite; -} -@keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.4; } -} - /* ─── Transições ───────────────────────────────────────────── */ .lift-enter-active, .lift-leave-active { diff --git a/src/layout/melissa/MelissaSettingsPanel.vue b/src/layout/melissa/MelissaSettingsPanel.vue new file mode 100644 index 0000000..81d48c3 --- /dev/null +++ b/src/layout/melissa/MelissaSettingsPanel.vue @@ -0,0 +1,537 @@ + + + + + + + + + Personalizar + + + + + + + + + + Plano de Fundo + + + + Trocar imagem de fundo + + + Recomendado: 1920×1080 (Full HD), JPG ou PNG. Máximo 2 MB. + + + + Voltar ao padrão + + + + + + Transparência da imagem + {{ Math.round(bgImageOpacity * 100) }}% + + + + + + + Opacidade do fundo + {{ Math.round(overlayOpacity * 100) }}% + + + + + + Relógio & Som + + + + Formato 24h + (relógio) + + + + + + + + Toque de término + + + + {{ t.label }} + + + + + + + + + + Tema + + + Modo escuro + + + + + + + Preset + + {{ p }} + + + + + Cor primária + + + + + + + Surface + + + + + + + + + diff --git a/src/layout/melissa/MelissaTimelineHoje.vue b/src/layout/melissa/MelissaTimelineHoje.vue new file mode 100644 index 0000000..6adce0f --- /dev/null +++ b/src/layout/melissa/MelissaTimelineHoje.vue @@ -0,0 +1,814 @@ + + + + + + + + + Linha do tempo — Hoje + + + Feriado{{ todayFeriado.nome ? `: ${todayFeriado.nome}` : '' }} + + + + Folga + + + {{ filtroLabel }} + + + + + + Agora + + + + + + + + + + {{ h }}h + + + + + {{ ev.label }} + + + + + + + + + + + + + + + + + + + + + + + {{ h }}h + + + + + + + {{ fmtHora(ev.startH) }} – {{ fmtHora(ev.endH) }} + + {{ ev.label }} + + + + + + + + + + + diff --git a/src/layout/melissa/composables/useMelissaToques.js b/src/layout/melissa/composables/useMelissaToques.js new file mode 100644 index 0000000..59f963a --- /dev/null +++ b/src/layout/melissa/composables/useMelissaToques.js @@ -0,0 +1,41 @@ +/* + * useMelissaToques — preferencia de toque de termino do Melissa + * ------------------------------------------------------------- + * Encapsula apenas a preferencia (qual toque tocar) e o botao de + * teste do painel Personalizar. NAO controla o cronometro em si — + * o componente recebe `toque-termino` como prop + * e dispara o som ao final da sessao com a propria logica. + * + * Estado: + * - toqueTermino: string — id do toque selecionado (default 'sino') + * + * Acao: + * - testarToque(): toca o toque selecionado (preview no Personalizar) + * + * Constante exportada: + * - TOQUE_IDS: Set — ids validos, usado pra sanitizar payload + * vindo de localStorage/DB no MelissaLayout + * + * Persistencia: NAO eh responsabilidade deste composable. O pai + * (MelissaLayout) persiste `toqueTermino` junto com outras prefs em + * user_settings.melissa_prefs. + */ +import { ref } from 'vue'; +import { TOQUES, playToque } from '../melissaToques'; + +export const TOQUE_IDS = new Set(TOQUES.map((t) => t.id)); + +export function useMelissaToques(initialId = 'sino') { + // Sanitiza o default — se passarem id invalido cai pro 'sino' + const safeInitial = TOQUE_IDS.has(initialId) ? initialId : 'sino'; + const toqueTermino = ref(safeInitial); + + function testarToque() { + playToque(toqueTermino.value); + } + + return { + toqueTermino, + testarToque + }; +} diff --git a/src/layout/melissa/composables/useMelissaWallpaper.js b/src/layout/melissa/composables/useMelissaWallpaper.js new file mode 100644 index 0000000..248fb43 --- /dev/null +++ b/src/layout/melissa/composables/useMelissaWallpaper.js @@ -0,0 +1,94 @@ +/* + * useMelissaWallpaper — wallpaper/background do MelissaLayout + * ----------------------------------------------------------- + * Encapsula o estado e as operacoes do plano de fundo: + * - bgUrl: data URL da imagem custom (vazio = usa gradiente default) + * - overlayOpacity: 0–0.8, escurecedor sobre o bg (sempre aplicado) + * - bgImageOpacity: 0.01–1, transparencia da foto custom (so quando bgUrl) + * + * Operacoes: + * - onFileChange(e): valida tipo + tamanho, gera data URL + * - clearBg(): zera bgUrl pra voltar ao gradiente default + * + * Estilos prontos: + * - defaultBgStyle: gradiente bloom radial + linear, sempre renderizado + * atras de tudo (cores via CSS vars que flipam com dark/light) + * - photoStyle: computed que liga url(bgUrl) + opacity(bgImageOpacity) + * + * Persistencia: NAO eh responsabilidade deste composable. O pai + * (MelissaLayout) persiste estes refs junto com outras prefs em + * localStorage + user_settings.melissa_prefs. + */ +import { ref, computed } from 'vue'; +import { useToast } from 'primevue/usetoast'; + +// Limite de upload — protege quota do localStorage (~5MB) e evita data URL +// gigante atravessando a UI. JPG/PNG 1920×1080 cabe folgado nesse teto. +export const MAX_BG_BYTES = 2 * 1024 * 1024; // 2 MB + +// Gradiente default — sempre renderizado no .win11-root (atras de tudo). +// Quando o user faz upload, .win11-photo aparece por cima com opacidade +// controlada pelo slider — permite blend natural com o gradiente abaixo. +// Cores vem de CSS vars que flipam com dark/light AND seguem o preset +// (ver style global no MelissaLayout: --bloom-c1/c2/base-1/base-2). +export const defaultBgStyle = Object.freeze({ + backgroundImage: + 'radial-gradient(circle at 70% 30%, var(--bloom-c1) 0%, transparent 55%), radial-gradient(circle at 25% 75%, var(--bloom-c2) 0%, transparent 50%), linear-gradient(135deg, var(--bloom-base-1) 0%, var(--bloom-base-2) 50%, var(--bloom-base-1) 100%)', + backgroundSize: 'cover' +}); + +export function useMelissaWallpaper() { + const toast = useToast(); + + const bgUrl = ref(''); // vazio = usa gradiente default + const overlayOpacity = ref(0.35); // 0–0.8 — escurecedor sobre o bg + const bgImageOpacity = ref(1); // 0.01–1 — transparencia da foto custom + + function onFileChange(e) { + const file = e.target.files?.[0]; + if (!file) return; + if (!file.type.startsWith('image/')) { + toast.add({ + severity: 'warn', + summary: 'Formato inválido', + detail: 'Selecione um arquivo de imagem (JPG, PNG, WEBP).', + life: 4000 + }); + e.target.value = ''; + return; + } + if (file.size > MAX_BG_BYTES) { + toast.add({ + severity: 'warn', + summary: 'Imagem muito grande', + detail: 'Máximo 2 MB. Reduza a resolução ou compressão e tente novamente.', + life: 4500 + }); + e.target.value = ''; + return; + } + const reader = new FileReader(); + reader.onload = (ev) => (bgUrl.value = ev.target.result); + reader.readAsDataURL(file); + } + + function clearBg() { + bgUrl.value = ''; + } + + const photoStyle = computed(() => ({ + backgroundImage: bgUrl.value ? `url(${bgUrl.value})` : 'none', + opacity: bgImageOpacity.value + })); + + return { + bgUrl, + overlayOpacity, + bgImageOpacity, + MAX_BG_BYTES, + defaultBgStyle, + photoStyle, + onFileChange, + clearBg + }; +}
- Recomendado: 1920×1080 (Full HD), JPG ou PNG. Tamanho máximo: 2 MB. -
+ Recomendado: 1920×1080 (Full HD), JPG ou PNG. Máximo 2 MB. +