feat: multi-usuário com autenticação JWT
- Tabela `profiles` + coluna `profile_id` em todas as entidades (categories, transactions, recurring_expenses, accounts, player_profile, xp_events, player_quests, player_achievements, player_cosmetics) - Dados existentes migrados para profile_id = 1 (Manoel) - CLI `./api create-user --name <n> --password <p>` cria perfil com seed de categorias e player_profile; faz upsert de senha se já existir - Auth substituída: cookie+APP_PASSWORD → JWT Bearer 24h (HS256) - Middleware RequireAuth injeta profile_id no context de todas as rotas - Todos os repositórios filtram por profile_id do context - Endpoints: POST /api/auth/login, GET /api/auth/me, POST /api/auth/change-password, POST /api/logout - Frontend: auth store usa localStorage (fc_token/fc_profile), api.ts envia Authorization header, LoginView usa campo name - SettingsView reescrita com troca de senha e logout - docker-compose.yml: remove APP_USERNAME/APP_PASSWORD, adiciona JWT_SECRET Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<svg
|
||||
:width="cols * scale"
|
||||
:height="rows * scale"
|
||||
:viewBox="`0 0 ${cols * scale} ${rows * scale}`"
|
||||
shape-rendering="crispEdges"
|
||||
class="fc-sprite"
|
||||
:class="{ 'fc-sprite--bob': bob && !celebrate, 'fc-sprite--celebrate': celebrate }"
|
||||
aria-label="Personagem pixel art"
|
||||
>
|
||||
<rect
|
||||
v-for="(px, i) in pixels"
|
||||
:key="i"
|
||||
:x="px.x * scale"
|
||||
:y="px.y * scale"
|
||||
:width="scale"
|
||||
:height="scale"
|
||||
:fill="colors[px.k]"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Renders Manoel as a 17x24 pixel sprite.
|
||||
*
|
||||
* The pixel grid is imported from `sprite-data.json`. Each cell's character
|
||||
* is a color *slot key* — '.' is transparent, otherwise the key maps into
|
||||
* `defaultColors` (which the user can override via the `theme` prop).
|
||||
*
|
||||
* Cosmetics work by overriding slots. For example, `Camisa Tripulante` sets
|
||||
* `{ c: '#0ea5e9', C: '#0369a1' }`. Merge equipped cosmetics' color maps in
|
||||
* your store and pass the result here.
|
||||
*/
|
||||
|
||||
import { computed } from 'vue'
|
||||
// In Vite/TS, ensure `resolveJsonModule: true` and adjust import path:
|
||||
import spriteData from '../sprites/sprite-data.json'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** Pixel zoom factor. 2 = mobile, 3 = widget, 4 = personagem hero (default), 6 = focus. */
|
||||
scale?: number
|
||||
/** Per-slot color overrides (cosmetics). Keys are sprite-data color slots. */
|
||||
theme?: Partial<Record<string, string>>
|
||||
/** Idle bob animation. Off when celebrate is on. */
|
||||
bob?: boolean
|
||||
/** Celebration pose (use after quest complete, ~3s). */
|
||||
celebrate?: boolean
|
||||
}>(), {
|
||||
scale: 4,
|
||||
bob: true,
|
||||
})
|
||||
|
||||
const rows = spriteData.rows
|
||||
const cols = spriteData.cols
|
||||
|
||||
const defaultColors: Record<string, string> = {
|
||||
L: 'var(--fc-sprite-outline, #10131c)',
|
||||
s: 'var(--fc-sprite-skin, #f3c79b)',
|
||||
S: 'var(--fc-sprite-skin-shade, #c98863)',
|
||||
e: 'var(--fc-sprite-eye, #10131c)',
|
||||
m: 'var(--fc-sprite-mouth, #80484b)',
|
||||
h: 'var(--fc-sprite-hat, #ffd84d)',
|
||||
w: 'var(--fc-sprite-band, #fde68a)',
|
||||
c: 'var(--fc-sprite-shirt, #00f0ff)',
|
||||
C: 'var(--fc-sprite-shirt-shade, #0369a1)',
|
||||
p: 'var(--fc-sprite-pants, #3a1f6e)',
|
||||
P: 'var(--fc-sprite-pants-shade, #2d1857)',
|
||||
b: 'var(--fc-sprite-boots, #0f172a)',
|
||||
}
|
||||
|
||||
const colors = computed(() => ({ ...defaultColors, ...(props.theme ?? {}) }))
|
||||
|
||||
const pixels = computed(() => {
|
||||
const out: { x: number; y: number; k: string }[] = []
|
||||
spriteData.grid.forEach((row: string, y: number) => {
|
||||
for (let x = 0; x < row.length; x++) {
|
||||
const k = row[x]
|
||||
if (k === '.' || !colors.value[k]) continue
|
||||
out.push({ x, y, k })
|
||||
}
|
||||
})
|
||||
return out
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-sprite {
|
||||
image-rendering: pixelated;
|
||||
display: inline-block;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
@keyframes fc-sprite-bob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-3px); }
|
||||
}
|
||||
.fc-sprite--bob { animation: fc-sprite-bob 2.2s steps(2) infinite; }
|
||||
|
||||
@keyframes fc-sprite-celebrate {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
25% { transform: translateY(-6px) rotate(-3deg); }
|
||||
75% { transform: translateY(-6px) rotate(3deg); }
|
||||
}
|
||||
.fc-sprite--celebrate { animation: fc-sprite-celebrate 1s ease-in-out infinite; }
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="fc-hud-stat">
|
||||
<div class="fc-hud-stat__label">{{ label }}</div>
|
||||
<div class="fc-hud-stat__value">
|
||||
<span :style="{ color, textShadow: `0 0 8px ${color}66` }">{{ value }}</span>
|
||||
<span v-if="sub" class="fc-hud-stat__sub">{{ sub }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
/** Short uppercase label, ex: "LV", "XP", "STREAK", "META". */
|
||||
label: string
|
||||
/** Main value, displayed in pixel font. */
|
||||
value: string | number
|
||||
/** Optional fraction-style suffix, ex: "/4900" or "/40%". */
|
||||
sub?: string
|
||||
/** CSS color string (use var(--fc-*) tokens). Default = text color. */
|
||||
color?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-hud-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.fc-hud-stat__label {
|
||||
font-family: var(--fc-font-pixel);
|
||||
font-size: 7px;
|
||||
color: var(--fc-text-dim);
|
||||
letter-spacing: .06em;
|
||||
}
|
||||
.fc-hud-stat__value {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
font-family: var(--fc-font-pixel);
|
||||
font-size: 13px;
|
||||
}
|
||||
.fc-hud-stat__sub {
|
||||
font-family: var(--fc-font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--fc-text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div
|
||||
class="fc-panel"
|
||||
:class="{ 'fc-panel--glow': glow, [`fc-panel--${variant}`]: !!variant }"
|
||||
>
|
||||
<template v-if="corners">
|
||||
<span class="fc-corner fc-corner--tl" />
|
||||
<span class="fc-corner fc-corner--tr" />
|
||||
<span class="fc-corner fc-corner--bl" />
|
||||
<span class="fc-corner fc-corner--br" />
|
||||
</template>
|
||||
|
||||
<header v-if="title || $slots.header" class="fc-panel__header">
|
||||
<div v-if="title" class="fc-panel__title">:: {{ title }}</div>
|
||||
<slot name="header" />
|
||||
</header>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
/** Painel "principal" da rota: borda roxa + halo. Use com parcimônia. */
|
||||
glow?: boolean
|
||||
/** Cantos em L decorativos. Reserve pro card de destaque. */
|
||||
corners?: boolean
|
||||
/** 'danger' (recorrência ausente, meta falhou) | 'success' */
|
||||
variant?: 'danger' | 'success'
|
||||
/** Título estilo HUD; renderizado automaticamente como `:: NOME`. */
|
||||
title?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-panel {
|
||||
position: relative;
|
||||
background: linear-gradient(180deg, var(--fc-bg-raised), var(--fc-bg-panel));
|
||||
border: 2px solid var(--fc-panel-edge);
|
||||
border-radius: var(--fc-radius);
|
||||
padding: var(--fc-space-4);
|
||||
box-shadow: var(--fc-shadow-panel);
|
||||
}
|
||||
|
||||
.fc-panel--glow {
|
||||
border-color: var(--fc-accent-3);
|
||||
box-shadow: var(--fc-shadow-panel-glow);
|
||||
}
|
||||
|
||||
.fc-panel--danger {
|
||||
border-color: var(--fc-red);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255,255,255,.05),
|
||||
0 0 0 1px var(--fc-red),
|
||||
0 0 16px rgba(255, 59, 107, .3),
|
||||
0 8px 24px rgba(0,0,0,.4);
|
||||
}
|
||||
|
||||
.fc-panel--success {
|
||||
border-color: var(--fc-green);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255,255,255,.05),
|
||||
0 0 0 1px var(--fc-green),
|
||||
0 0 16px rgba(57, 255, 122, .25),
|
||||
0 8px 24px rgba(0,0,0,.4);
|
||||
}
|
||||
|
||||
.fc-panel__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--fc-space-3);
|
||||
gap: var(--fc-space-2);
|
||||
}
|
||||
|
||||
.fc-panel__title {
|
||||
font-family: var(--fc-font-pixel);
|
||||
font-size: 9px;
|
||||
color: var(--fc-accent-2);
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.fc-corner {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border: 2px solid var(--fc-accent-2);
|
||||
pointer-events: none;
|
||||
}
|
||||
.fc-corner--tl { top: -2px; left: -2px; border-right: none; border-bottom: none; }
|
||||
.fc-corner--tr { top: -2px; right: -2px; border-left: none; border-bottom: none; }
|
||||
.fc-corner--bl { bottom: -2px; left: -2px; border-right: none; border-top: none; }
|
||||
.fc-corner--br { bottom: -2px; right: -2px; border-left: none; border-top: none; }
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div class="fc-bar" :class="[`fc-bar--${size}`, { 'fc-bar--success': success, 'fc-bar--danger': danger }]">
|
||||
<div class="fc-bar__fill" :style="{ width: clamped + '%' }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** Percentual 0–100 (ou maior, será clampado). */
|
||||
pct: number
|
||||
/** 'sm' (6px) | 'md' (8px) | 'lg' (14px) */
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
/** Pinta de verde quando meta foi atingida. */
|
||||
success?: boolean
|
||||
/** Pinta de vermelho. */
|
||||
danger?: boolean
|
||||
}>(), { size: 'md' })
|
||||
|
||||
const clamped = computed(() => Math.max(0, Math.min(100, props.pct)))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-bar {
|
||||
background: var(--fc-bg);
|
||||
border: 2px solid var(--fc-panel-edge);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fc-bar--sm { height: 6px; }
|
||||
.fc-bar--md { height: 8px; }
|
||||
.fc-bar--lg { height: 14px; }
|
||||
|
||||
.fc-bar__fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--fc-accent-2), var(--fc-accent-3), var(--fc-accent));
|
||||
position: relative;
|
||||
transition: width .6s cubic-bezier(.2,.7,.3,1);
|
||||
}
|
||||
.fc-bar__fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: repeating-linear-gradient(90deg, rgba(0,0,0,.2) 0 2px, transparent 2px 6px);
|
||||
}
|
||||
|
||||
.fc-bar--success { border-color: var(--fc-green); }
|
||||
.fc-bar--success .fc-bar__fill {
|
||||
background: linear-gradient(90deg, var(--fc-green), var(--fc-accent-2));
|
||||
}
|
||||
|
||||
.fc-bar--danger { border-color: var(--fc-red); }
|
||||
.fc-bar--danger .fc-bar__fill {
|
||||
background: linear-gradient(90deg, var(--fc-red), var(--fc-accent));
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user