refactor: clean up component templates and improve code readability across various Vue files by consolidating attributes and removing unnecessary line breaks

This commit is contained in:
Yoga Pangestu 2026-06-25 20:23:48 +07:00
parent cecbf92310
commit 459d568c1a
32 changed files with 324 additions and 598 deletions

View File

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Component } from 'vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { Component } from 'vue';
interface LowStockItem { interface LowStockItem {
name: string; name: string;
@ -26,36 +26,23 @@ withDefaults(defineProps<Props>(), {
<template> <template>
<Card> <Card>
<CardHeader <CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
class="flex flex-row items-center justify-between space-y-0 pb-2"
>
<div> <div>
<CardTitle class="text-sm font-medium">{{ title }}</CardTitle> <CardTitle class="text-sm font-medium">{{ title }}</CardTitle>
</div> </div>
<component <component v-if="icon" :is="icon" class="size-4" :class="iconColorClass" />
v-if="icon"
:is="icon"
class="size-4"
:class="iconColorClass"
/>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div v-if="items.length > 0" class="space-y-2"> <div v-if="items.length > 0" class="space-y-2">
<div <div v-for="item in items" :key="item.name"
v-for="item in items" class="flex items-center justify-between rounded-md border px-3 py-2">
:key="item.name"
class="flex items-center justify-between rounded-md border px-3 py-2"
>
<span class="text-sm">{{ item.name }}</span> <span class="text-sm">{{ item.name }}</span>
<Badge variant="outline" :class="badgeColorClass"> <Badge variant="outline" :class="badgeColorClass">
{{ item.stock }} {{ item.unit || 'pcs' }} {{ item.stock }} {{ item.unit || 'pcs' }}
</Badge> </Badge>
</div> </div>
</div> </div>
<div <div v-else class="flex h-[80px] items-center justify-center text-sm text-muted-foreground">
v-else
class="flex h-[80px] items-center justify-center text-sm text-muted-foreground"
>
{{ emptyText }} {{ emptyText }}
</div> </div>
</CardContent> </CardContent>

View File

@ -124,7 +124,7 @@ const getTotalStock = (product: Product) => {
{{ getProductPriceRange(product) }} {{ getProductPriceRange(product) }}
</p> </p>
<span class="text-[11px] text-slate-400 font-light mt-0.5"> <span class="text-[11px] text-slate-400 font-light mt-0.5">
Ukuran tersedia: {{ product.variants.map(v => v.name).join(', ') }} Ukuran tersedia: {{product.variants.map(v => v.name).join(', ')}}
</span> </span>
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { Component } from 'vue'; import type { Component } from 'vue';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface StatItem { interface StatItem {
label: string; label: string;
@ -40,16 +40,11 @@ withDefaults(defineProps<Props>(), {
{{ subLabel }} {{ subLabel }}
</p> </p>
</div> </div>
<div <div class="border-t pt-2 text-center"
class="border-t pt-2 text-center" :class="cols === 2 ? 'grid grid-cols-2 gap-2' : 'grid grid-cols-3 gap-2'">
:class="cols === 2 ? 'grid grid-cols-2 gap-2' : 'grid grid-cols-3 gap-2'"
>
<div v-for="item in items" :key="item.label"> <div v-for="item in items" :key="item.label">
<p class="text-xs text-muted-foreground">{{ item.label }}</p> <p class="text-xs text-muted-foreground">{{ item.label }}</p>
<p <p class="text-sm font-semibold" :class="item.color ?? ''">
class="text-sm font-semibold"
:class="item.color ?? ''"
>
{{ item.value }} {{ item.value }}
</p> </p>
</div> </div>

View File

@ -5,7 +5,6 @@ import { FlexRender, getCoreRowModel, useVueTable } from '@tanstack/vue-table';
import { computed, provide } from 'vue'; import { computed, provide } from 'vue';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue'; import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import DataTableEmpty from './DataTableEmpty.vue';
import { import {
Table, Table,
TableBody, TableBody,
@ -21,6 +20,7 @@ import type {
DataTablePaginationLink, DataTablePaginationLink,
DataTableSort, DataTableSort,
} from '@/types/data-table'; } from '@/types/data-table';
import DataTableEmpty from './DataTableEmpty.vue';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@ -157,11 +157,8 @@ function resolveRowSpan(row: TData, columnId: string): number | undefined {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id"> <TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead <TableHead v-for="header in headerGroup.headers" :key="header.id"
v-for="header in headerGroup.headers" :class="header.column.id === '_row_number' ? ROW_NUMBER_COLUMN_CLASS : undefined">
:key="header.id"
:class="header.column.id === '_row_number' ? ROW_NUMBER_COLUMN_CLASS : undefined"
>
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header" <FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header"
:props="header.getContext()" /> :props="header.getContext()" />
</TableHead> </TableHead>
@ -169,27 +166,17 @@ function resolveRowSpan(row: TData, columnId: string): number | undefined {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<template v-if="table.getRowModel().rows.length"> <template v-if="table.getRowModel().rows.length">
<TableRow <TableRow v-for="row in table.getRowModel().rows" :key="row.id"
v-for="row in table.getRowModel().rows"
:key="row.id"
:class="getRowClassName?.(row.original, row.index)" :class="getRowClassName?.(row.original, row.index)"
:data-state="row.getIsSelected() ? 'selected' : undefined" :data-state="row.getIsSelected() ? 'selected' : undefined">
> <template v-for="cell in row.getVisibleCells()" :key="cell.id">
<template <TableCell v-if="(resolveRowSpan(row.original, cell.column.id) ?? 1) !== 0" :rowspan="(resolveRowSpan(row.original, cell.column.id) ?? 1) > 1
v-for="cell in row.getVisibleCells()" ? resolveRowSpan(row.original, cell.column.id)
:key="cell.id" : undefined" :class="[
> cell.column.id === '_row_number' ? ROW_NUMBER_COLUMN_CLASS : undefined,
<TableCell (resolveRowSpan(row.original, cell.column.id) ?? 1) > 1 ? 'align-middle' : undefined,
v-if="(resolveRowSpan(row.original, cell.column.id) ?? 1) !== 0" cell.column.columnDef.meta?.cellClassName,
:rowspan="(resolveRowSpan(row.original, cell.column.id) ?? 1) > 1 ]">
? resolveRowSpan(row.original, cell.column.id)
: undefined"
:class="[
cell.column.id === '_row_number' ? ROW_NUMBER_COLUMN_CLASS : undefined,
(resolveRowSpan(row.original, cell.column.id) ?? 1) > 1 ? 'align-middle' : undefined,
cell.column.columnDef.meta?.cellClassName,
]"
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" /> <FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</TableCell> </TableCell>
</template> </template>
@ -202,26 +189,15 @@ function resolveRowSpan(row: TData, columnId: string): number | undefined {
</Table> </Table>
</div> </div>
<div <div v-if="pagination" class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
v-if="pagination"
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<p class="text-muted-foreground text-sm"> <p class="text-muted-foreground text-sm">
{{ paginationSummary }} {{ paginationSummary }}
</p> </p>
<div <div v-if="paginationLinks?.length && pagination.lastPage > 1"
v-if="paginationLinks?.length && pagination.lastPage > 1" class="flex flex-wrap items-center justify-center gap-1 sm:justify-end">
class="flex flex-wrap items-center justify-center gap-1 sm:justify-end" <Button v-for="link in paginationLinks" :key="`${link.label}-${link.url}`" variant="outline" size="sm"
> :disabled="!link.url || link.active" as-child>
<Button
v-for="link in paginationLinks"
:key="`${link.label}-${link.url}`"
variant="outline"
size="sm"
:disabled="!link.url || link.active"
as-child
>
<Link v-if="link.url" :href="link.url" preserve-scroll> <Link v-if="link.url" :href="link.url" preserve-scroll>
<span v-html="link.label" /> <span v-html="link.label" />
</Link> </Link>

View File

@ -36,12 +36,9 @@ function sortDirection(): 'asc' | 'desc' | null {
<template> <template>
<div :class="cn('flex items-center gap-2', props.class)"> <div :class="cn('flex items-center gap-2', props.class)">
<Button <Button variant="ghost" size="sm"
variant="ghost"
size="sm"
:class="cn('-ml-3 h-8 data-[state=open]:bg-accent', props.class?.includes('justify-end') && 'ml-auto')" :class="cn('-ml-3 h-8 data-[state=open]:bg-accent', props.class?.includes('justify-end') && 'ml-auto')"
@click="sortContext?.onSort(column)" @click="sortContext?.onSort(column)">
>
<span>{{ title }}</span> <span>{{ title }}</span>
<ArrowDown v-if="sortDirection() === 'desc'" class="size-4" /> <ArrowDown v-if="sortDirection() === 'desc'" class="size-4" />
<ArrowUp v-else-if="sortDirection() === 'asc'" class="size-4" /> <ArrowUp v-else-if="sortDirection() === 'asc'" class="size-4" />

View File

@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from 'vue'; import type { HTMLAttributes } from 'vue';
import { cn } from '@/lib/utils';
import { import {
Empty, Empty,
EmptyDescription, EmptyDescription,
EmptyHeader, EmptyHeader,
EmptyTitle, EmptyTitle,
} from '@/components/ui/empty'; } from '@/components/ui/empty';
import { cn } from '@/lib/utils';
withDefaults( withDefaults(
defineProps<{ defineProps<{

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useMediaQuery } from '@vueuse/core';
import { ListFilter, Search } from '@lucide/vue'; import { ListFilter, Search } from '@lucide/vue';
import { useMediaQuery } from '@vueuse/core';
import { computed } from 'vue'; import { computed } from 'vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -66,12 +66,7 @@ const selectSideOffset = computed(() => (isMobile.value ? 4 : 12));
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div class="relative w-full max-w-sm"> <div class="relative w-full max-w-sm">
<Search class="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" /> <Search class="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
<Input <Input v-model="search" type="search" :placeholder="searchPlaceholder ?? 'Cari...'" class="pl-9" />
v-model="search"
type="search"
:placeholder="searchPlaceholder ?? 'Cari...'"
class="pl-9"
/>
</div> </div>
<Popover v-if="filterDefs?.length"> <Popover v-if="filterDefs?.length">
@ -79,25 +74,18 @@ const selectSideOffset = computed(() => (isMobile.value ? 4 : 12));
<Button variant="outline" class="shrink-0"> <Button variant="outline" class="shrink-0">
<ListFilter class="size-4" /> <ListFilter class="size-4" />
Filter Filter
<Badge <Badge v-if="activeFilterCount > 0" variant="secondary"
v-if="activeFilterCount > 0" class="ml-1 size-5 rounded-full px-1 text-xs">
variant="secondary"
class="ml-1 size-5 rounded-full px-1 text-xs"
>
{{ activeFilterCount }} {{ activeFilterCount }}
</Badge> </Badge>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent align="end" class="w-80 space-y-4 p-4" @pointer-down-outside="(event) => {
align="end" const target = event.target as HTMLElement;
class="w-80 space-y-4 p-4" if (target.closest('[data-slot=select-content]') || target.closest('[data-slot=select-trigger]')) {
@pointer-down-outside="(event) => { event.preventDefault();
const target = event.target as HTMLElement; }
if (target.closest('[data-slot=select-content]') || target.closest('[data-slot=select-trigger]')) { }">
event.preventDefault();
}
}"
>
<div class="space-y-1"> <div class="space-y-1">
<h4 class="text-sm font-medium"> <h4 class="text-sm font-medium">
Filter Filter
@ -107,26 +95,16 @@ const selectSideOffset = computed(() => (isMobile.value ? 4 : 12));
</p> </p>
</div> </div>
<div <div v-for="filter in filterDefs" :key="filter.key" class="space-y-2">
v-for="filter in filterDefs"
:key="filter.key"
class="space-y-2"
>
<Label :for="`filter-${filter.key}`">{{ filter.label }}</Label> <Label :for="`filter-${filter.key}`">{{ filter.label }}</Label>
<Input <Input v-if="filter.type === 'text'" :id="`filter-${filter.key}`"
v-if="filter.type === 'text'"
:id="`filter-${filter.key}`"
:model-value="filterValue(filter.key)" :model-value="filterValue(filter.key)"
:placeholder="filter.placeholder ?? `Filter ${filter.label.toLowerCase()}`" :placeholder="filter.placeholder ?? `Filter ${filter.label.toLowerCase()}`"
@update:model-value="onFilterChange(filter.key, String($event ?? ''))" @update:model-value="onFilterChange(filter.key, String($event ?? ''))" />
/>
<Select <Select v-else :model-value="filterValue(filter.key) || 'all'"
v-else @update:model-value="onFilterChange(filter.key, String($event ?? ''))">
:model-value="filterValue(filter.key) || 'all'"
@update:model-value="onFilterChange(filter.key, String($event ?? ''))"
>
<SelectTrigger :id="`filter-${filter.key}`" class="w-full"> <SelectTrigger :id="`filter-${filter.key}`" class="w-full">
<SelectValue :placeholder="filter.placeholder ?? 'Semua'" /> <SelectValue :placeholder="filter.placeholder ?? 'Semua'" />
</SelectTrigger> </SelectTrigger>
@ -134,24 +112,14 @@ const selectSideOffset = computed(() => (isMobile.value ? 4 : 12));
<SelectItem value="all"> <SelectItem value="all">
Semua Semua
</SelectItem> </SelectItem>
<SelectItem <SelectItem v-for="option in filter.options" :key="option.value" :value="option.value">
v-for="option in filter.options"
:key="option.value"
:value="option.value"
>
{{ option.label }} {{ option.label }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<Button <Button v-if="activeFilterCount > 0" variant="ghost" size="sm" class="w-full" @click="resetFilters">
v-if="activeFilterCount > 0"
variant="ghost"
size="sm"
class="w-full"
@click="resetFilters"
>
Reset Filter Reset Filter
</Button> </Button>
</PopoverContent> </PopoverContent>

View File

@ -20,15 +20,15 @@ function formatDecimal(value: string | number): string {
const str = String(value ?? '').trim(); const str = String(value ?? '').trim();
if (!str) { if (!str) {
return ''; return '';
} }
// Normalize to dot for processing // Normalize to dot for processing
const normalized = str.replace(',', '.'); const normalized = str.replace(',', '.');
if (isNaN(parseFloat(normalized))) { if (isNaN(parseFloat(normalized))) {
return ''; return '';
} }
// Split into integer and decimal parts // Split into integer and decimal parts
const parts = normalized.split('.'); const parts = normalized.split('.');
@ -83,13 +83,6 @@ const displayValue = computed({
</script> </script>
<template> <template>
<Input <Input :id="id" v-model="displayValue" inputmode="decimal" autocomplete="off" :placeholder="placeholder ?? '0'"
:id="id" :disabled="disabled" :class="props.class" />
v-model="displayValue"
inputmode="decimal"
autocomplete="off"
:placeholder="placeholder ?? '0'"
:disabled="disabled"
:class="props.class"
/>
</template> </template>

View File

@ -56,34 +56,20 @@ function clearFile() {
</FieldDescription> </FieldDescription>
<div class="space-y-3"> <div class="space-y-3">
<div <div v-if="displayUrl" :class="cn(
v-if="displayUrl" 'relative overflow-hidden rounded-lg border bg-muted/30',
:class="cn( previewClass ?? 'aspect-video max-w-md',
'relative overflow-hidden rounded-lg border bg-muted/30', )">
previewClass ?? 'aspect-video max-w-md',
)"
>
<img :src="displayUrl" :alt="label" class="size-full object-cover"> <img :src="displayUrl" :alt="label" class="size-full object-cover">
<Button <Button v-if="model" type="button" variant="secondary" size="icon" class="absolute top-2 right-2 size-7"
v-if="model" @click="clearFile">
type="button"
variant="secondary"
size="icon"
class="absolute top-2 right-2 size-7"
@click="clearFile"
>
<X class="size-4" /> <X class="size-4" />
</Button> </Button>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Input <Input :id="id" type="file" :accept="accept ?? 'image/*'" class="max-w-md cursor-pointer"
:id="id" @change="onFileChange" />
type="file"
:accept="accept ?? 'image/*'"
class="max-w-md cursor-pointer"
@change="onFileChange"
/>
<ImagePlus v-if="!displayUrl" class="text-muted-foreground size-5 shrink-0" /> <ImagePlus v-if="!displayUrl" class="text-muted-foreground size-5 shrink-0" />
</div> </div>
</div> </div>

View File

@ -101,54 +101,32 @@ onBeforeUnmount(() => {
<div class="space-y-3"> <div class="space-y-3">
<div v-if="visibleExisting.length > 0 || previewUrls.length > 0" class="flex flex-wrap items-center gap-2"> <div v-if="visibleExisting.length > 0 || previewUrls.length > 0" class="flex flex-wrap items-center gap-2">
<button <button v-for="item in visibleExisting" :key="`existing-${item.id}`" type="button"
v-for="item in visibleExisting"
:key="`existing-${item.id}`"
type="button"
class="group relative size-14 overflow-hidden rounded-md border bg-muted/30" class="group relative size-14 overflow-hidden rounded-md border bg-muted/30"
@click="openPreview(item.url)" @click="openPreview(item.url)">
>
<img :src="item.thumb_url" :alt="label" class="size-full object-cover"> <img :src="item.thumb_url" :alt="label" class="size-full object-cover">
<Button <Button type="button" variant="secondary" size="icon"
type="button"
variant="secondary"
size="icon"
class="absolute top-0.5 right-0.5 size-5 opacity-0 transition-opacity group-hover:opacity-100" class="absolute top-0.5 right-0.5 size-5 opacity-0 transition-opacity group-hover:opacity-100"
@click.stop="removeExisting(item.id)" @click.stop="removeExisting(item.id)">
>
<X class="size-3" /> <X class="size-3" />
</Button> </Button>
</button> </button>
<button <button v-for="(url, index) in previewUrls" :key="`new-${index}`" type="button"
v-for="(url, index) in previewUrls"
:key="`new-${index}`"
type="button"
class="group relative size-14 overflow-hidden rounded-md border bg-muted/30" class="group relative size-14 overflow-hidden rounded-md border bg-muted/30"
@click="openPreview(url)" @click="openPreview(url)">
>
<img :src="url" :alt="label" class="size-full object-cover"> <img :src="url" :alt="label" class="size-full object-cover">
<Button <Button type="button" variant="secondary" size="icon"
type="button"
variant="secondary"
size="icon"
class="absolute top-0.5 right-0.5 size-5 opacity-0 transition-opacity group-hover:opacity-100" class="absolute top-0.5 right-0.5 size-5 opacity-0 transition-opacity group-hover:opacity-100"
@click.stop="removeNew(index)" @click.stop="removeNew(index)">
>
<X class="size-3" /> <X class="size-3" />
</Button> </Button>
</button> </button>
</div> </div>
<div v-if="canAddMore" class="flex items-center gap-2"> <div v-if="canAddMore" class="flex items-center gap-2">
<Input <Input :id="id" type="file" accept="image/*" :multiple="maxFiles > 1" class="max-w-md cursor-pointer"
:id="id" @change="onFileChange" />
type="file"
accept="image/*"
:multiple="maxFiles > 1"
class="max-w-md cursor-pointer"
@change="onFileChange"
/>
<ImagePlus class="text-muted-foreground size-5 shrink-0" /> <ImagePlus class="text-muted-foreground size-5 shrink-0" />
</div> </div>
</div> </div>

View File

@ -20,8 +20,8 @@ function formatNumber(value: string | number): string {
const clean = str.replace(/\D/g, ''); const clean = str.replace(/\D/g, '');
if (!clean) { if (!clean) {
return ''; return '';
} }
return Number(clean).toLocaleString('id-ID'); return Number(clean).toLocaleString('id-ID');
} }
@ -39,13 +39,6 @@ const displayValue = computed({
</script> </script>
<template> <template>
<Input <Input :id="id" v-model="displayValue" inputmode="numeric" autocomplete="off" :placeholder="placeholder ?? '0'"
:id="id" :disabled="disabled" :class="props.class" />
v-model="displayValue"
inputmode="numeric"
autocomplete="off"
:placeholder="placeholder ?? '0'"
:disabled="disabled"
:class="props.class"
/>
</template> </template>

View File

@ -27,19 +27,10 @@ const displayValue = computed({
<template> <template>
<div class="relative"> <div class="relative">
<span <span class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-sm">
class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-sm"
>
Rp Rp
</span> </span>
<Input <Input :id="id" v-model="displayValue" inputmode="numeric" autocomplete="off" :placeholder="placeholder ?? '0'"
:id="id" :disabled="disabled" :class="cn('pl-9', props.class)" />
v-model="displayValue"
inputmode="numeric"
autocomplete="off"
:placeholder="placeholder ?? '0'"
:disabled="disabled"
:class="cn('pl-9', props.class)"
/>
</div> </div>
</template> </template>

View File

@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import DropZone from 'dropzone-vue';
import { computed, ref } from 'vue';
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue'; import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
import { import {
Field, Field,
@ -8,11 +10,9 @@ import {
} from '@/components/ui/field'; } from '@/components/ui/field';
import type { MediaUploadState } from '@/types/media'; import type { MediaUploadState } from '@/types/media';
import { createMediaUploadState } from '@/types/media'; import { createMediaUploadState } from '@/types/media';
import DropZone from 'dropzone-vue';
import 'dropzone-vue/dist/dropzone-vue.common.css'; import 'dropzone-vue/dist/dropzone-vue.common.css';
import { computed, ref } from 'vue';
const props = withDefaults( withDefaults(
defineProps<{ defineProps<{
id: string; id: string;
label: string; label: string;
@ -51,8 +51,14 @@ type FilePreview = { id: string; file: File; objectUrl: string };
const filePreviews = ref<FilePreview[]>([]); const filePreviews = ref<FilePreview[]>([]);
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
} }
@ -67,9 +73,13 @@ function onAddedFile(item: { id: string; file: File }) {
function onRemovedFile(item: { id: string; file: File }) { function onRemovedFile(item: { id: string; file: File }) {
const idx = state.value.newFiles.indexOf(item.file); const idx = state.value.newFiles.indexOf(item.file);
if (idx !== -1) state.value.newFiles.splice(idx, 1);
if (idx !== -1) {
state.value.newFiles.splice(idx, 1);
}
const pi = filePreviews.value.findIndex((p) => p.id === item.id); const pi = filePreviews.value.findIndex((p) => p.id === item.id);
if (pi !== -1) { if (pi !== -1) {
URL.revokeObjectURL(filePreviews.value[pi].objectUrl); URL.revokeObjectURL(filePreviews.value[pi].objectUrl);
filePreviews.value.splice(pi, 1); filePreviews.value.splice(pi, 1);
@ -99,8 +109,10 @@ const allPreviews = computed<NormalizedEntry[]>(() => {
sizeLabel: 'Tersimpan', sizeLabel: 'Tersimpan',
onRemove: () => { onRemove: () => {
const idx = state.value.existing.findIndex((e) => e.id === item.id); const idx = state.value.existing.findIndex((e) => e.id === item.id);
if (idx !== -1) { if (idx !== -1) {
state.value.existing.splice(idx, 1); state.value.existing.splice(idx, 1);
if (!state.value.removeIds.includes(item.id)) { if (!state.value.removeIds.includes(item.id)) {
state.value.removeIds.push(item.id); state.value.removeIds.push(item.id);
} }
@ -155,179 +167,104 @@ function triggerDropzonePicker() {
<template> <template>
<div class="media-dropzone-root"> <div class="media-dropzone-root">
<Field> <Field>
<FieldLabel :for="id" :required="required"> <FieldLabel :for="id" :required="required">
{{ label }} {{ label }}
</FieldLabel> </FieldLabel>
<FieldDescription v-if="description"> <FieldDescription v-if="description">
{{ description }} {{ description }}
</FieldDescription> </FieldDescription>
<FieldDescription v-else-if="maxFiles > 1"> <FieldDescription v-else-if="maxFiles > 1">
Maks. {{ maxFiles }} gambar. Gambar pertama akan dijadikan Maks. {{ maxFiles }} gambar. Gambar pertama akan dijadikan
thumbnail. thumbnail.
</FieldDescription> </FieldDescription>
<div class="dropzone-wrapper"> <div class="dropzone-wrapper">
<!-- Dropzone form: hidden when any preview exists to prevent layout corruption --> <!-- Dropzone form: hidden when any preview exists to prevent layout corruption -->
<div <div :class="[
:class="[
'dz-form-host', 'dz-form-host',
{ 'dz-form-hidden': allPreviews.length > 0 }, { 'dz-form-hidden': allPreviews.length > 0 },
]" ]">
> <DropZone ref="dropzoneRef" :max-files="maxFiles" :max-file-size="maxFileSize"
<DropZone :accepted-files="acceptedFiles" :upload-on-drop="false" :clickable="true"
ref="dropzoneRef" dropzone-class-name="dz-box" dropzone-message-class-name="dz-message" @added-file="onAddedFile"
:max-files="maxFiles" @removed-file="onRemovedFile">
:max-file-size="maxFileSize" <template #message>
:accepted-files="acceptedFiles" <div class="dz-placeholder">
:upload-on-drop="false" <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24"
:clickable="true" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"
dropzone-class-name="dz-box" stroke-linejoin="round">
dropzone-message-class-name="dz-message" <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
@added-file="onAddedFile" <polyline points="17 8 12 3 7 8" />
@removed-file="onRemovedFile" <line x1="12" y1="3" x2="12" y2="15" />
> </svg>
<template #message> <span class="dz-placeholder-primary">Klik atau seret file ke sini</span>
<div class="dz-placeholder"> <span class="dz-placeholder-secondary">
<svg {{ acceptedFiles.join(', ') }} &mdash; Maks.
xmlns="http://www.w3.org/2000/svg" {{ maxFiles }} file,
width="22" {{ formatBytes(maxFileSize) }}/file
height="22" </span>
viewBox="0 0 24 24" </div>
fill="none" </template>
stroke="currentColor" </DropZone>
stroke-width="1.75" </div>
stroke-linecap="round"
stroke-linejoin="round" <!-- Overlay: shown when any preview exists, triggers file picker on click -->
> <div v-if="allPreviews.length > 0" class="dz-overlay" @click="triggerDropzonePicker">
<path <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none"
d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
/> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" /> <polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" /> <line x1="12" y1="3" x2="12" y2="15" />
</svg>
<span class="dz-placeholder-primary">Klik atau seret file ke sini</span>
<span class="dz-placeholder-secondary">
{{ acceptedFiles.join(', ') }} &mdash; Maks.
{{ maxFiles }} file, {{ formatBytes(maxFileSize) }}/file
</span>
</div>
</div>
<!-- Preview grid (existing + new) -->
<div v-if="allPreviews.length > 0" :class="['preview-grid', { 'preview-grid--single': maxFiles === 1 }]">
<div v-for="entry in allPreviews" :key="entry.key" class="preview-card">
<!-- Thumbnail -->
<div class="preview-thumb" @click="openPreview(entry.previewSrc)">
<img v-if="entry.isImage" :src="entry.thumbSrc" :alt="entry.name" />
<div v-else class="preview-thumb-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg> </svg>
<span class="dz-placeholder-primary"
>Klik atau seret file ke sini</span
>
<span class="dz-placeholder-secondary">
{{ acceptedFiles.join(', ') }} &mdash; Maks.
{{ maxFiles }} file,
{{ formatBytes(maxFileSize) }}/file
</span>
</div> </div>
</template> </div>
</DropZone>
</div>
<!-- Overlay: shown when any preview exists, triggers file picker on click --> <!-- Info + Remove -->
<div <div class="preview-footer">
v-if="allPreviews.length > 0" <div class="preview-info">
class="dz-overlay" <p class="preview-name" :title="entry.name">
@click="triggerDropzonePicker" {{ entry.name }}
> </p>
<svg <p class="preview-size">{{ entry.sizeLabel }}</p>
xmlns="http://www.w3.org/2000/svg" </div>
width="22" <button type="button" class="preview-remove" title="Hapus" @click="entry.onRemove()">
height="22" <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
fill="none" stroke-linejoin="round">
stroke="currentColor" <line x1="18" y1="6" x2="6" y2="18" />
stroke-width="1.75" <line x1="6" y1="6" x2="18" y2="18" />
stroke-linecap="round" </svg>
stroke-linejoin="round" </button>
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<span class="dz-placeholder-primary"
>Klik atau seret file ke sini</span
>
<span class="dz-placeholder-secondary">
{{ acceptedFiles.join(', ') }} &mdash; Maks.
{{ maxFiles }} file, {{ formatBytes(maxFileSize) }}/file
</span>
</div>
</div>
<!-- Preview grid (existing + new) -->
<div v-if="allPreviews.length > 0" :class="['preview-grid', { 'preview-grid--single': maxFiles === 1 }]">
<div
v-for="entry in allPreviews"
:key="entry.key"
class="preview-card"
>
<!-- Thumbnail -->
<div
class="preview-thumb"
@click="openPreview(entry.previewSrc)"
>
<img
v-if="entry.isImage"
:src="entry.thumbSrc"
:alt="entry.name"
/>
<div v-else class="preview-thumb-icon">
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
</svg>
</div> </div>
</div> </div>
<!-- Info + Remove -->
<div class="preview-footer">
<div class="preview-info">
<p class="preview-name" :title="entry.name">
{{ entry.name }}
</p>
<p class="preview-size">{{ entry.sizeLabel }}</p>
</div>
<button
type="button"
class="preview-remove"
title="Hapus"
@click="entry.onRemove()"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div> </div>
</div>
<FieldError :errors="errors" /> <FieldError :errors="errors" />
<MediaPreviewDialog <MediaPreviewDialog v-model:open="previewOpen" :url="previewUrl" :title="label" />
v-model:open="previewOpen" </Field>
:url="previewUrl"
:title="label"
/>
</Field>
</div> </div>
</template> </template>

View File

@ -20,12 +20,8 @@ defineProps<{
<DialogHeader class="sr-only"> <DialogHeader class="sr-only">
<DialogTitle>{{ title ?? 'Pratinjau Gambar' }}</DialogTitle> <DialogTitle>{{ title ?? 'Pratinjau Gambar' }}</DialogTitle>
</DialogHeader> </DialogHeader>
<img <img v-if="url" :src="url" :alt="title ?? 'Pratinjau gambar'"
v-if="url" class="max-h-[80vh] w-full rounded-md object-contain">
:src="url"
:alt="title ?? 'Pratinjau gambar'"
class="max-h-[80vh] w-full rounded-md object-contain"
>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</template> </template>

View File

@ -22,13 +22,8 @@ function openPreview(url: string) {
<template> <template>
<div v-if="items.length" class="flex items-center gap-1"> <div v-if="items.length" class="flex items-center gap-1">
<button <button v-for="item in visibleItems" :key="item.id" type="button"
v-for="item in visibleItems" class="size-8 overflow-hidden rounded border bg-muted/30" @click="openPreview(item.url)">
:key="item.id"
type="button"
class="size-8 overflow-hidden rounded border bg-muted/30"
@click="openPreview(item.url)"
>
<img :src="item.thumb_url" alt="Foto" class="size-full object-cover"> <img :src="item.thumb_url" alt="Foto" class="size-full object-cover">
</button> </button>
<span v-if="hiddenCount > 0" class="text-muted-foreground text-xs"> <span v-if="hiddenCount > 0" class="text-muted-foreground text-xs">

View File

@ -108,13 +108,12 @@ const handleAddToCart = () => {
<!-- Image Thumbnails --> <!-- Image Thumbnails -->
<div v-if="allImages.length > 1" class="flex gap-2 overflow-x-auto scrollbar-hide pb-1"> <div v-if="allImages.length > 1" class="flex gap-2 overflow-x-auto scrollbar-hide pb-1">
<button v-for="(img, idx) in allImages" :key="img.id" <button v-for="(img, idx) in allImages" :key="img.id" @click="activeImageIndex = idx" :class="[
@click="activeImageIndex = idx" :class="[ 'shrink-0 w-16 h-16 rounded-lg overflow-hidden border-2 transition-all cursor-pointer',
'shrink-0 w-16 h-16 rounded-lg overflow-hidden border-2 transition-all cursor-pointer', activeImageIndex === idx
activeImageIndex === idx ? 'border-amber-500 shadow-md shadow-amber-200/40'
? 'border-amber-500 shadow-md shadow-amber-200/40' : 'border-slate-200/60 opacity-60 hover:opacity-100'
: 'border-slate-200/60 opacity-60 hover:opacity-100' ]">
]">
<img :src="img.thumb_url || img.url" :alt="`Gambar ${idx + 1}`" <img :src="img.thumb_url || img.url" :alt="`Gambar ${idx + 1}`"
class="w-full h-full object-cover" /> class="w-full h-full object-cover" />
</button> </button>
@ -226,6 +225,7 @@ const handleAddToCart = () => {
.scrollbar-hide::-webkit-scrollbar { .scrollbar-hide::-webkit-scrollbar {
display: none; display: none;
} }
.scrollbar-hide { .scrollbar-hide {
-ms-overflow-style: none; -ms-overflow-style: none;
scrollbar-width: none; scrollbar-width: none;

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AlertDialogEmits, AlertDialogProps } from "reka-ui" import type { AlertDialogEmits, AlertDialogProps } from "reka-ui";
import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui" import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui";
const props = defineProps<AlertDialogProps>() const props = defineProps<AlertDialogProps>()
const emits = defineEmits<AlertDialogEmits>() const emits = defineEmits<AlertDialogEmits>()
@ -9,7 +9,7 @@ const forwarded = useForwardPropsEmits(props, emits)
</script> </script>
<template> <template>
<AlertDialogRoot v-slot="slotProps" data-slot="alert-dialog" v-bind="forwarded"> <AlertDialogRoot v-slot="slotProps" data-slot="alert-dialog" v-bind="forwarded">
<slot v-bind="slotProps" /> <slot v-bind="slotProps" />
</AlertDialogRoot> </AlertDialogRoot>
</template> </template>

View File

@ -12,7 +12,7 @@ const delegatedProps = reactiveOmit(props, "class")
</script> </script>
<template> <template>
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)"> <AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
<slot /> <slot />
</AlertDialogAction> </AlertDialogAction>
</template> </template>

View File

@ -12,14 +12,11 @@ const delegatedProps = reactiveOmit(props, "class")
</script> </script>
<template> <template>
<AlertDialogCancel <AlertDialogCancel v-bind="delegatedProps" :class="cn(
v-bind="delegatedProps" buttonVariants({ variant: 'outline' }),
:class="cn( 'mt-2 sm:mt-0',
buttonVariants({ variant: 'outline' }), props.class,
'mt-2 sm:mt-0', )">
props.class, <slot />
)" </AlertDialogCancel>
>
<slot />
</AlertDialogCancel>
</template> </template>

View File

@ -3,15 +3,15 @@ import type { AlertDialogContentEmits, AlertDialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core"
import { import {
AlertDialogContent, AlertDialogContent,
AlertDialogOverlay, AlertDialogOverlay,
AlertDialogPortal, AlertDialogPortal,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from "reka-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) })
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes["class"] }>()
@ -23,22 +23,15 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script> </script>
<template> <template>
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay <AlertDialogOverlay data-slot="alert-dialog-overlay"
data-slot="alert-dialog-overlay" class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 pointer-events-none fixed inset-0 z-[200] bg-black/80" />
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 pointer-events-none fixed inset-0 z-[200] bg-black/80" <AlertDialogContent data-slot="alert-dialog-content" v-bind="{ ...$attrs, ...forwarded }" :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 pointer-events-auto fixed top-[50%] left-[50%] z-[201] 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',
<AlertDialogContent props.class,
data-slot="alert-dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
: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 pointer-events-auto fixed top-[50%] left-[50%] z-[201] 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',
props.class,
) )
" ">
> <slot />
<slot /> </AlertDialogContent>
</AlertDialogContent> </AlertDialogPortal>
</AlertDialogPortal>
</template> </template>

View File

@ -3,7 +3,7 @@ import type { AlertDialogDescriptionProps } from "reka-ui"
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core"
import { import {
AlertDialogDescription, AlertDialogDescription,
} from "reka-ui" } from "reka-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -13,11 +13,8 @@ const delegatedProps = reactiveOmit(props, "class")
</script> </script>
<template> <template>
<AlertDialogDescription <AlertDialogDescription data-slot="alert-dialog-description" v-bind="delegatedProps"
data-slot="alert-dialog-description" :class="cn('text-muted-foreground text-sm', props.class)">
v-bind="delegatedProps" <slot />
:class="cn('text-muted-foreground text-sm', props.class)" </AlertDialogDescription>
>
<slot />
</AlertDialogDescription>
</template> </template>

View File

@ -1,22 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"]
}>() }>()
</script> </script>
<template> <template>
<div <div data-slot="alert-dialog-footer" :class="cn(
data-slot="alert-dialog-footer"
:class="
cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', 'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
props.class, props.class,
) )
" ">
> <slot />
<slot /> </div>
</div>
</template> </template>

View File

@ -1,17 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"]
}>() }>()
</script> </script>
<template> <template>
<div <div data-slot="alert-dialog-header" :class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)">
data-slot="alert-dialog-header" <slot />
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)" </div>
>
<slot />
</div>
</template> </template>

View File

@ -11,11 +11,8 @@ const delegatedProps = reactiveOmit(props, "class")
</script> </script>
<template> <template>
<AlertDialogTitle <AlertDialogTitle data-slot="alert-dialog-title" v-bind="delegatedProps"
data-slot="alert-dialog-title" :class="cn('text-lg font-semibold', props.class)">
v-bind="delegatedProps" <slot />
:class="cn('text-lg font-semibold', props.class)" </AlertDialogTitle>
>
<slot />
</AlertDialogTitle>
</template> </template>

View File

@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AlertDialogTriggerProps } from "reka-ui" import type { AlertDialogTriggerProps } from "reka-ui";
import { AlertDialogTrigger } from "reka-ui" import { AlertDialogTrigger } from "reka-ui";
const props = defineProps<AlertDialogTriggerProps>() const props = defineProps<AlertDialogTriggerProps>()
</script> </script>
<template> <template>
<AlertDialogTrigger data-slot="alert-dialog-trigger" v-bind="props"> <AlertDialogTrigger data-slot="alert-dialog-trigger" v-bind="props">
<slot /> <slot />
</AlertDialogTrigger> </AlertDialogTrigger>
</template> </template>

View File

@ -1,18 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { AvatarRoot } from "reka-ui" import { AvatarRoot } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"]
}>() }>()
</script> </script>
<template> <template>
<AvatarRoot <AvatarRoot data-slot="avatar"
data-slot="avatar" :class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)">
:class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)" <slot />
> </AvatarRoot>
<slot />
</AvatarRoot>
</template> </template>

View File

@ -11,11 +11,8 @@ const delegatedProps = reactiveOmit(props, "class")
</script> </script>
<template> <template>
<AvatarFallback <AvatarFallback data-slot="avatar-fallback" v-bind="delegatedProps"
data-slot="avatar-fallback" :class="cn('bg-muted flex size-full items-center justify-center rounded-full', props.class)">
v-bind="delegatedProps" <slot />
:class="cn('bg-muted flex size-full items-center justify-center rounded-full', props.class)" </AvatarFallback>
>
<slot />
</AvatarFallback>
</template> </template>

View File

@ -1,16 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AvatarImageProps } from "reka-ui" import type { AvatarImageProps } from "reka-ui";
import { AvatarImage } from "reka-ui" import { AvatarImage } from "reka-ui";
const props = defineProps<AvatarImageProps>() const props = defineProps<AvatarImageProps>()
</script> </script>
<template> <template>
<AvatarImage <AvatarImage data-slot="avatar-image" v-bind="props" class="aspect-square size-full">
data-slot="avatar-image" <slot />
v-bind="props" </AvatarImage>
class="aspect-square size-full"
>
<slot />
</AvatarImage>
</template> </template>

View File

@ -8,19 +8,15 @@ import { cn } from "@/lib/utils"
import { badgeVariants } from "." import { badgeVariants } from "."
const props = defineProps<PrimitiveProps & { const props = defineProps<PrimitiveProps & {
variant?: BadgeVariants["variant"] variant?: BadgeVariants["variant"]
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"]
}>() }>()
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class")
</script> </script>
<template> <template>
<Primitive <Primitive data-slot="badge" :class="cn(badgeVariants({ variant }), props.class)" v-bind="delegatedProps">
data-slot="badge" <slot />
:class="cn(badgeVariants({ variant }), props.class)" </Primitive>
v-bind="delegatedProps"
>
<slot />
</Primitive>
</template> </template>

View File

@ -7,25 +7,19 @@ import { cn } from "@/lib/utils"
import { buttonVariants } from "." import { buttonVariants } from "."
interface Props extends PrimitiveProps { interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"] variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"] size?: ButtonVariants["size"]
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"]
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
as: "button", as: "button",
}) })
</script> </script>
<template> <template>
<Primitive <Primitive data-slot="button" :data-variant="variant" :data-size="size" :as="as" :as-child="asChild"
data-slot="button" :class="cn(buttonVariants({ variant, size }), props.class)">
:data-variant="variant" <slot />
:data-size="size" </Primitive>
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template> </template>

View File

@ -49,16 +49,13 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<div class="absolute inset-0 flex h-full items-center text-sm pl-2 pointer-events-none"> <div class="absolute inset-0 flex h-full items-center text-sm pl-2 pointer-events-none">
{{ formatter.custom(toDate(date), { month: 'short' }) }} {{ formatter.custom(toDate(date), { month: 'short' }) }}
</div> </div>
<NativeSelect <NativeSelect class="text-xs h-8 pr-6 pl-2 text-transparent relative" :model-value="date.month" @change="(e: Event) => {
class="text-xs h-8 pr-6 pl-2 text-transparent relative" placeholder = placeholder.set({
:model-value="date.month" month: Number((e?.target as any)?.value),
@change="(e: Event) => { })
placeholder = placeholder.set({ }">
month: Number((e?.target as any)?.value), <NativeSelectOption v-for="(month) in createYear({ dateObj: date })" :key="month.toString()"
}) :value="month.month" :selected="date.month === month.month">
}"
>
<NativeSelectOption v-for="(month) in createYear({ dateObj: date })" :key="month.toString()" :value="month.month" :selected="date.month === month.month">
{{ formatter.custom(toDate(month), { month: 'short' }) }} {{ formatter.custom(toDate(month), { month: 'short' }) }}
</NativeSelectOption> </NativeSelectOption>
</NativeSelect> </NativeSelect>
@ -72,16 +69,13 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<div class="absolute inset-0 flex h-full items-center text-sm pl-2 pointer-events-none"> <div class="absolute inset-0 flex h-full items-center text-sm pl-2 pointer-events-none">
{{ formatter.custom(toDate(date), { year: 'numeric' }) }} {{ formatter.custom(toDate(date), { year: 'numeric' }) }}
</div> </div>
<NativeSelect <NativeSelect class="text-xs h-8 pr-6 pl-2 text-transparent relative" :model-value="date.year" @change="(e: Event) => {
class="text-xs h-8 pr-6 pl-2 text-transparent relative" placeholder = placeholder.set({
:model-value="date.year" year: Number((e?.target as any)?.value),
@change="(e: Event) => { })
placeholder = placeholder.set({ }">
year: Number((e?.target as any)?.value), <NativeSelectOption v-for="(year) in yearRange" :key="year.toString()" :value="year.year"
}) :selected="date.year === year.year">
}"
>
<NativeSelectOption v-for="(year) in yearRange" :key="year.toString()" :value="year.year" :selected="date.year === year.year">
{{ formatter.custom(toDate(year), { year: 'numeric' }) }} {{ formatter.custom(toDate(year), { year: 'numeric' }) }}
</NativeSelectOption> </NativeSelectOption>
</NativeSelect> </NativeSelect>
@ -89,13 +83,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
</div> </div>
</DefineYearTemplate> </DefineYearTemplate>
<CalendarRoot <CalendarRoot v-slot="{ grid, weekDays, date }" v-bind="forwarded" v-model:placeholder="placeholder"
v-slot="{ grid, weekDays, date }" data-slot="calendar" :class="cn('p-3', props.class)">
v-bind="forwarded"
v-model:placeholder="placeholder"
data-slot="calendar"
:class="cn('p-3', props.class)"
>
<CalendarHeader class="pt-0"> <CalendarHeader class="pt-0">
<nav class="flex items-center gap-1 absolute top-0 inset-x-0 justify-between"> <nav class="flex items-center gap-1 absolute top-0 inset-x-0 justify-between">
<CalendarPrevButton> <CalendarPrevButton>
@ -135,24 +124,15 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<CalendarGrid v-for="month in grid" :key="month.value.toString()"> <CalendarGrid v-for="month in grid" :key="month.value.toString()">
<CalendarGridHead> <CalendarGridHead>
<CalendarGridRow> <CalendarGridRow>
<CalendarHeadCell <CalendarHeadCell v-for="day in weekDays" :key="day">
v-for="day in weekDays" :key="day"
>
{{ day }} {{ day }}
</CalendarHeadCell> </CalendarHeadCell>
</CalendarGridRow> </CalendarGridRow>
</CalendarGridHead> </CalendarGridHead>
<CalendarGridBody> <CalendarGridBody>
<CalendarGridRow v-for="(weekDates, index) in month.rows" :key="`weekDate-${index}`" class="mt-2 w-full"> <CalendarGridRow v-for="(weekDates, index) in month.rows" :key="`weekDate-${index}`" class="mt-2 w-full">
<CalendarCell <CalendarCell v-for="weekDate in weekDates" :key="weekDate.toString()" :date="weekDate">
v-for="weekDate in weekDates" <CalendarCellTrigger :day="weekDate" :month="month.value" />
:key="weekDate.toString()"
:date="weekDate"
>
<CalendarCellTrigger
:day="weekDate"
:month="month.value"
/>
</CalendarCell> </CalendarCell>
</CalendarGridRow> </CalendarGridRow>
</CalendarGridBody> </CalendarGridBody>

View File

@ -16,24 +16,20 @@ const forwardedProps = useForwardProps(delegatedProps)
</script> </script>
<template> <template>
<CalendarCellTrigger <CalendarCellTrigger data-slot="calendar-cell-trigger" :class="cn(
data-slot="calendar-cell-trigger" buttonVariants({ variant: 'ghost' }),
:class="cn( 'size-8 p-0 font-normal aria-selected:opacity-100 cursor-default',
buttonVariants({ variant: 'ghost' }), '[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
'size-8 p-0 font-normal aria-selected:opacity-100 cursor-default', // Selected
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground', 'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 [&[data-selected]:hover]:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
// Selected // Disabled
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 [&[data-selected]:hover]:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground', 'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
// Disabled // Unavailable
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50', 'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
// Unavailable // Outside months
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through', 'data-[outside-view]:text-muted-foreground',
// Outside months props.class,
'data-[outside-view]:text-muted-foreground', )" v-bind="forwardedProps">
props.class,
)"
v-bind="forwardedProps"
>
<slot /> <slot />
</CalendarCellTrigger> </CalendarCellTrigger>
</template> </template>