7 Commits

Author SHA1 Message Date
fede 3b4800d0d9 refactor: eliminar estilos que no seguían la guía de diseño 2026-02-23 13:08:36 -03:00
fede 022623e22c fix: Arregladas updates del panel de admin 2026-02-16 18:41:21 -03:00
fede c96df5af92 chore: eliminados import redundantes 2026-02-14 21:10:18 -03:00
Fran d278c75688 fix race condition in isfollowing 2026-02-14 19:17:50 -03:00
fede 3e07252c6f refactor: enlineadas funciones que solo se usan una vez 2026-02-13 15:09:08 -03:00
fede ec9ec1f58a fix: añadida paginacion 2026-02-12 18:14:31 -03:00
Fran 4ca434da9e add UsersAdmin 2026-02-12 17:25:53 -03:00
12 changed files with 275 additions and 220 deletions
BIN
View File
Binary file not shown.
+7 -10
View File
@@ -11,7 +11,7 @@
import { seguirUsuario } from '@/hooks/seguirUsuario'; import { seguirUsuario } from '@/hooks/seguirUsuario';
import type { Post } from '../../types'; import type { Post } from '../../types';
import CardError from './CardError.svelte'; import CardError from './CardError.svelte';
import { cacheSeguidos } from '@/stores/cacheSeguidos.svelte'; import { cacheSeguidos } from '@/stores/cacheSeguidos.js';
let { let {
post, post,
@@ -41,16 +41,13 @@
}); });
async function cargarSeguido() { async function cargarSeguido() {
let a = cacheSeguidos.get(post.authorId); seguido = await cacheSeguidos.getOrFetch(
if (a === undefined) { post.authorId,
const seguidoStatus = await esSeguido(post as Post); async () => {
if (seguidoStatus) { const seguidoStatus = await esSeguido(post as Post);
cacheSeguidos.set(post.authorId, seguidoStatus.isFollowing || false); return seguidoStatus?.isFollowing || false;
seguido = seguidoStatus.isFollowing || false;
} }
return; );
}
seguido = a;
} }
let mensajeError: string | null = $state(null); let mensajeError: string | null = $state(null);
+71 -63
View File
@@ -16,12 +16,7 @@
import TooltipTrigger from './ui/tooltip/tooltip-trigger.svelte'; import TooltipTrigger from './ui/tooltip/tooltip-trigger.svelte';
import TooltipContent from './ui/tooltip/tooltip-content.svelte'; import TooltipContent from './ui/tooltip/tooltip-content.svelte';
import RecuperarContraseña from './admin/RecuperarContraseña.svelte'; import RecuperarContraseña from './admin/RecuperarContraseña.svelte';
import { Dialog } from './ui/dialog';
import DialogContent from './ui/dialog/dialog-content.svelte';
import ModificarUsuario from './admin/ModificarUsuario.svelte'; import ModificarUsuario from './admin/ModificarUsuario.svelte';
import { fade } from 'svelte/transition';
import type { Unsubscriber } from 'svelte/store';
import Input from './ui/input/input.svelte';
import Trash_2 from '@lucide/svelte/icons/trash-2'; import Trash_2 from '@lucide/svelte/icons/trash-2';
import BorrarUsuario from './BorrarUsuario.svelte'; import BorrarUsuario from './BorrarUsuario.svelte';
import InputGroup from './ui/input-group/input-group.svelte'; import InputGroup from './ui/input-group/input-group.svelte';
@@ -30,12 +25,25 @@
import AgregarUsuario from './admin/AgregarUsuario.svelte'; import AgregarUsuario from './admin/AgregarUsuario.svelte';
import DarAdmin from './admin/DarAdmin.svelte'; import DarAdmin from './admin/DarAdmin.svelte';
import { busquedaAdminUsuarios } from '@/hooks/busquedaAdminUsuarios'; import { busquedaAdminUsuarios } from '@/hooks/busquedaAdminUsuarios';
import { invalidate, replaceState } from '$app/navigation';
interface Props { interface Props {
usuarios: UserResponseDto[]; usuarios: UserResponseDto[];
hayMas: boolean;
} }
let { usuarios = $bindable() }: Props = $props(); let { usuarios = $bindable(), hayMas }: Props = $props();
let paginaActual = $derived.by(() => {
const url = new URL(window.location.href);
return Number(url.searchParams.get('p')) || 1;
});
let search = $derived.by(() => {
const url = new URL(window.location.href);
let ret = url.searchParams.get('q') || '';
return ret;
});
let hayMass = $derived(hayMas);
let open = $state(false); let open = $state(false);
let openModificarUsuario = $state(false); let openModificarUsuario = $state(false);
@@ -48,13 +56,11 @@
let usuarioModificar: UserResponseDto | null = $state(null); let usuarioModificar: UserResponseDto | null = $state(null);
let usuarioDarAdmin: UserResponseDto | null = $state(null); let usuarioDarAdmin: UserResponseDto | null = $state(null);
let search = $state('');
type SortKey = 'username' | 'displayName' | 'postsCount' | 'createdAt'; type SortKey = 'username' | 'displayName' | 'postsCount' | 'createdAt';
let sortBy = $state<SortKey | null>(null); let sortBy = $state<SortKey | null>(null);
let sortDirection = $state<'asc' | 'desc'>('asc'); let sortDirection = $state<'asc' | 'desc'>('asc');
let usuariosFiltrados = $state(usuarios); let usuariosFiltrados = $derived(usuarios);
function ordenarPor(campo: SortKey) { function ordenarPor(campo: SortKey) {
if (sortBy === campo) { if (sortBy === campo) {
@@ -90,41 +96,26 @@
return sortDirection === 'asc' ? '↑' : '↓'; return sortDirection === 'asc' ? '↑' : '↓';
} }
function handleCambiarContraseña(usuario: UserResponseDto) {
open = true;
usuarioCambioPass = usuario;
}
function handleModificar(usuario: UserResponseDto) {
openModificarUsuario = true;
usuarioModificar = usuario;
}
function handleBorrar(usuario: UserResponseDto) {
openBorrar = true;
usuarioBorrar = usuario;
}
function handleDarAdmin(usuario: UserResponseDto) {
openDarAdmin = true;
usuarioDarAdmin = usuario;
}
// $inspect(usuarios); // $inspect(usuarios);
let timeoutId: ReturnType<typeof setTimeout> | number | undefined; let timeoutId: ReturnType<typeof setTimeout> | number | undefined;
function buscarUsuarios() { function buscarUsuarios() {
if (timeoutId) { if (timeoutId) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
timeoutId = setTimeout(async () => { timeoutId = setTimeout(async () => {
paginaActual = 1; const url = new URL(window.location.href);
if (search === '') { if (!search.trim()) {
usuariosFiltrados = usuarios; url.searchParams.delete('q');
return; } else {
url.searchParams.set('q', search);
} }
usuariosFiltrados = await busquedaAdminUsuarios(search); replaceState(url, {});
let ret = await busquedaAdminUsuarios(search, ITEMS_POR_PAGINA, paginaActual);
usuariosFiltrados = ret.usuarios;
// invalidate('admin:load');
hayMass = ret.hayMas;
}, 200); }, 200);
return () => { return () => {
@@ -133,13 +124,9 @@
} }
const ITEMS_POR_PAGINA = 5; const ITEMS_POR_PAGINA = 5;
let paginaActual = $state(1); // const usuariosPaginados = $derived(
// usuariosFiltrados.slice((paginaActual - 1) * ITEMS_POR_PAGINA, paginaActual * ITEMS_POR_PAGINA)
const totalPaginas = $derived(Math.ceil(usuariosFiltrados.length / ITEMS_POR_PAGINA)); // );
const usuariosPaginados = $derived(
usuariosFiltrados.slice((paginaActual - 1) * ITEMS_POR_PAGINA, paginaActual * ITEMS_POR_PAGINA)
);
</script> </script>
<div class="mb-4 flex gap-2"> <div class="mb-4 flex gap-2">
@@ -184,7 +171,7 @@
<p class="text-center">No hay usuarios por el nombre de: {search}</p> <p class="text-center">No hay usuarios por el nombre de: {search}</p>
</TableCell> </TableCell>
</TableRow>{:else} </TableRow>{:else}
{#each usuariosPaginados as usuario} {#each usuariosFiltrados as usuario}
<TableRow> <TableRow>
<TableCell <TableCell
>@<a href={'/' + usuario.username}> >@<a href={'/' + usuario.username}>
@@ -197,7 +184,11 @@
<TableCell class="flex gap-2"> <TableCell class="flex gap-2">
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<Button onclick={() => handleCambiarContraseña(usuario)}><KeyIcon></KeyIcon></Button <Button
onclick={() => {
open = true;
usuarioCambioPass = usuario;
}}><KeyIcon></KeyIcon></Button
> >
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
@@ -206,7 +197,12 @@
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<Button onclick={() => handleModificar(usuario)}><UserPen /></Button> <Button
onclick={() => {
openModificarUsuario = true;
usuarioModificar = usuario;
}}><UserPen /></Button
>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Modificar Usuario</p> <p>Modificar Usuario</p>
@@ -216,7 +212,10 @@
<TooltipTrigger> <TooltipTrigger>
<Button <Button
disabled={usuario.isAdmin} disabled={usuario.isAdmin}
onclick={() => handleBorrar(usuario)} onclick={() => {
openBorrar = true;
usuarioBorrar = usuario;
}}
variant="destructive"><Trash_2 /></Button variant="destructive"><Trash_2 /></Button
> >
</TooltipTrigger> </TooltipTrigger>
@@ -232,7 +231,10 @@
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<Button <Button
onclick={() => handleDarAdmin(usuario)} onclick={() => {
openDarAdmin = true;
usuarioDarAdmin = usuario;
}}
variant={usuario.isAdmin ? 'destructive' : 'default'} variant={usuario.isAdmin ? 'destructive' : 'default'}
> >
<Shield /> <Shield />
@@ -253,23 +255,29 @@
</TableBody> </TableBody>
</Table> </Table>
<div class="mt-4 flex items-center justify-between"> <div class="mt-4 flex items-center justify-between">
<p class="text-sm text-muted-foreground"> <Button
Página {paginaActual} de {totalPaginas} disabled={paginaActual === 1}
</p> onclick={() => {
const url = new URL(window.location.href);
url.searchParams.set('p', String(--paginaActual));
replaceState(url, {});
buscarUsuarios();
}}
variant="secondary"
>
Anterior
</Button>
<div class="flex gap-2"> <Button
<Button disabled={paginaActual === 1} onclick={() => paginaActual--} variant="secondary"> disabled={!hayMass}
Anterior onclick={() => {
</Button> const url = new URL(window.location.href);
url.searchParams.set('p', String(++paginaActual));
<Button replaceState(url, {});
disabled={paginaActual === totalPaginas || totalPaginas === 0} buscarUsuarios();
onclick={() => paginaActual++} }}
variant="secondary" variant="secondary">Siguiente</Button
> >
Siguiente
</Button>
</div>
</div> </div>
<BorrarUsuario bind:open={openBorrar} usuario={usuarioBorrar} /> <BorrarUsuario bind:open={openBorrar} usuario={usuarioBorrar} />
<RecuperarContraseña bind:open usuario={usuarioCambioPass} /> <RecuperarContraseña bind:open usuario={usuarioCambioPass} />
+1 -7
View File
@@ -30,13 +30,7 @@
}} }}
> >
<DialogContent> <DialogContent>
<div {mensajeResultado}
class={esExitoso
? 'rounded border border-green-400 bg-green-100/10 px-4 py-3 text-green-700'
: 'rounded border border-red-400 bg-red-100/10 px-4 py-3 text-red-700'}
>
{mensajeResultado}
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/if} {/if}
@@ -10,6 +10,7 @@
import Label from '../ui/label/label.svelte'; import Label from '../ui/label/label.svelte';
import Spinner from '../ui/spinner/spinner.svelte'; import Spinner from '../ui/spinner/spinner.svelte';
import { updateUsuario } from '@/hooks/updateUsuario'; import { updateUsuario } from '@/hooks/updateUsuario';
import { invalidate } from '$app/navigation';
interface Prop { interface Prop {
open: boolean; open: boolean;
@@ -38,6 +39,7 @@
error = ret; error = ret;
} else { } else {
usuario!.displayName = ret.displayName; usuario!.displayName = ret.displayName;
invalidate('admin:load');
open = false; open = false;
} }
cargando = false; cargando = false;
+25
View File
@@ -0,0 +1,25 @@
import { apiBase } from '@/stores/url';
import { sesionStore } from '@/stores/usuario';
import { redirect } from '@sveltejs/kit';
import { get } from 'svelte/store';
import type { UserResponseDto } from '../../types';
export async function fetchUsuariosAdmin(page: number, limit: number) {
let response = await fetch(get(apiBase) + `/api/admin/users?page=${page}&pageSize=${limit}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${get(sesionStore)?.accessToken}`
}
});
if (response.status === 401) {
throw redirect(302, '/');
}
if (!response.ok) {
return { error: true };
}
const ret: { usuarios: UserResponseDto[]; hayMas: boolean } = await response.json();
return { ret, error: false };
}
+12 -7
View File
@@ -2,15 +2,20 @@ import { apiBase } from '@/stores/url';
import { sesionStore } from '@/stores/usuario'; import { sesionStore } from '@/stores/usuario';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
export async function busquedaAdminUsuarios(q: string) { export async function busquedaAdminUsuarios(q: string, limit = 5, page = 1, fetch2?: Function) {
try { try {
const response = await fetch(get(apiBase) + '/api/admin/users?q=' + q, { const fetchFn = fetch2 ? fetch2 : fetch;
method: 'GET', const response = await fetchFn(
headers: { get(apiBase) +
'Content-Type': 'application/json', `/api/admin/users${q ? `?q=${q}` : ''}${q ? '&' : '?'}page=${page}&pageSize=${limit}`,
Authorization: `Bearer ${get(sesionStore)?.accessToken}` {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${get(sesionStore)?.accessToken}`
}
} }
}); );
if (response.ok) { if (response.ok) {
return await response.json(); return await response.json();
} }
+1 -1
View File
@@ -1,5 +1,5 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { cacheSeguidos } from '@/stores/cacheSeguidos.svelte'; import { cacheSeguidos } from '@/stores/cacheSeguidos';
import { apiBase } from '@/stores/url'; import { apiBase } from '@/stores/url';
import { sesionStore } from '@/stores/usuario'; import { sesionStore } from '@/stores/usuario';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
+134
View File
@@ -0,0 +1,134 @@
import { browser } from '$app/environment';
import { writable } from 'svelte/store';
class FollowCache {
constructor() {
if (browser) {
this.loadFromStorage();
}
}
/** @type {Map<string, boolean | Promise<boolean>>} */
#cache = new Map();
/** @type {import('svelte/store').Writable<Map<string, boolean>>} */
store = writable(new Map());
/** @param {string} userId */
get(userId) {
const value = this.#cache.get(userId);
return value instanceof Promise ? undefined : value;
}
/** @param {string} userId */
has(userId) {
return this.#cache.has(userId);
}
/**
* @param {string} userId
* @param {() => Promise<boolean>} fetchFn
*/
async getOrFetch(userId, fetchFn) {
const existing = this.#cache.get(userId);
if (existing !== undefined) {
if (existing instanceof Promise) {
return existing;
}
return existing;
}
const promise = fetchFn()
.then((result) => {
this.#setFinal(userId, result);
return result;
})
.catch((err) => {
this.#cache.delete(userId);
this.#updateStore();
throw err;
});
this.#cache.set(userId, promise);
this.#updateStore();
return promise;
}
/**
* @param {string} userId
* @param {boolean} isFollowed
*/
set(userId, isFollowed) {
this.#setFinal(userId, isFollowed);
}
/**
* @param {string} userId
* @param {boolean} value
*/
#setFinal(userId, value) {
this.#cache.set(userId, value);
this.#updateStore();
this.saveToStorage();
if (browser) {
window.dispatchEvent(
new CustomEvent('followCacheUpdated', {
detail: { userId, isFollowed: value }
})
);
}
}
#updateStore() {
const filtered = Array.from(this.#cache.entries())
.filter(([_, v]) => typeof v === 'boolean');
this.store.set(
/** @type {Map<string, boolean>} */
(new Map(filtered))
);
}
/** @param {string} userId */
delete(userId) {
this.#cache.delete(userId);
this.#updateStore();
this.saveToStorage();
}
clear() {
this.#cache.clear();
this.store.set(new Map());
this.saveToStorage();
}
saveToStorage() {
if (!browser) return;
const filtered = Array.from(this.#cache.entries())
.filter(([_, v]) => typeof v === 'boolean');
const data = Object.fromEntries(filtered);
sessionStorage.setItem('follow-cache', JSON.stringify(data));
}
loadFromStorage() {
if (!browser) return;
try {
const stored = sessionStorage.getItem('follow-cache');
if (!stored) return;
const data = JSON.parse(stored);
this.#cache = new Map(Object.entries(data));
this.#updateStore();
} catch (err) {
console.error('Error cargando follow-cache:', err);
}
}
}
export const cacheSeguidos = new FollowCache();
-105
View File
@@ -1,105 +0,0 @@
import { browser } from '$app/environment';
import { writable } from 'svelte/store';
class FollowCache {
constructor() {
if (browser) {
this.loadFromStorage();
}
}
/** @type {Map<string, boolean>} */
#cache = new Map();
/** @type {import('svelte/store').Writable<Map<string, boolean>>} */
store = writable(this.#cache);
/**
* @param {string} userId
* @returns {boolean | undefined}
*/
get(userId) {
return this.#cache.get(userId);
}
/**
* @param {string} userId
* @param {boolean} isFollowed
*/
set(userId, isFollowed) {
this.#cache.set(userId, isFollowed);
this.store.set(this.#cache);
this.saveToStorage();
if (browser) {
window.dispatchEvent(
new CustomEvent('followCacheUpdated', {
detail: { userId, isFollowed }
})
);
}
}
/**
* @param {string} userId
* @returns {boolean}
*/
has(userId) {
return this.#cache.has(userId);
}
/**
* @param {string} userId
*/
delete(userId) {
this.#cache.delete(userId);
this.store.set(this.#cache);
this.saveToStorage();
if (browser) {
window.dispatchEvent(
new CustomEvent('followCacheUpdated', {
detail: { userId, isFollowed: false }
})
);
}
}
clear() {
this.#cache.clear();
this.store.set(this.#cache);
this.saveToStorage();
if (browser) {
window.dispatchEvent(
new CustomEvent('followCacheUpdated', {
detail: { clearAll: true }
})
);
}
}
saveToStorage() {
if (browser) {
const data = Object.fromEntries(this.#cache);
sessionStorage.setItem('follow-cache', JSON.stringify(data));
}
}
loadFromStorage() {
if (browser) {
try {
const stored = sessionStorage.getItem('follow-cache');
if (stored) {
const data = JSON.parse(stored);
this.#cache = new Map(Object.entries(data));
this.store.set(this.#cache);
}
} catch (error) {
console.error('Error cargando desde sesion:', error);
}
}
}
}
export const cacheSeguidos = new FollowCache();
+3 -7
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import CardContent from '@/components/ui/card/card-content.svelte'; import CardContent from '@/components/ui/card/card-content.svelte';
import Card from '@/components/ui/card/card.svelte'; import Card from '@/components/ui/card/card.svelte';
import CardDescription from '@/components/ui/card/card-description.svelte';
import TablaUsuarios from '@/components/TablaUsuarios.svelte'; import TablaUsuarios from '@/components/TablaUsuarios.svelte';
import CardTitle from '@/components/ui/card/card-title.svelte'; import CardTitle from '@/components/ui/card/card-title.svelte';
import CardHeader from '@/components/ui/card/card-header.svelte'; import CardHeader from '@/components/ui/card/card-header.svelte';
@@ -9,7 +8,8 @@
interface Prop { interface Prop {
data: { data: {
usuarios?: UserResponseDto[]; usuarios: UserResponseDto[];
hayMas: boolean;
error: boolean; error: boolean;
}; };
} }
@@ -28,11 +28,7 @@
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{#if data.usuarios?.length === 0} <TablaUsuarios usuarios={data.usuarios} hayMas={data.hayMas}></TablaUsuarios>
<CardDescription>No hay usuarios que mostar</CardDescription>
{:else}
<TablaUsuarios usuarios={data.usuarios || []}></TablaUsuarios>
{/if}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+19 -20
View File
@@ -1,28 +1,27 @@
import { apiBase } from '@/stores/url.js'; import { busquedaAdminUsuarios } from '@/hooks/busquedaAdminUsuarios.js';
import { sesionStore } from '@/stores/usuario'; import type { PageLoad } from './$types.js';
import { redirect } from '@sveltejs/kit'; import { fetchUsuariosAdmin } from '@/hooks/UsuariosAdmin.js';
import { get } from 'svelte/store';
import type { UserResponseDto } from '../../../types.js';
export const ssr = false; export const ssr = false;
export async function load({ depends, fetch }) { export const load: PageLoad = async ({ depends, fetch }) => {
depends('admin:load'); depends('admin:load');
const response = await fetch(get(apiBase) + '/api/admin/users', { let url = new URL(location.href);
method: 'GET', let query = url.searchParams.get('q') ?? '';
headers: { let page = Number(url.searchParams.get('p'));
'Content-Type': 'application/json', if (isNaN(page) || page < 1) {
Authorization: `Bearer ${get(sesionStore)?.accessToken}` page = 1;
}
});
if (response.status === 401) {
throw redirect(302, '/');
} }
if (!response.ok) {
const result = await busquedaAdminUsuarios(query, 5, page, fetch);
if (result.error) {
return { error: true }; return { error: true };
} }
const usuarios: UserResponseDto[] = await response.json(); return {
usuarios: result.usuarios,
return { usuarios, error: false }; hayMas: result.hayMas,
} error: false
};
};