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:
parent
cecbf92310
commit
459d568c1a
@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { Component } from 'vue';
|
||||
|
||||
interface LowStockItem {
|
||||
name: string;
|
||||
@ -26,36 +26,23 @@ withDefaults(defineProps<Props>(), {
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader
|
||||
class="flex flex-row items-center justify-between space-y-0 pb-2"
|
||||
>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div>
|
||||
<CardTitle class="text-sm font-medium">{{ title }}</CardTitle>
|
||||
</div>
|
||||
<component
|
||||
v-if="icon"
|
||||
:is="icon"
|
||||
class="size-4"
|
||||
:class="iconColorClass"
|
||||
/>
|
||||
<component v-if="icon" :is="icon" class="size-4" :class="iconColorClass" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div v-if="items.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.name"
|
||||
class="flex items-center justify-between rounded-md border px-3 py-2"
|
||||
>
|
||||
<div v-for="item in items" :key="item.name"
|
||||
class="flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<span class="text-sm">{{ item.name }}</span>
|
||||
<Badge variant="outline" :class="badgeColorClass">
|
||||
{{ item.stock }} {{ item.unit || 'pcs' }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-[80px] items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
<div v-else class="flex h-[80px] items-center justify-center text-sm text-muted-foreground">
|
||||
{{ emptyText }}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@ -124,7 +124,7 @@ const getTotalStock = (product: Product) => {
|
||||
{{ getProductPriceRange(product) }}
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { Component } from 'vue';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
interface StatItem {
|
||||
label: string;
|
||||
@ -40,16 +40,11 @@ withDefaults(defineProps<Props>(), {
|
||||
{{ subLabel }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="border-t pt-2 text-center"
|
||||
:class="cols === 2 ? 'grid grid-cols-2 gap-2' : 'grid grid-cols-3 gap-2'"
|
||||
>
|
||||
<div class="border-t pt-2 text-center"
|
||||
:class="cols === 2 ? 'grid grid-cols-2 gap-2' : 'grid grid-cols-3 gap-2'">
|
||||
<div v-for="item in items" :key="item.label">
|
||||
<p class="text-xs text-muted-foreground">{{ item.label }}</p>
|
||||
<p
|
||||
class="text-sm font-semibold"
|
||||
:class="item.color ?? ''"
|
||||
>
|
||||
<p class="text-sm font-semibold" :class="item.color ?? ''">
|
||||
{{ item.value }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -5,7 +5,6 @@ import { FlexRender, getCoreRowModel, useVueTable } from '@tanstack/vue-table';
|
||||
import { computed, provide } from 'vue';
|
||||
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import DataTableEmpty from './DataTableEmpty.vue';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@ -21,6 +20,7 @@ import type {
|
||||
DataTablePaginationLink,
|
||||
DataTableSort,
|
||||
} from '@/types/data-table';
|
||||
import DataTableEmpty from './DataTableEmpty.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@ -157,11 +157,8 @@ function resolveRowSpan(row: TData, columnId: string): number | undefined {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead
|
||||
v-for="header in headerGroup.headers"
|
||||
:key="header.id"
|
||||
:class="header.column.id === '_row_number' ? ROW_NUMBER_COLUMN_CLASS : undefined"
|
||||
>
|
||||
<TableHead v-for="header in headerGroup.headers" :key="header.id"
|
||||
:class="header.column.id === '_row_number' ? ROW_NUMBER_COLUMN_CLASS : undefined">
|
||||
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header"
|
||||
:props="header.getContext()" />
|
||||
</TableHead>
|
||||
@ -169,27 +166,17 @@ function resolveRowSpan(row: TData, columnId: string): number | undefined {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<template v-if="table.getRowModel().rows.length">
|
||||
<TableRow
|
||||
v-for="row in table.getRowModel().rows"
|
||||
:key="row.id"
|
||||
<TableRow v-for="row in table.getRowModel().rows" :key="row.id"
|
||||
:class="getRowClassName?.(row.original, row.index)"
|
||||
:data-state="row.getIsSelected() ? 'selected' : undefined"
|
||||
>
|
||||
<template
|
||||
v-for="cell in row.getVisibleCells()"
|
||||
:key="cell.id"
|
||||
>
|
||||
<TableCell
|
||||
v-if="(resolveRowSpan(row.original, cell.column.id) ?? 1) !== 0"
|
||||
: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,
|
||||
]"
|
||||
>
|
||||
:data-state="row.getIsSelected() ? 'selected' : undefined">
|
||||
<template v-for="cell in row.getVisibleCells()" :key="cell.id">
|
||||
<TableCell v-if="(resolveRowSpan(row.original, cell.column.id) ?? 1) !== 0" :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()" />
|
||||
</TableCell>
|
||||
</template>
|
||||
@ -202,26 +189,15 @@ function resolveRowSpan(row: TData, columnId: string): number | undefined {
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="pagination"
|
||||
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div 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">
|
||||
{{ paginationSummary }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="paginationLinks?.length && pagination.lastPage > 1"
|
||||
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
|
||||
>
|
||||
<div v-if="paginationLinks?.length && pagination.lastPage > 1"
|
||||
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>
|
||||
<Link v-if="link.url" :href="link.url" preserve-scroll>
|
||||
<span v-html="link.label" />
|
||||
</Link>
|
||||
|
||||
@ -36,12 +36,9 @@ function sortDirection(): 'asc' | 'desc' | null {
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex items-center gap-2', props.class)">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
<Button variant="ghost" size="sm"
|
||||
: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>
|
||||
<ArrowDown v-if="sortDirection() === 'desc'" class="size-4" />
|
||||
<ArrowUp v-else-if="sortDirection() === 'asc'" class="size-4" />
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useMediaQuery } from '@vueuse/core';
|
||||
import { ListFilter, Search } from '@lucide/vue';
|
||||
import { useMediaQuery } from '@vueuse/core';
|
||||
import { computed } from 'vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
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="relative w-full max-w-sm">
|
||||
<Search class="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
v-model="search"
|
||||
type="search"
|
||||
:placeholder="searchPlaceholder ?? 'Cari...'"
|
||||
class="pl-9"
|
||||
/>
|
||||
<Input v-model="search" type="search" :placeholder="searchPlaceholder ?? 'Cari...'" class="pl-9" />
|
||||
</div>
|
||||
|
||||
<Popover v-if="filterDefs?.length">
|
||||
@ -79,25 +74,18 @@ const selectSideOffset = computed(() => (isMobile.value ? 4 : 12));
|
||||
<Button variant="outline" class="shrink-0">
|
||||
<ListFilter class="size-4" />
|
||||
Filter
|
||||
<Badge
|
||||
v-if="activeFilterCount > 0"
|
||||
variant="secondary"
|
||||
class="ml-1 size-5 rounded-full px-1 text-xs"
|
||||
>
|
||||
<Badge v-if="activeFilterCount > 0" variant="secondary"
|
||||
class="ml-1 size-5 rounded-full px-1 text-xs">
|
||||
{{ activeFilterCount }}
|
||||
</Badge>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
class="w-80 space-y-4 p-4"
|
||||
@pointer-down-outside="(event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('[data-slot=select-content]') || target.closest('[data-slot=select-trigger]')) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}"
|
||||
>
|
||||
<PopoverContent align="end" class="w-80 space-y-4 p-4" @pointer-down-outside="(event) => {
|
||||
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">
|
||||
<h4 class="text-sm font-medium">
|
||||
Filter
|
||||
@ -107,26 +95,16 @@ const selectSideOffset = computed(() => (isMobile.value ? 4 : 12));
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="filter in filterDefs"
|
||||
:key="filter.key"
|
||||
class="space-y-2"
|
||||
>
|
||||
<div v-for="filter in filterDefs" :key="filter.key" class="space-y-2">
|
||||
<Label :for="`filter-${filter.key}`">{{ filter.label }}</Label>
|
||||
|
||||
<Input
|
||||
v-if="filter.type === 'text'"
|
||||
:id="`filter-${filter.key}`"
|
||||
<Input v-if="filter.type === 'text'" :id="`filter-${filter.key}`"
|
||||
:model-value="filterValue(filter.key)"
|
||||
:placeholder="filter.placeholder ?? `Filter ${filter.label.toLowerCase()}`"
|
||||
@update:model-value="onFilterChange(filter.key, String($event ?? ''))"
|
||||
/>
|
||||
@update:model-value="onFilterChange(filter.key, String($event ?? ''))" />
|
||||
|
||||
<Select
|
||||
v-else
|
||||
:model-value="filterValue(filter.key) || 'all'"
|
||||
@update:model-value="onFilterChange(filter.key, String($event ?? ''))"
|
||||
>
|
||||
<Select v-else :model-value="filterValue(filter.key) || 'all'"
|
||||
@update:model-value="onFilterChange(filter.key, String($event ?? ''))">
|
||||
<SelectTrigger :id="`filter-${filter.key}`" class="w-full">
|
||||
<SelectValue :placeholder="filter.placeholder ?? 'Semua'" />
|
||||
</SelectTrigger>
|
||||
@ -134,24 +112,14 @@ const selectSideOffset = computed(() => (isMobile.value ? 4 : 12));
|
||||
<SelectItem value="all">
|
||||
Semua
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
v-for="option in filter.options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
<SelectItem v-for="option in filter.options" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="activeFilterCount > 0"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
@click="resetFilters"
|
||||
>
|
||||
<Button v-if="activeFilterCount > 0" variant="ghost" size="sm" class="w-full" @click="resetFilters">
|
||||
Reset Filter
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
|
||||
@ -20,21 +20,21 @@ function formatDecimal(value: string | number): string {
|
||||
const str = String(value ?? '').trim();
|
||||
|
||||
if (!str) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
// Normalize to dot for processing
|
||||
const normalized = str.replace(',', '.');
|
||||
|
||||
if (isNaN(parseFloat(normalized))) {
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Split into integer and decimal parts
|
||||
const parts = normalized.split('.');
|
||||
const integerPart = parts[0];
|
||||
let decimalPart = parts[1];
|
||||
|
||||
|
||||
if (decimalPart !== undefined) {
|
||||
decimalPart = decimalPart.slice(0, 2);
|
||||
|
||||
@ -49,20 +49,20 @@ function parseDecimal(value: string): string {
|
||||
let normalized = value;
|
||||
const hasComma = value.includes(',');
|
||||
const dotCount = (value.match(/\./g) || []).length;
|
||||
|
||||
|
||||
if (!hasComma && dotCount === 1) {
|
||||
normalized = value.replace('.', ',');
|
||||
}
|
||||
|
||||
|
||||
// Remove all dots (if any were entered as thousands) and replace comma with dot
|
||||
let clean = normalized.replace(/\./g, '').replace(',', '.');
|
||||
|
||||
|
||||
const parts = clean.split('.');
|
||||
|
||||
if (parts.length > 2) {
|
||||
clean = parts[0] + '.' + parts.slice(1).join('');
|
||||
}
|
||||
|
||||
|
||||
clean = clean.replace(/[^0-9.]/g, '');
|
||||
|
||||
const cleanParts = clean.split('.');
|
||||
@ -70,7 +70,7 @@ function parseDecimal(value: string): string {
|
||||
if (cleanParts[1] !== undefined) {
|
||||
clean = `${cleanParts[0]}.${cleanParts[1].slice(0, 2)}`;
|
||||
}
|
||||
|
||||
|
||||
return clean;
|
||||
}
|
||||
|
||||
@ -83,13 +83,6 @@ const displayValue = computed({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Input
|
||||
:id="id"
|
||||
v-model="displayValue"
|
||||
inputmode="decimal"
|
||||
autocomplete="off"
|
||||
:placeholder="placeholder ?? '0'"
|
||||
:disabled="disabled"
|
||||
:class="props.class"
|
||||
/>
|
||||
<Input :id="id" v-model="displayValue" inputmode="decimal" autocomplete="off" :placeholder="placeholder ?? '0'"
|
||||
:disabled="disabled" :class="props.class" />
|
||||
</template>
|
||||
|
||||
@ -56,34 +56,20 @@ function clearFile() {
|
||||
</FieldDescription>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-if="displayUrl"
|
||||
:class="cn(
|
||||
'relative overflow-hidden rounded-lg border bg-muted/30',
|
||||
previewClass ?? 'aspect-video max-w-md',
|
||||
)"
|
||||
>
|
||||
<div v-if="displayUrl" :class="cn(
|
||||
'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">
|
||||
<Button
|
||||
v-if="model"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
class="absolute top-2 right-2 size-7"
|
||||
@click="clearFile"
|
||||
>
|
||||
<Button v-if="model" type="button" variant="secondary" size="icon" class="absolute top-2 right-2 size-7"
|
||||
@click="clearFile">
|
||||
<X class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
:id="id"
|
||||
type="file"
|
||||
:accept="accept ?? 'image/*'"
|
||||
class="max-w-md cursor-pointer"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<Input :id="id" 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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -101,54 +101,32 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div class="space-y-3">
|
||||
<div v-if="visibleExisting.length > 0 || previewUrls.length > 0" class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
v-for="item in visibleExisting"
|
||||
:key="`existing-${item.id}`"
|
||||
type="button"
|
||||
<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"
|
||||
@click="openPreview(item.url)"
|
||||
>
|
||||
@click="openPreview(item.url)">
|
||||
<img :src="item.thumb_url" :alt="label" class="size-full object-cover">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
<Button type="button" variant="secondary" size="icon"
|
||||
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" />
|
||||
</Button>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-for="(url, index) in previewUrls"
|
||||
:key="`new-${index}`"
|
||||
type="button"
|
||||
<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"
|
||||
@click="openPreview(url)"
|
||||
>
|
||||
@click="openPreview(url)">
|
||||
<img :src="url" :alt="label" class="size-full object-cover">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
<Button type="button" variant="secondary" size="icon"
|
||||
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" />
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="canAddMore" class="flex items-center gap-2">
|
||||
<Input
|
||||
:id="id"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
:multiple="maxFiles > 1"
|
||||
class="max-w-md cursor-pointer"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<Input :id="id" type="file" accept="image/*" :multiple="maxFiles > 1" class="max-w-md cursor-pointer"
|
||||
@change="onFileChange" />
|
||||
<ImagePlus class="text-muted-foreground size-5 shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -20,8 +20,8 @@ function formatNumber(value: string | number): string {
|
||||
const clean = str.replace(/\D/g, '');
|
||||
|
||||
if (!clean) {
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
return Number(clean).toLocaleString('id-ID');
|
||||
}
|
||||
@ -39,13 +39,6 @@ const displayValue = computed({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Input
|
||||
:id="id"
|
||||
v-model="displayValue"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
:placeholder="placeholder ?? '0'"
|
||||
:disabled="disabled"
|
||||
:class="props.class"
|
||||
/>
|
||||
<Input :id="id" v-model="displayValue" inputmode="numeric" autocomplete="off" :placeholder="placeholder ?? '0'"
|
||||
:disabled="disabled" :class="props.class" />
|
||||
</template>
|
||||
|
||||
@ -27,19 +27,10 @@ const displayValue = computed({
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<span
|
||||
class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-sm"
|
||||
>
|
||||
<span class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-sm">
|
||||
Rp
|
||||
</span>
|
||||
<Input
|
||||
:id="id"
|
||||
v-model="displayValue"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
:placeholder="placeholder ?? '0'"
|
||||
:disabled="disabled"
|
||||
:class="cn('pl-9', props.class)"
|
||||
/>
|
||||
<Input :id="id" v-model="displayValue" inputmode="numeric" autocomplete="off" :placeholder="placeholder ?? '0'"
|
||||
:disabled="disabled" :class="cn('pl-9', props.class)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import DropZone from 'dropzone-vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
|
||||
import {
|
||||
Field,
|
||||
@ -8,11 +10,9 @@ import {
|
||||
} from '@/components/ui/field';
|
||||
import type { MediaUploadState } from '@/types/media';
|
||||
import { createMediaUploadState } from '@/types/media';
|
||||
import DropZone from 'dropzone-vue';
|
||||
import 'dropzone-vue/dist/dropzone-vue.common.css';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
id: string;
|
||||
label: string;
|
||||
@ -51,8 +51,14 @@ type FilePreview = { id: string; file: File; objectUrl: string };
|
||||
const filePreviews = ref<FilePreview[]>([]);
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
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 }) {
|
||||
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);
|
||||
|
||||
if (pi !== -1) {
|
||||
URL.revokeObjectURL(filePreviews.value[pi].objectUrl);
|
||||
filePreviews.value.splice(pi, 1);
|
||||
@ -99,8 +109,10 @@ const allPreviews = computed<NormalizedEntry[]>(() => {
|
||||
sizeLabel: 'Tersimpan',
|
||||
onRemove: () => {
|
||||
const idx = state.value.existing.findIndex((e) => e.id === item.id);
|
||||
|
||||
if (idx !== -1) {
|
||||
state.value.existing.splice(idx, 1);
|
||||
|
||||
if (!state.value.removeIds.includes(item.id)) {
|
||||
state.value.removeIds.push(item.id);
|
||||
}
|
||||
@ -155,179 +167,104 @@ function triggerDropzonePicker() {
|
||||
|
||||
<template>
|
||||
<div class="media-dropzone-root">
|
||||
<Field>
|
||||
<FieldLabel :for="id" :required="required">
|
||||
{{ label }}
|
||||
</FieldLabel>
|
||||
<FieldDescription v-if="description">
|
||||
{{ description }}
|
||||
</FieldDescription>
|
||||
<FieldDescription v-else-if="maxFiles > 1">
|
||||
Maks. {{ maxFiles }} gambar. Gambar pertama akan dijadikan
|
||||
thumbnail.
|
||||
</FieldDescription>
|
||||
<Field>
|
||||
<FieldLabel :for="id" :required="required">
|
||||
{{ label }}
|
||||
</FieldLabel>
|
||||
<FieldDescription v-if="description">
|
||||
{{ description }}
|
||||
</FieldDescription>
|
||||
<FieldDescription v-else-if="maxFiles > 1">
|
||||
Maks. {{ maxFiles }} gambar. Gambar pertama akan dijadikan
|
||||
thumbnail.
|
||||
</FieldDescription>
|
||||
|
||||
<div class="dropzone-wrapper">
|
||||
<!-- Dropzone form: hidden when any preview exists to prevent layout corruption -->
|
||||
<div
|
||||
:class="[
|
||||
<div class="dropzone-wrapper">
|
||||
<!-- Dropzone form: hidden when any preview exists to prevent layout corruption -->
|
||||
<div :class="[
|
||||
'dz-form-host',
|
||||
{ 'dz-form-hidden': allPreviews.length > 0 },
|
||||
]"
|
||||
>
|
||||
<DropZone
|
||||
ref="dropzoneRef"
|
||||
:max-files="maxFiles"
|
||||
:max-file-size="maxFileSize"
|
||||
:accepted-files="acceptedFiles"
|
||||
:upload-on-drop="false"
|
||||
:clickable="true"
|
||||
dropzone-class-name="dz-box"
|
||||
dropzone-message-class-name="dz-message"
|
||||
@added-file="onAddedFile"
|
||||
@removed-file="onRemovedFile"
|
||||
>
|
||||
<template #message>
|
||||
<div class="dz-placeholder">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
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" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
]">
|
||||
<DropZone ref="dropzoneRef" :max-files="maxFiles" :max-file-size="maxFileSize"
|
||||
:accepted-files="acceptedFiles" :upload-on-drop="false" :clickable="true"
|
||||
dropzone-class-name="dz-box" dropzone-message-class-name="dz-message" @added-file="onAddedFile"
|
||||
@removed-file="onRemovedFile">
|
||||
<template #message>
|
||||
<div class="dz-placeholder">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24"
|
||||
fill="none" 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" />
|
||||
<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(', ') }} — Maks.
|
||||
{{ maxFiles }} file,
|
||||
{{ formatBytes(maxFileSize) }}/file
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</DropZone>
|
||||
</div>
|
||||
|
||||
<!-- Overlay: shown when any preview exists, triggers file picker on click -->
|
||||
<div v-if="allPreviews.length > 0" class="dz-overlay" @click="triggerDropzonePicker">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none"
|
||||
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" />
|
||||
<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(', ') }} — 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>
|
||||
<span class="dz-placeholder-primary"
|
||||
>Klik atau seret file ke sini</span
|
||||
>
|
||||
<span class="dz-placeholder-secondary">
|
||||
{{ acceptedFiles.join(', ') }} — Maks.
|
||||
{{ maxFiles }} file,
|
||||
{{ formatBytes(maxFileSize) }}/file
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</DropZone>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overlay: shown when any preview exists, triggers file picker on click -->
|
||||
<div
|
||||
v-if="allPreviews.length > 0"
|
||||
class="dz-overlay"
|
||||
@click="triggerDropzonePicker"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
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" />
|
||||
<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(', ') }} — 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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<FieldError :errors="errors" />
|
||||
<FieldError :errors="errors" />
|
||||
|
||||
<MediaPreviewDialog
|
||||
v-model:open="previewOpen"
|
||||
:url="previewUrl"
|
||||
:title="label"
|
||||
/>
|
||||
</Field>
|
||||
<MediaPreviewDialog v-model:open="previewOpen" :url="previewUrl" :title="label" />
|
||||
</Field>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@ -20,12 +20,8 @@ defineProps<{
|
||||
<DialogHeader class="sr-only">
|
||||
<DialogTitle>{{ title ?? 'Pratinjau Gambar' }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<img
|
||||
v-if="url"
|
||||
:src="url"
|
||||
:alt="title ?? 'Pratinjau gambar'"
|
||||
class="max-h-[80vh] w-full rounded-md object-contain"
|
||||
>
|
||||
<img v-if="url" :src="url" :alt="title ?? 'Pratinjau gambar'"
|
||||
class="max-h-[80vh] w-full rounded-md object-contain">
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@ -22,13 +22,8 @@ function openPreview(url: string) {
|
||||
|
||||
<template>
|
||||
<div v-if="items.length" class="flex items-center gap-1">
|
||||
<button
|
||||
v-for="item in visibleItems"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="size-8 overflow-hidden rounded border bg-muted/30"
|
||||
@click="openPreview(item.url)"
|
||||
>
|
||||
<button v-for="item in visibleItems" :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">
|
||||
</button>
|
||||
<span v-if="hiddenCount > 0" class="text-muted-foreground text-xs">
|
||||
|
||||
@ -108,13 +108,12 @@ const handleAddToCart = () => {
|
||||
|
||||
<!-- Image Thumbnails -->
|
||||
<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"
|
||||
@click="activeImageIndex = idx" :class="[
|
||||
'shrink-0 w-16 h-16 rounded-lg overflow-hidden border-2 transition-all cursor-pointer',
|
||||
activeImageIndex === idx
|
||||
? 'border-amber-500 shadow-md shadow-amber-200/40'
|
||||
: 'border-slate-200/60 opacity-60 hover:opacity-100'
|
||||
]">
|
||||
<button v-for="(img, idx) in allImages" :key="img.id" @click="activeImageIndex = idx" :class="[
|
||||
'shrink-0 w-16 h-16 rounded-lg overflow-hidden border-2 transition-all cursor-pointer',
|
||||
activeImageIndex === idx
|
||||
? 'border-amber-500 shadow-md shadow-amber-200/40'
|
||||
: 'border-slate-200/60 opacity-60 hover:opacity-100'
|
||||
]">
|
||||
<img :src="img.thumb_url || img.url" :alt="`Gambar ${idx + 1}`"
|
||||
class="w-full h-full object-cover" />
|
||||
</button>
|
||||
@ -226,6 +225,7 @@ const handleAddToCart = () => {
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogEmits, AlertDialogProps } from "reka-ui"
|
||||
import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui"
|
||||
import type { AlertDialogEmits, AlertDialogProps } from "reka-ui";
|
||||
import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui";
|
||||
|
||||
const props = defineProps<AlertDialogProps>()
|
||||
const emits = defineEmits<AlertDialogEmits>()
|
||||
@ -9,7 +9,7 @@ const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogRoot v-slot="slotProps" data-slot="alert-dialog" v-bind="forwarded">
|
||||
<slot v-bind="slotProps" />
|
||||
</AlertDialogRoot>
|
||||
<AlertDialogRoot v-slot="slotProps" data-slot="alert-dialog" v-bind="forwarded">
|
||||
<slot v-bind="slotProps" />
|
||||
</AlertDialogRoot>
|
||||
</template>
|
||||
|
||||
@ -12,7 +12,7 @@ const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
|
||||
<slot />
|
||||
</AlertDialogAction>
|
||||
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
|
||||
<slot />
|
||||
</AlertDialogAction>
|
||||
</template>
|
||||
|
||||
@ -12,14 +12,11 @@ const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogCancel
|
||||
v-bind="delegatedProps"
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'mt-2 sm:mt-0',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogCancel v-bind="delegatedProps" :class="cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'mt-2 sm:mt-0',
|
||||
props.class,
|
||||
)">
|
||||
<slot />
|
||||
</AlertDialogCancel>
|
||||
</template>
|
||||
|
||||
@ -3,15 +3,15 @@ import type { AlertDialogContentEmits, AlertDialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
AlertDialogContent,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
useForwardPropsEmits,
|
||||
AlertDialogContent,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
@ -23,22 +23,15 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay
|
||||
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"
|
||||
/>
|
||||
<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',
|
||||
props.class,
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay 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" />
|
||||
<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',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
">
|
||||
<slot />
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</template>
|
||||
|
||||
@ -3,7 +3,7 @@ import type { AlertDialogDescriptionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
AlertDialogDescription,
|
||||
AlertDialogDescription,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@ -13,11 +13,8 @@ const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogDescription
|
||||
data-slot="alert-dialog-description"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogDescription data-slot="alert-dialog-description" v-bind="delegatedProps"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)">
|
||||
<slot />
|
||||
</AlertDialogDescription>
|
||||
</template>
|
||||
|
||||
@ -1,22 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
:class="
|
||||
cn(
|
||||
<div data-slot="alert-dialog-footer" :class="cn(
|
||||
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
)
|
||||
">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -1,17 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<div data-slot="alert-dialog-header" :class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -11,11 +11,8 @@ const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogTitle
|
||||
data-slot="alert-dialog-title"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-lg font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogTitle data-slot="alert-dialog-title" v-bind="delegatedProps"
|
||||
:class="cn('text-lg font-semibold', props.class)">
|
||||
<slot />
|
||||
</AlertDialogTitle>
|
||||
</template>
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogTriggerProps } from "reka-ui"
|
||||
import { AlertDialogTrigger } from "reka-ui"
|
||||
import type { AlertDialogTriggerProps } from "reka-ui";
|
||||
import { AlertDialogTrigger } from "reka-ui";
|
||||
|
||||
const props = defineProps<AlertDialogTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogTrigger data-slot="alert-dialog-trigger" v-bind="props">
|
||||
<slot />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogTrigger data-slot="alert-dialog-trigger" v-bind="props">
|
||||
<slot />
|
||||
</AlertDialogTrigger>
|
||||
</template>
|
||||
|
||||
@ -1,18 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { AvatarRoot } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { AvatarRoot } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AvatarRoot
|
||||
data-slot="avatar"
|
||||
:class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AvatarRoot>
|
||||
<AvatarRoot data-slot="avatar"
|
||||
:class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)">
|
||||
<slot />
|
||||
</AvatarRoot>
|
||||
</template>
|
||||
|
||||
@ -11,11 +11,8 @@ const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AvatarFallback
|
||||
data-slot="avatar-fallback"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('bg-muted flex size-full items-center justify-center rounded-full', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AvatarFallback>
|
||||
<AvatarFallback data-slot="avatar-fallback" v-bind="delegatedProps"
|
||||
:class="cn('bg-muted flex size-full items-center justify-center rounded-full', props.class)">
|
||||
<slot />
|
||||
</AvatarFallback>
|
||||
</template>
|
||||
|
||||
@ -1,16 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { AvatarImageProps } from "reka-ui"
|
||||
import { AvatarImage } from "reka-ui"
|
||||
import type { AvatarImageProps } from "reka-ui";
|
||||
import { AvatarImage } from "reka-ui";
|
||||
|
||||
const props = defineProps<AvatarImageProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AvatarImage
|
||||
data-slot="avatar-image"
|
||||
v-bind="props"
|
||||
class="aspect-square size-full"
|
||||
>
|
||||
<slot />
|
||||
</AvatarImage>
|
||||
<AvatarImage data-slot="avatar-image" v-bind="props" class="aspect-square size-full">
|
||||
<slot />
|
||||
</AvatarImage>
|
||||
</template>
|
||||
|
||||
@ -8,19 +8,15 @@ import { cn } from "@/lib/utils"
|
||||
import { badgeVariants } from "."
|
||||
|
||||
const props = defineProps<PrimitiveProps & {
|
||||
variant?: BadgeVariants["variant"]
|
||||
class?: HTMLAttributes["class"]
|
||||
variant?: BadgeVariants["variant"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="badge"
|
||||
:class="cn(badgeVariants({ variant }), props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
<Primitive data-slot="badge" :class="cn(badgeVariants({ variant }), props.class)" v-bind="delegatedProps">
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
|
||||
@ -7,25 +7,19 @@ import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "."
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants["variant"]
|
||||
size?: ButtonVariants["size"]
|
||||
class?: HTMLAttributes["class"]
|
||||
variant?: ButtonVariants["variant"]
|
||||
size?: ButtonVariants["size"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: "button",
|
||||
as: "button",
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="button"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
<Primitive data-slot="button" :data-variant="variant" :data-size="size" :as="as" :as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)">
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
|
||||
@ -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">
|
||||
{{ formatter.custom(toDate(date), { month: 'short' }) }}
|
||||
</div>
|
||||
<NativeSelect
|
||||
class="text-xs h-8 pr-6 pl-2 text-transparent relative"
|
||||
:model-value="date.month"
|
||||
@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">
|
||||
<NativeSelect class="text-xs h-8 pr-6 pl-2 text-transparent relative" :model-value="date.month" @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">
|
||||
{{ formatter.custom(toDate(month), { month: 'short' }) }}
|
||||
</NativeSelectOption>
|
||||
</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">
|
||||
{{ formatter.custom(toDate(date), { year: 'numeric' }) }}
|
||||
</div>
|
||||
<NativeSelect
|
||||
class="text-xs h-8 pr-6 pl-2 text-transparent relative"
|
||||
:model-value="date.year"
|
||||
@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">
|
||||
<NativeSelect class="text-xs h-8 pr-6 pl-2 text-transparent relative" :model-value="date.year" @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">
|
||||
{{ formatter.custom(toDate(year), { year: 'numeric' }) }}
|
||||
</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
@ -89,13 +83,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</div>
|
||||
</DefineYearTemplate>
|
||||
|
||||
<CalendarRoot
|
||||
v-slot="{ grid, weekDays, date }"
|
||||
v-bind="forwarded"
|
||||
v-model:placeholder="placeholder"
|
||||
data-slot="calendar"
|
||||
:class="cn('p-3', props.class)"
|
||||
>
|
||||
<CalendarRoot v-slot="{ grid, weekDays, date }" v-bind="forwarded" v-model:placeholder="placeholder"
|
||||
data-slot="calendar" :class="cn('p-3', props.class)">
|
||||
<CalendarHeader class="pt-0">
|
||||
<nav class="flex items-center gap-1 absolute top-0 inset-x-0 justify-between">
|
||||
<CalendarPrevButton>
|
||||
@ -135,24 +124,15 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<CalendarGrid v-for="month in grid" :key="month.value.toString()">
|
||||
<CalendarGridHead>
|
||||
<CalendarGridRow>
|
||||
<CalendarHeadCell
|
||||
v-for="day in weekDays" :key="day"
|
||||
>
|
||||
<CalendarHeadCell v-for="day in weekDays" :key="day">
|
||||
{{ day }}
|
||||
</CalendarHeadCell>
|
||||
</CalendarGridRow>
|
||||
</CalendarGridHead>
|
||||
<CalendarGridBody>
|
||||
<CalendarGridRow v-for="(weekDates, index) in month.rows" :key="`weekDate-${index}`" class="mt-2 w-full">
|
||||
<CalendarCell
|
||||
v-for="weekDate in weekDates"
|
||||
:key="weekDate.toString()"
|
||||
:date="weekDate"
|
||||
>
|
||||
<CalendarCellTrigger
|
||||
:day="weekDate"
|
||||
:month="month.value"
|
||||
/>
|
||||
<CalendarCell v-for="weekDate in weekDates" :key="weekDate.toString()" :date="weekDate">
|
||||
<CalendarCellTrigger :day="weekDate" :month="month.value" />
|
||||
</CalendarCell>
|
||||
</CalendarGridRow>
|
||||
</CalendarGridBody>
|
||||
|
||||
@ -16,24 +16,20 @@ const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarCellTrigger
|
||||
data-slot="calendar-cell-trigger"
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'size-8 p-0 font-normal aria-selected:opacity-100 cursor-default',
|
||||
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
|
||||
// Selected
|
||||
'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',
|
||||
// Disabled
|
||||
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
|
||||
// Unavailable
|
||||
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
|
||||
// Outside months
|
||||
'data-[outside-view]:text-muted-foreground',
|
||||
props.class,
|
||||
)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<CalendarCellTrigger data-slot="calendar-cell-trigger" :class="cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'size-8 p-0 font-normal aria-selected:opacity-100 cursor-default',
|
||||
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
|
||||
// Selected
|
||||
'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',
|
||||
// Disabled
|
||||
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
|
||||
// Unavailable
|
||||
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
|
||||
// Outside months
|
||||
'data-[outside-view]:text-muted-foreground',
|
||||
props.class,
|
||||
)" v-bind="forwardedProps">
|
||||
<slot />
|
||||
</CalendarCellTrigger>
|
||||
</template>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user