añadido componente para crear posts y modificado menu auth

This commit is contained in:
2025-11-23 17:42:52 -03:00
parent e677e83f27
commit 3fbacce3fe
24 changed files with 618 additions and 122 deletions

View File

@@ -0,0 +1,34 @@
<script>
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';
let mensaje = $state('');
</script>
<InputGroup>
<InputGroupTextarea bind:value={mensaje} maxlength="255" placeholder="Alguna novedad?"
></InputGroupTextarea>
<!-- <hr class="w-full" /> -->
<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 > 229}>
{mensaje.length}
</p>
/ 255
</Kbd>
<InputGroupButton
variant="default"
class="transform rounded-full transition-transform ease-in hover:scale-120"
size="xs"
>
<p>Publicar</p>
<ArrowUpIcon />
</InputGroupButton>
</div>
</InputGroupAddon>
</InputGroup>

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,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,34 @@
<script>
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>
<AvatarImage src={$sesionStore?.url} />
<AvatarFallback>{$sesionStore?.displayName[0]}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuGroup>
<DropdownMenuItem>
<a href={'/'}> Mi Perfil </a>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onclick={async () => await logout(menuOpen)}>Cerrar Sesion</DropdownMenuItem
>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -8,43 +8,40 @@
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();
goto("/");
}
}
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">
@@ -53,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 -->
@@ -107,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>

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

@@ -1,7 +1,16 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
import type { Sesion } from '../../types';
export const currentSesion = writable<Sesion| null>(null);
const initialValue = browser ? JSON.parse(localStorage.getItem('sesion') || 'null') : null;
export const currentSesion = writable<Sesion | null>(initialValue);
if (browser) {
currentSesion.subscribe((value) => {
localStorage.setItem('sesion', JSON.stringify(value));
});
}
export const sesionStore = {
subscribe: currentSesion.subscribe,

View File

@@ -2,65 +2,72 @@
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';
$effect(()=>{
getPosts();
});
$effect(() => {
getPosts();
});
let posts: Post[] = $state([]);
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}/api/posts/timeline?pageSize=3`);
if (req.ok) {
posts = 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}
<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>
</Content>
</Card>
{/each}
{/if}
</div>
</div>
</div>

View File

8
src/types.d.ts vendored
View File

@@ -27,10 +27,10 @@ export interface User {
}
export interface Sesion {
accessToken:string?;
message:string;
url:string;
displayname:string;
accessToken: string?;
message: string;
url: string;
displayName: string;
}
export interface LoginDto {