Merge pull request #24 from emailerfacu-spec/dev

Dev
This commit is contained in:
emailerfacu-spec
2025-12-05 13:30:11 -03:00
committed by GitHub
45 changed files with 1396 additions and 118 deletions

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import Ellipsis from '@lucide/svelte/icons/ellipsis';
import Trash2 from '@lucide/svelte/icons/trash-2';
import ThumbsUp from '@lucide/svelte/icons/thumbs-up';
import MessageCircle from '@lucide/svelte/icons/message-circle-more';
import Pen from '@lucide/svelte/icons/pen';
import type { Post } from '../../types';
import Button from './ui/button/button.svelte';
@@ -27,6 +29,7 @@
import DialogTitle from './ui/dialog/dialog-title.svelte';
import DialogDescription from './ui/dialog/dialog-description.svelte';
import { sesionStore } from '@/stores/usuario';
import { likePost } from '@/hooks/likePost';
interface postProp {
post: Post;
@@ -38,6 +41,8 @@
let cargandoBorrar = $state(false);
let mensajeError = $state('');
let cargandoEditar = $state(false);
let cargandoLike = $state(false);
let errorLike = $state(false);
async function handleBorrar() {
await deletePost(
@@ -50,9 +55,27 @@
);
}
async function handleEditar() {
function handleEditar() {
postAModificar = post;
}
async function likeHandler() {
cargandoLike = true;
let { message, ok } = await likePost(post);
if (ok) {
if (post.isLiked) {
post.likesCount--;
} else {
post.likesCount++;
}
post.isLiked = !post.isLiked;
} else {
errorLike = true;
mensajeError = message;
}
updatePostStore(post.id, post);
cargandoLike = false;
}
</script>
<Card>
@@ -60,10 +83,12 @@
<div class="flex flex-col">
<div class="flex items-center justify-between">
<div class="flex gap-3">
<Avatar>
<AvatarImage></AvatarImage>
<AvatarFallback>{post.authorDisplayName[0].toUpperCase()}</AvatarFallback>
</Avatar>
<a href={`/${post.authorName}`}>
<Avatar>
<AvatarImage></AvatarImage>
<AvatarFallback>{post.authorDisplayName[0].toUpperCase()}</AvatarFallback>
</Avatar>
</a>
<div class="flex space-x-2">
<span class="text-lg font-medium">{post.authorDisplayName}</span>
<span class="text-lg text-muted-foreground">@{post.authorName}</span>
@@ -72,7 +97,7 @@
{#if post.authorName === $sesionStore?.username}
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="ghost" class="rounded-full"><Ellipsis /></Button>
<Button variant="ghost" class=" rounded-full bg-accent"><Ellipsis /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuGroup>
@@ -101,16 +126,32 @@
</div>
</div>
</CardHeader>
<Content>
<p class="text-sm">{post.content}</p>
<Content class="mx-5 -mt-4 rounded-full bg-accent p-6">
<p class=" text-sm">{post.content}</p>
{#if post.imageUrl}
<img src={post.imageUrl} alt="Post" class="mt-2 rounded-md" />
{/if}
</Content>
<CardFooter>
<div class="flex items-center justify-between gap-2 pt-2 text-xs text-muted-foreground">
<span>{post.likesCount} likes</span>
<span>{post.repliesCount} replies</span>
<div class="-mt-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<Button
variant="ghost"
disabled={!$sesionStore?.accessToken}
class={`${post.isLiked ? 'bg-blue-500/30' : 'bg-accent'} flex items-center gap-2 rounded-full p-3 text-lg`}
onclick={() => likeHandler()}
>
<p>
{post.likesCount}
</p>
<ThumbsUp />
</Button>
<Button variant="ghost" class="flex items-center gap-2 rounded-full bg-accent p-3 text-lg">
<p>
{post.repliesCount}
</p>
<MessageCircle />
</Button>
<span class="text-xs text-muted-foreground"
>{post.createdAt.replace('T', ' ').split('.')[0]}</span
>
@@ -120,7 +161,7 @@
</div>
</CardFooter>
</Card>
{#if mensajeError}
{#if mensajeError || errorLike}
<Dialog>
<DialogContent>
<DialogHeader>

View File

@@ -0,0 +1,177 @@
<script lang="ts">
import TableBody from '@/components/ui/table/table-body.svelte';
import TableCell from '@/components/ui/table/table-cell.svelte';
import TableHead from '@/components/ui/table/table-head.svelte';
import TableHeader from '@/components/ui/table/table-header.svelte';
import TableRow from '@/components/ui/table/table-row.svelte';
import Table from '@/components/ui/table/table.svelte';
import type { UserResponseDto } from '../../types';
import Button from './ui/button/button.svelte';
import KeyIcon from '@lucide/svelte/icons/key';
import UserPen from '@lucide/svelte/icons/user-pen';
import { Tooltip } from './ui/tooltip';
import TooltipTrigger from './ui/tooltip/tooltip-trigger.svelte';
import TooltipContent from './ui/tooltip/tooltip-content.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 { fade } from 'svelte/transition';
import type { Unsubscriber } from 'svelte/store';
import Input from './ui/input/input.svelte';
interface Props {
usuarios: UserResponseDto[];
}
let { usuarios = $bindable() }: Props = $props();
let open = $state(false);
let openModificarUsuario = $state(false);
//si ponia contraseña en español quedaba muy largo el nombre
let usuarioCambioPass: UserResponseDto | null = $state(null);
let usuarioModificar: UserResponseDto | null = $state(null);
let search = $state("");
type SortKey = 'username' | 'displayName' | 'postsCount' | 'createdAt';
let sortBy = $state<SortKey | null>(null);
let sortDirection = $state<'asc' | 'desc'>('asc');
function ordenarPor(campo: SortKey) {
if (sortBy === campo) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortBy = campo;
sortDirection = 'asc';
}
}
let usuariosFiltrados = $derived(
usuarios
.filter((u) =>
u.username.toLowerCase().startsWith(search.toLowerCase()) ||
u.displayName.toLowerCase().startsWith(search.toLowerCase())
)
.toSorted((a, b) => {
if (!sortBy) return 0;
const key: SortKey = sortBy;
if (key === 'createdAt') {
const ta = new Date(a.createdAt).getTime();
const tb = new Date(b.createdAt).getTime();
return sortDirection === 'asc' ? ta - tb : tb - ta;
}
if (key === 'postsCount') {
return sortDirection === 'asc'
? a.postsCount - b.postsCount
: b.postsCount - a.postsCount;
}
const sa = a[key].toString().toLowerCase();
const sb = b[key].toString().toLowerCase();
return sortDirection === 'asc'
? sa.localeCompare(sb)
: sb.localeCompare(sa);
})
);
function getSortIcon(campo: SortKey) {
if (sortBy !== campo) return ""; // no ícono si no está ordenando por esa columna
return sortDirection === "asc" ? "↑" : "↓"; // ascendente / descendente
}
//let usuariosFiltrados = $derived(
//usuarios.filter((u) =>
// u.username.toLowerCase().startsWith(search.toLowerCase()) ||
// u.displayName.toLowerCase().startsWith(search.toLowerCase())
// )
//);
$effect(() => {
if (!open) {
usuarioCambioPass = null;
}
});
function handleCambiarContraseña(usuario: UserResponseDto) {
open = true;
usuarioCambioPass = usuario;
}
function handleModificar(usuario: UserResponseDto) {
openModificarUsuario = true;
usuarioModificar = usuario;
}
</script>
<div class="mb-4">
<Input type= "text"
placeholder="Buscar usuario..."
bind:value={search}
class="border px-3 py-2 rounded w-full"
/>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead onclick={() => ordenarPor("username")} class="cursor-pointer select-none">
Usuario {getSortIcon("username")}
</TableHead>
<TableHead onclick={() => ordenarPor("displayName")} class="cursor-pointer select-none">
Nombre {getSortIcon("displayName")}
</TableHead>
<TableHead onclick={() => ordenarPor("postsCount")} class="cursor-pointer select-none">
Cantidad de posts {getSortIcon("postsCount")}
</TableHead>
<TableHead onclick={() => ordenarPor("createdAt")} class="cursor-pointer select-none">
Fecha de Creacion {getSortIcon("createdAt")}
</TableHead>
<TableHead>Acciones</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each usuariosFiltrados as usuario}
<TableRow>
<TableCell
>@<a href={'/' + usuario.username}>
{usuario.username}
</a>
</TableCell>
<TableCell>{usuario.displayName}</TableCell>
<TableCell class="text-center">{usuario.postsCount}</TableCell>
<TableCell>{usuario.createdAt.replace('Z', ' ').replace('T', ' | ')}</TableCell>
<TableCell class="flex gap-2">
<Tooltip>
<TooltipTrigger>
<Button onclick={() => handleCambiarContraseña(usuario)}><KeyIcon></KeyIcon></Button>
</TooltipTrigger>
<TooltipContent>
<p>Recuperar Contraseña</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<Button onclick={() => handleModificar(usuario)}><UserPen /></Button>
</TooltipTrigger>
<TooltipContent>
<p>Modificar Usuario</p>
</TooltipContent>
</Tooltip>
<Button></Button>
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
<RecuperarContraseña bind:open usuario={usuarioCambioPass} />
<ModificarUsuario bind:open={openModificarUsuario} bind:usuario={usuarioModificar} />

View File

@@ -0,0 +1,103 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { Dialog, DialogTitle, DialogContent, DialogHeader } from '../ui/dialog';
import InputGroup from '../ui/input-group/input-group.svelte';
import InputGroupInput from '../ui/input-group/input-group-input.svelte';
import InputGroupAddon from '../ui/input-group/input-group-addon.svelte';
import Button from '../ui/button/button.svelte';
import type { UserResponseDto } from '../../../types';
import Checkbox from '../ui/checkbox/checkbox.svelte';
import Label from '../ui/label/label.svelte';
import Spinner from '../ui/spinner/spinner.svelte';
import { updateUsuario } from '@/hooks/updateUsuario';
interface Prop {
open: boolean;
usuario: UserResponseDto | null;
}
let { open = $bindable(), usuario = $bindable() }: Prop = $props();
let imagen = $state(!!usuario?.profileImageUrl);
let fallback = usuario?.displayName;
let cargando = $state(false);
let error = $state('');
async function onsubmit(e: SubmitEvent) {
e.preventDefault();
cargando = true;
let ret: { displayName: string } | string = await updateUsuario({
id: usuario?.id || '',
bio: usuario?.bio || '',
displayName: usuario?.displayName || '',
oldImageUrl: usuario?.profileImageUrl || '',
profileImage: imagen
});
if (typeof ret === 'string') {
error = ret;
} else {
usuario!.displayName = ret.displayName;
open = false;
}
cargando = false;
}
</script>
<div transition:fade>
<Dialog
{open}
onOpenChange={() => {
open = !open;
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Modificar Usuario</DialogTitle>
</DialogHeader>
<form {onsubmit}>
<div class="flex flex-col gap-3">
<InputGroup>
<InputGroupInput disabled={cargando} bind:value={usuario!.displayName} />
<InputGroupAddon>Nombre</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput disabled={cargando} bind:value={usuario!.bio} />
<InputGroupAddon>Bio</InputGroupAddon>
</InputGroup>
<div class="ms-1 flex gap-2">
<Checkbox disabled={usuario?.profileImageUrl == null || cargando} bind:checked={imagen}
></Checkbox>
<Label>
{#if usuario?.profileImageUrl == null}
No tiene imagen de perfil
{:else if imagen}
Borrar imagen
{:else}
Mantener imagen
{/if}
</Label>
</div>
<hr class="my-2" />
<div class="flex justify-between">
<Button type="submit" disabled={cargando}>
{#if cargando}
<Spinner /> Cargando...
{:else}
Aceptar
{/if}
</Button>
<Button variant="secondary" disabled={cargando} onclick={() => (open = !open)}
>Cerrar</Button
>
</div>
</div>
</form>
</DialogContent>
</Dialog>
</div>
<div transition:fade>
<Dialog open={error != ''} onOpenChange={() => (error = '')}>
<DialogContent>
{error}
</DialogContent>
</Dialog>
</div>

View File

@@ -0,0 +1,88 @@
<script lang="ts">
import Key from '@lucide/svelte/icons/key';
import { Dialog } from '../ui/dialog';
import DialogContent from '../ui/dialog/dialog-content.svelte';
import InputGroupAddon from '../ui/input-group/input-group-addon.svelte';
import InputGroupInput from '../ui/input-group/input-group-input.svelte';
import InputGroup from '../ui/input-group/input-group.svelte';
import DialogTitle from '../ui/dialog/dialog-title.svelte';
import type { UserResponseDto } from '../../../types';
import DialogHeader from '../ui/dialog/dialog-header.svelte';
import { fade } from 'svelte/transition';
import { cambiarContraseña } from '@/hooks/cambiarContraseña';
import Button from '../ui/button/button.svelte';
import Spinner from '../ui/spinner/spinner.svelte';
interface Prop {
open: boolean;
usuario: UserResponseDto | null;
}
let { open = $bindable(), usuario }: Prop = $props();
let cargando = $state(false);
let error = $state('');
let nuevapass = $state('');
let openMensaje = $state(false);
async function onsubmit(e: SubmitEvent) {
cargando = true;
error = await cambiarContraseña(usuario!, nuevapass);
if (error === '') {
open = false;
nuevapass = '';
}
openMensaje = true;
cargando = false;
}
</script>
<div transition:fade>
<Dialog open={open && !!usuario} onOpenChange={() => (open = !open)}>
<DialogContent>
<DialogHeader>
<DialogTitle><p>Cambiar Contraseña</p></DialogTitle>
</DialogHeader>
<form {onsubmit}>
<InputGroup>
<InputGroupInput
disabled={cargando}
type="password"
name=""
minlength={8}
bind:value={nuevapass}
/>
<InputGroupAddon><Key /></InputGroupAddon>
<InputGroupAddon align="inline-end">
<p
class={{
'text-red-500': nuevapass.length < 8,
'text-blue-500': nuevapass.length >= 8
}}
>
{nuevapass.length}
</p>
/ 8
</InputGroupAddon>
</InputGroup>
<div class="mt-2 flex justify-between">
<Button type="submit" disabled={cargando}>
{#if cargando}
<Spinner />
{:else}
Aceptar
{/if}
</Button>
<Button variant="secondary" onclick={() => (open = false)} disabled={cargando}
>Cerrar</Button
>
</div>
</form>
</DialogContent>
</Dialog>
</div>
<div transition:fade>
<Dialog open={openMensaje} onOpenChange={() => (openMensaje = false)}>
<!-- {$inspect(error)} -->
<DialogContent>{error === '' ? 'Se modificó el usuario' : error}</DialogContent>
</Dialog>
</div>

View File

@@ -4,13 +4,14 @@
import InputGroupTextarea from './ui/input-group/input-group-textarea.svelte';
import InputGroup from './ui/input-group/input-group.svelte';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import Loader2Icon from '@lucide/svelte/icons/loader-2';
import Kbd from './ui/kbd/kbd.svelte';
import { apiBase } from '@/stores/url';
import { sesionStore } from '@/stores/usuario';
import type { CreatePostDto } from '../../types';
import { addPost } from '@/stores/posts';
import { Tooltip, TooltipProvider } from './ui/tooltip';
import { Tooltip } from './ui/tooltip';
import TooltipContent from './ui/tooltip/tooltip-content.svelte';
import TooltipTrigger from './ui/tooltip/tooltip-trigger.svelte';
@@ -22,21 +23,18 @@
async function handlePost(e: Event) {
e.preventDefault();
try {
const data: CreatePostDto = {
content: mensaje,
imageUrl: null,
parentPostId: null
};
const formData = new FormData();
formData.append('content', mensaje);
// formData.append('imageUrl', '');
// formData.append('parentPostId', '');
const req = fetch($apiBase + '/api/posts', {
method: 'POST',
//credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${$sesionStore?.accessToken}`
},
body: JSON.stringify(data)
body: formData
});
cargando = true;
@@ -79,24 +77,28 @@
</p>
/ 280
</Kbd>
<TooltipProvider>
<Tooltip>
<TooltipTrigger class="*: flex">
<InputGroupButton
variant="default"
type="submit"
class="transform rounded-full transition-transform ease-in hover:scale-120"
size="xs"
>
<p>Publicar</p>
<Tooltip>
<TooltipTrigger class="*: flex">
<InputGroupButton
variant="default"
disabled={cargando}
type="submit"
class="transform rounded-full transition-transform ease-in hover:scale-120"
size="xs"
>
{#if cargando}
<Loader2Icon class="animate-spin" />
Publicando...
{:else}
Publicar
<ArrowUpIcon class="mt-0.5 h-3.5! w-3.5!" />
</InputGroupButton>
</TooltipTrigger>
<TooltipContent>
<Kbd>Ctrl</Kbd>+<Kbd>Enter</Kbd>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/if}
</InputGroupButton>
</TooltipTrigger>
<TooltipContent>
<Kbd>Ctrl</Kbd>+<Kbd>Enter</Kbd>
</TooltipContent>
</Tooltip>
</div>
</InputGroupAddon>
</InputGroup>

View File

@@ -5,13 +5,55 @@
import { Input } from '$lib/components/ui/input/index.js';
import type { RegisterDto } from '../../types';
import { register } from '@/hooks/register';
import Loader2Icon from '@lucide/svelte/icons/loader-2';
import Check from '@lucide/svelte/icons/check';
import Cross from '@lucide/svelte/icons/x';
let {showAlert = $bindable() } = $props();
import Spinner from './ui/spinner/spinner.svelte';
import { checkUsername } from '@/hooks/checkUsername';
import { checkEmail } from '@/hooks/checkEmail';
const setAlert = () => showAlert = true;
let { showAlert = $bindable() } = $props();
let dto: RegisterDto = $state({password: "", username: "", email:"", displayName: ""});
let cargando = $state(false);
let repetirContraseña = $state('');
let checkeandoUsuario: boolean | null = $state(null);
let esUsuarioValido = $state(false);
let checkeandoEmail: boolean | null = $state(null);
let esEmailValido = $state(false);
let dto: RegisterDto = $state({ password: '', username: '', email: '', displayName: '' });
let coinsidenLasPass = $derived(repetirContraseña == dto.password);
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9])[A-Za-z\d\W_]*$/;
let esContraseñaValida = $derived(passwordRegex.test(dto.password));
async function checkUsuario() {
checkeandoUsuario = true;
esUsuarioValido = await checkUsername(dto.username);
checkeandoUsuario = false;
}
async function checkEmaill() {
checkeandoEmail = true;
esEmailValido = await checkEmail(dto.email);
checkeandoEmail = false;
}
const setAlert = () => (showAlert = true);
const handleSubmit = async (e: SubmitEvent) => {
if (esUsuarioValido == false) return;
if (!coinsidenLasPass) return;
if (!esContraseñaValida) return;
cargando = true;
await register(e, dto, setAlert);
cargando = false;
};
</script>
<Card.Root>
@@ -20,11 +62,29 @@
<hr />
</Card.Header>
<Card.Content>
<form onsubmit={(e)=>register(e, dto, setAlert)}>
<form onsubmit={handleSubmit}>
<Field.Group>
<Field.Field>
<Field.Label for="name">Nombre de Usuario</Field.Label>
<Input id="name" bind:value={dto.username} type="text" placeholder="JPepe" required />
<div class="flex justify-between">
<Field.Label for="name">Nombre de Usuario</Field.Label>
{#if checkeandoUsuario == null}
<div hidden></div>
{:else if checkeandoUsuario == true}
<Spinner></Spinner>
{:else if esUsuarioValido}
<Check class="text-green-500" />
{:else}
<Cross class="text-red-500" />
{/if}
</div>
<Input
id="name"
bind:value={dto.username}
type="text"
placeholder="JPepe"
required
onchange={checkUsuario}
/>
</Field.Field>
<Field.Field>
@@ -33,22 +93,62 @@
</Field.Field>
<Field.Field>
<Field.Label for="email">Email</Field.Label>
<Input id="email" type="email" bind:value={dto.email} placeholder="m@ejemplo.com" required />
<div class="flex justify-between">
<Field.Label for="email">Email</Field.Label>
{#if checkeandoEmail == null}
<div hidden></div>
{:else if checkeandoEmail == true}
<Spinner></Spinner>
{:else if esEmailValido}
<Check class="text-green-500" />
{:else}
<Cross class="text-red-500" />
{/if}
</div>
<Input
id="email"
type="email"
bind:value={dto.email}
placeholder="m@ejemplo.com"
required
onchange={checkEmaill}
/>
</Field.Field>
<Field.Field>
<Field.Label for="password">Contraseña</Field.Label>
<Input id="password" type="password" bind:value={dto.password} required />
<Field.Description>Debe de tener por lo menos 8 caracteres.</Field.Description>
<Input
id="password"
type="password"
bind:value={dto.password}
required
class={{ 'border-red-500': dto.password && !esContraseñaValida }}
/>
<Field.Description
>Debe de tener por lo menos 8 caracteres, una minúscula, una mayúscula, un número y un
carácter especial.</Field.Description
>
</Field.Field>
<Field.Field>
<Field.Label for="confirm-password">Confirmar Contraseña</Field.Label>
<Input id="confirm-password" type="password" required />
<Input
id="confirm-password"
type="password"
required
bind:value={repetirContraseña}
class={{ 'border-red-500': !coinsidenLasPass }}
/>
<Field.Description>Confirma la contraseña</Field.Description>
</Field.Field>
<Field.Group>
<Field.Field>
<Button type="submit">Crear Cuenta</Button>
<Button type="submit" disabled={cargando || !coinsidenLasPass || !esContraseñaValida}>
{#if cargando}
Creando Cuenta...
<Loader2Icon class="animate-spin" />
{:else}
Crear Cuenta
{/if}
</Button>
<Field.Description class="px-6 text-center">
Tenes una cuenta? <a href="/login">Iniciar Sesion</a>
</Field.Description>

View File

@@ -0,0 +1,50 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
variants: {
variant: {
default:
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
destructive:
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
});
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
href,
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
} = $props();
</script>
<svelte:element
this={href ? "a" : "span"}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>

View File

@@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { Checkbox as CheckboxPrimitive } from "bits-ui";
import CheckIcon from "@lucide/svelte/icons/check";
import MinusIcon from "@lucide/svelte/icons/minus";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
...restProps
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
</script>
<CheckboxPrimitive.Root
bind:ref
data-slot="checkbox"
class={cn(
"border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-xs peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
bind:checked
bind:indeterminate
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<div data-slot="checkbox-indicator" class="text-current transition-none">
{#if checked}
<CheckIcon class="size-3.5" />
{:else if indeterminate}
<MinusIcon class="size-3.5" />
{/if}
</div>
{/snippet}
</CheckboxPrimitive.Root>

View File

@@ -0,0 +1,6 @@
import Root from "./checkbox.svelte";
export {
Root,
//
Root as Checkbox,
};

View File

@@ -5,11 +5,20 @@
import { FieldGroup, Field, FieldLabel, FieldDescription } from '@/components/ui/field';
import type { LoginDto } from '../../../../types';
import { login } from '@/hooks/login';
let {id, showAlert = $bindable() } = $props();
import Loader2Icon from '@lucide/svelte/icons/loader-2';
let { id, showAlert = $bindable() } = $props();
let dto: LoginDto = $state({password: "", username: ""});
let dto: LoginDto = $state({ password: '', username: '' });
const setAlert = () => showAlert = true;
const setAlert = () => (showAlert = true);
let cargando = $state(false);
const handleSubmit = async (e: SubmitEvent) => {
cargando = true;
await login(e, dto, setAlert);
cargando = false;
};
</script>
<Card.Root class="mx-auto w-full max-w-sm">
@@ -18,7 +27,7 @@
<Card.Description>ingrese su usuario para logearse en la cuenta</Card.Description>
</Card.Header>
<Card.Content>
<form onsubmit="{(e)=>login(e,dto, setAlert)}">
<form onsubmit={handleSubmit}>
<FieldGroup>
<Field>
<FieldLabel for="email-{id}">Usuario</FieldLabel>
@@ -34,7 +43,14 @@
<Input bind:value={dto.password} type="password" required />
</Field>
<Field>
<Button type="submit" class="w-full">Login</Button>
<Button type="submit" class="w-full" disabled={cargando}>
{#if cargando}
Cargando...
<Loader2Icon class="animate-spin" />
{:else}
Login
{/if}
</Button>
<FieldDescription class="text-center">
No tenes una cuenta? <a href="/register">Registrate</a>

View File

@@ -0,0 +1,28 @@
import Root from "./table.svelte";
import Body from "./table-body.svelte";
import Caption from "./table-caption.svelte";
import Cell from "./table-cell.svelte";
import Footer from "./table-footer.svelte";
import Head from "./table-head.svelte";
import Header from "./table-header.svelte";
import Row from "./table-row.svelte";
export {
Root,
Body,
Caption,
Cell,
Footer,
Head,
Header,
Row,
//
Root as Table,
Body as TableBody,
Caption as TableCaption,
Cell as TableCell,
Footer as TableFooter,
Head as TableHead,
Header as TableHeader,
Row as TableRow,
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script>
<tbody
bind:this={ref}
data-slot="table-body"
class={cn("[&_tr:last-child]:border-0", className)}
{...restProps}
>
{@render children?.()}
</tbody>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<caption
bind:this={ref}
data-slot="table-caption"
class={cn("text-muted-foreground mt-4 text-sm", className)}
{...restProps}
>
{@render children?.()}
</caption>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLTdAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLTdAttributes> = $props();
</script>
<td
bind:this={ref}
data-slot="table-cell"
class={cn(
"whitespace-nowrap bg-clip-padding p-2 align-middle [&:has([role=checkbox])]:pe-0",
className
)}
{...restProps}
>
{@render children?.()}
</td>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script>
<tfoot
bind:this={ref}
data-slot="table-footer"
class={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
{...restProps}
>
{@render children?.()}
</tfoot>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLThAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLThAttributes> = $props();
</script>
<th
bind:this={ref}
data-slot="table-head"
class={cn(
"text-foreground h-10 whitespace-nowrap bg-clip-padding px-2 text-start align-middle font-medium [&:has([role=checkbox])]:pe-0",
className
)}
{...restProps}
>
{@render children?.()}
</th>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
</script>
<thead
bind:this={ref}
data-slot="table-header"
class={cn("[&_tr]:border-b", className)}
{...restProps}
>
{@render children?.()}
</thead>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLTableRowElement>> = $props();
</script>
<tr
bind:this={ref}
data-slot="table-row"
class={cn(
"hover:[&,&>svelte-css-wrapper]:[&>th,td]:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...restProps}
>
{@render children?.()}
</tr>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import type { HTMLTableAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLTableAttributes> = $props();
</script>
<div data-slot="table-container" class="relative w-full overflow-x-auto">
<table
bind:this={ref}
data-slot="table"
class={cn("w-full caption-bottom text-sm", className)}
{...restProps}
>
{@render children?.()}
</table>
</div>

View File

@@ -27,6 +27,10 @@
<DropdownMenuItem onclick={() => goto('/' + $sesionStore?.username)}
>Mi Perfil</DropdownMenuItem
>
{#if $sesionStore?.isAdmin}
<DropdownMenuItem onclick={() => goto('/admin')}>Menu Admin</DropdownMenuItem>
{/if}
<DropdownMenuSeparator />
<DropdownMenuItem onclick={async () => await logout(menuOpen)}>Cerrar Sesion</DropdownMenuItem
>

View File

@@ -0,0 +1,24 @@
import { apiBase } from '@/stores/url';
import { get } from 'svelte/store';
import type { UserResponseDto } from '../../types';
import { sesionStore } from '@/stores/usuario';
export async function cambiarContraseña(usuario: UserResponseDto, newpass: string) {
try {
const req = await fetch(get(apiBase) + '/api/admin/password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${get(sesionStore)?.accessToken}`
},
body: JSON.stringify({ id: usuario.id, newpass })
});
if (req.ok) {
return '';
}
const data = await req.json();
return data.message;
} catch {
return 'No se pudo alcanzar el servidor';
}
}

View File

@@ -0,0 +1,16 @@
import { apiBase } from "@/stores/url";
import { get } from "svelte/store";
export async function checkEmail(email: string) {
try {
const req = await fetch(`${get(apiBase)}/api/users/check-email/${email}`, {
method: "GET"
});
if (req.ok){
return (await req.json()).available;
}
return false;
} catch {
return false;
}
}

View File

@@ -0,0 +1,16 @@
import { apiBase } from "@/stores/url";
import { get } from "svelte/store";
export async function checkUsername(username: string) {
try {
const req = await fetch(`${get(apiBase)}/api/users/check-username/${username}`, {
method: "GET"
});
if (req.ok){
return (await req.json()).available;
}
return false;
} catch {
return false;
}
}

17
src/lib/hooks/getPosts.ts Normal file
View File

@@ -0,0 +1,17 @@
import { apiBase } from "@/stores/url";
import { sesionStore } from "@/stores/usuario";
import { get } from "svelte/store";
export async function getPosts() {
const req = await fetch(`${get(apiBase)}/timeline?pageSize=20`,{
headers: {
Authorization: `Bearer ${get(sesionStore)?.accessToken}`
}
});
if (req.ok) {
return await req.json();
}
}

22
src/lib/hooks/likePost.ts Normal file
View File

@@ -0,0 +1,22 @@
import { apiBase } from '@/stores/url';
import { get } from 'svelte/store';
import { sesionStore } from '@/stores/usuario';
import type { Post } from '../../types';
export async function likePost(post: Post) {
let method = post.isLiked ? "DELETE" : "POST";
try {
const req = await fetch(get(apiBase) + `/api/posts/${post.id}/like`, {
method: method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${get(sesionStore)?.accessToken}`
}
});
const data: { message: string } = await req.json();
return { message: data.message, ok: req.ok };
} catch {
return { message: 'No se pudo alcanzar el servidor', ok: false };
}
}

View File

@@ -3,7 +3,7 @@ import type { LoginDto } from "../../types";
import { sesionStore } from "@/stores/usuario";
import { goto } from "$app/navigation";
export async function login(e:FormDataEvent,dto: LoginDto, callbackfn:()=>void){
export async function login(e:SubmitEvent, dto: LoginDto, callbackfn:()=>void){
e.preventDefault();
if (dto.password == "" || dto.username == "") return;
try {

View File

@@ -1,14 +1,15 @@
import { goto } from '$app/navigation';
import { apiBase } from '@/stores/url';
import { sesionStore } from '@/stores/usuario';
import { get } from 'svelte/store';
export async function logout(menuOpen: boolean) {
try {
const req = await fetch($apiBase + '/api/auth/logout', {
const req = await fetch(get(apiBase) + '/api/auth/logout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${$sesionStore.accessToken}`
Authorization: `Bearer ${get(sesionStore)?.accessToken}`
},
credentials: 'include'
});

View File

@@ -0,0 +1,25 @@
import { sesionStore } from "@/stores/usuario";
import type { UserResponseDto } from "../../types";
import { get } from "svelte/store";
import { apiBase } from "@/stores/url";
export async function obtenerSeguidoresPorUsuario(id: string, limit:number = 20): Promise<UserResponseDto[] | null> {
try {
const response = await fetch(`${get(apiBase)}/api/users/${id}/followers?limit=${limit}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${get(sesionStore)?.accessToken}`
}
});
if (!response.ok) {
return null;
}
const followers: UserResponseDto[] = await response.json();
return followers;
} catch (error) {
return null;
}
}

View File

@@ -0,0 +1,25 @@
import { sesionStore } from "@/stores/usuario";
import type { UserResponseDto } from "../../types";
import { apiBase } from "@/stores/url";
import { get } from "svelte/store";
export async function obtenerSeguidosPorUsuario(id: string, limit:number = 20): Promise<UserResponseDto[] | null> {
try {
const response = await fetch(`${get(apiBase)}/api/users/${id}/following?limit=${limit}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${get(sesionStore)?.accessToken}`
}
});
if (!response.ok) {
return null;
}
const users: UserResponseDto[] = await response.json();
return users;
} catch (error) {
return null;
}
}

View File

@@ -0,0 +1,27 @@
import { apiBase } from '@/stores/url';
import type { UserResponseDto } from '../../types';
import { get } from 'svelte/store';
import { sesionStore } from '@/stores/usuario';
export async function obtenerUsuarioPorUsername(username: string): Promise<UserResponseDto | null> {
try {
const response = await fetch(`${get(apiBase)}/api/users/username/${username}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${get(sesionStore)?.accessToken}`
}
});
if (!response.ok) {
//console.error('Error fetching user data:', response.status);
return null;
}
const user: UserResponseDto = await response.json();
return user;
} catch (error) {
//console.error('Failed to reach the server while fetching user:', error);
return null;
}
}

View File

@@ -2,7 +2,7 @@ import { apiBase } from "@/stores/url";
import { goto } from "$app/navigation";
import type { RegisterDto } from "../../types";
export async function register(e:FormDataEvent,dto: RegisterDto, callbackfn:()=>void){
export async function register(e: SubmitEvent, dto: RegisterDto, callbackfn:()=>void){
e.preventDefault();
if (dto.password == "" || dto.username == "" ||
!dto.email?.includes("@") || dto.displayName=="") return;

View File

@@ -5,17 +5,16 @@ import { sesionStore } from '@/stores/usuario';
export async function updatePost(post: Post, callbackfn: Function, message: string) {
try {
const data = {
content: post.content,
imageUrl: post.imageUrl
};
const formData = new FormData();
formData.append("content", post.content);
formData.append("imageUrl", post.imageUrl||"");
const req = await fetch(get(apiBase) + `/api/posts/${post.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${get(sesionStore)?.accessToken}`
},
body: JSON.stringify(data)
body: formData
});
if (req.ok) {
const newpost: PostResponseDto = await req.json();

View File

@@ -0,0 +1,44 @@
import { apiBase } from "@/stores/url"
import { sesionStore } from "@/stores/usuario"
import { get } from "svelte/store"
export interface AdminUpdateUsuario {
id:string,
displayName: string,
bio: string,
profileImage:boolean,
oldImageUrl:string
}
export async function updateUsuario(usuario: AdminUpdateUsuario) {
const formData = new FormData();
formData.append('displayName', usuario.displayName);
formData.append('bio', usuario.bio);
if (usuario.profileImage) {
formData.append('profileImageUrl', usuario.oldImageUrl);
}
try {
const req = await fetch(get(apiBase) + "/api/users/"+usuario.id, {
method: "PUT",
headers: {
Authorization: `Bearer ${get(sesionStore)?.accessToken}`
},
body: formData,
});
if (req.status === 204) {
let ret = {
// bio: usuario.bio,
displayName: usuario.displayName,
// oldImageUrl: usuario.oldImageUrl,
}
return ret;
}
const dataa = await req.json();
return dataa.message;
} catch {
return "No se pudo alcanzar el servidor"
}
}

View File

@@ -27,8 +27,43 @@ if (browser) {
});
}
if (browser) {
const decodeJWT = (token: string) => {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
);
return JSON.parse(jsonPayload);
} catch (error) {
console.error('Error decodificando JWT:', error);
return null;
}
};
const shouldRefreshToken = (sesion: Sesion | null): boolean => {
if (!sesion || !sesion.accessToken) return false;
const decoded = decodeJWT(sesion.accessToken);
if (!decoded || !decoded.exp) return false;
const expirationTime = decoded.exp * 1000;
const currentTime = Date.now();
const timeUntilExpiration = expirationTime - currentTime;
return timeUntilExpiration <= 60 * 1000; // 1 minuto
};
const refreshAccessToken = async () => {
try {
const sesion = get(currentSesion);
if (!shouldRefreshToken(sesion)) return;
console.log('refrescando token');
const response = await fetch(get(apiBase) + '/api/auth/refresh', {
method: 'POST',
headers: {
@@ -46,14 +81,15 @@ if (browser) {
return sesion;
});
} else {
console.error('Error refreshing token:', response.statusText);
console.error('Error refrescando token:', response.statusText);
currentSesion.set(null);
}
} catch (error) {
console.error('Error refreshing token:', error);
console.error('Error refrescando token:', error);
currentSesion.set(null);
}
};
setInterval(refreshAccessToken, 10 * 60 * 1000);
setInterval(refreshAccessToken, 30 * 1000); // Check every 30 seconds
refreshAccessToken();
}

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import CardContent from '@/components/ui/card/card-content.svelte';
import Card from '@/components/ui/card/card.svelte';
import CardDescription from '@/components/ui/card/card-description.svelte';
import { page } from '$app/state';
import TablaUsuarios from '@/components/TablaUsuarios.svelte';
import CardTitle from '@/components/ui/card/card-title.svelte';
import CardHeader from '@/components/ui/card/card-header.svelte';
let cargando = $state(true);
let usuarios = $state(page.data.usuarios);
</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">
<Card class={page.data.error ? 'border-red-400' : ''}>
<CardHeader class="w-full">
<CardTitle class="rounded-full bg-accent-foreground/10">
<h1 class="mt-3 mb-4 scroll-m-20 text-center text-2xl font-extrabold tracking-tight">
Gestion Usuarios
</h1>
</CardTitle>
</CardHeader>
<CardContent>
{#if page.data.usuarios.length === 0}
<CardDescription>No hay posts que mostar</CardDescription>
{:else}
<TablaUsuarios bind:usuarios></TablaUsuarios>
{/if}
</CardContent>
</Card>
</div>
</div>

View File

@@ -0,0 +1,28 @@
import { apiBase } from '@/stores/url.js';
import { sesionStore } from '@/stores/usuario';
import { redirect } from '@sveltejs/kit';
import { get } from 'svelte/store';
import type { UserResponseDto } from '../../../types.js';
export const ssr = false;
export async function load({}) {
const response = await fetch(get(apiBase) + '/api/admin/users', {
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 usuarios: UserResponseDto[] = await response.json();
return { usuarios, error: false };
}

20
src/routes/+error.svelte Normal file
View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { page } from '$app/state';
import CardContent from '@/components/ui/card/card-content.svelte';
import Card from '@/components/ui/card/card.svelte';
</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">
<Card>
<CardContent>
<h1 class="mb-4 text-center text-3xl font-bold text-gray-800">
{page.status}
</h1>
<p class="text-center">
{page.error!.message}
</p>
</CardContent>
</Card>
</div>
</div>

View File

@@ -3,6 +3,7 @@
import favicon from '$lib/assets/favicon.ico';
import { ModeWatcher } from 'mode-watcher';
import Header from '@/head/Header.svelte';
import { TooltipProvider } from '@/components/ui/tooltip';
let { children } = $props();
</script>
@@ -13,4 +14,6 @@
</svelte:head>
<ModeWatcher />
<Header />
{@render children()}
<TooltipProvider>
{@render children()}
</TooltipProvider>

View File

@@ -10,6 +10,7 @@
import ModalEditar from './[perfil]/modalEditar.svelte';
import { updatePost } from '@/hooks/updatePost';
import { fade, slide } from 'svelte/transition';
import { getPosts } from '@/hooks/getPosts';
$effect(() => {
(async () => {
@@ -17,20 +18,6 @@
})();
});
async function getPosts() {
const { subscribe } = apiBase;
let baseUrl: string = '';
subscribe((value) => {
baseUrl = value;
})();
const req = await fetch(`${baseUrl}/timeline?pageSize=20`);
if (req.ok) {
return await req.json();
}
}
let postAModificar: Post | null = $state(null);
let mensajeError = $state('');

View File

@@ -1 +1 @@
export const ssr = true;
//export const ssr = true;

View File

@@ -0,0 +1,15 @@
import { obtenerUsuarioPorUsername } from '@/hooks/obtenerUsuario.js';
import type { User, UserResponseDto } from '../../types.js';
import { error } from '@sveltejs/kit';
import { obtenerSeguidosPorUsuario } from '@/hooks/obtenerSeguidosPorUsuario.js';
import { obtenerSeguidoresPorUsuario } from '@/hooks/obtenerSeguidoresPorUsuario.js';
export async function load({ params }) {
const usuario: UserResponseDto | null = await obtenerUsuarioPorUsername(params.perfil);
if(!usuario) error(404, 'No se encontro el usuario, ' + params.perfil);
const seguidos = await obtenerSeguidosPorUsuario(usuario.id, 3);
const seguidores = await obtenerSeguidoresPorUsuario(usuario.id, 3);
return { ...usuario, seguidos, seguidores };
}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { apiBase } from '@/stores/url';
import Ban from '@lucide/svelte/icons/ban';
import PenLine from '@lucide/svelte/icons/pen-line';
import Card from '@/components/ui/card/card.svelte';
import Avatar from '@/components/ui/avatar/avatar.svelte';
import AvatarImage from '@/components/ui/avatar/avatar-image.svelte';
@@ -11,11 +12,19 @@
import { fade, slide } from 'svelte/transition';
import PostCard from '@/components/PostCard.svelte';
import { posts, setPosts, updatePostStore } from '@/stores/posts.js';
import InputGroup from '@/components/ui/input-group/input-group.svelte';
import InputGroupTextarea from '@/components/ui/input-group/input-group-textarea.svelte';
import InputGroupAddon from '@/components/ui/input-group/input-group-addon.svelte';
import { updatePost } from '@/hooks/updatePost.js';
import ModalEditar from './modalEditar.svelte';
import { page } from '$app/state';
import Button from '@/components/ui/button/button.svelte';
import { Dialog } from '@/components/ui/dialog/index.js';
import CrearPost from '@/components/crear-post.svelte';
import DialogContent from '@/components/ui/dialog/dialog-content.svelte';
import DialogHeader from '@/components/ui/dialog/dialog-header.svelte';
import DialogTitle from '@/components/ui/dialog/dialog-title.svelte';
import { sesionStore } from '@/stores/usuario.js';
import CardHeader from '@/components/ui/card/card-header.svelte';
import CardTitle from '@/components/ui/card/card-title.svelte';
import Badge from '@/components/ui/badge/badge.svelte';
let { params } = $props();
@@ -23,6 +32,9 @@
let mensajeError = $state('');
let postAModificar: Post | null = $state(null);
let showCrearPost = $state(false);
const toggleCrearPost = () => (showCrearPost = !showCrearPost);
const { subscribe } = apiBase;
let baseUrl: string = '';
@@ -53,7 +65,6 @@
async function handleEditar(e: SubmitEvent) {
e.preventDefault();
// post.content = 'test';
if (postAModificar == null) return;
await updatePost(
postAModificar,
@@ -67,25 +78,87 @@
<div class="flex min-h-fit w-full items-center justify-center p-6 md:p-10">
<div class="w-full max-w-6xl">
<Card class="mb-2 overflow-hidden">
<CardContent>
<div class="flex justify-center">
<Avatar class="mt-2 scale-250 border-2 border-slate-950">
<AvatarImage></AvatarImage>
<AvatarFallback>{params.perfil[0].toUpperCase()}</AvatarFallback>
</Avatar>
</div>
<h1
class="mt-10 scroll-m-20 text-center text-2xl font-extrabold tracking-tight lg:text-5xl"
<div class="flex gap-2">
<Card class="mb-2 flex w-3/4 overflow-hidden">
<CardContent>
<div class="flex justify-center">
<Avatar class="mt-2 scale-250 border-2 border-slate-950">
<AvatarImage></AvatarImage>
<AvatarFallback>{page.data.displayName?.[0]?.toUpperCase() || ''}</AvatarFallback>
</Avatar>
</div>
<h1
class="mt-10 scroll-m-20 text-center text-2xl font-extrabold tracking-tight lg:text-5xl"
>
{page.data.displayName}
</h1>
<h3 class="scroll-m-20 text-center text-2xl tracking-tight text-muted-foreground">
@{params.perfil}
</h3>
<p class="mt-4 rounded-full bg-accent p-4 text-center text-muted-foreground">
{page.data.bio}
</p>
</CardContent>
</Card>
<aside class="flex w-1/4 flex-col gap-2">
<Card class="w-full">
<CardContent>
<CardHeader class="flex justify-between">
<CardTitle>Seguidos:</CardTitle>
<Badge variant="secondary">{page.data.seguidos.length}</Badge>
</CardHeader>
<CardContent>
{#if page.data.seguidos.length === 0}
<h3>No hay Seguidos</h3>
{:else}
{#each page.data.seguidos as seguidos (seguidos.id)}
<p class="text-muted-foreground">
{seguidos.username}
</p>
{/each}
{/if}
</CardContent>
</CardContent>
</Card>
<Card class="w-full">
<CardContent>
<CardHeader class="flex justify-between">
<CardTitle>Seguidores:</CardTitle>
<Badge variant="secondary">{page.data.seguidores.length}</Badge>
</CardHeader>
<CardContent>
{#if page.data.seguidores.length === 0}
<h3>No hay Seguidores</h3>
{:else}
{#each page.data.seguidores as seguidores (seguidores.id)}
<p class="text-muted-foreground">
{seguidores.username}
</p>
{/each}
{/if}
</CardContent>
</CardContent>
</Card>
</aside>
</div>
<h1
class="mt-10 flex scroll-m-20 justify-between text-3xl font-extrabold tracking-tight lg:text-3xl"
>
Posts:
{#if params.perfil == $sesionStore?.username}
<Button
variant="ghost"
class="m-1 rounded-full bg-blue-600"
onclick={() => {
showCrearPost = true;
}}
>
{'test'}
</h1>
<h3 class="scroll-m-20 text-center text-2xl tracking-tight text-muted-foreground">
@{params.perfil}
</h3>
</CardContent>
</Card>
<h1 class="mt-10 scroll-m-20 text-3xl font-extrabold tracking-tight lg:text-3xl">Posts:</h1>
<PenLine />
</Button>
{/if}
</h1>
<hr class="mb-8" />
{#if cargando}
<div out:slide>
@@ -123,3 +196,18 @@
<ModalEditar callbackfn={handleEditar} bind:post={postAModificar} />
</div>
{/if}
<div transition:fade>
<Dialog open={showCrearPost} onOpenChange={() => (showCrearPost = false)}>
<DialogContent
onkeydown={(e: KeyboardEvent) => {
if (e.ctrlKey && e.key === 'Enter') {
showCrearPost = false;
}
}}
>
<DialogTitle>Crear Publicacion</DialogTitle>
<CrearPost />
</DialogContent>
</Dialog>
</div>

View File

@@ -14,6 +14,7 @@
import { Tooltip, TooltipProvider } from '@/components/ui/tooltip';
import TooltipTrigger from '@/components/ui/tooltip/tooltip-trigger.svelte';
import TooltipContent from '@/components/ui/tooltip/tooltip-content.svelte';
import Spinner from '@/components/ui/spinner/spinner.svelte';
interface Props {
post: Post | null;
@@ -21,11 +22,21 @@
}
let { post = $bindable(), callbackfn }: Props = $props();
function handleKeydown(e: KeyboardEvent) {
let cargando = $state(false);
async function handleKeydown(e: KeyboardEvent) {
if (e.ctrlKey && e.key === 'Enter') {
callbackfn(e);
cargando = true;
await callbackfn(e);
cargando = false;
}
}
async function onsubmit(e: SubmitEvent) {
e.preventDefault();
cargando = true;
await callbackfn(e);
cargando = false;
}
</script>
<Dialog open={true} onOpenChange={() => (post = null)}>
@@ -34,11 +45,7 @@
<DialogTitle>Editar Publicacion</DialogTitle>
</DialogHeader>
<DialogDescription>
<form
onsubmit={(e: SubmitEvent) => {
callbackfn(e);
}}
>
<form {onsubmit}>
<InputGroup>
<InputGroupTextarea
bind:value={post!.content}
@@ -61,13 +68,19 @@
<TooltipTrigger>
<InputGroupButton
variant="default"
disabled={cargando}
type="submit"
class="transform rounded-full transition-transform ease-in hover:scale-120"
size="xs"
>
<p class="flex items-center gap-1">
Modificar
<ArrowUpIcon class="mt-0.5 h-3.5! w-3.5!" />
{#if cargando}
<Spinner />
Cargando...
{:else}
Modificar
<ArrowUpIcon class="mt-0.5 h-3.5! w-3.5!" />
{/if}
</p>
</InputGroupButton>
</TooltipTrigger>

21
src/types.d.ts vendored
View File

@@ -15,6 +15,7 @@ export interface Post {
isEdited: boolean;
visibility: string;
hashtags?: string[];
isLiked: boolean;
}
export interface User {
@@ -37,6 +38,7 @@ export interface Sesion {
url: string;
displayName: string;
username: string;
isAdmin: boolean;
}
export interface LoginDto {
@@ -45,10 +47,10 @@ export interface LoginDto {
}
export interface RegisterDto {
username: string?;
email: string?;
username: string;
email: string;
password: string?;
displayName: string?;
displayName: string;
}
export interface CreatePostDto {
@@ -74,3 +76,16 @@ export interface PostResponseDto {
visibility: string;
hashtags: string[]?;
}
export interface UserResponseDto {
id: string;
username: string;
displayName: string;
email: string;
bio: string;
profileImageUrl: string;
followersCount: number;
followingCount: number;
createdAt: string;
postsCount: number;
}