Merge pull request #72 from emailerfacu-spec/dev

Dev
This commit is contained in:
emailerfacu-spec
2026-01-09 22:39:01 -03:00
committed by GitHub
19 changed files with 529 additions and 37 deletions

View File

@@ -0,0 +1,99 @@
<script lang="ts">
import { sesionStore } from '@/stores/usuario';
import { Tooltip } from './ui/tooltip';
import TooltipTrigger from './ui/tooltip/tooltip-trigger.svelte';
import Button from './ui/button/button.svelte';
import UserX from '@lucide/svelte/icons/user-x';
import UserPlus from '@lucide/svelte/icons/user-plus';
import Spinner from './ui/spinner/spinner.svelte';
import TooltipContent from './ui/tooltip/tooltip-content.svelte';
import { esSeguido } from '@/hooks/esSeguido';
import { seguirUsuario } from '@/hooks/seguirUsuario';
import type { Post } from '../../types';
import CardError from './CardError.svelte';
import { cacheSeguidos } from '@/stores/cacheSeguidos.svelte';
let {
post,
variant = 'icon-lg'
}: { post: Omit<Partial<Post>, 'authorId'> & { authorId: string }; variant?: string } = $props();
let seguido: Boolean | null = $state(null);
if (typeof window !== 'undefined') {
window.addEventListener('followCacheUpdated', ((
event: CustomEvent<{ userId: string; isFollowed: boolean } | { clearAll: true }>
) => {
if ('clearAll' in event.detail && event.detail.clearAll === true) {
cargarSeguido();
} else if ('userId' in event.detail && event.detail.userId === post.authorId) {
seguido = event.detail.isFollowed;
}
}) as EventListener);
}
$effect(() => {
(async () => {
await cargarSeguido();
})();
});
async function cargarSeguido() {
let a = cacheSeguidos.get(post.authorId);
if (a === undefined) {
const seguidoStatus = await esSeguido(post);
cacheSeguidos.set(post.authorId, seguidoStatus.isFollowing || false);
seguido = seguidoStatus.isFollowing || false;
return;
}
seguido = a;
}
let mensajeError: string | null = $state(null);
</script>
{#if mensajeError}
<CardError {mensajeError} />
{/if}
{#if post.authorName !== $sesionStore?.username && post.authorName !== '[deleted]'}
<Tooltip>
<TooltipTrigger>
<Button
variant={seguido == true ? 'destructive' : 'outline'}
disabled={seguido == null}
size={variant}
onclick={async () => {
if (seguido == null) return;
const anteriorEstado = seguido;
let ret = seguirUsuario(post.authorId, seguido);
seguido = null;
let [res] = await Promise.all([
await ret,
new Promise((resolve) => setTimeout(resolve, 300))
]);
if (res === null) mensajeError = 'Fallo al intentar seguir el usuario';
if (res === true) seguido = !anteriorEstado;
cacheSeguidos.set(post.authorId, seguido == null ? false : Boolean(seguido));
}}
>
{#if seguido == true}
<UserX />
{:else if seguido == false}
<UserPlus />
{:else}
<Spinner />
{/if}
</Button>
</TooltipTrigger>
<TooltipContent>
{#if seguido == true}
Dejar de seguir
{:else}
Seguir
{/if}
</TooltipContent>
</Tooltip>
{/if}

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import Pen from '@lucide/svelte/icons/pen';
import ArrowRight from '@lucide/svelte/icons/chevron-right';
import AvatarFallback from './ui/avatar/avatar-fallback.svelte';
import AvatarImage from './ui/avatar/avatar-image.svelte';
import Avatar from './ui/avatar/avatar.svelte';
@@ -14,6 +15,8 @@
import CardHeader from './ui/card/card-header.svelte';
import CardTitle from './ui/card/card-title.svelte';
import Badge from './ui/badge/badge.svelte';
import { resolve } from '$app/paths';
import { goto } from '$app/navigation';
let { data = $bindable() } = $props();
@@ -138,18 +141,34 @@
<CardHeader class="flex justify-between">
<CardTitle>Seguidos:</CardTitle>
{#if Array.isArray(data?.seguidos?.response)}
<Badge variant="secondary">{data.seguidos.response.length || 0}</Badge>
<Badge variant="secondary">{data.countSeguidos || 0}</Badge>
{/if}
</CardHeader>
<CardContent>
{#if (data.seguidos.response?.length || 0) === 0}
<h3>No hay Seguidos</h3>
{:else}
{#each data.seguidos.response as seguidos (seguidos.id)}
<p class="text-muted-foreground">
{seguidos.username}
</p>
{/each}
<div class="flex items-center justify-between">
<div class="flex -space-x-2">
{#each data.seguidos.response as seguidos (seguidos.id)}
<a href={resolve('/[perfil]', { perfil: seguidos.username })}>
<Avatar class="h-8 w-8 border-2 border-background">
<AvatarImage src={seguidos.imageUrl} alt={seguidos.username} />
<AvatarFallback class="text-xs">
{seguidos.displayName?.[0] || ''}
</AvatarFallback>
</Avatar>
</a>
{/each}
</div>
{#if data.seguidos.response?.length < data.countSeguidos}
<Button variant="ghost" class="mt-1 ml-4">
<a href="/{data.username}/seguidos" class="flex items-center gap-2">
Ver más<ArrowRight />
</a>
</Button>
{/if}
</div>
{/if}
</CardContent>
</CardContent>
@@ -159,18 +178,38 @@
<CardHeader class="flex justify-between">
<CardTitle>Seguidores:</CardTitle>
{#if Array.isArray(data?.seguidores?.response)}
<Badge variant="secondary">{data.seguidores.response.length || 0}</Badge>
<Badge variant="secondary">{data.countSeguidores || 0}</Badge>
{/if}
</CardHeader>
<CardContent>
{#if (data.seguidores.response?.length || 0) === 0}
<h3>No hay Seguidores</h3>
{:else}
{#each data.seguidores.response as seguidores (seguidores.id)}
<p class="text-muted-foreground">
{seguidores.username}
</p>
{/each}
<div class="flex items-center justify-between">
<div class="flex -space-x-2">
{#each data.seguidores.response as seguidores (seguidores.id)}
<a href={resolve('/[perfil]', { perfil: seguidores.username })}>
<Avatar class="h-8 w-8 border-2 border-background">
<AvatarImage src={seguidores.imageUrl} alt={seguidores.username} />
<AvatarFallback class="text-xs">
{seguidores.displayName?.[0] || ''}
</AvatarFallback>
</Avatar>
</a>
{/each}
</div>
{#if data.seguidores.response?.length < data.countSeguidores}
<Button
variant="ghost"
onclick={() => goto(`/${data.username}/seguidores`)}
class="mt-1 ml-4"
>
<a href="/{data.username}/seguidores" class="flex items-center gap-2">
Ver más<ArrowRight />
</a>
</Button>
{/if}
</div>
{/if}
</CardContent>
</CardContent>

View File

@@ -32,6 +32,7 @@
import { likePost } from '@/hooks/likePost';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import BotonSeguir from './BotonSeguir.svelte';
interface postProp {
post: Post;
@@ -43,7 +44,6 @@
let cargandoBorrar = $state(false);
let mensajeError = $state('');
let cargandoEditar = $state(false);
let cargandoLike = $state(false);
let errorLike = $state(false);
@@ -123,6 +123,10 @@
<span class="text-lg text-muted-foreground">@{post.authorName}</span>
</div>
</div>
{#if $sesionStore?.accessToken}
<BotonSeguir {post} />
{/if}
{#if post.authorName === $sesionStore?.username}
<DropdownMenu>
<DropdownMenuTrigger>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { UserResponseDto } from '../../types';
import BotonSeguir from './BotonSeguir.svelte';
import AvatarFallback from './ui/avatar/avatar-fallback.svelte';
import AvatarImage from './ui/avatar/avatar-image.svelte';
import Avatar from './ui/avatar/avatar.svelte';
@@ -17,7 +18,7 @@
let { usu }: Props = $props();
</script>
<Card class="w-[50%]">
<Card>
<CardContent>
<div class="flex justify-between">
<a class="flex items-center gap-2" href={resolve(`/${usu.username}`)}>
@@ -31,7 +32,7 @@
<p class="text-sm text-muted-foreground">@{usu.username}</p>
</a>
<div>
<Button variant="outline">Seguir</Button>
<BotonSeguir post={{ authorId: usu.id }} />
</div>
</div>
{#if usu.bio}

View File

@@ -0,0 +1,26 @@
import { sesionStore } from '@/stores/usuario';
import { get } from 'svelte/store';
import type { Post } from '../../types';
import { apiBase } from '@/stores/url';
export async function esSeguido(post: Post) {
if (!get(sesionStore)?.accessToken || post.authorName === '[deleted]') return;
const id = post.authorId;
try {
const response = await fetch(`${get(apiBase)}/api/users/${id}/is-following`, {
headers: {
Authorization: `Bearer ${get(sesionStore)?.accessToken}`
}
});
if (response.ok) {
const data = await response.json();
return data;
} else {
return false;
}
} catch {
return null;
}
}

View File

@@ -1,4 +1,5 @@
import { goto } from '$app/navigation';
import { cacheSeguidos } from '@/stores/cacheSeguidos.svelte';
import { apiBase } from '@/stores/url';
import { sesionStore } from '@/stores/usuario';
import { get } from 'svelte/store';
@@ -15,6 +16,7 @@ export async function logout(menuOpen: boolean) {
});
if (req.ok) {
sesionStore.reset();
cacheSeguidos.clear();
menuOpen = false;
}
} catch {

View File

@@ -0,0 +1,22 @@
import { apiBase } from '@/stores/url';
import { sesionStore } from '@/stores/usuario';
import { get } from 'svelte/store';
export async function obtenerCantidadDeSeguidores(id: string, fetch2?: Function) {
const fetchFn = fetch2 || fetch;
try {
const response = await fetchFn(`${get(apiBase)}/api/users/${id}/followers/count`, {
method: 'GET',
headers: {
Authorization: `Bearer ${get(sesionStore)?.accessToken}`
}
});
if (!response.ok) {
return 0;
}
return await response.json();
} catch {
return 0;
}
}

View File

@@ -0,0 +1,22 @@
import { apiBase } from '@/stores/url';
import { sesionStore } from '@/stores/usuario';
import { get } from 'svelte/store';
export async function obtenerCantidadDeSeguidos(id: string, fetch2?: Function) {
const fetchFn = fetch2 || fetch;
try {
const response = await fetchFn(`${get(apiBase)}/api/users/${id}/following/count`, {
method: 'GET',
headers: {
Authorization: `Bearer ${get(sesionStore)?.accessToken}`
}
});
if (!response.ok) {
return 0;
}
return await response.json();
} catch {
return 0;
}
}

View File

@@ -0,0 +1,21 @@
import { apiBase } from '@/stores/url';
import { sesionStore } from '@/stores/usuario';
import { get } from 'svelte/store';
export async function seguirUsuario(idusuario: string, toggle: Boolean = false) {
try {
const req = await fetch(`${get(apiBase)}/api/users/${idusuario}/follow`, {
method: !toggle ? 'POST' : 'DELETE',
headers: {
Authorization: `Bearer ${get(sesionStore)?.accessToken}`
}
});
if (req.ok) {
return true;
} else {
return false;
}
} catch {
return null;
}
}

View File

@@ -0,0 +1,105 @@
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();

View File

@@ -38,7 +38,7 @@
<svelte:head>
<meta property="og:title" content="Mini-x" />
<meta property="og:description" content="Pagina Principal" />
<meta property="og:image" content="https://tusitio.com/x.png" />
<meta property="og:image" content="/x.png" />
<meta property="og:url" content="https://minix-front.vercel.app/" />
<meta property="og:type" content="website" />
</svelte:head>

View File

@@ -18,6 +18,7 @@
import CardError from '@/components/CardError.svelte';
import CardPerfil from '@/components/CardPerfil.svelte';
import DialogModificarUsuario from '@/components/DialogModificarUsuario.svelte';
import BotonSeguir from '@/components/BotonSeguir.svelte';
let { params } = $props();
@@ -79,6 +80,7 @@
{#if params.perfil == $sesionStore?.username}
<Button
variant="ghost"
size="icon-sm"
class="m-1 rounded-full bg-blue-600"
onclick={() => {
showCrearPost = true;
@@ -86,6 +88,8 @@
>
<PenLine />
</Button>
{:else if $posts?.length == 0}
<BotonSeguir post={{ authorId: data.id }} />
{/if}
</h1>

View File

@@ -3,16 +3,26 @@ import type { User, UserResponseDto } from '../../types.js';
import { error } from '@sveltejs/kit';
import { obtenerSeguidosPorUsuario } from '@/hooks/obtenerSeguidosPorUsuario.js';
import { obtenerSeguidoresPorUsuario } from '@/hooks/obtenerSeguidoresPorUsuario.js';
import { obtenerCantidadDeSeguidores } from '@/hooks/obtenerCantidadDeSeguidores.js';
import { obtenerCantidadDeSeguidos } from '@/hooks/obtenerCantidadDeSeguidos.js';
export async function load({ params, depends, fetch }) {
depends('perfil:general');
const usuario: UserResponseDto | null = await obtenerUsuarioPorUsername(params.perfil, fetch);
if (!usuario) error(404, 'No se encontro el usuario, ' + params.perfil);
const [seguidos, seguidores] = await Promise.all([
obtenerSeguidosPorUsuario(usuario.id, 3, fetch),
obtenerSeguidoresPorUsuario(usuario.id, 3, fetch)
const [seguidos, seguidores, countSeguidores, countSeguidos] = await Promise.all([
obtenerSeguidosPorUsuario(usuario.id, 5, fetch),
obtenerSeguidoresPorUsuario(usuario.id, 5, fetch),
obtenerCantidadDeSeguidores(usuario.id, fetch),
obtenerCantidadDeSeguidos(usuario.id, fetch)
]);
return { ...usuario, seguidos, seguidores };
return {
...usuario,
seguidos,
seguidores,
countSeguidores: countSeguidores.count,
countSeguidos: countSeguidos.count
};
}

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import ArrowLeft from '@lucide/svelte/icons/chevron-left';
import type { UserResponseDto } from '../../../types';
import UserCard from '@/components/UserCard.svelte';
import { goto } from '$app/navigation';
type Data = {
usuario: UserResponseDto;
seguidores: UserResponseDto[];
};
let { data }: { data: Data } = $props();
</script>
<div class="flex min-h-fit w-full items-center justify-center p-6 md:p-10">
<div class="w-full max-w-6xl">
<div class="mb-4 flex items-center justify-between gap-2 rounded-md border bg-card p-2">
<p class="text-2xl">
Seguidores de @{data.usuario.username}
</p>
<button
class="rounded-full p-2 hover:bg-accent"
onclick={() => goto(`/${data.usuario.username}`)}
>
<ArrowLeft />
</button>
</div>
{#if data.seguidores.length === 0}
<div class="py-8 text-center text-muted-foreground">
<p>No hay seguidores para mostrar.</p>
</div>
{:else}
<div class="flex flex-col sm:grid" style="grid-template-columns: repeat(2, 1fr); gap: 1rem;">
{#each data.seguidores as follower (follower.id)}
<div class="h-fit">
<UserCard usu={follower} />
</div>
{/each}
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,20 @@
import { obtenerSeguidoresPorUsuario } from '@/hooks/obtenerSeguidoresPorUsuario';
import { obtenerUsuarioPorUsername } from '@/hooks/obtenerUsuario';
import { error } from '@sveltejs/kit';
import type { UserResponseDto, UsersResponseDto } from '../../../types';
export async function load({ params, fetch }) {
const usuario: UserResponseDto | null = await obtenerUsuarioPorUsername(params.perfil, fetch);
if (!usuario) error(404, 'No se encontro el usuario, ' + params.perfil);
const seguidoresResponse: UsersResponseDto | null = await obtenerSeguidoresPorUsuario(
usuario.id,
100,
fetch
);
return {
usuario,
seguidores: seguidoresResponse?.response || []
};
}

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import ArrowLeft from '@lucide/svelte/icons/chevron-left';
import type { UserResponseDto } from '../../../types';
import UserCard from '@/components/UserCard.svelte';
import { goto } from '$app/navigation';
type Data = {
usuario: UserResponseDto;
seguidos: UserResponseDto[];
};
let { data }: { data: Data } = $props();
</script>
<div class="flex min-h-fit w-full items-center justify-center p-6 md:p-10">
<div class="w-full max-w-6xl">
<div class="mb-4 flex items-center justify-between gap-2 rounded-md border bg-card p-2">
<p class="text-2xl">
Seguidos de @{data.usuario.username}
</p>
<button
class="rounded-full p-2 hover:bg-accent"
onclick={() => goto(`/${data.usuario.username}`)}
>
<ArrowLeft />
</button>
</div>
{#if data.seguidos.length === 0}
<div class="py-8 text-center text-muted-foreground">
<p>No hay seguidos para mostrar.</p>
</div>
{:else}
<div class="flex flex-col sm:grid" style="grid-template-columns: repeat(2, 1fr); gap: 1rem;">
{#each data.seguidos as follower (follower.id)}
<div class="h-fit">
<UserCard usu={follower} />
</div>
{/each}
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,20 @@
import { obtenerUsuarioPorUsername } from '@/hooks/obtenerUsuario';
import { error } from '@sveltejs/kit';
import type { UserResponseDto, UsersResponseDto } from '../../../types';
import { obtenerSeguidosPorUsuario } from '@/hooks/obtenerSeguidosPorUsuario';
export async function load({ params, fetch }) {
const usuario: UserResponseDto | null = await obtenerUsuarioPorUsername(params.perfil, fetch);
if (!usuario) error(404, 'No se encontro el usuario, ' + params.perfil);
const seguidosResponse: UsersResponseDto | null = await obtenerSeguidosPorUsuario(
usuario.id,
100,
fetch
);
return {
usuario,
seguidos: seguidosResponse?.response || []
};
}

View File

@@ -17,6 +17,7 @@
import { likePost } from '@/hooks/likePost';
import ThumbsUp from '@lucide/svelte/icons/thumbs-up';
import { TamañoPantalla } from './TamañoPantalla.svelte';
import BotonSeguir from '@/components/BotonSeguir.svelte';
interface Prop {
data: {
@@ -65,7 +66,11 @@
: ''}
</title>
<meta name="og:description" content={data.post?.content?.slice(0, 150)} />
<meta name="og:image" content={`/post/img/${data.post.id}`} />
{#if data.post?.imageUrl}
<meta name="og:image" content={data.post.imageUrl} />
{:else}
<meta name="og:image" content={`/post/img/${data.post.id}`} />
{/if}
</svelte:head>
<div class="flex min-h-fit w-full items-center justify-center p-6 md:p-10">
@@ -111,22 +116,29 @@
{#snippet Respuesta(post: Post)}
<div class="ml-2 flex-1">
<div class="flex items-center space-x-1">
{#if post.authorImageUrl}
<img
src={post.authorImageUrl}
alt={post.authorDisplayName}
class="h-8 w-8 shrink-0 rounded-full object-cover"
/>
{:else}
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-gray-300 text-xs font-medium text-white"
>
{post.authorName?.charAt(0).toUpperCase()}
<div class="flex justify-between">
<div class="flex items-center space-x-1">
{#if post.authorImageUrl}
<img
src={post.authorImageUrl}
alt={post.authorDisplayName}
class="h-8 w-8 shrink-0 rounded-full object-cover"
/>
{:else}
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-gray-300 text-xs font-medium text-white"
>
{post.authorName?.charAt(0).toUpperCase()}
</div>
{/if}
<span class="text-sm font-semibold">@{post.authorName}</span>
<span class="text-xs text-gray-500">{new Date(post.createdAt).toLocaleDateString()}</span>
</div>
{#key $sesionStore?.accessToken}
<div class="flex gap-2">
<BotonSeguir {post} variant="icon-sm" />
</div>
{/if}
<span class="text-sm font-semibold">@{post.authorName}</span>
<span class="text-xs text-gray-500">{new Date(post.createdAt).toLocaleDateString()}</span>
{/key}
</div>
<p class=" mt-1 line-clamp-2 rounded-md p-2 text-lg">
{post.content}

View File

@@ -212,7 +212,8 @@ export const GET: RequestHandler = async ({ params, fetch, request }) => {
return new Response(new Uint8Array(pngBuffer), {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=31536000, immutable'
'Cache-Control': 'public, max-age=31536000, immutable',
'Content-Length': pngBuffer.length.toString()
}
});
};