theme and virtualized list

This commit is contained in:
pa
2026-01-18 20:50:58 +09:00
committed by Natsumi
parent 9081dbe2b1
commit 265e0f999c
30 changed files with 853 additions and 270 deletions
+4 -4
View File
@@ -1,6 +1,6 @@
<template>
<Dialog :open="visible" @update:open="(open) => (open ? null : handleClose())">
<DialogContent class="custom-nav-dialog">
<DialogContent class="custom-nav-dialog sm:min-w-[50vw]">
<DialogHeader>
<DialogTitle>{{ t('nav_menu.custom_nav.dialog_title') }}</DialogTitle>
</DialogHeader>
@@ -154,7 +154,7 @@
</div>
<div class="mt-2">
<a
class="cursor-pointer"
class="cursor-pointer text-blue-600"
@click.prevent="openExternalLink('https://remixicon.com/')">
https://remixicon.com/
</a>
@@ -542,18 +542,18 @@
flex-direction: column;
align-items: center;
gap: 12px;
max-height: 430px;
overflow-y: auto;
padding-right: 4px;
}
.custom-nav-entry {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
max-width: 420px;
width: 100%;
margin: 0 auto;
}
@@ -1,79 +1,62 @@
<template>
<Dialog v-model:open="socialStatusDialog.visible">
<DialogContent class="x-dialog sm:max-w-100">
<DialogContent class="x-dialog sm:-w-20">
<DialogHeader>
<DialogTitle>{{ t('dialog.social_status.header') }}</DialogTitle>
</DialogHeader>
<div>
<Select :model-value="socialStatusDialog.status" @update:modelValue="handleSocialStatusChange">
<SelectTrigger size="sm" style="margin-top: 10px; width: 100%">
<span class="flex items-center gap-2">
<i v-if="socialStatusDialog.status === 'join me'" class="x-user-status joinme"></i>
<i v-else-if="socialStatusDialog.status === 'active'" class="x-user-status online"></i>
<i v-else-if="socialStatusDialog.status === 'ask me'" class="x-user-status askme"></i>
<i v-else-if="socialStatusDialog.status === 'busy'" class="x-user-status busy"></i>
<i v-else-if="socialStatusDialog.status === 'offline'" class="x-user-status offline"></i>
<SelectValue :placeholder="t('dialog.social_status.status_placeholder')" />
</span>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="join me" :text-value="t('dialog.user.status.join_me')">
<i class="x-user-status joinme"></i> {{ t('dialog.user.status.join_me') }}
</SelectItem>
<SelectItem value="active" :text-value="t('dialog.user.status.online')">
<i class="x-user-status online"></i> {{ t('dialog.user.status.online') }}
</SelectItem>
<SelectItem value="ask me" :text-value="t('dialog.user.status.ask_me')">
<i class="x-user-status askme"></i> {{ t('dialog.user.status.ask_me') }}
</SelectItem>
<SelectItem value="busy" :text-value="t('dialog.user.status.busy')">
<i class="x-user-status busy"></i> {{ t('dialog.user.status.busy') }}
</SelectItem>
<SelectItem
v-if="currentUser.$isModerator"
value="offline"
:text-value="t('dialog.user.status.offline')">
<i class="x-user-status offline"></i> {{ t('dialog.user.status.offline') }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<div class="pt-6 pb-4 px-16">
<div class="flex items-center gap-2">
<InputGroupField
v-model="socialStatusDialog.statusDescription"
:placeholder="t('dialog.social_status.status_placeholder')"
:maxlength="32"
clearable>
</InputGroupField>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<InputGroupButton variant="outline" size="icon-lg">
<History class="text-lg" />
</InputGroupButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem v-if="!historyItems.length" disabled>
{{ t('dialog.social_status.history') }}
</DropdownMenuItem>
<DropdownMenuItem
v-for="item in historyItems"
:key="item.no ?? item.status"
@click="setSocialStatusFromHistory(item)">
{{ item.status }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<InputGroupField
v-model="socialStatusDialog.statusDescription"
:placeholder="t('dialog.social_status.status_placeholder')"
:maxlength="32"
clearable
show-count
class="mt-2.5" />
<Collapsible v-model:open="isOpen" class="mt-3 flex w-full flex-col gap-2">
<div class="flex items-center justify-between gap-4 px-4">
<h4 class="text-sm font-semibold">{{ t('dialog.social_status.history') }}</h4>
<CollapsibleTrigger as-child>
<Button variant="ghost" size="icon" class="size-8">
<ChevronsUpDown />
<span class="sr-only">Toggle</span>
</Button>
</CollapsibleTrigger>
</div>
<div
v-if="!isOpen && latestHistoryItem"
class="cursor-pointer rounded-md border w-full px-4 py-2 font-mono text-sm"
@click="setSocialStatusFromHistory(latestHistoryItem)">
{{ latestHistoryItem.status }}
</div>
<CollapsibleContent class="flex flex-col gap-2">
<div
v-for="item in historyItems"
:key="item.no ?? item.status"
class="cursor-pointer rounded-md border w-full px-4 py-2 font-mono text-sm"
@click="setSocialStatusFromHistory(item)">
{{ item.status }}
</div>
</CollapsibleContent></Collapsible
>
<div class="mt-6 flex flex-col gap-2" role="radiogroup">
<Item
v-for="option in statusOptions"
:key="option.value"
variant="outline"
size="sm"
role="radio"
tabindex="0"
:aria-checked="socialStatusDialog.status === option.value"
class="cursor-pointer hover:bg-accent"
@click="handleSocialStatusChange(option.value)"
@keydown.enter.prevent="handleSocialStatusChange(option.value)"
@keydown.space.prevent="handleSocialStatusChange(option.value)">
<ItemMedia>
<i class="x-user-status" :class="option.statusClass"></i>
</ItemMedia>
<ItemContent>
<ItemTitle>{{ option.label }}</ItemTitle>
</ItemContent>
<ItemActions>
<Check v-if="socialStatusDialog.status === option.value" class="text-base text-primary" />
</ItemActions>
</Item>
</div>
</div>
<DialogFooter>
@@ -86,13 +69,18 @@
</template>
<script setup lang="ts">
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { computed, ref } from 'vue';
import { Item, ItemActions, ItemContent, ItemMedia, ItemTitle } from '@/components/ui/item';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { Check, History } from 'lucide-vue-next';
import { InputGroupButton, InputGroupField } from '@/components/ui/input-group';
import { Button } from '@/components/ui/button';
import { ChevronsUpDown } from 'lucide-vue-next';
import { InputGroupField } from '@/components/ui/input-group';
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { toast } from 'vue-sonner';
import { useI18n } from 'vue-i18n';
@@ -114,9 +102,40 @@
}
});
const isOpen = ref(false);
const historyItems = computed(() => props.socialStatusHistoryTable?.data ?? []);
const latestHistoryItem = computed(() => historyItems.value[0] ?? null);
const statusOptions = computed(() => {
const options = [
{
value: 'join me',
statusClass: 'joinme',
label: t('dialog.user.status.join_me')
},
{
value: 'active',
statusClass: 'online',
label: t('dialog.user.status.online')
},
{
value: 'ask me',
statusClass: 'askme',
label: t('dialog.user.status.ask_me')
},
{
value: 'busy',
statusClass: 'busy',
label: t('dialog.user.status.busy')
}
];
if (currentUser.value?.$isModerator) {
options.push({
value: 'offline',
statusClass: 'offline',
label: t('dialog.user.status.offline')
});
}
return options;
});
function handleSocialStatusChange(value) {
props.socialStatusDialog.status = String(value);
+24
View File
@@ -0,0 +1,24 @@
<script setup>
import { Primitive } from "reka-ui";
import { cn } from "@/lib/utils";
import { itemVariants } from ".";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false, default: "div" },
class: { type: null, required: false },
variant: { type: null, required: false },
size: { type: null, required: false },
});
</script>
<template>
<Primitive
data-slot="item"
:as="as"
:as-child="asChild"
:class="cn(itemVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>
+16
View File
@@ -0,0 +1,16 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="item-actions"
:class="cn('flex items-center gap-2', props.class)"
>
<slot />
</div>
</template>
+21
View File
@@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="item-content"
:class="
cn(
'flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,22 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<p
data-slot="item-description"
:class="
cn(
'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
props.class,
)
"
>
<slot />
</p>
</template>
+18
View File
@@ -0,0 +1,18 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="item-footer"
:class="
cn('flex basis-full items-center justify-between gap-2', props.class)
"
>
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
role="list"
data-slot="item-group"
:class="cn('group/item-group flex flex-col', props.class)"
>
<slot />
</div>
</template>
+18
View File
@@ -0,0 +1,18 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="item-header"
:class="
cn('flex basis-full items-center justify-between gap-2', props.class)
"
>
<slot />
</div>
</template>
+19
View File
@@ -0,0 +1,19 @@
<script setup>
import { cn } from "@/lib/utils";
import { itemMediaVariants } from ".";
const props = defineProps({
class: { type: null, required: false },
variant: { type: null, required: false },
});
</script>
<template>
<div
data-slot="item-media"
:data-variant="props.variant"
:class="cn(itemMediaVariants({ variant }), props.class)"
>
<slot />
</div>
</template>
+20
View File
@@ -0,0 +1,20 @@
<script setup>
import { cn } from "@/lib/utils";
import { Separator } from '@/components/ui/separator';
const props = defineProps({
orientation: { type: String, required: false },
decorative: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
</script>
<template>
<Separator
data-slot="item-separator"
orientation="horizontal"
:class="cn('my-0', props.class)"
/>
</template>
+21
View File
@@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="item-title"
:class="
cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium',
props.class,
)
"
>
<slot />
</div>
</template>
+50
View File
@@ -0,0 +1,50 @@
import { cva } from "class-variance-authority";
export { default as Item } from "./Item.vue";
export { default as ItemActions } from "./ItemActions.vue";
export { default as ItemContent } from "./ItemContent.vue";
export { default as ItemDescription } from "./ItemDescription.vue";
export { default as ItemFooter } from "./ItemFooter.vue";
export { default as ItemGroup } from "./ItemGroup.vue";
export { default as ItemHeader } from "./ItemHeader.vue";
export { default as ItemMedia } from "./ItemMedia.vue";
export { default as ItemSeparator } from "./ItemSeparator.vue";
export { default as ItemTitle } from "./ItemTitle.vue";
export const itemVariants = cva(
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border-border",
muted: "bg-muted/50",
},
size: {
default: "p-4 gap-4 ",
sm: "py-3 px-4 gap-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
{
variants: {
variant: {
default: "bg-transparent",
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
image:
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
},
);