Merge pull request #58 from emailerfacu-spec/dev

Dev
This commit is contained in:
emailerfacu-spec
2026-01-01 22:07:06 -03:00
committed by GitHub
36 changed files with 3757 additions and 95 deletions

View File

@@ -8,13 +8,13 @@
"mode-watcher": "^1.1.0",
},
"devDependencies": {
"@internationalized/date": "^3.8.1",
"@lucide/svelte": "^0.544.0",
"@internationalized/date": "^3.10.0",
"@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-vercel": "^6.0.0",
"@sveltejs/kit": "^2.47.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.14",
"bits-ui": "^2.11.0",
"bits-ui": "^2.14.4",
"clsx": "^2.1.1",
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.0",
@@ -22,7 +22,7 @@
"svelte": "^5.41.0",
"svelte-check": "^4.3.3",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^3.1.1",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.14",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
@@ -105,7 +105,7 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@lucide/svelte": ["@lucide/svelte@0.544.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-9f9O6uxng2pLB01sxNySHduJN3HTl5p0HDu4H26VR51vhZfiMzyOMe9Mhof3XAk4l813eTtl+/DYRvGyoRR+yw=="],
"@lucide/svelte": ["@lucide/svelte@0.561.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-vofKV2UFVrKE6I4ewKJ3dfCXSV6iP6nWVmiM83MLjsU91EeJcEg7LoWUABLp/aOTxj1HQNbJD1f3g3L0JQgH9A=="],
"@mapbox/node-pre-gyp": ["@mapbox/node-pre-gyp@2.0.0", "", { "dependencies": { "consola": "^3.2.3", "detect-libc": "^2.0.0", "https-proxy-agent": "^7.0.5", "node-fetch": "^2.6.7", "nopt": "^8.0.0", "semver": "^7.5.3", "tar": "^7.4.0" }, "bin": { "node-pre-gyp": "bin/node-pre-gyp" } }, "sha512-llMXd39jtP0HpQLVI37Bf1m2ADlEb35GYSh1SDSLsBhR+5iCxiNGlT31yqbNtVHygHAtMy6dWFERpU2JgufhPg=="],
@@ -231,7 +231,7 @@
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
"bits-ui": ["bits-ui@2.14.3", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-Dkpenu6F5WUfdDJn5D8ALkTaAM+7sUCszKjzav5TWAzsq1fj2tcqKYJcUm82OS+JlgcolI7LOkrqIXzKnt56RA=="],
"bits-ui": ["bits-ui@2.14.4", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-W6kenhnbd/YVvur+DKkaVJ6GldE53eLewur5AhUCqslYQ0vjZr8eWlOfwZnMiPB+PF5HMVqf61vXBvmyrAmPWg=="],
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
@@ -419,7 +419,7 @@
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"tailwind-variants": ["tailwind-variants@3.1.1", "", { "peerDependencies": { "tailwind-merge": ">=3.0.0", "tailwindcss": "*" }, "optionalPeers": ["tailwind-merge"] }, "sha512-ftLXe3krnqkMHsuBTEmaVUXYovXtPyTK7ckEfDRXS8PBZx0bAUas+A0jYxuKA5b8qg++wvQ3d2MQ7l/xeZxbZQ=="],
"tailwind-variants": ["tailwind-variants@3.2.2", "", { "peerDependencies": { "tailwind-merge": ">=3.0.0", "tailwindcss": "*" }, "optionalPeers": ["tailwind-merge"] }, "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg=="],
"tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="],

2967
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -14,13 +14,13 @@
"lint": "prettier --check ."
},
"devDependencies": {
"@internationalized/date": "^3.8.1",
"@lucide/svelte": "^0.544.0",
"@internationalized/date": "^3.10.0",
"@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-vercel": "^6.0.0",
"@sveltejs/kit": "^2.47.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.14",
"bits-ui": "^2.11.0",
"bits-ui": "^2.14.4",
"clsx": "^2.1.1",
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.0",
@@ -28,7 +28,7 @@
"svelte": "^5.41.0",
"svelte-check": "^4.3.3",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^3.1.1",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.14",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { Dialog } from './ui/dialog';
import DialogContent from './ui/dialog/dialog-content.svelte';
import DialogHeader from './ui/dialog/dialog-header.svelte';
import DialogTitle from './ui/dialog/dialog-title.svelte';
import Button from './ui/button/button.svelte';
import Spinner from './ui/spinner/spinner.svelte';
import { borrarUsuario } from '@/hooks/borrarUsuario';
import { invalidate } from '$app/navigation';
let cargando = $state(false);
let { usuario, open = $bindable() } = $props();
async function handleBorrar() {
if (!usuario) return;
await borrarUsuario(usuario.id);
open = false;
invalidate('admin:load');
}
</script>
<div transition:fade>
<Dialog {open} onOpenChange={() => (open = false)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Borrar Usuario</DialogTitle>
</DialogHeader>
<p>¿Está seguro que desea borrar al usuario {usuario?.displayName}?</p>
<div class="mt-4 flex justify-between">
<Button variant="destructive" onclick={handleBorrar} disabled={cargando}>
{#if cargando}
<Spinner />
{:else}
Borrar
{/if}
</Button>
<Button variant="secondary" onclick={() => (open = false)} disabled={cargando}>
Cancelar
</Button>
</div>
</DialogContent>
</Dialog>
</div>

View File

@@ -22,6 +22,17 @@
let image: File | null = $state(null);
let usu = $state({ displayName: data.displayName, bio: data.bio });
let contenido = $derived(() => {
let t = data.bio
.replaceAll('&', '')
.replaceAll('<', '')
.replaceAll('>', '')
.replaceAll('fetch', '')
.replaceAll('\n', '<br>');
return t;
});
async function cambiarFotoDePerfil() {
const input = document.createElement('input');
input.type = 'file';
@@ -92,7 +103,8 @@
</h1>
{#if usu.bio}
<p class="mt-4 rounded-4xl bg-accent p-4 text-center text-muted-foreground">
{usu.bio.replaceAll('<', '')}
{@html contenido()}
<!-- {usu.bio.replaceAll('<', '')} -->
<!-- {@html usu.bio.replaceAll('\n', '<br>')} -->
</p>
{/if}

View File

@@ -77,7 +77,12 @@
async function likeHandler() {
cargandoLike = true;
let { message, ok } = await likePost(post);
//para que se vea el spinner
let [{ message, ok }] = await Promise.all([
likePost(post),
new Promise((resolve) => setTimeout(resolve, 300))
]);
console.log(1);
if (ok) {
if (post.isLiked) {
post.likesCount--;
@@ -89,7 +94,9 @@
errorLike = true;
mensajeError = message;
}
console.log(1);
updatePostStore(post.id, post);
console.log(1);
cargandoLike = false;
}
</script>
@@ -99,12 +106,14 @@
<div class="flex flex-col">
<div class="flex items-center justify-between">
<div class="flex gap-3">
<a href={`/${post.authorName}`}>
<Avatar>
<AvatarImage src={post.authorImageUrl}></AvatarImage>
<AvatarFallback>{post.authorDisplayName[0].toUpperCase()}</AvatarFallback>
</Avatar>
</a>
{#if post.authorName !== '[deleted]'}
<a href={`/${post.authorName}`}>
<Avatar>
<AvatarImage src={post.authorImageUrl}></AvatarImage>
<AvatarFallback>{post.authorDisplayName[0].toUpperCase()}</AvatarFallback>
</Avatar>
</a>
{/if}
<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>
@@ -157,14 +166,18 @@
<div class="-mt-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<Button
variant="ghost"
disabled={!$sesionStore?.accessToken}
disabled={!$sesionStore?.accessToken || cargandoLike}
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 />
{#if cargandoLike}
<Spinner />
{:else}
<ThumbsUp />
{/if}
</Button>
<Button variant="ghost" class="flex items-center gap-2 rounded-full bg-accent p-3 text-lg">
<p>

View File

@@ -9,6 +9,8 @@
import Button from './ui/button/button.svelte';
import KeyIcon from '@lucide/svelte/icons/key';
import UserPen from '@lucide/svelte/icons/user-pen';
import Search from '@lucide/svelte/icons/search';
import Plus from '@lucide/svelte/icons/plus';
import { Tooltip } from './ui/tooltip';
import TooltipTrigger from './ui/tooltip/tooltip-trigger.svelte';
import TooltipContent from './ui/tooltip/tooltip-content.svelte';
@@ -19,6 +21,12 @@
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 BorrarUsuario from './BorrarUsuario.svelte';
import InputGroup from './ui/input-group/input-group.svelte';
import InputGroupAddon from './ui/input-group/input-group-addon.svelte';
import InputGroupInput from './ui/input-group/input-group-input.svelte';
import AgregarUsuario from './admin/AgregarUsuario.svelte';
interface Props {
usuarios: UserResponseDto[];
@@ -29,6 +37,9 @@
let open = $state(false);
let openModificarUsuario = $state(false);
let openBorrar = $state(false);
let usuarioBorrar: UserResponseDto | null = $state(null);
//si ponia contraseña en español quedaba muy largo el nombre
let usuarioCambioPass: UserResponseDto | null = $state(null);
@@ -84,12 +95,6 @@
return sortDirection === 'asc' ? '↑' : '↓';
}
$effect(() => {
if (!open) {
usuarioCambioPass = null;
}
});
function handleCambiarContraseña(usuario: UserResponseDto) {
open = true;
usuarioCambioPass = usuario;
@@ -99,15 +104,22 @@
openModificarUsuario = true;
usuarioModificar = usuario;
}
function handleBorrar(usuario: UserResponseDto) {
openBorrar = true;
usuarioBorrar = usuario;
}
let opencrearUsuario = $state(false);
// $inspect(usuarios);
</script>
<div class="mb-4">
<Input
type="text"
placeholder="Buscar usuario..."
bind:value={search}
class="w-full rounded border px-3 py-2"
/>
<div class="mb-4 flex gap-2">
<InputGroup>
<InputGroupAddon align="inline-start"><Search></Search></InputGroupAddon>
<InputGroupInput type="text" placeholder="Buscar usuario..." bind:value={search} />
</InputGroup>
<Button onclick={() =>opencrearUsuario = !opencrearUsuario} variant="secondary" class="bg-blue-500/20"><Plus /></Button>
</div>
<Table>
@@ -129,38 +141,63 @@
</TableRow>
</TableHeader>
<TableBody>
{#each usuariosFiltrados as usuario}
{#if usuariosFiltrados.length == 0}
<TableRow>
<TableCell
>@<a href={'/' + usuario.username}>
{usuario.username}
</a>
<TableCell colspan={5}>
<p class="text-center">No hay usuarios por el nombre de: {search}</p>
</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}
</TableRow>{:else}
{#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>
<Tooltip>
<TooltipTrigger>
<Button
disabled={usuario.isAdmin}
onclick={() => handleBorrar(usuario)}
variant="destructive"><Trash_2 /></Button
>
</TooltipTrigger>
<TooltipContent>
{#if usuario.isAdmin}
No se pueden eliminar usuarios Admin
{:else}
Eliminar Usuario
{/if}
</TooltipContent>
</Tooltip>
</TableCell>
</TableRow>
{/each}
{/if}
</TableBody>
</Table>
<BorrarUsuario bind:open={openBorrar} usuario={usuarioBorrar} />
<RecuperarContraseña bind:open usuario={usuarioCambioPass} />
<ModificarUsuario bind:open={openModificarUsuario} bind:usuario={usuarioModificar} />
<AgregarUsuario bind:open={opencrearUsuario} />

View File

@@ -0,0 +1,111 @@
<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 Spinner from '../ui/spinner/spinner.svelte';
import { register } from '@/hooks/register';
import type { RegisterDto } from '../../../types';
interface Prop {
open: boolean;
}
let { open = $bindable() }: Prop = $props();
let cargando = $state(false);
let error = $state('');
let dto = $state<RegisterDto>({
username: '',
email: '',
password: '',
displayName: ''
});
async function onsubmit(e: SubmitEvent) {
cargando = true;
error = '';
await register(e, dto, () => {
error = 'Error al registrar el usuario';
});
cargando = false;
}
</script>
<div transition:fade>
<Dialog
{open}
onOpenChange={() => {
open = !open;
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Agregar Usuario</DialogTitle>
</DialogHeader>
<form {onsubmit}>
<div class="flex flex-col gap-3">
<InputGroup>
<InputGroupInput disabled={cargando} bind:value={dto.username} />
<InputGroupAddon>Usuario</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput
type="email"
disabled={cargando}
bind:value={dto.email}
/>
<InputGroupAddon>Email</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput disabled={cargando} bind:value={dto.displayName} />
<InputGroupAddon>Nombre visible</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput
type="password"
disabled={cargando}
bind:value={dto.password}
/>
<InputGroupAddon>Contraseña</InputGroupAddon>
</InputGroup>
<hr class="my-2" />
<div class="flex justify-between">
<Button type="submit" disabled={cargando}>
{#if cargando}
<Spinner /> Creando...
{:else}
Crear
{/if}
</Button>
<Button
variant="secondary"
disabled={cargando}
onclick={() => (open = false)}
>
Cancelar
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
</div>
<div transition:fade>
<Dialog open={error !== ''} onOpenChange={() => (error = '')}>
<DialogContent>{error}</DialogContent>
</Dialog>
</div>

View File

@@ -4,15 +4,15 @@
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = 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 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
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 shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs",
destructive:
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
"bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs",
outline:
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
"bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import type { Command as CommandPrimitive, Dialog as DialogPrimitive } from "bits-ui";
import type { Snippet } from "svelte";
import Command from "./command.svelte";
import * as Dialog from "$lib/components/ui/dialog/index.js";
import type { WithoutChildrenOrChild } from "$lib/utils.js";
let {
open = $bindable(false),
ref = $bindable(null),
value = $bindable(""),
title = "Command Palette",
description = "Search for a command to run",
portalProps,
children,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.RootProps> &
WithoutChildrenOrChild<CommandPrimitive.RootProps> & {
portalProps?: DialogPrimitive.PortalProps;
children: Snippet;
title?: string;
description?: string;
} = $props();
</script>
<Dialog.Root bind:open {...restProps}>
<Dialog.Header class="sr-only">
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>{description}</Dialog.Description>
</Dialog.Header>
<Dialog.Content class="overflow-hidden p-0" {portalProps}>
<Command
class="**:data-[slot=command-input-wrapper]:h-12 [&_[data-command-group]]:px-2 [&_[data-command-group]:not([hidden])_~[data-command-group]]:pt-0 [&_[data-command-input-wrapper]_svg]:h-5 [&_[data-command-input-wrapper]_svg]:w-5 [&_[data-command-input]]:h-12 [&_[data-command-item]]:px-2 [&_[data-command-item]]:py-3 [&_[data-command-item]_svg]:h-5 [&_[data-command-item]_svg]:w-5"
{...restProps}
bind:value
bind:ref
{children}
/>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.EmptyProps = $props();
</script>
<CommandPrimitive.Empty
bind:ref
data-slot="command-empty"
class={cn("py-6 text-center text-sm", className)}
{...restProps}
/>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { Command as CommandPrimitive, useId } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
heading,
value,
...restProps
}: CommandPrimitive.GroupProps & {
heading?: string;
} = $props();
</script>
<CommandPrimitive.Group
bind:ref
data-slot="command-group"
class={cn("text-foreground overflow-hidden p-1", className)}
value={value ?? heading ?? `----${useId()}`}
{...restProps}
>
{#if heading}
<CommandPrimitive.GroupHeading
class="text-muted-foreground px-2 py-1.5 text-xs font-medium"
>
{heading}
</CommandPrimitive.GroupHeading>
{/if}
<CommandPrimitive.GroupItems {children} />
</CommandPrimitive.Group>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import SearchIcon from "@lucide/svelte/icons/search";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value = $bindable(""),
...restProps
}: CommandPrimitive.InputProps = $props();
</script>
<div class="flex h-9 items-center gap-2 border-b ps-3 pe-8" data-slot="command-input-wrapper">
<SearchIcon class="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
class={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
bind:ref
{...restProps}
bind:value
/>
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.ItemProps = $props();
</script>
<CommandPrimitive.Item
bind:ref
data-slot="command-item"
class={cn(
"aria-selected:bg-accent aria-selected:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.LinkItemProps = $props();
</script>
<CommandPrimitive.LinkItem
bind:ref
data-slot="command-item"
class={cn(
"aria-selected:bg-accent aria-selected:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.ListProps = $props();
</script>
<CommandPrimitive.List
bind:ref
data-slot="command-list"
class={cn("max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: CommandPrimitive.LoadingProps = $props();
</script>
<CommandPrimitive.Loading bind:ref {...restProps} />

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.SeparatorProps = $props();
</script>
<CommandPrimitive.Separator
bind:ref
data-slot="command-separator"
class={cn("bg-border -mx-1 h-px", className)}
{...restProps}
/>

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<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="command-shortcut"
class={cn("text-muted-foreground ms-auto text-xs tracking-widest", className)}
{...restProps}
>
{@render children?.()}
</span>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Command as CommandPrimitive } from "bits-ui";
export type CommandRootApi = CommandPrimitive.Root;
let {
api = $bindable(null),
ref = $bindable(null),
value = $bindable(""),
class: className,
...restProps
}: CommandPrimitive.RootProps & {
api?: CommandRootApi | null;
} = $props();
</script>
<CommandPrimitive.Root
bind:this={api}
bind:value
bind:ref
data-slot="command"
class={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,37 @@
import Root from "./command.svelte";
import Loading from "./command-loading.svelte";
import Dialog from "./command-dialog.svelte";
import Empty from "./command-empty.svelte";
import Group from "./command-group.svelte";
import Item from "./command-item.svelte";
import Input from "./command-input.svelte";
import List from "./command-list.svelte";
import Separator from "./command-separator.svelte";
import Shortcut from "./command-shortcut.svelte";
import LinkItem from "./command-link-item.svelte";
export {
Root,
Dialog,
Empty,
Group,
Item,
LinkItem,
Input,
List,
Separator,
Shortcut,
Loading,
//
Root as Command,
Dialog as CommandDialog,
Empty as CommandEmpty,
Group as CommandGroup,
Item as CommandItem,
LinkItem as CommandLinkItem,
Input as CommandInput,
List as CommandList,
Separator as CommandSeparator,
Shortcut as CommandShortcut,
Loading as CommandLoading,
};

View File

@@ -1,9 +1,11 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import DialogPortal from "./dialog-portal.svelte";
import XIcon from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte";
import * as Dialog from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
@@ -13,19 +15,19 @@
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: DialogPrimitive.PortalProps;
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
children: Snippet;
showCloseButton?: boolean;
} = $props();
</script>
<Dialog.Portal {...portalProps}>
<DialogPortal {...portalProps}>
<Dialog.Overlay />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
class={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed start-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...restProps}
@@ -33,11 +35,11 @@
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close
class="ring-offset-background focus:ring-ring rounded-xs focus:outline-hidden absolute end-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
class="ring-offset-background focus:ring-ring absolute end-4 top-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
</Dialog.Portal>
</DialogPortal>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ...restProps }: DialogPrimitive.PortalProps = $props();
</script>
<DialogPrimitive.Portal {...restProps} />

View File

@@ -12,6 +12,6 @@
<DialogPrimitive.Title
bind:ref
data-slot="dialog-title"
class={cn("text-lg font-semibold leading-none", className)}
class={cn("text-lg leading-none font-semibold", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: DialogPrimitive.RootProps = $props();
</script>
<DialogPrimitive.Root bind:open {...restProps} />

View File

@@ -1,5 +1,5 @@
import { Dialog as DialogPrimitive } from "bits-ui";
import Root from "./dialog.svelte";
import Portal from "./dialog-portal.svelte";
import Title from "./dialog-title.svelte";
import Footer from "./dialog-footer.svelte";
import Header from "./dialog-header.svelte";
@@ -9,9 +9,6 @@ import Description from "./dialog-description.svelte";
import Trigger from "./dialog-trigger.svelte";
import Close from "./dialog-close.svelte";
const Root = DialogPrimitive.Root;
const Portal = DialogPrimitive.Portal;
export {
Root,
Title,

View File

@@ -0,0 +1,126 @@
<script lang="ts">
import CommandDialog from '@/components/ui/command/command-dialog.svelte';
import CommandGroup from '@/components/ui/command/command-group.svelte';
import CommandInput from '@/components/ui/command/command-input.svelte';
import CommandItem from '@/components/ui/command/command-item.svelte';
import CommandList from '@/components/ui/command/command-list.svelte';
import InputGroupAddon from '@/components/ui/input-group/input-group-addon.svelte';
import InputGroupInput from '@/components/ui/input-group/input-group-input.svelte';
import InputGroup from '@/components/ui/input-group/input-group.svelte';
import Kbd from '@/components/ui/kbd/kbd.svelte';
import Search from '@lucide/svelte/icons/search';
import User from '@lucide/svelte/icons/user';
import Hash from '@lucide/svelte/icons/hash';
import Spinner from '@/components/ui/spinner/spinner.svelte';
import { busquedaUsuarios } from '@/hooks/busquedaUsuarios';
import type { UserResponseDto } from '../../types';
import Avatar from '@/components/ui/avatar/avatar.svelte';
import AvatarImage from '@/components/ui/avatar/avatar-image.svelte';
import Label from '@/components/ui/label/label.svelte';
import { resolve } from '$app/paths';
import { busquedaHashtags } from '@/hooks/busquedaHashtags';
let search: string = $state('');
let open = $state(false);
let usuarios: Promise<UserResponseDto[]> | undefined = $state();
let hashtags: Promise<any[]> | undefined = $state();
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
open = !open;
}
}
// $inspect(usuarios, loading);
let timeoutId: number | undefined;
function buscar() {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(async () => {
usuarios = busquedaUsuarios(search);
hashtags = busquedaHashtags(search);
}, 200);
return () => {
if (timeoutId) clearTimeout(timeoutId);
};
}
</script>
<svelte:document onkeydown={handleKeydown} />
<InputGroup class="group">
<InputGroupAddon align="inline-start"><Search /></InputGroupAddon>
<InputGroupInput
type="text"
placeholder="Buscar Usuario o Hashtag"
bind:value={search}
oninput={() => (open = true)}
class="max-w-0 transition-[max-width] duration-1000 ease-out group-hover:max-w-xs focus:max-w-xs"
/>
<InputGroupAddon align="inline-end" class="flex gap-0">
<Kbd>Ctrl</Kbd>+<Kbd>K</Kbd>
</InputGroupAddon>
</InputGroup>
<CommandDialog bind:open>
<CommandInput
type="text"
placeholder="Buscar Usuario o Hashtag"
bind:value={search}
oninput={buscar}
/>
{#if search}
<ul class="m-2 space-y-2">
<Label class="ms-3 text-sm font-medium text-muted-foreground">Usuarios</Label>
{#await usuarios}
<li>
<Spinner class="ms-2" />
</li>
{:then usuariosr}
{#if usuariosr?.length == 0}
<li><p class="ms-2 text-sm">No se encontraron Usuarios</p></li>
{/if}
{#each usuariosr as usuario}
<li class=" w-full cursor-pointer rounded-md hover:bg-accent">
<a
href={resolve('/[perfil]', { perfil: usuario.username })}
class="flex items-center gap-2 px-3 py-2"
>
{#if usuario.imageUrl}
<img src={usuario.imageUrl} alt={usuario.username} class="h-5 w-5 rounded-full" />
{:else}
<User class="h-5 w-5" />
{/if}
<span>{usuario.username}</span>
</a>
</li>
{/each}
{/await}
<Label class="ms-3 text-sm font-medium text-muted-foreground">Hashtag</Label>
{#await hashtags}
<li>
<Spinner class="ms-2" />
</li>
{:then hashtagsResult}
{#if hashtagsResult?.length == 0}
<li>
<p class="ms-2 text-sm">No se encontraron Hashtags</p>
</li>
{/if}
{#each hashtagsResult as hashtag}
<a href={resolve('/htag/[htag]', { htag: hashtag })}>
<li class="flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 hover:bg-accent">
<Hash class="h-5 w-5" />
<span>{hashtag}</span>
</li>
</a>
{/each}
{/await}
</ul>
{/if}
</CommandDialog>

View File

@@ -9,6 +9,9 @@
import { apiBase } from '@/stores/url';
import { goto } from '$app/navigation';
import AvatarButton from './AvatarButton.svelte';
import Busqueda from './Busqueda.svelte';
import Avatar from '@/components/ui/avatar/avatar.svelte';
import AvatarImage from '@/components/ui/avatar/avatar-image.svelte';
let menuOpen = $state(false);
const toggleMenu = () => (menuOpen = !menuOpen);
@@ -48,7 +51,11 @@
<div class="mx-4 ms-2 flex h-12 items-center justify-between">
<div class="flex items-center">
<a href="/" class="mr-6 flex items-center space-x-2">
<p class="leading-7 not-first:mt-6">Mini-X</p>
<Avatar
class="transform rounded-sm! transition-transform duration-300 ease-in-out hover:scale-130 hover:rotate-12"
>
<AvatarImage src="/x.png" alt="minix" />
</Avatar>
</a>
<nav class="me-2 items-center space-x-6 text-sm font-medium md:flex">
<ButtonTheme />
@@ -56,7 +63,8 @@
</div>
<!-- Desktop menu -->
<div class="flex items-center justify-end md:flex">
<div class="flex items-center justify-end gap-2 md:flex">
<Busqueda></Busqueda>
{#if showCerrarSesion}
{#if $sesionStore !== null}
<AvatarButton></AvatarButton>

View File

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

View File

@@ -0,0 +1,18 @@
import { apiBase } from '@/stores/url';
import { get } from 'svelte/store';
export async function busquedaHashtags(htag: string) {
if (!htag) return null;
try {
const req = await fetch(`${get(apiBase)}/api/htag?q=${htag}`, {
method: 'GET'
});
if (req.ok) {
let data = await req.json();
return data;
}
return [];
} catch {
return null;
}
}

View File

@@ -2,10 +2,11 @@ import { apiBase } from '@/stores/url';
import { sesionStore } from '@/stores/usuario';
import { get } from 'svelte/store';
export async function obtenerCantidadDeUsosdeHtag(htag: string) {
export async function obtenerCantidadDeUsosdeHtag(htag: string, fetch2?: Function) {
if (!htag) return null;
const fetchFn = fetch2 || fetch;
try {
const req = await fetch(`${get(apiBase)}/api/posts/hashtag/${htag}`, {
const req = await fetchFn(`${get(apiBase)}/api/posts/hashtag/${htag}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${get(sesionStore)?.accessToken}`

View File

@@ -23,9 +23,11 @@
</CardHeader>
<CardContent>
{#if page.data.usuarios.length === 0}
<CardDescription>No hay posts que mostar</CardDescription>
<CardDescription>No hay usuarios que mostar</CardDescription>
{:else}
<TablaUsuarios bind:usuarios></TablaUsuarios>
{#key page.data.usuarios}
<TablaUsuarios bind:usuarios></TablaUsuarios>
{/key}
{/if}
</CardContent>
</Card>

View File

@@ -6,7 +6,8 @@ import type { UserResponseDto } from '../../../types.js';
export const ssr = false;
export async function load({}) {
export async function load({ depends, fetch }) {
depends('admin:load');
const response = await fetch(get(apiBase) + '/api/admin/users', {
method: 'GET',
headers: {

View File

@@ -5,11 +5,12 @@
import Card from '@/components/ui/card/card.svelte';
import type { Post } from '../../../types.js';
import ModalEditar from '../../[perfil]/modalEditar.svelte';
import { fade } from 'svelte/transition';
import { updatePostStore } from '@/stores/posts';
import { fade, slide } from 'svelte/transition';
import { posts, setPosts, updatePostStore } from '@/stores/posts';
import { updatePost } from '@/hooks/updatePost';
import Separator from '@/components/ui/separator/separator.svelte';
import { page } from '$app/state';
import { onMount } from 'svelte';
interface props {
data: {
@@ -23,11 +24,13 @@
let { data }: props = $props();
let posts = $state(data.posts.response);
//seteo los posts en el store
$effect(() => setPosts(data.posts.response));
let postAModificar: Post | null = $state(null);
let postsfiltro = $derived(
posts.filter((x) => {
$posts?.filter((x) => {
const regex = new RegExp(`#${data.htag}\\b`, 'gm');
return regex.test(x.content);
})
@@ -68,8 +71,10 @@
<hr class="my-2" />
<div class="mt-1 flex flex-col gap-3">
{#each postsfiltro as post}
<PostCard {post} bind:postAModificar />
{#each postsfiltro as post (post.id)}
<div transition:slide>
<PostCard {post} bind:postAModificar />
</div>
{/each}
</div>
</div>

View File

@@ -1,10 +1,9 @@
import { obtenerCantidadDeUsosdeHtag } from '@/hooks/obtenerCantidadDeUsosdeHtag.js';
import { error } from '@sveltejs/kit';
export async function load({ params }) {
export async function load({ params, fetch }) {
let { htag } = params;
const posts = await obtenerCantidadDeUsosdeHtag(htag);
const posts = await obtenerCantidadDeUsosdeHtag(htag, fetch);
// if (cantidad == null || posts.lenght == 0) return error(404, 'no existe el #(hashtag)');
return { htag, posts };
}

1
src/types.d.ts vendored
View File

@@ -90,6 +90,7 @@ export interface UserResponseDto {
followingCount: number;
createdAt: string;
postsCount: number;
isAdmin?: bool;
}
export interface UsersResponseDto {
response: {