chore: commit all missing files from Qute/Alpine migration
CI / Build Native (push) Successful in 7m9s
CI / Build Native (push) Successful in 7m9s
This commit adds all the files that were created during the frontend migration (Qute templates + Alpine.js) but were never committed to git. Includes: - TemplateData.java (shared Qute page helpers) - Static resources: app.js, defaults.js, calc.js, alpine.min.js, pico.min.css, favicon.svg - Qute templates: changePassword, formulas, insumos, login, register, usuarios - Partial templates: header.html, layout/base.html - Modified: MustChangePasswordFilter.java (added login/register paths) - Modified: AppState.java (added price fields) - Modified: format.js (simplified number formatting) - Modified: application.properties (static resources comment) - Modified: calculadora.html (Qute/Alpine rewrite)
This commit is contained in:
@@ -54,6 +54,9 @@ public class MustChangePasswordFilter implements ContainerRequestFilter {
|
|||||||
if (path.startsWith("/")) path = path.substring(1);
|
if (path.startsWith("/")) path = path.substring(1);
|
||||||
if (path.startsWith("api/auth/change-password")) return;
|
if (path.startsWith("api/auth/change-password")) return;
|
||||||
if (path.startsWith("api/auth/logout")) return;
|
if (path.startsWith("api/auth/logout")) return;
|
||||||
|
if (path.equals("login")) return;
|
||||||
|
if (path.equals("change-password")) return;
|
||||||
|
if (path.equals("register")) return;
|
||||||
|
|
||||||
ctx.abortWith(Response.status(403)
|
ctx.abortWith(Response.status(403)
|
||||||
.entity(new MustChangeBody())
|
.entity(new MustChangeBody())
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ public record AppState(
|
|||||||
Map<String, Integer> cristales,
|
Map<String, Integer> cristales,
|
||||||
int soulOre,
|
int soulOre,
|
||||||
int spiritOre,
|
int spiritOre,
|
||||||
Map<String, Map<String, Integer>> venta) {
|
int dwarvenFeePerCraft,
|
||||||
|
Map<String, Map<String, Integer>> venta,
|
||||||
|
Map<String, Map<String, Integer>> precioMercado,
|
||||||
|
Map<String, Integer> ngVenta) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@RegisterForReflection
|
@RegisterForReflection
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.l2.shots.ui;
|
||||||
|
|
||||||
|
import com.l2.shots.auth.AuthMeResponse;
|
||||||
|
import com.l2.shots.auth.AuthService;
|
||||||
|
import com.l2.shots.auth.JwtCookieAuth;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.ws.rs.core.HttpHeaders;
|
||||||
|
import org.eclipse.microprofile.jwt.JsonWebToken;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helpers compartidos por las pages Qute: extracción de usuario desde la cookie
|
||||||
|
* JWT, defaults de navegación, etc.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class TemplateData {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
JwtCookieAuth jwtCookieAuth;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
AuthService authService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devuelve el usuario actual si la cookie JWT es válida, o empty si no hay
|
||||||
|
* sesión. Si devuelve empty, el caller debe redirigir a /login.
|
||||||
|
*/
|
||||||
|
public Optional<AuthMeResponse> currentUser(HttpHeaders headers) {
|
||||||
|
Optional<JsonWebToken> jwt = jwtCookieAuth.extractToken(headers);
|
||||||
|
if (jwt.isEmpty()) return Optional.empty();
|
||||||
|
return authService.getUserFromToken(jwt.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gem" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#10b981"/>
|
||||||
|
<stop offset="100%" stop-color="#047857"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d="M32 6 L52 22 L32 58 L12 22 Z" fill="url(#gem)" stroke="#065f46" stroke-width="2"/>
|
||||||
|
<path d="M32 6 L52 22 L12 22 Z" fill="rgba(255,255,255,0.2)"/>
|
||||||
|
<path d="M32 58 L52 22 L32 22 Z" fill="rgba(0,0,0,0.12)"/>
|
||||||
|
<circle cx="40" cy="14" r="3" fill="white" opacity="0.8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 552 B |
File diff suppressed because one or more lines are too long
@@ -0,0 +1,240 @@
|
|||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
|
||||||
|
Alpine.store('app', {
|
||||||
|
user: null,
|
||||||
|
state: window.makeDefaultAppState(),
|
||||||
|
status: 'idle',
|
||||||
|
errorMessage: null,
|
||||||
|
saveTimer: null,
|
||||||
|
firstLoad: true,
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
try {
|
||||||
|
const user = await window.api.me();
|
||||||
|
if (!user) { window.location.href = '/login'; return; }
|
||||||
|
if (user.mustChangePassword) {
|
||||||
|
window.location.href = '/change-password';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.user = user;
|
||||||
|
const state = await window.api.getState();
|
||||||
|
if (state) {
|
||||||
|
this.state = this.mergeWithDefaults(state);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status !== 401) {
|
||||||
|
this.errorMessage = err.message || 'Error cargando estado';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mergeWithDefaults(state) {
|
||||||
|
const d = window.makeDefaultAppState();
|
||||||
|
return {
|
||||||
|
insumos: {
|
||||||
|
cristales: { ...d.insumos.cristales, ...state.insumos?.cristales },
|
||||||
|
soulOre: state.insumos?.soulOre ?? d.insumos.soulOre,
|
||||||
|
spiritOre: state.insumos?.spiritOre ?? d.insumos.spiritOre,
|
||||||
|
dwarvenFeePerCraft: state.insumos?.dwarvenFeePerCraft ?? d.insumos.dwarvenFeePerCraft,
|
||||||
|
venta: {
|
||||||
|
Soulshot: { ...d.insumos.venta.Soulshot, ...state.insumos?.venta?.Soulshot },
|
||||||
|
Spiritshot: { ...d.insumos.venta.Spiritshot, ...state.insumos?.venta?.Spiritshot },
|
||||||
|
'Blessed Spiritshot': { ...d.insumos.venta['Blessed Spiritshot'], ...state.insumos?.venta?.['Blessed Spiritshot'] },
|
||||||
|
},
|
||||||
|
precioMercado: {
|
||||||
|
Soulshot: { ...d.insumos.precioMercado.Soulshot, ...state.insumos?.precioMercado?.Soulshot },
|
||||||
|
Spiritshot: { ...d.insumos.precioMercado.Spiritshot, ...state.insumos?.precioMercado?.Spiritshot },
|
||||||
|
'Blessed Spiritshot': { ...d.insumos.precioMercado['Blessed Spiritshot'], ...state.insumos?.precioMercado?.['Blessed Spiritshot'] },
|
||||||
|
},
|
||||||
|
ngVenta: {
|
||||||
|
Soulshot: state.insumos?.ngVenta?.Soulshot ?? d.insumos.ngVenta.Soulshot,
|
||||||
|
Spiritshot: state.insumos?.ngVenta?.Spiritshot ?? d.insumos.ngVenta.Spiritshot,
|
||||||
|
'Blessed Spiritshot': state.insumos?.ngVenta?.['Blessed Spiritshot'] ?? d.insumos.ngVenta['Blessed Spiritshot'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
formulas: state.formulas ?? d.formulas,
|
||||||
|
disponibles: state.disponibles ?? d.disponibles,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
scheduleSave() {
|
||||||
|
if (this.firstLoad) { this.firstLoad = false; return; }
|
||||||
|
if (!this.state) return;
|
||||||
|
if (this.saveTimer) clearTimeout(this.saveTimer);
|
||||||
|
this.status = 'saving';
|
||||||
|
this.saveTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await window.api.putState(this.snapshot());
|
||||||
|
this.status = 'saved';
|
||||||
|
setTimeout(() => { if (this.status === 'saved') this.status = 'idle'; }, 1500);
|
||||||
|
this.errorMessage = null;
|
||||||
|
} catch (err) {
|
||||||
|
this.status = 'error';
|
||||||
|
this.errorMessage = err.message || 'Error al guardar';
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
},
|
||||||
|
|
||||||
|
snapshot() {
|
||||||
|
return { insumos: this.state.insumos, formulas: this.state.formulas, disponibles: this.state.disponibles };
|
||||||
|
},
|
||||||
|
|
||||||
|
setState(next) {
|
||||||
|
if (typeof next === 'function') {
|
||||||
|
this.state = next(this.state);
|
||||||
|
} else {
|
||||||
|
this.state = next;
|
||||||
|
}
|
||||||
|
this.scheduleSave();
|
||||||
|
},
|
||||||
|
|
||||||
|
async reset() {
|
||||||
|
await window.api.resetState();
|
||||||
|
this.state = window.makeDefaultAppState();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Alpine.data('authLogin', () => ({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
submitting: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
async handleSubmit() {
|
||||||
|
this.error = null;
|
||||||
|
this.submitting = true;
|
||||||
|
try {
|
||||||
|
const user = await window.api.login(this.username, this.password);
|
||||||
|
if (user && user.mustChangePassword) {
|
||||||
|
window.location.href = '/change-password';
|
||||||
|
} else {
|
||||||
|
window.location.href = '/insumos';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.message || 'Error desconocido';
|
||||||
|
} finally {
|
||||||
|
this.submitting = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
goToRegister() { window.location.href = '/register'; },
|
||||||
|
reset() { this.username = ''; this.password = ''; this.error = null; },
|
||||||
|
}));
|
||||||
|
|
||||||
|
Alpine.data('authRegister', () => ({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
submitting: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
async handleSubmit() {
|
||||||
|
this.error = null;
|
||||||
|
this.submitting = true;
|
||||||
|
try {
|
||||||
|
const user = await window.api.register(this.username, this.password);
|
||||||
|
if (user && user.mustChangePassword) {
|
||||||
|
window.location.href = '/change-password';
|
||||||
|
} else {
|
||||||
|
window.location.href = '/insumos';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message.includes('no disponible')) {
|
||||||
|
this.error = 'Ese username ya esta en uso.';
|
||||||
|
} else if (err.message.toLowerCase().includes('datos')) {
|
||||||
|
this.error = 'Username (3-30 chars, alfanumerico o _) y password (>= 8 chars).';
|
||||||
|
} else {
|
||||||
|
this.error = err.message;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.submitting = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
goToLogin() { window.location.href = '/login'; },
|
||||||
|
reset() { this.username = ''; this.password = ''; this.error = null; },
|
||||||
|
}));
|
||||||
|
|
||||||
|
Alpine.data('authChangePassword', () => ({
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
submitting: false,
|
||||||
|
done: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
async handleSubmit() {
|
||||||
|
this.error = null;
|
||||||
|
this.submitting = true;
|
||||||
|
try {
|
||||||
|
await window.api.changePassword(this.currentPassword, this.newPassword);
|
||||||
|
await window.api.me();
|
||||||
|
this.done = true;
|
||||||
|
setTimeout(() => { window.location.href = '/insumos'; }, 1000);
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.message || 'Error desconocido';
|
||||||
|
} finally {
|
||||||
|
this.submitting = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
Alpine.data('appShell', () => ({
|
||||||
|
async init() {
|
||||||
|
await Alpine.store('app').load();
|
||||||
|
},
|
||||||
|
async logout() {
|
||||||
|
await window.api.logout();
|
||||||
|
Alpine.store('app').user = null;
|
||||||
|
Alpine.store('app').state = null;
|
||||||
|
window.location.href = '/login';
|
||||||
|
},
|
||||||
|
resetExamples() {
|
||||||
|
if (!confirm('Restablecer todos los valores a los ejemplos?')) return;
|
||||||
|
Alpine.store('app').state = window.makeDefaultAppState();
|
||||||
|
Alpine.store('app').scheduleSave();
|
||||||
|
},
|
||||||
|
clearAll() {
|
||||||
|
if (!confirm('Poner todos los valores en cero?')) return;
|
||||||
|
Alpine.store('app').state = window.makeEmptyAppState();
|
||||||
|
Alpine.store('app').scheduleSave();
|
||||||
|
},
|
||||||
|
async resetServer() {
|
||||||
|
if (!confirm('Borrar el estado guardado en el servidor?')) return;
|
||||||
|
await Alpine.store('app').reset();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
Alpine.data('appTabBar', () => ({
|
||||||
|
user() { return Alpine.store('app').user; },
|
||||||
|
isAdmin() { return this.user()?.isAdmin === true; },
|
||||||
|
activeTab(href) {
|
||||||
|
return window.location.pathname === href ? 'active' : '';
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
Alpine.data('appHeader', () => ({
|
||||||
|
user() { return Alpine.store('app').user; },
|
||||||
|
saveStatus() { return Alpine.store('app').status; },
|
||||||
|
saveError() { return Alpine.store('app').errorMessage; },
|
||||||
|
async logout() {
|
||||||
|
await window.api.logout();
|
||||||
|
Alpine.store('app').user = null;
|
||||||
|
Alpine.store('app').state = null;
|
||||||
|
window.location.href = '/login';
|
||||||
|
},
|
||||||
|
resetExamples() {
|
||||||
|
if (!confirm('Restablecer todos los valores a los ejemplos?')) return;
|
||||||
|
Alpine.store('app').state = window.makeDefaultAppState();
|
||||||
|
Alpine.store('app').scheduleSave();
|
||||||
|
},
|
||||||
|
clearAll() {
|
||||||
|
if (!confirm('Poner todos los valores en cero?')) return;
|
||||||
|
Alpine.store('app').state = window.makeEmptyAppState();
|
||||||
|
Alpine.store('app').scheduleSave();
|
||||||
|
},
|
||||||
|
async resetServer() {
|
||||||
|
if (!confirm('Borrar el estado guardado en el servidor?')) return;
|
||||||
|
await Alpine.store('app').reset();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
window.calc = {
|
||||||
|
|
||||||
|
calcularFila(formula, cristalesDisponibles, insumos) {
|
||||||
|
const orePerCraft = formula.soulOreReq != null ? formula.soulOreReq : formula.spiritOreReq != null ? formula.spiritOreReq : 0;
|
||||||
|
const oreLabel = formula.soulOreReq != null ? 'Soul Ore' : formula.spiritOreReq != null ? 'Spirit Ore' : null;
|
||||||
|
|
||||||
|
if (formula.cristalesReq <= 0) {
|
||||||
|
return {
|
||||||
|
tipo: formula.tipo,
|
||||||
|
grado: formula.grado,
|
||||||
|
cristalesDisponibles,
|
||||||
|
cristalesUsados: 0,
|
||||||
|
oreNecesario: 0,
|
||||||
|
oreLabel,
|
||||||
|
crafteosPosibles: 0,
|
||||||
|
shotsObtenidos: 0,
|
||||||
|
costoTotal: 0,
|
||||||
|
valorVenta: 0,
|
||||||
|
ganancia: 0,
|
||||||
|
warning: 'Completa la receta en la pestana Formulas',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const crafteosPosibles = Math.floor(cristalesDisponibles / formula.cristalesReq);
|
||||||
|
const cristalesUsados = crafteosPosibles * formula.cristalesReq;
|
||||||
|
const oreNecesario = crafteosPosibles * orePerCraft;
|
||||||
|
const shotsObtenidos = crafteosPosibles * formula.shotsObtenidos;
|
||||||
|
|
||||||
|
const precioCristal = insumos.cristales[formula.grado] || 0;
|
||||||
|
const precioOre = orePerCraft > 0
|
||||||
|
? (formula.soulOreReq != null ? insumos.soulOre : insumos.spiritOre)
|
||||||
|
: 0;
|
||||||
|
const precioVenta = insumos.venta[formula.tipo]?.[formula.grado] || 0;
|
||||||
|
|
||||||
|
const dwarvenFee = crafteosPosibles * (insumos.dwarvenFeePerCraft || 0);
|
||||||
|
const costoTotal = cristalesUsados * precioCristal + oreNecesario * precioOre + dwarvenFee;
|
||||||
|
const valorVenta = shotsObtenidos * precioVenta;
|
||||||
|
const ganancia = valorVenta - costoTotal;
|
||||||
|
|
||||||
|
return {
|
||||||
|
tipo: formula.tipo,
|
||||||
|
grado: formula.grado,
|
||||||
|
cristalesDisponibles,
|
||||||
|
cristalesUsados,
|
||||||
|
oreNecesario,
|
||||||
|
oreLabel,
|
||||||
|
crafteosPosibles,
|
||||||
|
shotsObtenidos,
|
||||||
|
costoTotal,
|
||||||
|
valorVenta,
|
||||||
|
ganancia,
|
||||||
|
warning: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
sumarTotales(calculos) {
|
||||||
|
return calculos.reduce((acc, c) => ({
|
||||||
|
cristalesUsados: acc.cristalesUsados + c.cristalesUsados,
|
||||||
|
oreNecesario: acc.oreNecesario + c.oreNecesario,
|
||||||
|
crafteosPosibles: acc.crafteosPosibles + c.crafteosPosibles,
|
||||||
|
shotsObtenidos: acc.shotsObtenidos + c.shotsObtenidos,
|
||||||
|
costoTotal: acc.costoTotal + c.costoTotal,
|
||||||
|
valorVenta: acc.valorVenta + c.valorVenta,
|
||||||
|
ganancia: acc.ganancia + c.ganancia,
|
||||||
|
}), {
|
||||||
|
cristalesUsados: 0, oreNecesario: 0, crafteosPosibles: 0,
|
||||||
|
shotsObtenidos: 0, costoTotal: 0, valorVenta: 0, ganancia: 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
window.DEFAULT_INSUMOS = {
|
||||||
|
cristales: { D: 1500, C: 4500, B: 18000, A: 60000, S: 200000 },
|
||||||
|
soulOre: 320,
|
||||||
|
spiritOre: 450,
|
||||||
|
dwarvenFeePerCraft: 100,
|
||||||
|
venta: {
|
||||||
|
Soulshot: { D: 7, C: 18, B: 70, A: 220, S: 800 },
|
||||||
|
Spiritshot: { D: 22, C: 60, B: 180, A: 400, S: 950 },
|
||||||
|
'Blessed Spiritshot': { D: 60, C: 180, B: 700, A: 1800, S: 4500 },
|
||||||
|
},
|
||||||
|
precioMercado: {
|
||||||
|
Soulshot: { D: 0, C: 0, B: 0, A: 0, S: 0 },
|
||||||
|
Spiritshot: { D: 0, C: 0, B: 0, A: 0, S: 0 },
|
||||||
|
'Blessed Spiritshot': { D: 0, C: 0, B: 0, A: 0, S: 0 },
|
||||||
|
},
|
||||||
|
ngVenta: {
|
||||||
|
Soulshot: 8,
|
||||||
|
Spiritshot: 17,
|
||||||
|
'Blessed Spiritshot': 39,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
window.DEFAULT_FORMULAS = [
|
||||||
|
{ id: 'Soulshot-D', tipo: 'Soulshot', grado: 'D', cristalesReq: 1, soulOreReq: 3, spiritOreReq: null, shotsObtenidos: 200 },
|
||||||
|
{ id: 'Soulshot-C', tipo: 'Soulshot', grado: 'C', cristalesReq: 1, soulOreReq: 15, spiritOreReq: null, shotsObtenidos: 600 },
|
||||||
|
{ id: 'Soulshot-B', tipo: 'Soulshot', grado: 'B', cristalesReq: 1, soulOreReq: 54, spiritOreReq: null, shotsObtenidos: 540 },
|
||||||
|
{ id: 'Soulshot-A', tipo: 'Soulshot', grado: 'A', cristalesReq: 1, soulOreReq: 60, spiritOreReq: null, shotsObtenidos: 500 },
|
||||||
|
{ id: 'Soulshot-S', tipo: 'Soulshot', grado: 'S', cristalesReq: 1, soulOreReq: 40, spiritOreReq: null, shotsObtenidos: 350 },
|
||||||
|
{ id: 'Spiritshot-D', tipo: 'Spiritshot', grado: 'D', cristalesReq: 1, soulOreReq: null, spiritOreReq: 3, shotsObtenidos: 140 },
|
||||||
|
{ id: 'Spiritshot-C', tipo: 'Spiritshot', grado: 'C', cristalesReq: 1, soulOreReq: null, spiritOreReq: 10, shotsObtenidos: 270 },
|
||||||
|
{ id: 'Spiritshot-B', tipo: 'Spiritshot', grado: 'B', cristalesReq: 1, soulOreReq: null, spiritOreReq: 15, shotsObtenidos: 200 },
|
||||||
|
{ id: 'Spiritshot-A', tipo: 'Spiritshot', grado: 'A', cristalesReq: 1, soulOreReq: null, spiritOreReq: 30, shotsObtenidos: 350 },
|
||||||
|
{ id: 'Spiritshot-S', tipo: 'Spiritshot', grado: 'S', cristalesReq: 1, soulOreReq: null, spiritOreReq: 15, shotsObtenidos: 200 },
|
||||||
|
{ id: 'Blessed Spiritshot-D', tipo: 'Blessed Spiritshot', grado: 'D', cristalesReq: 2, soulOreReq: null, spiritOreReq: 8, shotsObtenidos: 120 },
|
||||||
|
{ id: 'Blessed Spiritshot-C', tipo: 'Blessed Spiritshot', grado: 'C', cristalesReq: 2, soulOreReq: null, spiritOreReq: 30, shotsObtenidos: 240 },
|
||||||
|
{ id: 'Blessed Spiritshot-B', tipo: 'Blessed Spiritshot', grado: 'B', cristalesReq: 2, soulOreReq: null, spiritOreReq: 16, shotsObtenidos: 120 },
|
||||||
|
{ id: 'Blessed Spiritshot-A', tipo: 'Blessed Spiritshot', grado: 'A', cristalesReq: 2, soulOreReq: null, spiritOreReq: 84, shotsObtenidos: 300 },
|
||||||
|
{ id: 'Blessed Spiritshot-S', tipo: 'Blessed Spiritshot', grado: 'S', cristalesReq: 2, soulOreReq: null, spiritOreReq: 50, shotsObtenidos: 200 },
|
||||||
|
];
|
||||||
|
|
||||||
|
window.DEFAULT_CRISTALES_DISP = {
|
||||||
|
Soulshot: { D: 0, C: 0, B: 0, A: 0, S: 0 },
|
||||||
|
Spiritshot: { D: 0, C: 0, B: 0, A: 0, S: 0 },
|
||||||
|
'Blessed Spiritshot': { D: 0, C: 0, B: 0, A: 0, S: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
window.EMPTY_INSUMOS = {
|
||||||
|
cristales: { D: 0, C: 0, B: 0, A: 0, S: 0 },
|
||||||
|
soulOre: 0,
|
||||||
|
spiritOre: 0,
|
||||||
|
dwarvenFeePerCraft: 0,
|
||||||
|
venta: {
|
||||||
|
Soulshot: { D: 0, C: 0, B: 0, A: 0, S: 0 },
|
||||||
|
Spiritshot: { D: 0, C: 0, B: 0, A: 0, S: 0 },
|
||||||
|
'Blessed Spiritshot': { D: 0, C: 0, B: 0, A: 0, S: 0 },
|
||||||
|
},
|
||||||
|
precioMercado: {
|
||||||
|
Soulshot: { D: 0, C: 0, B: 0, A: 0, S: 0 },
|
||||||
|
Spiritshot: { D: 0, C: 0, B: 0, A: 0, S: 0 },
|
||||||
|
'Blessed Spiritshot': { D: 0, C: 0, B: 0, A: 0, S: 0 },
|
||||||
|
},
|
||||||
|
ngVenta: {
|
||||||
|
Soulshot: 0,
|
||||||
|
Spiritshot: 0,
|
||||||
|
'Blessed Spiritshot': 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
window.EMPTY_FORMULAS = window.DEFAULT_FORMULAS.map(f => ({
|
||||||
|
...f,
|
||||||
|
cristalesReq: 0,
|
||||||
|
soulOreReq: null,
|
||||||
|
spiritOreReq: null,
|
||||||
|
shotsObtenidos: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function structuredCopy(obj) {
|
||||||
|
return JSON.parse(JSON.stringify(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.makeDefaultAppState = function() {
|
||||||
|
return {
|
||||||
|
insumos: structuredCopy(window.DEFAULT_INSUMOS),
|
||||||
|
formulas: structuredCopy(window.DEFAULT_FORMULAS),
|
||||||
|
disponibles: structuredCopy(window.DEFAULT_CRISTALES_DISP),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
window.makeEmptyAppState = function() {
|
||||||
|
return {
|
||||||
|
insumos: structuredCopy(window.EMPTY_INSUMOS),
|
||||||
|
formulas: structuredCopy(window.EMPTY_FORMULAS),
|
||||||
|
disponibles: structuredCopy(window.DEFAULT_CRISTALES_DISP),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
window.fmt = {
|
window.fmt = {
|
||||||
adena(n) {
|
adena(n) {
|
||||||
if (n == null) return '—';
|
if (n == null) return '—';
|
||||||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
return Math.round(n).toLocaleString('es-CL');
|
||||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
|
||||||
return n.toLocaleString('es-CL');
|
|
||||||
},
|
},
|
||||||
number(n) {
|
number(n) {
|
||||||
if (n == null) return '—';
|
if (n == null) return '—';
|
||||||
|
|||||||
@@ -31,4 +31,7 @@ quarkus.http.auth.proactive=false
|
|||||||
app.bootstrap.admin.enabled=true
|
app.bootstrap.admin.enabled=true
|
||||||
app.bootstrap.admin.username=admin
|
app.bootstrap.admin.username=admin
|
||||||
|
|
||||||
|
# Static resources are served from META-INF/resources/static/* by default.
|
||||||
|
# Nothing else to configure.
|
||||||
|
|
||||||
%native.quarkus.native.resources.includes=META-INF/resources/.*,publicKey.pem,privateKey.pem
|
%native.quarkus.native.resources.includes=META-INF/resources/.*,publicKey.pem,privateKey.pem
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Cambiar contrasena — Calculadora de Craft de Shots</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/pico.min.css"/>
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css"/>
|
||||||
|
</head>
|
||||||
|
<body class="login-page" x-data="authChangePassword()">
|
||||||
|
<article class="login-card">
|
||||||
|
<svg class="login-logo" viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-label="Logo">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gem" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#10b981"/>
|
||||||
|
<stop offset="100%" stop-color="#047857"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d="M40 8 L62 28 L40 72 L18 28 Z" fill="url(#gem)" stroke="#065f46" stroke-width="1.5"/>
|
||||||
|
<path d="M40 8 L62 28 L18 28 Z" fill="rgba(255,255,255,0.15)"/>
|
||||||
|
<path d="M40 72 L62 28 L40 28 Z" fill="rgba(0,0,0,0.1)"/>
|
||||||
|
<g class="sparkle">
|
||||||
|
<circle cx="50" cy="18" r="2.5" fill="white"/>
|
||||||
|
<path d="M50 14 L51 18 L50 22 L49 18 Z M46 18 L50 17 L54 18 L50 19 Z"
|
||||||
|
fill="white" opacity="0.7"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<hgroup style="text-align:center;margin-bottom:1.5rem">
|
||||||
|
<h1 style="font-size:1.5rem;margin-bottom:0.25rem">Cambiar contrasena</h1>
|
||||||
|
<p style="margin-bottom:0">Debes cambiar tu contrasena antes de continuar.</p>
|
||||||
|
</hgroup>
|
||||||
|
|
||||||
|
<div x-show="!done">
|
||||||
|
<form @submit.prevent="handleSubmit">
|
||||||
|
<label>Contrasena actual
|
||||||
|
<input type="password" x-model="currentPassword"
|
||||||
|
autocomplete="current-password" autofocus required minlength="8"/>
|
||||||
|
</label>
|
||||||
|
<label>Nueva contrasena
|
||||||
|
<input type="password" x-model="newPassword"
|
||||||
|
autocomplete="new-password" required minlength="8"/>
|
||||||
|
<small>Minimo 8 caracteres.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div x-show="error" x-transition class="error-msg" x-text="error"></div>
|
||||||
|
|
||||||
|
<button type="submit" :disabled="submitting">
|
||||||
|
<span x-show="!submitting">Cambiar contrasena</span>
|
||||||
|
<span x-show="submitting">Procesando…</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="done" x-transition class="text-center">
|
||||||
|
<p class="success-msg mb-4">Contrasena actualizada.</p>
|
||||||
|
<p class="text-sm text-muted">Redirigiendo a la app…</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<script src="/static/js/defaults.js"></script>
|
||||||
|
<script src="/static/js/api.js"></script>
|
||||||
|
<script src="/static/js/app.js"></script>
|
||||||
|
<script defer src="/static/js/alpine.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Formulas — Calculadora de Craft de Shots</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/pico.min.css"/>
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css"/>
|
||||||
|
</head>
|
||||||
|
<body x-data="appShell()">
|
||||||
|
|
||||||
|
{#include partials/header.html /}
|
||||||
|
{#include partials/tab-bar.html /}
|
||||||
|
|
||||||
|
<main class="container" x-data="formulasSection()">
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h2>Recetas de crafteo</h2>
|
||||||
|
<p class="text-sm text-muted">
|
||||||
|
Edita los recursos necesarios por cada accion de crafteo. Los cambios se reflejan en la
|
||||||
|
calculadora y se persisten automaticamente.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="cell-label text-left">Tipo</th>
|
||||||
|
<th class="cell-label text-center">Grado</th>
|
||||||
|
<th class="cell-label text-right">Cristales req.</th>
|
||||||
|
<th class="cell-label text-right">Soul Ore req.</th>
|
||||||
|
<th class="cell-label text-right">Spirit Ore req.</th>
|
||||||
|
<th class="cell-label text-right">Shots obtenidos</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="f in store().state.formulas" :key="f.id">
|
||||||
|
<tr>
|
||||||
|
<td class="text-bold" x-text="f.tipo"></td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span class="grade-badge" x-text="f.grado"></span>
|
||||||
|
</td>
|
||||||
|
<td><input type="number" min="0" step="1" x-model.number="f.cristalesReq" @input="Alpine.store('app').scheduleSave()"/></td>
|
||||||
|
<td><input type="number" min="0" step="1" x-model.number="f.soulOreReq" :disabled="f.tipo !== 'Soulshot'" @input="Alpine.store('app').scheduleSave()"/></td>
|
||||||
|
<td><input type="number" min="0" step="1" x-model.number="f.spiritOreReq" :disabled="f.tipo === 'Soulshot'" @input="Alpine.store('app').scheduleSave()"/></td>
|
||||||
|
<td><input type="number" min="0" step="1" x-model.number="f.shotsObtenidos" @input="Alpine.store('app').scheduleSave()"/></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="container">
|
||||||
|
<p class="text-center text-xs text-muted">
|
||||||
|
Auto-guardado activo · Cambios persistidos en el servidor cada ~500ms
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="/static/js/defaults.js"></script>
|
||||||
|
<script src="/static/js/api.js"></script>
|
||||||
|
<script src="/static/js/app.js"></script>
|
||||||
|
<script defer src="/static/js/alpine.min.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('formulasSection', () => ({
|
||||||
|
store() { return Alpine.store('app'); },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Insumos — Calculadora de Craft de Shots</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/pico.min.css"/>
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css"/>
|
||||||
|
</head>
|
||||||
|
<body x-data="appShell()">
|
||||||
|
|
||||||
|
{#include partials/header.html /}
|
||||||
|
{#include partials/tab-bar.html /}
|
||||||
|
|
||||||
|
<main class="container" x-data="insumosSection()">
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h2>Cristales</h2>
|
||||||
|
<p class="text-sm text-muted">Precio unitario en adena</p>
|
||||||
|
</header>
|
||||||
|
<div class="grid-5">
|
||||||
|
<template x-for="grado in ['D','C','B','A','S']" :key="grado">
|
||||||
|
<label>
|
||||||
|
<span class="text-sm text-muted" x-text="'Cristal ' + grado"></span>
|
||||||
|
<input type="number" min="0" step="1"
|
||||||
|
x-model.number="store().state.insumos.cristales[grado]"
|
||||||
|
@input="store().scheduleSave()"/>
|
||||||
|
<span class="text-xs text-mono text-muted" x-text="fmtAdena(store().state.insumos.cristales[grado])"></span>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h2>Ores</h2>
|
||||||
|
<p class="text-sm text-muted">Precio por unidad</p>
|
||||||
|
</header>
|
||||||
|
<div class="grid-2" style="max-width:28rem">
|
||||||
|
<label>
|
||||||
|
<span class="text-sm text-muted">Soul Ore (Soulstone)</span>
|
||||||
|
<input type="number" min="0" step="1"
|
||||||
|
x-model.number="store().state.insumos.soulOre"
|
||||||
|
@input="store().scheduleSave()"/>
|
||||||
|
<span class="text-xs text-mono text-muted" x-text="fmtAdena(store().state.insumos.soulOre)"></span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span class="text-sm text-muted">Spirit Ore</span>
|
||||||
|
<input type="number" min="0" step="1"
|
||||||
|
x-model.number="store().state.insumos.spiritOre"
|
||||||
|
@input="store().scheduleSave()"/>
|
||||||
|
<span class="text-xs text-mono text-muted" x-text="fmtAdena(store().state.insumos.spiritOre)"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h2>Craft fee</h2>
|
||||||
|
<p class="text-sm text-muted">Costo del Dwarven Workshop por cada batch de crafteo (descontado del profit)</p>
|
||||||
|
</header>
|
||||||
|
<div style="max-width:20rem">
|
||||||
|
<label>
|
||||||
|
<span class="text-sm text-muted">Fee por crafteo</span>
|
||||||
|
<input type="number" min="0" step="1"
|
||||||
|
x-model.number="store().state.insumos.dwarvenFeePerCraft"
|
||||||
|
@input="store().scheduleSave()"/>
|
||||||
|
<span class="text-xs text-mono text-muted" x-text="fmtAdena(store().state.insumos.dwarvenFeePerCraft) + ' adena / craft'"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<template x-for="tipo in ['Soulshot','Spiritshot','Blessed Spiritshot']" :key="tipo">
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h2 x-text="'Precio de venta — ' + tipo"></h2>
|
||||||
|
<p class="text-sm text-muted">Precio unitario del shot vendido</p>
|
||||||
|
</header>
|
||||||
|
<div class="grid-5">
|
||||||
|
<template x-for="grado in ['D','C','B','A','S']" :key="grado">
|
||||||
|
<label>
|
||||||
|
<span class="text-sm text-muted" x-text="tipo + ' ' + grado"></span>
|
||||||
|
<input type="number" min="0" step="1"
|
||||||
|
x-model.number="store().state.insumos.venta[tipo][grado]"
|
||||||
|
@input="store().scheduleSave()"/>
|
||||||
|
<span class="text-xs text-mono text-muted" x-text="fmtAdena(store().state.insumos.venta[tipo][grado])"></span>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h2>Precio de mercado (compra directa)</h2>
|
||||||
|
<p class="text-sm text-muted">Precio al que conseguis el shot ya crafteado en el mercado. Usado para comparar vs craftear vos.</p>
|
||||||
|
</header>
|
||||||
|
<template x-for="tipo in ['Soulshot','Spiritshot','Blessed Spiritshot']" :key="tipo">
|
||||||
|
<div style="margin-bottom:1rem">
|
||||||
|
<p class="text-sm text-bold mb-2" x-text="tipo"></p>
|
||||||
|
<div class="grid-5">
|
||||||
|
<template x-for="grado in ['D','C','B','A','S']" :key="grado">
|
||||||
|
<label>
|
||||||
|
<span class="text-sm text-muted" x-text="tipo + ' ' + grado"></span>
|
||||||
|
<input type="number" min="0" step="1"
|
||||||
|
x-model.number="store().state.insumos.precioMercado[tipo][grado]"
|
||||||
|
@input="store().scheduleSave()"/>
|
||||||
|
<span class="text-xs text-mono text-muted" x-text="fmtAdena(store().state.insumos.precioMercado[tipo][grado])"></span>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h2>No-Grade (NG) — Precio NPC</h2>
|
||||||
|
<p class="text-sm text-muted">Precio de venta del shot No-Grade en NPCs. Usado para comparar NG vs craftear D-grade.</p>
|
||||||
|
</header>
|
||||||
|
<div class="grid-3" style="max-width:24rem">
|
||||||
|
<template x-for="tipo in ['Soulshot','Spiritshot','Blessed Spiritshot']" :key="tipo">
|
||||||
|
<label>
|
||||||
|
<span class="text-sm text-muted" x-text="tipoShort(tipo) + ' NG'"></span>
|
||||||
|
<input type="number" min="0" step="1"
|
||||||
|
x-model.number="store().state.insumos.ngVenta[tipo]"
|
||||||
|
@input="store().scheduleSave()"/>
|
||||||
|
<span class="text-xs text-mono text-muted" x-text="fmtAdena(store().state.insumos.ngVenta[tipo])"></span>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="container">
|
||||||
|
<p class="text-center text-xs text-muted">
|
||||||
|
Auto-guardado activo · Cambios persistidos en el servidor cada ~500ms
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="/static/js/defaults.js"></script>
|
||||||
|
<script src="/static/js/api.js"></script>
|
||||||
|
<script src="/static/js/app.js"></script>
|
||||||
|
<script defer src="/static/js/alpine.min.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('insumosSection', () => ({
|
||||||
|
store() { return Alpine.store('app'); },
|
||||||
|
fmtAdena(n) {
|
||||||
|
if (n == null) return '—';
|
||||||
|
return Math.round(n).toLocaleString('es-CL');
|
||||||
|
},
|
||||||
|
tipoShort(tipo) {
|
||||||
|
return { 'Soulshot': 'SS', 'Spiritshot': 'SPS', 'Blessed Spiritshot': 'BSS' }[tipo] || tipo;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Calculadora de Craft de Shots — Lineage 2</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/pico.min.css"/>
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css"/>
|
||||||
|
</head>
|
||||||
|
<body class="login-page" x-data="authLogin()">
|
||||||
|
<article class="login-card">
|
||||||
|
<svg class="login-logo" viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-label="Logo">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gem" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#10b981"/>
|
||||||
|
<stop offset="100%" stop-color="#047857"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d="M40 8 L62 28 L40 72 L18 28 Z" fill="url(#gem)" stroke="#065f46" stroke-width="1.5"/>
|
||||||
|
<path d="M40 8 L62 28 L18 28 Z" fill="rgba(255,255,255,0.15)"/>
|
||||||
|
<path d="M40 72 L62 28 L40 28 Z" fill="rgba(0,0,0,0.1)"/>
|
||||||
|
<g class="sparkle">
|
||||||
|
<circle cx="50" cy="18" r="2.5" fill="white"/>
|
||||||
|
<path d="M50 14 L51 18 L50 22 L49 18 Z M46 18 L50 17 L54 18 L50 19 Z"
|
||||||
|
fill="white" opacity="0.7"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<hgroup style="text-align:center;margin-bottom:1.5rem">
|
||||||
|
<h1 style="font-size:1.5rem;margin-bottom:0.25rem">Calculadora de Craft de Shots</h1>
|
||||||
|
<p style="margin-bottom:0">Lineage 2 — Interlude / Clasico</p>
|
||||||
|
</hgroup>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSubmit">
|
||||||
|
<label>Username
|
||||||
|
<input type="text" x-model="username" autocomplete="username" autofocus
|
||||||
|
required minlength="3" maxlength="30"/>
|
||||||
|
</label>
|
||||||
|
<label>Contrasena
|
||||||
|
<input type="password" x-model="password" autocomplete="current-password"
|
||||||
|
required minlength="8"/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div x-show="error" x-transition class="error-msg" x-text="error"></div>
|
||||||
|
|
||||||
|
<button type="submit" :disabled="submitting">
|
||||||
|
<span x-show="!submitting">Iniciar sesion</span>
|
||||||
|
<span x-show="submitting">Procesando…</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="login-footer">
|
||||||
|
<a href="/register" class="link-action">No tenes cuenta? Crea una</a>
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<script src="/static/js/defaults.js"></script>
|
||||||
|
<script src="/static/js/api.js"></script>
|
||||||
|
<script src="/static/js/app.js"></script>
|
||||||
|
<script defer src="/static/js/alpine.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Crear cuenta — Calculadora de Craft de Shots</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/pico.min.css"/>
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css"/>
|
||||||
|
</head>
|
||||||
|
<body class="login-page" x-data="authRegister()">
|
||||||
|
<article class="login-card">
|
||||||
|
<svg class="login-logo" viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-label="Logo">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gem" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#10b981"/>
|
||||||
|
<stop offset="100%" stop-color="#047857"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d="M40 8 L62 28 L40 72 L18 28 Z" fill="url(#gem)" stroke="#065f46" stroke-width="1.5"/>
|
||||||
|
<path d="M40 8 L62 28 L18 28 Z" fill="rgba(255,255,255,0.15)"/>
|
||||||
|
<path d="M40 72 L62 28 L40 28 Z" fill="rgba(0,0,0,0.1)"/>
|
||||||
|
<g class="sparkle">
|
||||||
|
<circle cx="50" cy="18" r="2.5" fill="white"/>
|
||||||
|
<path d="M50 14 L51 18 L50 22 L49 18 Z M46 18 L50 17 L54 18 L50 19 Z"
|
||||||
|
fill="white" opacity="0.7"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<hgroup style="text-align:center;margin-bottom:1.5rem">
|
||||||
|
<h1 style="font-size:1.5rem;margin-bottom:0.25rem">Crear cuenta</h1>
|
||||||
|
<p style="margin-bottom:0">Unete a la comunidad de crafteo</p>
|
||||||
|
</hgroup>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSubmit">
|
||||||
|
<label>Username
|
||||||
|
<input type="text" x-model="username" autocomplete="username" autofocus
|
||||||
|
required minlength="3" maxlength="30"/>
|
||||||
|
<small>3-30 caracteres, letras, numeros y guion bajo.</small>
|
||||||
|
</label>
|
||||||
|
<label>Contrasena
|
||||||
|
<input type="password" x-model="password" autocomplete="new-password"
|
||||||
|
required minlength="8"/>
|
||||||
|
<small>Minimo 8 caracteres.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div x-show="error" x-transition class="error-msg" x-text="error"></div>
|
||||||
|
|
||||||
|
<button type="submit" :disabled="submitting">
|
||||||
|
<span x-show="!submitting">Crear cuenta</span>
|
||||||
|
<span x-show="submitting">Procesando…</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="login-footer">
|
||||||
|
<a href="/login" class="link-action">Ya tenes cuenta? Inicia sesion</a>
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<script src="/static/js/defaults.js"></script>
|
||||||
|
<script src="/static/js/api.js"></script>
|
||||||
|
<script src="/static/js/app.js"></script>
|
||||||
|
<script defer src="/static/js/alpine.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Usuarios — Calculadora de Craft de Shots</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/pico.min.css"/>
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css"/>
|
||||||
|
</head>
|
||||||
|
<body x-data="appShell()">
|
||||||
|
|
||||||
|
{#include partials/header.html /}
|
||||||
|
{#include partials/tab-bar.html /}
|
||||||
|
|
||||||
|
<main class="container" x-data="usuariosSection()">
|
||||||
|
|
||||||
|
<header class="flex-row justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-bold" style="margin-bottom:0.25rem">Usuarios</h2>
|
||||||
|
<p class="text-sm text-muted">
|
||||||
|
Resetea la contrasena de cualquier usuario. Al resetear, debera
|
||||||
|
cambiarla en su proximo login.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" @click="load()" :disabled="loading" class="secondary">
|
||||||
|
<span x-show="!loading">Refrescar</span>
|
||||||
|
<span x-show="loading">Cargando…</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div x-show="error" class="error-msg mb-4" x-text="error"></div>
|
||||||
|
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="cell-label text-left">Username</th>
|
||||||
|
<th class="cell-label text-left">Rol</th>
|
||||||
|
<th class="cell-label text-left">Estado</th>
|
||||||
|
<th class="cell-label text-left">Creado</th>
|
||||||
|
<th class="cell-label text-left">Ultimo login</th>
|
||||||
|
<th class="cell-label text-right">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="u in users" :key="u.id">
|
||||||
|
<tr>
|
||||||
|
<td class="text-bold" x-text="u.username"></td>
|
||||||
|
<td>
|
||||||
|
<span x-show="u.isAdmin" class="badge badge-admin">admin</span>
|
||||||
|
<span x-show="!u.isAdmin" class="badge" style="background:var(--pico-muted-border-color);color:var(--pico-muted-color)">usuario</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span x-show="u.mustChangePassword" class="badge badge-warning">cambiar pass</span>
|
||||||
|
<span x-show="!u.mustChangePassword" class="badge badge-success">ok</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-mono text-xs" x-text="fmtDateTime(u.createdAt)"></td>
|
||||||
|
<td class="text-mono text-xs" x-text="u.lastLoginAt ? fmtDateTime(u.lastLoginAt) : '—'"></td>
|
||||||
|
<td class="text-right">
|
||||||
|
<button type="button"
|
||||||
|
@click="openReset(u)"
|
||||||
|
class="outline"
|
||||||
|
style="font-size:0.75rem;padding:0.25rem 0.5rem">
|
||||||
|
Resetear contrasena
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr x-show="users.length === 0 && !loading">
|
||||||
|
<td colspan="6" class="text-center p-6 text-muted">Sin usuarios para listar.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="resetUser"
|
||||||
|
x-cloak
|
||||||
|
class="modal-backdrop">
|
||||||
|
<div class="modal-content" style="max-width:28rem">
|
||||||
|
<header style="padding:1.5rem 1.5rem 1rem;border-bottom:1px solid var(--pico-muted-border-color)">
|
||||||
|
<h2 style="font-size:1rem;margin-bottom:0.25rem"
|
||||||
|
x-text="'Resetear contrasena de ' + (resetUser?.username || '')"></h2>
|
||||||
|
<p class="text-sm text-muted">El usuario debera cambiar esta contrasena en su proximo login.</p>
|
||||||
|
</header>
|
||||||
|
<form @submit.prevent="submitReset()" style="padding:1.5rem">
|
||||||
|
<label>Nueva contrasena temporal
|
||||||
|
<input type="text"
|
||||||
|
x-model="resetPassword"
|
||||||
|
autofocus
|
||||||
|
required
|
||||||
|
minlength="8"/>
|
||||||
|
</label>
|
||||||
|
<div x-show="resetError" class="error-msg mt-2" x-text="resetError"></div>
|
||||||
|
<div class="flex-row justify-end mt-4" style="gap:0.5rem">
|
||||||
|
<button type="button" @click="closeReset()" :disabled="resetSubmitting" class="secondary">Cancelar</button>
|
||||||
|
<button type="submit" :disabled="resetSubmitting || resetPassword.length < 8" class="primary">
|
||||||
|
<span x-show="!resetSubmitting">Resetear</span>
|
||||||
|
<span x-show="resetSubmitting">Reseteando…</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="container">
|
||||||
|
<p class="text-center text-xs text-muted">
|
||||||
|
Auto-guardado activo · Cambios persistidos en el servidor cada ~500ms
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="/static/js/defaults.js"></script>
|
||||||
|
<script src="/static/js/api.js"></script>
|
||||||
|
<script src="/static/js/app.js"></script>
|
||||||
|
<script defer src="/static/js/alpine.min.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('usuariosSection', () => ({
|
||||||
|
store() { return Alpine.store('app'); },
|
||||||
|
users: [],
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
resetUser: null,
|
||||||
|
resetPassword: '',
|
||||||
|
resetSubmitting: false,
|
||||||
|
resetError: null,
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
try {
|
||||||
|
const list = await window.api.adminListUsers();
|
||||||
|
this.users = list || [];
|
||||||
|
} catch (e) {
|
||||||
|
this.error = 'HTTP ' + (e.status || '') + ': ' + (e.message || 'Error');
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
openReset(user) {
|
||||||
|
this.resetUser = user;
|
||||||
|
this.resetPassword = '';
|
||||||
|
this.resetError = null;
|
||||||
|
this.resetSubmitting = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
closeReset() {
|
||||||
|
this.resetUser = null;
|
||||||
|
this.resetPassword = '';
|
||||||
|
this.resetError = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitReset() {
|
||||||
|
if (!this.resetUser || this.resetPassword.length < 8) return;
|
||||||
|
this.resetSubmitting = true;
|
||||||
|
this.resetError = null;
|
||||||
|
try {
|
||||||
|
await window.api.adminResetPassword(this.resetUser.username, this.resetPassword);
|
||||||
|
this.closeReset();
|
||||||
|
await this.load();
|
||||||
|
} catch (e) {
|
||||||
|
this.resetError = 'HTTP ' + (e.status || '') + ': ' + (e.message || 'Error');
|
||||||
|
} finally {
|
||||||
|
this.resetSubmitting = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fmtDateTime(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return iso;
|
||||||
|
return d.toLocaleString('es-ES', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>{title ?: 'Calculadora de Craft de Shots'}</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/styles.css"/>
|
||||||
|
<script defer src="/static/js/alpine.min.js"></script>
|
||||||
|
<script defer src="/static/js/api.js"></script>
|
||||||
|
<script defer src="/static/js/app.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen flex flex-col">
|
||||||
|
{#insert body /}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<header x-data="appHeader()">
|
||||||
|
<div class="container">
|
||||||
|
<nav class="header-nav">
|
||||||
|
<div>
|
||||||
|
<hgroup>
|
||||||
|
<h1>Calculadora de Craft de Shots</h1>
|
||||||
|
<p class="header-meta">
|
||||||
|
<span x-text="user()?.username || ''"></span>
|
||||||
|
<span x-show="user()?.isAdmin" class="badge badge-admin">admin</span>
|
||||||
|
</p>
|
||||||
|
</hgroup>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<span x-show="saveStatus() !== 'idle'"
|
||||||
|
x-transition
|
||||||
|
class="badge"
|
||||||
|
:class="saveStatus() === 'saved' ? 'badge-success' :
|
||||||
|
saveStatus() === 'saving' ? 'badge-warning' :
|
||||||
|
'badge-error'">
|
||||||
|
<span x-show="saveStatus() === 'saved'">Guardado</span>
|
||||||
|
<span x-show="saveStatus() === 'saving'">Guardando…</span>
|
||||||
|
<span x-show="saveStatus() === 'error'" x-text="saveError() || 'Error'"></span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button type="button" class="link-action" @click="resetExamples()">Restablecer ejemplos</button>
|
||||||
|
<button type="button" class="link-action" @click="clearAll()">Poner todo en cero</button>
|
||||||
|
<button type="button" class="link-action" @click="resetServer()" title="Borrar el estado en el servidor (solo este navegador)">Borrar del servidor</button>
|
||||||
|
<button type="button" class="contrast outline" @click="logout()">Cerrar sesion</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
Reference in New Issue
Block a user