MelissaLayout: extrai Settings/Hero/Timeline + composables wallpaper/toques + push-back veil perf
- MelissaSettingsPanel.vue: painel Personalizar (Plano de Fundo, Relogio & Som, Tema com preset Lara/Nora) - MelissaHeroClock.vue: relogio gigante + saudacao + cronometro + resumo do dia - MelissaTimelineHoje.vue: timeline horizontal (lg+) e vertical (mobile) com eco/cursor agora - useMelissaWallpaper(): bgUrl/overlayOpacity/bgImageOpacity + onFileChange/clearBg + photoStyle/defaultBgStyle - useMelissaToques(): toqueTermino + testarToque (preferencia, nao instance state do cronometro) - Push-back perf: filter:blur animado no .win11-summary substituido por veil unico com backdrop-filter (1 backdrop pass por frame em vez de N glass-panels re-blurados; will-change + contain:strict + transform/opacity GPU-friendly; 60fps em mobile) MelissaLayout: 4114 -> 2861 linhas (-1253, -30%) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
<script setup>
|
||||
/*
|
||||
* MelissaHeroClock — bloco hero do MelissaLayout
|
||||
* ----------------------------------------------
|
||||
* Relogio gigante + botao cronometro + data por extenso + saudacao +
|
||||
* resumo do dia (com chips clicaveis que filtram a timeline).
|
||||
*
|
||||
* Componente de apresentacao puro:
|
||||
* - Recebe valores derivados via props (hora, data, saudacao, resumo)
|
||||
* - Emite eventos pra o pai agir (cronometro, toggle-filtro)
|
||||
* - Sem state proprio, sem deps de session/store/API
|
||||
*
|
||||
* Props:
|
||||
* - hora: String — formatada pelo pai (24h ou 12h)
|
||||
* - dataExtenso: String — "segunda-feira, 7 de maio"
|
||||
* - saudacao: String — "Bom dia" / "Boa tarde" / "Boa noite"
|
||||
* - usuario: String — default "Dr. Leonardo"
|
||||
* - resumoPartes: Array<{ tipo, text }> — [] = "agenda livre"
|
||||
* - filtroTipo: String|null — destaca o chip ativo do resumo
|
||||
*
|
||||
* Emit:
|
||||
* - cronometro — clique no botao do relogio
|
||||
* - toggle-filtro(tipo) — clique num chip do resumo
|
||||
*/
|
||||
defineProps({
|
||||
hora: { type: String, required: true },
|
||||
dataExtenso: { type: String, required: true },
|
||||
saudacao: { type: String, required: true },
|
||||
usuario: { type: String, default: 'Dr. Leonardo' },
|
||||
resumoPartes: { type: Array, default: () => [] },
|
||||
filtroTipo: { type: String, default: null }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['cronometro', 'toggle-filtro']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="text-center text-white drop-shadow-lg select-none">
|
||||
<div class="inline-flex items-center gap-6">
|
||||
<div class="clock-display">{{ hora }}</div>
|
||||
<button
|
||||
class="crono-icon-btn w-12 h-12 rounded-xl grid place-items-center cursor-pointer shrink-0"
|
||||
title="Cronômetro de sessão"
|
||||
@click="emit('cronometro')"
|
||||
>
|
||||
<i class="pi pi-stopwatch text-xl text-white/85" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-lg md:text-xl font-light tracking-wide text-white/90 mt-1 capitalize">
|
||||
{{ dataExtenso }}
|
||||
</div>
|
||||
|
||||
<div class="text-2xl md:text-3xl font-light mt-6 text-white/95 tracking-tight">
|
||||
{{ saudacao }}, <span class="font-normal">{{ usuario }}</span>.
|
||||
</div>
|
||||
|
||||
<div class="text-base md:text-lg font-light text-white/70 mt-2 tracking-wide">
|
||||
<template v-if="resumoPartes.length === 0">
|
||||
Sua agenda está livre hoje.
|
||||
</template>
|
||||
<template v-else>
|
||||
Hoje há
|
||||
<template v-for="(p, i) in resumoPartes" :key="p.tipo">
|
||||
<button
|
||||
type="button"
|
||||
class="resumo-link"
|
||||
:class="{ 'is-active': filtroTipo === p.tipo }"
|
||||
@click="emit('toggle-filtro', p.tipo)"
|
||||
>{{ p.text }}</button><span v-if="i < resumoPartes.length - 2">, </span><span v-else-if="i === resumoPartes.length - 2"> e </span>
|
||||
</template>.
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Relogio gigante — clamp() + tabular-nums pra evitar pulo de digitos.
|
||||
text-shadow forte pra legibilidade sobre wallpapers claros (light
|
||||
tem override em .win11-root global no pai que substitui cor + shadow). */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Botao cronometro — size/layout sao Tailwind no template
|
||||
(w-12 h-12 rounded-xl grid place-items-center cursor-pointer shrink-0).
|
||||
Aqui ficam so as propriedades visuais com vars do tema + transicoes. */
|
||||
.crono-icon-btn {
|
||||
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);
|
||||
transition: all 200ms ease;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.crono-icon-btn:hover {
|
||||
background: var(--m-border-strong);
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
/* Chip clicavel do resumo do dia ("3 sessoes", "1 reuniao", etc).
|
||||
border-bottom dashed indica click affordance; is-active vira solido +
|
||||
peso 500 quando o filtro daquele tipo esta on. */
|
||||
.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);
|
||||
}
|
||||
</style>
|
||||
+153
-1187
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,537 @@
|
||||
<script setup>
|
||||
/*
|
||||
* MelissaSettingsPanel — popover "Personalizar" do MelissaLayout
|
||||
* --------------------------------------------------------------
|
||||
* Painel glass que abre via cog no canto superior direito do
|
||||
* Melissa. Concentra:
|
||||
* - Plano de fundo (imagem custom, opacidades, voltar ao default)
|
||||
* - Relogio & Som (formato 24h, toque de termino + botao testar)
|
||||
* - Tema (modo escuro, preset Lara/Nora, cor primaria, surface)
|
||||
*
|
||||
* Estado e setters chegam via inject('melissaSettings') — mesma
|
||||
* fonte usada por MelissaConfiguracoes (sem duplicar refs). Isso
|
||||
* mantem persistencia (localStorage + DB) centralizada no pai.
|
||||
*
|
||||
* Estado local:
|
||||
* - fileInput (template ref) + pickFile() — pura cola de DOM
|
||||
*
|
||||
* Emit:
|
||||
* - close — botao X clicado
|
||||
*/
|
||||
import { inject, ref } from 'vue';
|
||||
import { TOQUES } from './melissaToques';
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const settings = inject('melissaSettings', null);
|
||||
if (!settings) {
|
||||
// Fallback defensivo: nunca deveria acontecer (so se montarem
|
||||
// este componente fora do MelissaLayout). Loga e segue com noop.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[MelissaSettingsPanel] inject melissaSettings ausente — montagem fora do MelissaLayout?');
|
||||
}
|
||||
|
||||
const {
|
||||
layoutConfig,
|
||||
isDarkTheme,
|
||||
activeSurface,
|
||||
PRIMARY_COLORS,
|
||||
SURFACES,
|
||||
PRESETS,
|
||||
setPrimary,
|
||||
setSurface,
|
||||
setPreset,
|
||||
setDark,
|
||||
bgUrl,
|
||||
overlayOpacity,
|
||||
bgImageOpacity,
|
||||
onFileChange,
|
||||
clearBg,
|
||||
use24h,
|
||||
toqueTermino,
|
||||
testarToque
|
||||
} = settings || {};
|
||||
|
||||
// Cola de DOM: input <type=file> escondido + trigger via botao "Trocar
|
||||
// imagem". onFileChange (no pai) faz validacao de tipo/tamanho e ja
|
||||
// dispara watchers de persistencia em bgUrl.
|
||||
const fileInput = ref(null);
|
||||
function pickFile() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="glass-panel mp-panel absolute top-12 right-0 w-72">
|
||||
<!-- Cabecalho fixo -->
|
||||
<header class="mp-head">
|
||||
<div class="mp-head__title">
|
||||
<i class="pi pi-sliders-h" />
|
||||
<span>Personalizar</span>
|
||||
</div>
|
||||
<button
|
||||
class="mp-head__close"
|
||||
title="Fechar"
|
||||
aria-label="Fechar"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Corpo scrollavel -->
|
||||
<div class="mp-body">
|
||||
<!-- ── Plano de fundo ──────────────────────── -->
|
||||
<div class="mp-divider"><span>Plano de Fundo</span></div>
|
||||
|
||||
<button class="mp-btn mp-btn--primary" @click="pickFile">
|
||||
<i class="pi pi-image" />
|
||||
<span>Trocar imagem de fundo</span>
|
||||
</button>
|
||||
<p class="mp-hint">
|
||||
Recomendado: 1920×1080 (Full HD), JPG ou PNG. Máximo 2 MB.
|
||||
</p>
|
||||
<button
|
||||
v-if="bgUrl"
|
||||
class="mp-btn mp-btn--ghost"
|
||||
@click="clearBg"
|
||||
>
|
||||
<i class="pi pi-refresh" />
|
||||
<span>Voltar ao padrão</span>
|
||||
</button>
|
||||
<input ref="fileInput" type="file" accept="image/*" hidden @change="onFileChange" />
|
||||
|
||||
<div v-if="bgUrl" class="mp-field">
|
||||
<label class="mp-label">
|
||||
Transparência da imagem
|
||||
<span class="mp-label__value">{{ Math.round(bgImageOpacity * 100) }}%</span>
|
||||
</label>
|
||||
<input
|
||||
v-model.number="bgImageOpacity"
|
||||
type="range"
|
||||
min="0.01"
|
||||
max="1"
|
||||
step="0.01"
|
||||
class="settings-range w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mp-field">
|
||||
<label class="mp-label">
|
||||
Opacidade do fundo
|
||||
<span class="mp-label__value">{{ Math.round(overlayOpacity * 100) }}%</span>
|
||||
</label>
|
||||
<input
|
||||
v-model.number="overlayOpacity"
|
||||
type="range"
|
||||
min="0"
|
||||
max="0.8"
|
||||
step="0.05"
|
||||
class="settings-range w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Relogio & Toques ────────────────────── -->
|
||||
<div class="mp-divider"><span>Relógio & Som</span></div>
|
||||
|
||||
<div class="mp-row">
|
||||
<span class="mp-row__label">
|
||||
Formato 24h
|
||||
<span class="mp-row__sub">(relógio)</span>
|
||||
</span>
|
||||
<button
|
||||
class="settings-toggle w-10 h-6 rounded-full transition-colors relative"
|
||||
:class="use24h ? 'is-on' : 'bg-white/20'"
|
||||
@click="use24h = !use24h"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all"
|
||||
:style="{ left: use24h ? '1.125rem' : '0.125rem' }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mp-field">
|
||||
<label class="mp-label">Toque de término</label>
|
||||
<div class="flex gap-1.5">
|
||||
<select v-model="toqueTermino" class="settings-select flex-1">
|
||||
<option v-for="t in TOQUES" :key="t.id" :value="t.id">
|
||||
{{ t.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
class="settings-test-btn"
|
||||
title="Testar"
|
||||
:disabled="toqueTermino === 'nenhum'"
|
||||
@click="testarToque"
|
||||
>
|
||||
<i class="pi pi-play text-[0.65rem]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Tema ────────────────────────────────── -->
|
||||
<div class="mp-divider"><span>Tema</span></div>
|
||||
|
||||
<div class="mp-row">
|
||||
<span class="mp-row__label">Modo escuro</span>
|
||||
<button
|
||||
class="settings-toggle w-10 h-6 rounded-full transition-colors relative"
|
||||
:class="isDarkTheme ? 'is-on' : 'bg-white/20'"
|
||||
:title="isDarkTheme ? 'Mudar pra claro' : 'Mudar pra escuro'"
|
||||
@click="setDark(!isDarkTheme)"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all"
|
||||
:style="{ left: isDarkTheme ? '1.125rem' : '0.125rem' }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mp-field">
|
||||
<label class="mp-label">Preset</label>
|
||||
<div class="mp-presets">
|
||||
<button
|
||||
v-for="p in PRESETS"
|
||||
:key="p"
|
||||
type="button"
|
||||
class="mp-preset"
|
||||
:class="{ 'is-active': layoutConfig.preset === p }"
|
||||
@click="setPreset(p)"
|
||||
>{{ p }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mp-field">
|
||||
<label class="mp-label">Cor primária</label>
|
||||
<div class="grid grid-cols-9 gap-1.5">
|
||||
<button
|
||||
v-for="pc in PRIMARY_COLORS"
|
||||
:key="pc.name"
|
||||
class="settings-swatch"
|
||||
:class="{ 'is-active': layoutConfig.primary === pc.name }"
|
||||
:style="{ backgroundColor: pc.swatch === 'currentColor' ? 'var(--m-text)' : pc.swatch }"
|
||||
:title="pc.name"
|
||||
@click="setPrimary(pc.name)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mp-field">
|
||||
<label class="mp-label">Surface</label>
|
||||
<div class="grid grid-cols-8 gap-1.5">
|
||||
<button
|
||||
v-for="sf in SURFACES"
|
||||
:key="sf.name"
|
||||
class="settings-swatch"
|
||||
:class="{ 'is-active': activeSurface === sf.name }"
|
||||
:style="{ backgroundColor: sf.palette['500'] }"
|
||||
:title="sf.name"
|
||||
@click="setSurface(sf.name)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* glass-panel base — redeclarado aqui porque o do pai esta em
|
||||
<style scoped> e nao atravessa o limite do componente. Os
|
||||
overrides de light mode (html:not(.app-dark) .win11-root ...)
|
||||
estao em <style> nao-scoped do pai e continuam aplicando. */
|
||||
.glass-panel {
|
||||
background: var(--m-bg-soft);
|
||||
backdrop-filter: blur(24px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(140%);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* ─── Painel "Personalizar" (mp-*) ────────────────────────────
|
||||
Cores via vars (--m-text, --m-border, etc) pra adaptar light
|
||||
e dark — em light o glass-panel ganha fundo claro e texto
|
||||
branco hardcoded ficava invisivel.
|
||||
Estrutura: header fixo + body scrollavel, secoes separadas
|
||||
por .mp-divider (linha + label inline):
|
||||
- Plano de Fundo (imagem, opacidades)
|
||||
- Relogio & Som (24h, toque)
|
||||
- Tema (modo escuro, preset, primaria, surface)
|
||||
─────────────────────────────────────────────────────────── */
|
||||
.mp-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
max-height: calc(100vh - 5rem);
|
||||
overflow: hidden;
|
||||
color: var(--m-text);
|
||||
}
|
||||
.mp-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--m-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mp-head__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--m-text);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.mp-head__title > i {
|
||||
color: var(--p-primary-color);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.mp-head__close {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text-muted);
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
.mp-head__close:hover {
|
||||
background: var(--m-bg-soft-hover);
|
||||
color: var(--m-text);
|
||||
border-color: var(--m-border-strong);
|
||||
}
|
||||
|
||||
.mp-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 14px 16px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--m-border-strong) transparent;
|
||||
}
|
||||
.mp-body::-webkit-scrollbar { width: 5px; }
|
||||
.mp-body::-webkit-scrollbar-thumb {
|
||||
background: var(--m-border-strong);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Divider com label inline — separa secoes do painel */
|
||||
.mp-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 14px 0 10px;
|
||||
color: var(--m-text-muted);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
.mp-divider:first-child {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.mp-divider::before,
|
||||
.mp-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--m-border);
|
||||
}
|
||||
.mp-divider > span {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Botoes do painel */
|
||||
.mp-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 9px;
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease;
|
||||
text-align: left;
|
||||
}
|
||||
.mp-btn--primary {
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text);
|
||||
}
|
||||
.mp-btn--primary:hover {
|
||||
background: var(--m-bg-soft-hover);
|
||||
border-color: var(--m-border-strong);
|
||||
}
|
||||
.mp-btn--ghost {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--m-text-muted);
|
||||
padding: 6px 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.mp-btn--ghost:hover {
|
||||
background: var(--m-bg-soft);
|
||||
color: var(--m-text);
|
||||
}
|
||||
.mp-btn > i {
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Hint pequeno abaixo de botao */
|
||||
.mp-hint {
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.4;
|
||||
color: var(--m-text-faint);
|
||||
padding: 0 4px;
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
|
||||
/* Field generico (label + input/range/select abaixo) */
|
||||
.mp-field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.mp-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--m-text-muted);
|
||||
font-size: 0.72rem;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.mp-label__value {
|
||||
color: var(--m-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* Row (label esq + controle dir, mesma linha) */
|
||||
.mp-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.mp-row__label {
|
||||
color: var(--m-text);
|
||||
}
|
||||
.mp-row__sub {
|
||||
color: var(--m-text-faint);
|
||||
font-size: 0.7rem;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* Preset chips (Aura / Lara / Nora) */
|
||||
.mp-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.mp-preset {
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text);
|
||||
padding: 5px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.74rem;
|
||||
font-family: inherit;
|
||||
text-transform: capitalize;
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease;
|
||||
}
|
||||
.mp-preset:hover {
|
||||
background: var(--m-bg-soft-hover);
|
||||
border-color: var(--m-border-strong);
|
||||
}
|
||||
.mp-preset.is-active {
|
||||
background: var(--p-primary-color);
|
||||
border-color: var(--p-primary-color);
|
||||
color: var(--p-primary-contrast-color, white);
|
||||
}
|
||||
|
||||
/* ─── Settings popover: select + botao 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 semanticos 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 — accent-color pinta thumb + parte preenchida na
|
||||
primary. Sem sobrescrever ::-webkit-slider-track (Chrome desce a thumb
|
||||
se a track for estilizada). */
|
||||
.settings-range {
|
||||
accent-color: var(--p-primary-color);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Toggle "ligado" usa a primary do preset escolhido */
|
||||
.settings-toggle.is-on {
|
||||
background-color: var(--p-primary-color);
|
||||
}
|
||||
|
||||
/* Swatches de cor primaria — circulos 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);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,814 @@
|
||||
<script setup>
|
||||
/*
|
||||
* MelissaTimelineHoje — linha do tempo "Hoje" do MelissaLayout
|
||||
* ------------------------------------------------------------
|
||||
* Mostra os eventos do dia em duas modalidades responsivas:
|
||||
* - Horizontal (lg+): scroll horizontal com pilulas posicionadas
|
||||
* por hora, eco lateral pra eventos off-screen, cursor "Agora"
|
||||
* e auto-scroll inicial centrado no horario atual
|
||||
* - Vertical (< lg): tipo calendario "dia" com slots de 48px/h,
|
||||
* pilulas absolutas e cursor "Agora" tipo Google Calendar
|
||||
*
|
||||
* Tudo deriva-se das props (range de horas, eco, posicoes, status).
|
||||
* Nao acessa state externo direto — eh um componente puro de
|
||||
* apresentacao + interacoes locais (scroll, eco-click).
|
||||
*
|
||||
* Props:
|
||||
* - eventos: Array — raw eventosHojeReais (passa direto)
|
||||
* - now: Date — relogio atual (atualizado pelo pai a cada 1s)
|
||||
* - workRules: Array — regras semanais da agenda (filtra por dow)
|
||||
* - agendaSettings: Object|null — config da agenda (range fallback)
|
||||
* - feriados: Array — feriados do mes (procura entry pra hoje)
|
||||
* - filtroTipo: String|null — filtro ativo (sessao/supervisao/reuniao)
|
||||
*
|
||||
* Emit:
|
||||
* - evento(ev) — clique numa pilula (pai abre dialog)
|
||||
* - clear-filter — clique no X do chip de filtro
|
||||
*/
|
||||
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
eventos: { type: Array, default: () => [] },
|
||||
now: { type: Date, required: true },
|
||||
workRules: { type: Array, default: () => [] },
|
||||
agendaSettings: { type: Object, default: null },
|
||||
feriados: { type: Array, default: () => [] },
|
||||
filtroTipo: { type: String, default: null }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['evento', 'clear-filter']);
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Range de horas (HORA_INICIO/HORA_FIM) — derivado de:
|
||||
// 1. workRules do dia da semana atual (se houver)
|
||||
// 2. agendaSettings (fallback global 08–18h)
|
||||
// Range expande pra incluir eventos fora do expediente — sessao
|
||||
// excepcional nao some da timeline.
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
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 = props.now.getDay(); // 0=dom .. 6=sab
|
||||
return (props.workRules || []).filter((r) => r.dia_semana === dow && r.ativo !== false);
|
||||
});
|
||||
|
||||
const isFolga = computed(() => todayRules.value.length === 0);
|
||||
|
||||
const todayFeriado = computed(() => {
|
||||
const d = props.now;
|
||||
const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
return (props.feriados || []).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 = props.agendaSettings;
|
||||
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 minEv = props.eventos.length ? Math.min(...props.eventos.map((e) => e.startH)) : Infinity;
|
||||
return Math.max(0, Math.floor(Math.min(start, minEv)));
|
||||
});
|
||||
|
||||
const HORA_FIM = computed(() => {
|
||||
const { end } = _baseRange();
|
||||
const maxEv = props.eventos.length ? Math.max(...props.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;
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Filtro + lista visivel + label do chip
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
const eventosVisiveis = computed(() => {
|
||||
if (!props.filtroTipo) return props.eventos;
|
||||
return props.eventos.filter((ev) => ev.tipo === props.filtroTipo);
|
||||
});
|
||||
|
||||
const filtroLabel = computed(() => {
|
||||
if (!props.filtroTipo) return '';
|
||||
const map = { sessao: 'atendimentos', supervisao: 'supervisões', reuniao: 'reuniões' };
|
||||
return map[props.filtroTipo] || '';
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Helpers de status (cor, icone, em-curso) usados em pilulas
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
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 = props.now;
|
||||
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) }
|
||||
];
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Posicionamento das pilulas (horizontal + vertical) e cursor
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
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,
|
||||
// Expoe a cor pra CSS (glow/pulse usa color-mix com essa var)
|
||||
'--ev-color': ev.color
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
'--ev-color': ev.color
|
||||
};
|
||||
}
|
||||
|
||||
const nowCursorLeft = computed(() => {
|
||||
const d = props.now;
|
||||
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}%`;
|
||||
});
|
||||
|
||||
const nowCursorTop = computed(() => {
|
||||
const d = props.now;
|
||||
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`;
|
||||
});
|
||||
|
||||
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')}`;
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Scroll/eco do horizontal — minimap pulsante de cores nas bordas
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
const tlHScrollEl = ref(null);
|
||||
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 };
|
||||
});
|
||||
|
||||
// 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' });
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Auto-scroll inicial: centra "agora" na viewport ao montar (jornadas
|
||||
// longas ex: 02h-23h nao abrem com cursor off-screen). Roda uma vez.
|
||||
// ResizeObserver mantem o eco atualizado quando viewport muda.
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
let _tlAutoScrolled = false;
|
||||
|
||||
function _scrollTimelineToNow() {
|
||||
const el = tlHScrollEl.value;
|
||||
if (!el) return;
|
||||
const d = props.now;
|
||||
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);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
_updateTlScrollState();
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
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());
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="glass-panel mt-8 px-5 py-4 max-w-5xl w-full mx-auto">
|
||||
<!-- Header: titulo, badges (folga/feriado), chip de filtro, "Agora" -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2 text-white/90 text-sm font-medium">
|
||||
<i class="pi pi-clock text-xs" />
|
||||
Linha do tempo — Hoje
|
||||
<span
|
||||
v-if="todayFeriado"
|
||||
class="tl-day-badge tl-day-badge--feriado"
|
||||
:title="`Feriado: ${todayFeriado.nome}`"
|
||||
>
|
||||
<i class="pi pi-star text-[0.6rem]" />
|
||||
Feriado{{ todayFeriado.nome ? `: ${todayFeriado.nome}` : '' }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="isFolga"
|
||||
class="tl-day-badge tl-day-badge--folga"
|
||||
title="Hoje não é dia de trabalho na sua agenda — sessões fora do expediente continuam permitidas."
|
||||
>
|
||||
<i class="pi pi-moon text-[0.6rem]" />
|
||||
Folga
|
||||
</span>
|
||||
<button
|
||||
v-if="filtroTipo"
|
||||
type="button"
|
||||
class="filtro-chip"
|
||||
:title="`Mostrando apenas ${filtroLabel}. Clique pra mostrar tudo.`"
|
||||
@click="emit('clear-filter')"
|
||||
>
|
||||
{{ filtroLabel }}
|
||||
<i class="pi pi-times text-[0.6rem]" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-xs text-white/70 flex items-center gap-1.5">
|
||||
<span class="pulse-dot w-2.5 h-0.5 rounded-full bg-red-500" />
|
||||
Agora
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Horizontal (lg+) — scroll horizontal com pilulas + cursor "agora" + eco lateral.
|
||||
Auto-scroll inicial centra "agora" pra jornadas longas (02h-23h). Frame
|
||||
relativo abriga o eco como overlay absolute. -->
|
||||
<div class="tl-h-frame hidden lg:block">
|
||||
<div ref="tlHScrollEl" class="tl-h-scroll" @scroll.passive="onTimelineScroll">
|
||||
<div class="tl-h-inner relative" :style="{ '--m-tl-cols': HORA_FIM - HORA_INICIO }">
|
||||
<div class="flex justify-between mb-1">
|
||||
<div v-for="h in hoursRange" :key="h" class="flex-1 text-left">
|
||||
<span class="text-[0.65rem] text-white/55 font-medium">{{ h }}h</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative h-9 bg-white/5 rounded-md overflow-visible border border-white/10">
|
||||
<div
|
||||
v-for="ev in eventosVisiveis"
|
||||
:key="ev.id"
|
||||
class="tl-event-pill absolute h-[30px] rounded flex items-center px-2 overflow-hidden cursor-pointer min-w-[32px] hover:brightness-110 transition-[filter,opacity] duration-200 z-10"
|
||||
:class="pillStatusClass(ev)"
|
||||
:style="eventStyle(ev)"
|
||||
:title="ev.label"
|
||||
@click="emit('evento', ev)"
|
||||
>
|
||||
<span class="tl-event-pill__label text-[0.8rem] font-semibold truncate">{{ ev.label }}</span>
|
||||
<i
|
||||
v-if="statusIcon(ev)"
|
||||
:class="['tl-event-pill__status', statusIcon(ev)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="absolute top-0 h-full flex flex-col items-center pointer-events-none z-20"
|
||||
:style="{ left: nowCursorLeft }"
|
||||
>
|
||||
<div class="w-0.5 h-full bg-red-500 opacity-90" />
|
||||
<div class="absolute -top-0.5 w-[7px] h-[7px] rounded-full bg-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Eco lateral — minimap pulsante de cores. Tracinhos posicionados
|
||||
por tempo, mostrando "forma" do dia off-screen. Click = scroll
|
||||
suave ate o evento. -->
|
||||
<div
|
||||
v-if="tlEcoState.left.length"
|
||||
class="tl-eco tl-eco--left"
|
||||
:title="`${tlEcoState.left.length} antes — clique pra centralizar`"
|
||||
>
|
||||
<button
|
||||
v-for="ev in tlEcoState.left"
|
||||
:key="`eco-l-${ev.id}`"
|
||||
type="button"
|
||||
class="tl-eco__tick"
|
||||
:style="ecoTickStyle(ev, 'left')"
|
||||
:title="`${fmtHora(ev.startH)} · ${ev.label}`"
|
||||
@click="scrollToEvent(ev)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="tlEcoState.right.length"
|
||||
class="tl-eco tl-eco--right"
|
||||
:title="`${tlEcoState.right.length} à frente — clique pra centralizar`"
|
||||
>
|
||||
<button
|
||||
v-for="ev in tlEcoState.right"
|
||||
:key="`eco-r-${ev.id}`"
|
||||
type="button"
|
||||
class="tl-eco__tick"
|
||||
:style="ecoTickStyle(ev, 'right')"
|
||||
:title="`${fmtHora(ev.startH)} · ${ev.label}`"
|
||||
@click="scrollToEvent(ev)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vertical (< lg) — calendario "dia" empilhado. Slots de 48px/h. -->
|
||||
<div class="vt lg:hidden" :style="{ '--m-vt-rows': HORA_FIM - HORA_INICIO }">
|
||||
<div
|
||||
v-for="h in hoursRange"
|
||||
:key="h"
|
||||
class="vt-row"
|
||||
:style="{ top: `${(h - HORA_INICIO) * VT_HOUR_PX}px` }"
|
||||
>
|
||||
<span class="vt-hour">{{ h }}h</span>
|
||||
<div class="vt-line" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="ev in eventosVisiveis"
|
||||
:key="ev.id"
|
||||
class="vt-event hover:brightness-110 transition-[filter,opacity] duration-200"
|
||||
:class="pillStatusClass(ev)"
|
||||
:style="eventStyleVertical(ev)"
|
||||
:title="ev.label"
|
||||
@click="emit('evento', ev)"
|
||||
>
|
||||
<i
|
||||
v-if="statusIcon(ev)"
|
||||
:class="['vt-event__status', statusIcon(ev)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="vt-event-time">
|
||||
{{ fmtHora(ev.startH) }} – {{ fmtHora(ev.endH) }}
|
||||
</div>
|
||||
<div class="vt-event-label">{{ ev.label }}</div>
|
||||
</div>
|
||||
|
||||
<div class="vt-now" :style="{ top: nowCursorTop }">
|
||||
<div class="vt-now-dot" />
|
||||
<div class="vt-now-line" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* glass-panel base — redeclarado scoped (o do pai esta em scoped tambem
|
||||
e nao atravessa). Overrides light mode (.win11-root :is(.glass-panel...))
|
||||
estao em <style> nao-scoped do pai e continuam aplicando. */
|
||||
.glass-panel {
|
||||
background: var(--m-bg-soft);
|
||||
backdrop-filter: blur(24px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(140%);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* ─── 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" ──────────────────────────────
|
||||
Sinaliza dia nao-util sem bloquear: sessoes fora do expediente
|
||||
continuam permitidas e visiveis 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);
|
||||
}
|
||||
/* Light mode: amber-600 saturado ficaria estridente; baixa pra amber-700
|
||||
sobre amber-50 — legivel em fundo claro e mantem a identidade do feriado.
|
||||
Em scoped CSS, ancestor `html:not(.app-dark)` nao recebe o hash do
|
||||
componente — so o leaf `.tl-day-badge--feriado` recebe; o selector
|
||||
funciona normalmente. */
|
||||
html:not(.app-dark) .tl-day-badge--feriado {
|
||||
background: color-mix(in srgb, rgb(217, 119, 6) 12%, transparent);
|
||||
color: rgb(180, 83, 9);
|
||||
border-color: color-mix(in srgb, rgb(217, 119, 6) 32%, transparent);
|
||||
}
|
||||
|
||||
/* ─── Timeline vertical (< lg) — tipo calendario "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;
|
||||
/* Texto 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;
|
||||
}
|
||||
|
||||
/* ─── Pilula (horizontal) — label sempre branco sobre cor saturada.
|
||||
Override .text-white global flipava em light mode pra dark e quebrava
|
||||
contraste sobre indigo/verde/vermelho — forcar branco aqui. */
|
||||
.tl-event-pill__label {
|
||||
color: #fff;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ─── Status overlays nas pilulas (horizontal + vertical) ──────
|
||||
Cada status ganha tratamento especifico — icone no canto + variacao
|
||||
de opacidade/borda/hatch. var --ev-color (set no inline style do
|
||||
eventStyle) alimenta o pulse "em curso" com a cor do proprio evento. */
|
||||
.tl-event-pill {
|
||||
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;
|
||||
}
|
||||
|
||||
/* Realizado: glow verde sutil (cor do bg ja eh 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 */
|
||||
.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 ambar puxa atencao (status transiente, precisa decisao) */
|
||||
.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 proprio evento via --ev-color */
|
||||
.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 eh grande ────
|
||||
--m-tl-cols (set inline) = HORA_FIM - HORA_INICIO
|
||||
--m-tl-slot-w = largura minima por hora (default 80px). */
|
||||
.tl-h-scroll {
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
/* respiro pra pilulas e cursor "Agora" nao cortarem na borda */
|
||||
padding-bottom: 4px;
|
||||
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. */
|
||||
.tl-h-frame { position: relative; }
|
||||
.tl-eco {
|
||||
position: absolute;
|
||||
top: 16px; /* alinha com 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;
|
||||
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;
|
||||
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) .tl-eco {
|
||||
background: color-mix(in srgb, var(--m-bg-soft-hover) 90%, transparent);
|
||||
border-color: var(--m-border-strong);
|
||||
}
|
||||
|
||||
/* ─── Cursor "Agora" do vertical (mobile) ─────────────────── */
|
||||
.vt-now {
|
||||
position: absolute;
|
||||
left: 32px;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.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" (header) ────────────────────────────── */
|
||||
.pulse-dot {
|
||||
animation: pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
</style>
|
||||
@@ -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 <MelissaCronometro> 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<string> — 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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user