Merge pull request #5 from emailerfacu-spec/dev

snapshot 25/11/2025
This commit is contained in:
emailerfacu-spec
2025-11-25 19:18:22 -03:00
committed by GitHub
30 changed files with 899 additions and 130 deletions

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import Ellipsis from '@lucide/svelte/icons/ellipsis';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Pen from '@lucide/svelte/icons/pen';
import type { Post } from '../../types';
import Button from './ui/button/button.svelte';
import { Content } from './ui/card';
import CardFooter from './ui/card/card-footer.svelte';
import CardHeader from './ui/card/card-header.svelte';
import Card from './ui/card/card.svelte';
import { DropdownMenu } from './ui/dropdown-menu';
import DropdownMenuTrigger from './ui/dropdown-menu/dropdown-menu-trigger.svelte';
import DropdownMenuContent from './ui/dropdown-menu/dropdown-menu-content.svelte';
import DropdownMenuGroup from './ui/dropdown-menu/dropdown-menu-group.svelte';
import DropdownMenuLabel from './ui/dropdown-menu/dropdown-menu-label.svelte';
import DropdownMenuSeparator from './ui/dropdown-menu/dropdown-menu-separator.svelte';
import DropdownMenuItem from './ui/dropdown-menu/dropdown-menu-item.svelte';
interface postProp {
post: Post;
}
let { post }: postProp = $props();
</script>
<Card>
<CardHeader>
<div class="flex flex-col">
<div class="flex items-center justify-between">
<span class="text-sm font-medium">{post.authorId}</span>
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="ghost" class="rounded-full"><Ellipsis /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuGroup>
<DropdownMenuLabel>Opciones Publicación</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem><Pen /> Editar</DropdownMenuItem>
<DropdownMenuItem onclick={() => {}}>
<Trash2 class="text-red-500" />
<p class="text-red-500">Borrar</p>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardHeader>
<Content>
<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>
<span class="text-xs text-muted-foreground"
>{post.createdAt.replace('T', ' ').split('.')[0]}</span
>
{#if post.isEdited}
<span>Editado</span>
{/if}
</div>
</CardFooter>
</Card>

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import InputGroupAddon from './ui/input-group/input-group-addon.svelte';
import InputGroupButton from './ui/input-group/input-group-button.svelte';
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 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';
let mensaje = $state('');
let cargando = $state(false);
let mostrarError = $state('');
async function handlePost(e: Event) {
e.preventDefault();
try {
const data: CreatePostDto = {
content: mensaje,
imageUrl: null,
parentPostId: null
};
const req = fetch($apiBase + '/api/posts', {
method: 'POST',
//credentials: 'include',
headers: {
'Content-Type': 'application/json',
"Authorization": `Bearer ${$sesionStore?.accessToken}`
},
body: JSON.stringify(data)
});
cargando = true;
const res = await req;
if (res.ok) {
mensaje = '';
const post = await res.json();
addPost(post);
return;
}
mostrarError = 'No se pudo crear el post.';
} catch {
mostrarError = 'Fallo al alcanzar el servidor';
} finally {
cargando = false;
}
}
</script>
<form onsubmit={(e: Event) => handlePost(e)}>
<InputGroup>
<InputGroupTextarea bind:value={mensaje} maxlength="280" placeholder="Alguna novedad?"
></InputGroupTextarea>
<InputGroupAddon align="block-end" class="bg-">
<div class="flex w-full justify-between">
<Kbd class="text-sm leading-none font-medium italic">
<p class:text-red-500={mensaje.length > 239}>
{mensaje.length}
</p>
/ 280
</Kbd>
<InputGroupButton
variant="default"
type="submit"
class="transform rounded-full transition-transform ease-in hover:scale-120"
size="xs"
>
<p>Publicar</p>
<ArrowUpIcon />
</InputGroupButton>
</div>
</InputGroupAddon>
</InputGroup>
</form>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.FallbackProps = $props();
</script>
<AvatarPrimitive.Fallback
bind:ref
data-slot="avatar-fallback"
class={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
{...restProps}
/>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.ImageProps = $props();
</script>
<AvatarPrimitive.Image
bind:ref
data-slot="avatar-image"
class={cn("aspect-square size-full", className)}
{...restProps}
/>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
loadingStatus = $bindable("loading"),
class: className,
...restProps
}: AvatarPrimitive.RootProps = $props();
</script>
<AvatarPrimitive.Root
bind:ref
bind:loadingStatus
data-slot="avatar"
class={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
{...restProps}
/>

View File

@@ -0,0 +1,13 @@
import Root from "./avatar.svelte";
import Image from "./avatar-image.svelte";
import Fallback from "./avatar-fallback.svelte";
export {
Root,
Image,
Fallback,
//
Root as Avatar,
Image as AvatarImage,
Fallback as AvatarFallback,
};

View File

@@ -0,0 +1,22 @@
import Root from "./input-group.svelte";
import Addon from "./input-group-addon.svelte";
import Button from "./input-group-button.svelte";
import Input from "./input-group-input.svelte";
import Text from "./input-group-text.svelte";
import Textarea from "./input-group-textarea.svelte";
export {
Root,
Addon,
Button,
Input,
Text,
Textarea,
//
Root as InputGroup,
Addon as InputGroupAddon,
Button as InputGroupButton,
Input as InputGroupInput,
Text as InputGroupText,
Textarea as InputGroupTextarea,
};

View File

@@ -0,0 +1,55 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const inputGroupAddonVariants = tv({
base: "text-muted-foreground flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
variants: {
align: {
"inline-start":
"order-first ps-3 has-[>button]:ms-[-0.45rem] has-[>kbd]:ms-[-0.35rem]",
"inline-end":
"order-last pe-3 has-[>button]:me-[-0.45rem] has-[>kbd]:me-[-0.35rem]",
"block-start":
"[.border-b]:pb-3 order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"[.border-t]:pt-3 order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5",
},
},
defaultVariants: {
align: "inline-start",
},
});
export type InputGroupAddonAlign = VariantProps<typeof inputGroupAddonVariants>["align"];
</script>
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
align = "inline-start",
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
align?: InputGroupAddonAlign;
} = $props();
</script>
<div
bind:this={ref}
role="group"
data-slot="input-group-addon"
data-align={align}
class={cn(inputGroupAddonVariants({ align }), className)}
onclick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return;
}
e.currentTarget.parentElement?.querySelector("input")?.focus();
}}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,49 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
const inputGroupButtonVariants = tv({
base: "flex items-center gap-2 text-sm shadow-none",
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
"icon-xs": "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
});
export type InputGroupButtonSize = VariantProps<typeof inputGroupButtonVariants>["size"];
</script>
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import { Button } from "$lib/components/ui/button/index.js";
let {
ref = $bindable(null),
class: className,
children,
type = "button",
variant = "ghost",
size = "xs",
...restProps
}: Omit<ComponentProps<typeof Button>, "href" | "size"> & {
size?: InputGroupButtonSize;
} = $props();
</script>
<Button
bind:ref
{type}
data-size={size}
{variant}
class={cn(inputGroupButtonVariants({ size }), className)}
{...restProps}
>
{@render children?.()}
</Button>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import { Input } from "$lib/components/ui/input/index.js";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
...props
}: ComponentProps<typeof Input> = $props();
</script>
<Input
bind:ref
data-slot="input-group-control"
class={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
bind:value
{...props}
/>

View File

@@ -0,0 +1,22 @@
<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}
class={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className
)}
{...restProps}
>
{@render children?.()}
</span>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
...props
}: ComponentProps<typeof Textarea> = $props();
</script>
<Textarea
bind:ref
data-slot="input-group-control"
class={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
bind:value
{...props}
/>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...props
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="input-group"
role="group"
class={cn(
"group/input-group border-input dark:bg-input/30 shadow-xs relative flex w-full items-center rounded-md border outline-none transition-[color,box-shadow]",
"h-9 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:ps-2",
"has-[>[data-align=inline-end]]:[&>input]:pe-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className
)}
{...props}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,10 @@
import Root from "./kbd.svelte";
import Group from "./kbd-group.svelte";
export {
Root,
Group,
//
Root as Kbd,
Group as KbdGroup,
};

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>
<kbd
bind:this={ref}
data-slot="kbd-group"
class={cn("inline-flex items-center gap-1", className)}
{...restProps}
>
{@render children?.()}
</kbd>

View File

@@ -0,0 +1,25 @@
<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>
<kbd
bind:this={ref}
data-slot="kbd"
class={cn(
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 select-none items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium",
"[&_svg:not([class*='size-'])]:size-3",
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
className
)}
{...restProps}
>
{@render children?.()}
</kbd>

View File

@@ -0,0 +1 @@
export { default as Spinner } from "./spinner.svelte";

View File

@@ -0,0 +1,14 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import Loader2Icon from "@lucide/svelte/icons/loader-2";
import type { ComponentProps } from "svelte";
let { class: className, ...restProps }: ComponentProps<typeof Loader2Icon> = $props();
</script>
<Loader2Icon
role="status"
aria-label="Loading"
class={cn("size-4 animate-spin", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
import Root from "./textarea.svelte";
export {
Root,
//
Root as Textarea,
};

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
import type { HTMLTextareaAttributes } from "svelte/elements";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
"data-slot": dataSlot = "textarea",
...restProps
}: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
</script>
<textarea
bind:this={ref}
data-slot={dataSlot}
class={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 field-sizing-content shadow-xs flex min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
bind:value
{...restProps}
></textarea>

View File

@@ -0,0 +1,35 @@
<script>
import { goto } from '$app/navigation';
import AvatarFallback from '@/components/ui/avatar/avatar-fallback.svelte';
import AvatarImage from '@/components/ui/avatar/avatar-image.svelte';
import Avatar from '@/components/ui/avatar/avatar.svelte';
import { DropdownMenu } from '@/components/ui/dropdown-menu';
import DropdownMenuContent from '@/components/ui/dropdown-menu/dropdown-menu-content.svelte';
import DropdownMenuGroup from '@/components/ui/dropdown-menu/dropdown-menu-group.svelte';
import DropdownMenuItem from '@/components/ui/dropdown-menu/dropdown-menu-item.svelte';
import DropdownMenuSeparator from '@/components/ui/dropdown-menu/dropdown-menu-separator.svelte';
import DropdownMenuTrigger from '@/components/ui/dropdown-menu/dropdown-menu-trigger.svelte';
import { logout } from '@/hooks/logout';
import { sesionStore } from '@/stores/usuario';
let { menuOpen = false } = $props();
</script>
<DropdownMenu>
<DropdownMenuTrigger>
<Avatar class="border-2 border-zinc-950">
<AvatarImage src={$sesionStore?.url} />
<AvatarFallback>{$sesionStore?.displayName[0]}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuGroup>
<DropdownMenuItem onclick={() => goto('/' + $sesionStore?.username)}
>Mi Perfil</DropdownMenuItem
>
<DropdownMenuSeparator />
<DropdownMenuItem onclick={async () => await logout(menuOpen)}>Cerrar Sesion</DropdownMenuItem
>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -7,42 +7,41 @@
import { sesionStore } from '@/stores/usuario';
import { onMount } from 'svelte';
import { apiBase } from '@/stores/url';
import { goto } from '$app/navigation';
import AvatarButton from './AvatarButton.svelte';
let menuOpen = $state(false);
const toggleMenu = () => (menuOpen = !menuOpen);
let showCerrarSesion = $state(false);
let showCerrarSesion = $state(false);
onMount(()=>{
sesionStore.subscribe((value)=>{
showCerrarSesion = !!value?.accessToken;
})
onMount(() => {
sesionStore.subscribe((value) => {
showCerrarSesion = !!value?.accessToken;
});
});
});
async function cerrarSesion(){
try{
const req = await fetch($apiBase+"/api/auth/logout", {
method: 'POST',
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${$sesionStore?.accessToken}`
},
credentials: "include"
});
if(req.ok){
sesionStore.reset();
menuOpen = false;
}
}catch{
console.log("fallo el lougout")
} finally{
sesionStore.reset();
}
}
async function cerrarSesion() {
try {
const req = await fetch($apiBase + '/api/auth/logout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${$sesionStore?.accessToken}`
},
credentials: 'include'
});
if (req.ok) {
sesionStore.reset();
menuOpen = false;
}
} catch {
console.log('fallo el lougout');
} finally {
sesionStore.reset();
goto('/');
}
}
</script>
<header class="border-b bg-background/95 backdrop-blur">
@@ -51,31 +50,38 @@
<a href="/" class="mr-6 flex items-center space-x-2">
<p class="leading-7 not-first:mt-6">Mini-X</p>
</a>
<nav class="items-center space-x-6 text-sm font-medium md:flex">
<nav class="me-2 items-center space-x-6 text-sm font-medium md:flex">
<ButtonTheme />
</nav>
<div class="md:hidden">
{#if $sesionStore !== null}
<AvatarButton></AvatarButton>
{/if}
</div>
</div>
<!-- Desktop menu -->
<div class="hidden flex-1 items-center justify-end md:flex">
<ButtonGroup>
{#if showCerrarSesion}
<Button onclick={cerrarSesion}> Cerrar Sesion
</Button>
{:else}
<Button
variant={page.url.pathname !== '/login' ? 'outline' : 'secondary'}
href="/login"
class="text-foreground/60 transition-colors hover:text-foreground/80"
>Iniciar Sesion
</Button>
<Button
variant={page.url.pathname !== '/register' ? 'outline' : 'secondary'}
href="/register"
class="text-foreground/60 transition-colors hover:text-foreground/80">Registrarse
</Button>
{/if}
</ButtonGroup>
{#if showCerrarSesion}
{#if $sesionStore !== null}
<AvatarButton></AvatarButton>
{/if}
{:else}
<ButtonGroup>
<Button
variant={page.url.pathname !== '/login' ? 'outline' : 'secondary'}
href="/login"
class="text-foreground/60 transition-colors hover:text-foreground/80"
>Iniciar Sesion
</Button>
<Button
variant={page.url.pathname !== '/register' ? 'outline' : 'secondary'}
href="/register"
class="text-foreground/60 transition-colors hover:text-foreground/80"
>Registrarse
</Button>
</ButtonGroup>
{/if}
</div>
<!-- Mobile menu button -->
@@ -105,25 +111,25 @@
<!-- Mobile menu -->
{#if menuOpen}
<div class="md:hidden" transition:slide>
<div class="space-y-1 border-t bg-background/95 px-2 pt-2 pb-3">
{#if showCerrarSesion}
<Button onclick={cerrarSesion}> Cerrar Sesion
</Button>
{:else}
<Button
variant={page.url.pathname !== '/login' ? 'outline' : 'secondary'}
href="/login"
class="mb-2 w-full justify-start text-foreground/60 transition-colors hover:text-foreground/80"
onclick={() => (menuOpen = false)}>Iniciar Sesion</Button
>
<Button
variant={page.url.pathname !== '/register' ? 'outline' : 'secondary'}
href="/register"
class="w-full justify-start text-foreground/60 transition-colors hover:text-foreground/80"
onclick={() => (menuOpen = false)}>Registrarse
</Button>
{/if}
</div>
<div class="space-y-1 border-t bg-background/95 px-2 pt-2 pb-3">
<!---
{#if showCerrarSesion}{:else}
-->
<Button
variant={page.url.pathname !== '/login' ? 'outline' : 'secondary'}
href="/login"
class="mb-2 w-full justify-start text-foreground/60 transition-colors hover:text-foreground/80"
onclick={() => (menuOpen = false)}>Iniciar Sesion</Button
>
<Button
variant={page.url.pathname !== '/register' ? 'outline' : 'secondary'}
href="/register"
class="w-full justify-start text-foreground/60 transition-colors hover:text-foreground/80"
onclick={() => (menuOpen = false)}
>Registrarse
</Button>
<!-- {/if} -->
</div>
</div>
{/if}
</header>

View File

25
src/lib/hooks/logout.ts Normal file
View File

@@ -0,0 +1,25 @@
import { goto } from '$app/navigation';
import { apiBase } from '@/stores/url';
import { sesionStore } from '@/stores/usuario';
export async function logout(menuOpen: boolean) {
try {
const req = await fetch($apiBase + '/api/auth/logout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${$sesionStore.accessToken}`
},
credentials: 'include'
});
if (req.ok) {
sesionStore.reset();
menuOpen = false;
}
} catch {
console.log('fallo el lougout');
} finally {
sesionStore.reset();
goto('/');
}
}

View File

22
src/lib/stores/posts.ts Normal file
View File

@@ -0,0 +1,22 @@
import { writable } from 'svelte/store';
import type { Post } from '../../types';
export const posts = writable<Post[]>([]);
export const setPosts = (newPosts: Post[]) => {
posts.set(newPosts);
};
export const addPost = (post: Post) => {
posts.update((currentPosts) => [post, ...currentPosts]);
};
export const updatePost = (postId: string, updatedData: Partial<Post>) => {
posts.update((currentPosts) =>
currentPosts.map((post) => (post._id === postId ? { ...post, ...updatedData } : post))
);
};
export const removePost = (postId: string) => {
posts.update((currentPosts) => currentPosts.filter((post) => post._id !== postId));
};

View File

@@ -1,7 +1,18 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
import type { Sesion } from '../../types';
import { apiBase } from '@/stores/url';
export const currentSesion = writable<Sesion| null>(null);
const { subscribe } = apiBase;
let baseUrl: string = '';
subscribe((value) => {
baseUrl = value;
})();
const initialValue = browser ? JSON.parse(localStorage.getItem('sesion') || 'null') : null;
export const currentSesion = writable<Sesion | null>(initialValue);
export const sesionStore = {
subscribe: currentSesion.subscribe,
@@ -9,3 +20,40 @@ export const sesionStore = {
update: currentSesion.update,
reset: () => currentSesion.set(null)
};
if (browser) {
currentSesion.subscribe((value) => {
localStorage.setItem('sesion', JSON.stringify(value));
});
}
if (browser) {
const refreshAccessToken = async () => {
try {
const response = await fetch(baseUrl + '/api/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
currentSesion.update((sesion) => {
if (sesion) {
return { ...sesion, accessToken: data.accessToken };
}
return sesion;
});
} else {
console.error('Error refreshing token:', response.statusText);
currentSesion.set(null);
}
} catch (error) {
console.error('Error refreshing token:', error);
currentSesion.set(null);
}
};
setInterval(refreshAccessToken, 10 * 60 * 1000);
}

View File

@@ -1,66 +1,52 @@
<script lang="ts">
import Card from '@/components/ui/card/card.svelte';
import type { Post } from '../types';
import { Content } from '@/components/ui/card';
import { apiBase } from '@/stores/url';
import { apiBase } from '@/stores/url';
import { sesionStore } from '@/stores/usuario';
import CrearPost from '@/components/crear-post.svelte';
import { posts, setPosts } from '@/stores/posts';
import PostCard from '@/components/PostCard.svelte';
$effect(()=>{
getPosts();
});
$effect(() => {
(async () => {
setPosts(await getPosts());
})();
});
let posts: Post[] = $state([]);
async function getPosts() {
const { subscribe } = apiBase;
let baseUrl: string = '';
async function getPosts() {
const { subscribe } = apiBase;
let baseUrl: string = '';
subscribe((value) => {
baseUrl = value;
})();
const req = await fetch(`${baseUrl}/api/posts/timeline?pageSize=3`);
if (req.ok){
posts = await req.json();
}
}
subscribe((value) => {
baseUrl = value;
})();
const req = await fetch(`${baseUrl}/timeline?pageSize=20`);
if (req.ok) {
return await req.json();
}
}
</script>
<div class="flex min-h-fit w-full items-center justify-center p-6 md:p-10">
<div class="w-full max-w-sm">
{#if posts.length <= 0}
<Card>
<Content>
<p class=" text-center leading-7 not-first:mt-6">No hay Posts que mostrar</p>
</Content>
</Card>
{:else}
{#each posts as post}
<div class="w-full max-w-2xl">
<div class="flex flex-col gap-2">
{#if $sesionStore !== null}
<CrearPost />
{/if}
<hr />
{#if $posts.length <= 0}
<Card>
<Content>
<div class="flex flex-col space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm font-medium">{post.authorId}</span>
<span class="text-xs text-muted-foreground"
>{post.createdAt.toLocaleDateString()}</span
>
</div>
<p class="text-sm">{post.content}</p>
{#if post.imageUrl}
<img src={post.imageUrl} alt="Post" class="mt-2 rounded-md" />
{/if}
<div class="flex items-center justify-between pt-2 text-xs text-muted-foreground">
<span>{post.likesCount} likes</span>
<span>{post.repliesCount} replies</span>
{#if post.isEdited}
<span>Editado</span>
{/if}
</div>
</div>
<p class=" text-center leading-7 not-first:mt-6">No hay Posts que mostrar</p>
</Content>
</Card>
{/each}
{/if}
{:else}
{#each $posts as post}
<PostCard {post} />
{/each}
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,92 @@
<script lang="ts">
import { apiBase } from '@/stores/url';
import Ban from '@lucide/svelte/icons/ban';
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';
import AvatarFallback from '@/components/ui/avatar/avatar-fallback.svelte';
import { CardContent } from '@/components/ui/card';
import type { Post } from '../../types.js';
import Spinner from '@/components/ui/spinner/spinner.svelte';
import { fade, slide } from 'svelte/transition';
let { params } = $props();
let posts: Post[] = $state([]);
let cargando = $state(true);
let mensajeError = $state('');
const { subscribe } = apiBase;
let baseUrl: string = '';
subscribe((value) => {
baseUrl = value;
})();
$effect(() => {
obtenerPosts();
});
async function obtenerPosts() {
try {
const req = await fetch(baseUrl + '/api/posts/user/' + params.perfil, {
method: 'GET'
});
if (req.ok) {
posts = await req.json();
return;
}
mensajeError = 'Fallo al obtener los datos';
} catch {
mensajeError = 'No se alcanzo el servidor';
} finally {
cargando = false;
}
}
</script>
<div class="flex min-h-fit w-full items-center justify-center p-6 md:p-10">
<div class="w-full max-w-2xl">
<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"
>
{'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>
<hr class="mb-8" />
{#if cargando}
<div out:slide>
<Card>
<CardContent class="flex w-full flex-col items-center justify-center">
<Spinner class="size-9" />
<p class="leading-7 not-first:mt-6">Cargando</p>
</CardContent>
</Card>
</div>
{:else if mensajeError !== ''}
<div in:fade>
<Card class="border-red-500">
<CardContent class="flex w-full flex-col items-center justify-center">
<Ban class="scale-120 text-red-500"></Ban>
<p class="mt-2 text-lg leading-7 text-red-500">
{mensajeError}
</p>
</CardContent>
</Card>
</div>
{/if}
</div>
</div>

30
src/types.d.ts vendored
View File

@@ -6,12 +6,13 @@ export interface Post {
parentPostId?: string;
likesCount: number;
repliesCount: number;
createdAt: Date;
createdAt: string;
updatedAt?: Date;
isEdited: boolean;
visibility: string;
hashtags?: string[];
}
export interface User {
_id: string;
displayName: string;
@@ -27,20 +28,27 @@ export interface User {
}
export interface Sesion {
accessToken:string?;
message:string;
url:string;
displayname:string;
accessToken: string?;
message: string;
url: string;
displayName: string;
username: string;
}
export interface LoginDto {
username: string?;
password: string?;
username: string?;
password: string?;
}
export interface RegisterDto {
username: string?;
email: string?;
password: string?;
displayName: string?;
username: string?;
email: string?;
password: string?;
displayName: string?;
}
export interface CreatePostDto {
content: string;
imageUrl: string?;
parentPostId: string?;
}