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">
|
<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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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<{
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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(', ') }} — 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(', ') }} — 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(', ') }} — 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(', ') }} — 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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user