какие-то приколы, не помню

This commit is contained in:
2026-07-25 00:36:27 +06:00
parent ca8728c90c
commit 4584d42cb3
32 changed files with 647 additions and 125 deletions

View File

@@ -1,7 +1,9 @@
{
"permissions": {
"allow": [
"Bash(python -c \"import sys,json; p=json.load\\(sys.stdin\\); deps={**p.get\\('dependencies',{}\\), **p.get\\('devDependencies',{}\\)}; [print\\(k,v\\) for k in sorted\\(deps\\) if any\\(x in k for x in ['mediasoup','socket','vueuse','vue']\\)]\")"
"Bash(python -c \"import sys,json; p=json.load\\(sys.stdin\\); deps={**p.get\\('dependencies',{}\\), **p.get\\('devDependencies',{}\\)}; [print\\(k,v\\) for k in sorted\\(deps\\) if any\\(x in k for x in ['mediasoup','socket','vueuse','vue']\\)]\")",
"Bash(yarn vue-tsc *)",
"Bash(yarn eslint *)"
]
}
}

View File

@@ -24,6 +24,8 @@
"@zag-js/file-upload": "^1.41.0",
"@zag-js/file-utils": "^1.40.0",
"@zag-js/password-input": "^1.40.0",
"@zag-js/slider": "^1.41.2",
"@zag-js/switch": "^1.41.1",
"@zag-js/toggle": "^1.40.0",
"@zag-js/vue": "^1.40.0",
"date-fns": "^4.1.0",

View File

@@ -1,4 +1,4 @@
import type { ChatMessage } from '@shared/api/generated-chad-api.ts'
import type { Channel, ChatMessage } from '@shared/api/generated-chad-api.ts'
import { useAuth } from '@shared/composables/use-auth.ts'
import { useClients } from '@shared/composables/use-clients'
import { useEventBus } from '@shared/composables/use-event-bus.ts'
@@ -12,12 +12,10 @@ export default function () {
const { addClient, updateClient, removeClient, clear: clearClients } = useClients()
const eventBus = useEventBus()
watch(signaling.connected, (connected) => {
if (!connected)
watch(signaling.socket, (socket) => {
if (!socket)
return
const socket = signaling.socket.value!
socket.on('initialized', async ({ clients }) => {
addClient(...clients)
})
@@ -42,6 +40,22 @@ export default function () {
socket.on('chat:new-message', (message: ChatMessage) => {
eventBus.emit('chat:new-message', message)
})
socket.on('channel-created', async (channel: Channel) => {
eventBus.emit('channel:created', channel)
await queryClient.setQueryData(['channel-list'], (channels: Channel[]) => {
return [...channels, channel]
})
})
socket.on('channel-removed', async (channelId: Channel['id']) => {
eventBus.emit('channel:removed', channelId)
await queryClient.setQueryData(['channel-list'], (channels: Channel[]) => {
return channels.filter(channel => channel.id !== channelId)
})
})
})
watchEffect(() => {

View File

@@ -1,9 +1,10 @@
<template>
<ChadDialog :id="DIALOG_ID" title="Create channel">
<ChadInput v-model="name" label="Name" />
<ChadInput v-model="name" label="Name" placeholder="Enter channel name..." />
<ChadSwitch v-model="persistent" label="Persistent" style="margin-top: var(--space-4);" />
<template #actions>
<ChadButton style="grid-column: 2" :disabled="!valid" :loading="submitting" @click="submit()">
<ChadButton style="grid-column: 2" :disabled="!valid" :loading="submitting" size="lg" @click="submit()">
Create
</ChadButton>
</template>
@@ -14,6 +15,7 @@
import ChadButton from '@shared/components/ui/Button.vue'
import ChadDialog from '@shared/components/ui/Dialog.vue'
import ChadInput from '@shared/components/ui/Input.vue'
import ChadSwitch from '@shared/components/ui/Switch.vue'
import { useDialogManager } from '@shared/composables/use-dialog.ts'
import { computed, ref } from 'vue'
import { mCreateChannel } from '@/entities/channel/api/mCreateChannel.ts'
@@ -25,6 +27,7 @@ const dialogManager = useDialogManager()
const { mutateAsync: createChannel, isPending: submitting } = mCreateChannel()
const name = ref('')
const persistent = ref(false)
const valid = computed(() => {
return name.value.trim().length > 0
@@ -36,7 +39,7 @@ async function submit() {
await createChannel({
name: name.value,
persistent: false,
persistent: persistent.value,
})
dialogManager.close(DIALOG_ID)

View File

@@ -0,0 +1,93 @@
<template>
<div class="client-row" v-bind="api.getRootProps()">
<div class="client-row__header" v-bind="api.getTriggerProps()">
<ChadAvatar class="client-row__avatar" src="" :fallback="initials" size="sm" :highlighted="isMe" />
<span class="client-row__name">{{ client.username }}</span>
<div class="client-row__badges" />
</div>
<div class="client-row__settings" v-bind="api.getContentProps()">
<ChadSlider label="Volume" />
<div class="client-row__actions">
<ChadButton :icon="VolumeOffIcon" type="secondary" size="sm">
Mute
</ChadButton>
<ChadButton :icon="HandFistIcon" type="secondary" size="sm">
Poke
</ChadButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { ChadClient } from '@shared/types.ts'
import type { Props as CollapsibleProps } from '@zag-js/collapsible'
import { HandFistIcon, VolumeOffIcon } from '@lucide/vue'
import ChadAvatar from '@shared/components/ui/Avatar.vue'
import ChadButton from '@shared/components/ui/Button.vue'
import ChadSlider from '@shared/components/ui/Slider.vue'
import { useClients } from '@shared/composables/use-clients.ts'
import { connect as collapsibleConnect, machine as collapsibleMachine } from '@zag-js/collapsible'
import { normalizeProps, useMachine } from '@zag-js/vue'
import { computed } from 'vue'
const props = defineProps<{
client: ChadClient
}>()
const { self } = useClients()
const initials = computed(() => props.client.username.slice(props.client.username.length - 2))
const isMe = computed(() => self.value?.socketId === props.client.socketId)
const service = useMachine(collapsibleMachine, computed(() => {
return {
id: `client-${props.client.socketId}`,
} as CollapsibleProps
}))
const api = computed(() => collapsibleConnect(service, normalizeProps))
</script>
<style lang="scss">
.client-row {
padding-inline: var(--space-4);
&:hover,
&[data-state='open'] {
background-color: var(--grey-1);
}
&__header {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: var(--space-2);
overflow: hidden;
height: 40px;
}
&__name {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
&__settings {
padding-block: var(--space-2);
}
&__actions {
margin-top: var(--space-3);
display: flex;
align-items: center;
gap: var(--space-1);
}
}
</style>

View File

@@ -6,7 +6,7 @@
</div>
<template #actions>
<ChadButton full native-type="submit" :disabled="!valid" :loading="submitting">
<ChadButton full native-type="submit" :disabled="!valid" :loading="submitting" size="lg">
Let's go
</ChadButton>
</template>

View File

@@ -7,7 +7,7 @@
</div>
<template #actions>
<ChadButton full native-type="submit" :disabled="!valid" :loading="submitting">
<ChadButton full native-type="submit" :disabled="!valid" :loading="submitting" size="lg">
Create an account
</ChadButton>
</template>

View File

@@ -1,11 +1,19 @@
<template>
<div class="chad-avatar" :class="{ highlighted }" v-bind="api.getRootProps()">
<span class="chad-avatar__fallback" v-bind="api.getFallbackProps()">{{ fallback }}</span>
<img class="chad-avatar__image" :alt="fallback" :src="src" v-bind="api.getImageProps()">
<div
:class="[
bem.b(),
bem.is('highlighted', highlighted),
bem.exp(!!size, bem.m(`size_${size!}`)),
]"
v-bind="api.getRootProps()"
>
<span :class="bem.e('fallback')" v-bind="api.getFallbackProps()">{{ fallback }}</span>
<img :class="bem.e('image')" :alt="fallback" :src="src" v-bind="api.getImageProps()">
</div>
</template>
<script setup lang="ts">
import useBem from '@shared/composables/use-bem.ts'
import * as avatar from '@zag-js/avatar'
import { normalizeProps, useMachine } from '@zag-js/vue'
import { computed, useId } from 'vue'
@@ -21,6 +29,8 @@ defineProps<{
highlighted?: boolean
}>()
const bem = useBem('chad-avatar')
const service = useMachine(avatar.machine, { id: useId() })
const api = computed(() => avatar.connect(service, normalizeProps))
@@ -35,6 +45,12 @@ const api = computed(() => avatar.connect(service, normalizeProps))
width: 32px;
aspect-ratio: 1;
&--size_sm {
width: 24px;
outline-width: 1px;
outline-offset: -1px;
}
&__fallback {
@include font-label;
@@ -43,7 +59,13 @@ const api = computed(() => avatar.connect(service, normalizeProps))
line-height: 32px;
background-color: var(--grey-2);
#{$self}.highlighted & {
#{$self}--size_sm & {
@include font-micro;
line-height: 24px;
}
#{$self}.is-highlighted & {
background-color: var(--yellow);
}

View File

@@ -2,7 +2,8 @@
<button
:class="[
bem.b(),
bem.exp(!!type, bem.m(type!)),
bem.exp(!!type, bem.m(`type_${type!}`)),
bem.exp(!!size, bem.m(`size_${size!}`)),
bem.is('icon-only', iconOnly),
bem.is('loading', loading),
bem.is('disabled', disabled),
@@ -11,21 +12,20 @@
:disabled="disabled"
:type="nativeType"
>
<ChadSpinner v-if="loading" class="chad-button__spinner" />
<!-- <ChadSpinner v-if="loading" class="chad-button__spinner" /> -->
<template v-else>
<Component :is="icon" v-if="icon" class="chad-button__icon" />
<!-- <template v-else> -->
<Component :is="icon" v-if="icon" :class="bem.e('icon')" />
<span v-if="$slots.default">
<slot />
</span>
</template>
<!-- </template> -->
</button>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
import ChadSpinner from '@shared/components/ui/Spinner.vue'
import useBem from '@shared/composables/use-bem.ts'
import { computed, useSlots } from 'vue'
@@ -43,10 +43,11 @@ const props = withDefaults(
export interface ChadButtonProps {
loading?: boolean
disabled?: boolean
type?: 'secondary'
type?: 'secondary' | 'danger'
nativeType?: 'button' | 'submit'
icon?: Component
full?: boolean
size?: 'sm' | 'lg'
}
const slots = useSlots()
@@ -59,13 +60,15 @@ const iconOnly = computed(() => {
<style lang="scss">
.chad-button {
$self: &;
@include font-display-14;
flex-shrink: 0;
border: none;
color: var(--ink);
padding: 0 var(--space-3);
height: 44px;
height: 36px;
background-color: var(--yellow);
font-weight: 700;
text-align: center;
@@ -82,6 +85,7 @@ const iconOnly = computed(() => {
aspect-ratio: 1;
}
&:not(:disabled) {
&:hover,
&:focus {
background-color: var(--yellow-deep);
@@ -91,36 +95,72 @@ const iconOnly = computed(() => {
background-color: var(--ink);
color: var(--yellow);
}
}
&--secondary {
background-color: var(--grey-1);
&--size_sm {
@include font-body-bold;
height: 28px;
outline-width: 1px;
outline-offset: -1px;
}
&--size_lg {
height: 44px;
}
&--type_secondary {
background-color: var(--paper);
&:not(:disabled) {
&:hover,
&:focus {
background-color: var(--grey-2);
}
&:active {
background-color: var(--grey-3);
background-color: var(--yellow);
color: var(--ink);
}
}
}
&--type_danger {
outline-color: var(--red);
background-color: var(--paper);
color: var(--red);
&:not(:disabled) {
&:hover,
&:focus,
&:active {
background-color: var(--red);
color: var(--white);
}
}
}
&:disabled {
cursor: not-allowed;
background-color: var(--grey-1);
color: var(--grey-3);
opacity: 0.6;
}
&.is-loading {
background-color: var(--grey-1);
cursor: wait;
--stripe-width: 44px;
@include diagonal-stripes;
pointer-events: none;
}
&__icon {
&:not(:last-child) {
margin-right: var(--space-1);
}
#{$self}--size_sm & {
width: 16px;
}
}
}
</style>

View File

@@ -1,30 +1,32 @@
<template>
<Component :is="as" class="chad-card">
<div v-if="title || description" class="chad-card__header">
<p v-if="title" class="chad-card__title">
<Component :is="as" :class="bem.b()">
<div v-if="title || description" :class="bem.e('header')">
<p v-if="title" :class="bem.e('title')">
{{ title }}
</p>
<p v-if="description" class="chad-card__description">
<p v-if="description" :class="bem.e('description')">
{{ description }}
</p>
</div>
<div class="chad-card__content">
<div :class="bem.e('content')">
<slot />
</div>
<div v-if="!!slots.actions" class="chad-card__actions">
<div v-if="!!slots.actions" :class="bem.e('actions')">
<slot name="actions" />
</div>
<div v-if="!!slots.bottom" class="chad-card__bottom">
<div v-if="!!slots.bottom" :class="bem.e('bottom')">
<slot name="bottom" />
</div>
</Component>
</template>
<script setup lang="ts">
import useBem from '@shared/composables/use-bem.ts'
defineOptions({
name: 'ChadCard',
})
@@ -42,6 +44,8 @@ const slots = defineSlots<{
bottom: []
}>()
const bem = useBem('chad-card')
interface Props {
as?: string
title?: string

View File

@@ -1,26 +1,22 @@
<template>
<div
class="chad-input"
:class="{
'is-invalid': !!error,
}"
>
<label v-if="label" :for="inputId" class="chad-input__label">
<div :class="[bem.b(), bem.is('invalid', !!error)]">
<label v-if="label" :for="inputId" :class="bem.e('label')">
{{ label }}
</label>
<input :id="inputId" v-model="modelValue" class="chad-input__input" :placeholder="placeholder" type="text" autocomplete="off">
<input :id="inputId" v-model="modelValue" :class="bem.e('input')" :placeholder="placeholder" type="text" autocomplete="off">
<p v-if="helper" class="chad-input__helper">
<p v-if="helper" :class="bem.e('helper')">
{{ helper }}
</p>
<p v-if="error" class="chad-input__error">
<p v-if="error" :class="bem.e('error')">
{{ error }}
</p>
</div>
</template>
<script setup lang="ts">
import useBem from '@shared/composables/use-bem.ts'
import { computed, useId } from 'vue'
interface Props {
@@ -37,6 +33,8 @@ defineOptions({
const props = defineProps<Props>()
const bem = useBem('chad-input')
const modelValue = defineModel<string>('modelValue')
const inputId = computed(() => {

View File

@@ -1,11 +1,13 @@
<template>
<div class="chad-notification-badge">
<div :class="bem.b()">
<slot />
</div>
</template>
<script setup lang="ts">
import useBem from '@shared/composables/use-bem.ts'
const bem = useBem('chad-notification-badge')
</script>
<style lang="scss">

View File

@@ -1,13 +1,13 @@
<template>
<div class="chad-password-input" v-bind="api.getRootProps()">
<label v-if="label" class="chad-password-input__label" v-bind="api.getLabelProps()">
<div :class="bem.b()" v-bind="api.getRootProps()">
<label v-if="label" :class="bem.e('label')" v-bind="api.getLabelProps()">
{{ label }}
</label>
<div class="chad-password-input__control" v-bind="api.getControlProps()">
<input v-bind="api.getInputProps()" v-model="modelValue" class="chad-password-input__input">
<div :class="bem.e('control')" v-bind="api.getControlProps()">
<input v-bind="api.getInputProps()" v-model="modelValue" :class="bem.e('input')">
<button class="chad-password-input__visibility-trigger" v-bind="api.getVisibilityTriggerProps()">
<button :class="bem.e('visibility-trigger')" v-bind="api.getVisibilityTriggerProps()">
<Component :is="api.visible ? EyeIcon : EyeOffIcon " v-bind="api.getIndicatorProps()" />
</button>
</div>
@@ -16,6 +16,7 @@
<script setup lang="ts">
import { EyeIcon, EyeOffIcon } from '@lucide/vue'
import useBem from '@shared/composables/use-bem.ts'
import * as passwordInput from '@zag-js/password-input'
import { normalizeProps, useMachine } from '@zag-js/vue'
import { computed, useId } from 'vue'
@@ -34,6 +35,8 @@ defineOptions({
const props = defineProps<Props>()
const bem = useBem('chad-password-input')
const modelValue = defineModel<string>('modelValue')
const inputId = computed(() => {

View File

@@ -0,0 +1,86 @@
<template>
<div v-bind="api.getRootProps()" :class="[bem.b()]">
<div style="margin-bottom: var(--space-2);">
<label v-if="label" :class="bem.e('label')" v-bind="api.getLabelProps()">{{ label }}</label>
<output v-bind="api.getValueTextProps()">{{ api.value.at(0) }}</output>
</div>
<div :class="bem.e('control')" v-bind="api.getControlProps()">
<div :class="bem.e('track')" v-bind="api.getTrackProps()">
<div :class="bem.e('range')" v-bind="api.getRangeProps()" />
</div>
<div
v-for="(_, index) in api.value"
:key="index"
v-bind="api.getThumbProps({ index })"
:class="bem.e('thumb')"
>
<input v-bind="api.getHiddenInputProps({ index })">
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Props as SliderProps } from '@zag-js/slider'
import useBem from '@shared/composables/use-bem.ts'
import { connect, machine } from '@zag-js/slider'
import { normalizeProps, useMachine } from '@zag-js/vue'
import { computed, useId } from 'vue'
defineOptions({
name: 'ChadSlider',
})
const props = defineProps<ChadSliderProps>()
export interface ChadSliderProps {
label?: string
}
const id = useId()
const bem = useBem('chad-slider')
const service = useMachine(machine, computed(() => {
return {
id,
origin: 'center',
thumbAlignment: 'center',
} as SliderProps
}))
const api = computed(() => connect(service, normalizeProps))
</script>
<style lang="scss">
.chad-slider {
&__label {
@include font-micro;
color: var(--grey-3);
}
&__control {
background-color: var(--paper);
border: var(--border-w) solid var(--ink);
//outline-offset: calc(var(--border-w) * -1);
}
&__track {
height: 20px;
}
&__range {
background-color: var(--yellow);
height: 100%;
}
&__thumb {
width: 10px;
height: 28px;
background-color: var(--ink);
top: -4px;
outline: none;
}
}
</style>

View File

@@ -1,11 +1,15 @@
<template>
<div class="chad-spinner" />
<div :class="bem.b()" />
</template>
<script setup lang="ts">
import useBem from '@shared/composables/use-bem.ts'
defineOptions({
name: 'ChadSpinner',
})
const bem = useBem('chad-spinner')
</script>
<style lang="scss">

View File

@@ -0,0 +1,90 @@
<template>
<div :class="[bem.b()]">
<label :class="bem.e('root')" v-bind="api.getRootProps()">
<input v-bind="api.getHiddenInputProps()">
<span :class="bem.e('control')" v-bind="api.getControlProps()">
<span :class="bem.e('thumb')" v-bind="api.getThumbProps()" />
</span>
<span v-if="label" :class="bem.e('label')" v-bind="api.getLabelProps()">
<span>{{ label }}</span>
</span>
</label>
</div>
</template>
<script setup lang="ts">
import type { Props as SwitchProps } from '@zag-js/switch'
import useBem from '@shared/composables/use-bem'
import { connect, machine } from '@zag-js/switch'
import { normalizeProps, useMachine } from '@zag-js/vue'
import { computed, useId } from 'vue'
export interface ChadSwitchProps {
label?: string
}
defineOptions({
name: 'ChadSwitch',
})
const props = defineProps<ChadSwitchProps>()
const modelValue = defineModel('modelValue')
const id = useId()
const bem = useBem('chad-switch')
const service = useMachine(machine, computed(() => {
return {
id,
checked: modelValue.value,
onCheckedChange: (details) => {
modelValue.value = details.checked
},
} as SwitchProps
}))
const api = computed(() => connect(service, normalizeProps))
</script>
<style lang="scss">
.chad-switch {
&__root {
display: inline-flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
max-width: 100%;
overflow: hidden;
}
&__control {
border: var(--border-w) solid var(--ink);
height: var(--space-6);
aspect-ratio: 2;
position: relative;
display: flex;
padding: var(--border-w);
flex-shrink: 0;
}
&__thumb {
background-color: var(--ink);
height: 100%;
width: 50%;
&[data-state='checked'] {
background-color: var(--yellow);
translate: 100% 0;
}
}
&__label {
@include font-label;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
</style>

View File

@@ -1,10 +1,12 @@
<template>
<div class="chad-tag" :data-type="type">
<div :class="[bem.b(), bem.m(`type_${type}`)]">
<slot />
</div>
</template>
<script setup lang="ts">
import useBem from '@shared/composables/use-bem.ts'
interface Props {
type: 'success' | 'error'
}
@@ -14,6 +16,8 @@ defineOptions({
})
defineProps<Props>()
const bem = useBem('chad-tag')
</script>
<style lang="scss">
@@ -32,13 +36,13 @@ defineProps<Props>()
height: 10px;
}
&[data-type='success'] {
&--type_success {
&::before {
background-color: var(--green);
}
}
&[data-type='error'] {
&--type_error {
&::before {
background-color: var(--red);
}

View File

@@ -1,15 +1,18 @@
<template>
<button class="chad-to-bottom">
<button :class="bem.b()">
<ArrowDown />
</button>
</template>
<script setup lang="ts">
import { ArrowDown } from '@lucide/vue'
import useBem from '@shared/composables/use-bem.ts'
defineOptions({
name: 'ChadToBottom',
})
const bem = useBem('chad-to-bottom')
</script>
<style lang="scss">

View File

@@ -1,12 +1,13 @@
<template>
<button class="chad-toggle" v-bind="api.getRootProps()">
<span class="chad-toggle__indicator" v-bind="api.getIndicatorProps()" />
<button :class="bem.b()" v-bind="api.getRootProps()">
<span :class="bem.e('indicator')" v-bind="api.getIndicatorProps()" />
<slot />
</button>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
import useBem from '@shared/composables/use-bem.ts'
import * as toggle from '@zag-js/toggle'
import { normalizeProps, useMachine } from '@zag-js/vue'
import { computed } from 'vue'
@@ -20,6 +21,8 @@ const props = defineProps<{
icon?: Component
}>()
const bem = useBem('chad-toggle')
const modelValue = defineModel<boolean>('modelValue')
const service = useMachine(toggle.machine, computed(() => {

View File

@@ -1,4 +1,4 @@
import type { ChatMessage } from '@shared/api/generated-chad-api.ts'
import type { Channel, ChatMessage } from '@shared/api/generated-chad-api.ts'
import type { EventType } from 'mitt'
import mitt from 'mitt'
@@ -21,6 +21,10 @@ export interface AppEvents extends Record<EventType, unknown> {
// 'producer:paused': Producer
// 'producer:resumed': Producer
'channel:created': Channel
'channel:removed': Channel['id']
'channel:updated': Channel
'audio:muted': void
'audio:unmuted': void
'output:muted': void

View File

@@ -19,6 +19,12 @@
</header>
<aside class="sidebar">
<div class="sidebar__top">
<ChadButton type="secondary" full :icon="PlusIcon" @click="createChannelDialog.open()">
Create channel
</ChadButton>
</div>
<ChannelList class="sidebar__channel-list" />
<!-- <dl> -->
@@ -37,9 +43,6 @@
<!-- Logout -->
<!-- </ChadButton> -->
<ChadButton style="margin: var(--space-2)" type="secondary" @click="createChannelDialog.open()">
Create channel
</ChadButton>
<ControlPanel />
</aside>
@@ -50,6 +53,7 @@
</template>
<script setup lang="ts">
import { PlusIcon } from '@lucide/vue'
import ChadButton from '@shared/components/ui/Button.vue'
import { useApp } from '@shared/composables/use-app.js'
import { useDialog } from '@shared/composables/use-dialog.ts'
@@ -140,6 +144,10 @@ watchEffect(() => {
border-right: var(--border-w) solid var(--ink);
overflow-y: auto;
&__top {
padding: var(--space-3);
}
&__channel-list {
flex: 1;
}

View File

@@ -0,0 +1,8 @@
@keyframes diagonal-stripes {
from {
background-position-x: 0;
}
to {
background-position-x: var(--stripe-width);
}
}

View File

@@ -1,4 +1,5 @@
@use 'tokens.scss';
@use 'animations.scss';
html,
body,

View File

@@ -1 +0,0 @@
@forward 'mixins/typography';

View File

@@ -0,0 +1,11 @@
@mixin diagonal-stripes {
background-image: repeating-linear-gradient(
-45deg,
var(--grey-1) 25%,
var(--grey-1) 50%,
var(--grey-2) 50%,
var(--grey-2) 75%
);
background-size: var(--stripe-width) var(--stripe-width);
animation: diagonal-stripes 800ms infinite linear;
}

View File

@@ -0,0 +1,2 @@
@forward 'typography';
@forward 'animations';

View File

@@ -1,53 +1,119 @@
<template>
<li v-bind="api.getRootProps()" class="channel">
<div class="channel__header" v-bind="api.getTriggerProps()">
{{ channel.name }}
<div
class="channel"
:class="{
'is-active': isActive,
}"
>
<div class="channel__header" @click="joinChannel">
<span class="channel__name">{{ channel.name }}</span>
<div v-if="false" class="channel__actions">
<ChadButton size="sm" type="danger" :icon="TrashIcon" />
</div>
</div>
<div v-bind="api.getContentProps()" class="channel__content">
<p v-for="client in clients" :key="client.socketId">
{{ client.username }}
</p>
<div v-if="clients.length > 0" class="channel__content">
<ClientRow v-for="client in clients" :key="client.socketId" class="channel__client" :client="client" />
</div>
</div>
</li>
</template>
<script setup lang="ts">
import type { Channel } from '@shared/api/generated-chad-api.ts'
import { TrashIcon } from '@lucide/vue'
import ChadButton from '@shared/components/ui/Button.vue'
import { useClients } from '@shared/composables/use-clients.ts'
import * as collapsible from '@zag-js/collapsible'
import { normalizeProps, useMachine } from '@zag-js/vue'
import { useSignaling } from '@shared/composables/use-signaling.ts'
import { computed } from 'vue'
import ClientRow from '@/entities/client/ui/ClientRow.vue'
const props = defineProps<{
channel: Channel
}>()
const { findByChannel } = useClients()
const service = useMachine(collapsible.machine, computed(() => {
return {
id: props.channel.id,
defaultOpen: true,
}
}))
const api = computed(() => collapsible.connect(service, normalizeProps))
const { findByChannel, self } = useClients()
const { socket } = useSignaling()
const clients = computed(() => {
return findByChannel(props.channel.id)
})
const isActive = computed(() => {
return self.value?.channelId === props.channel.id
})
function joinChannel() {
if (isActive.value || !socket.value)
return
socket.value.emit('join-channel', { channelId: props.channel.id })
}
</script>
<style lang="scss">
.channel {
padding-inline: var(--space-3);
$self: &;
&__header {
@include font-micro;
padding-block: var(--space-2);
display: flex;
align-items: center;
position: relative;
padding-inline: var(--space-4);
color: var(--grey-3);
height: 40px;
cursor: pointer;
&::after {
position: absolute;
content: '';
top: 0;
bottom: 0;
left: 0;
width: var(--space-1);
}
&:hover,
&:focus {
background-color: var(--grey-1);
&::after {
background-color: var(--grey-2);
}
#{$self}__actions {
visibility: visible;
}
}
#{$self}.is-active &:after {
background-color: var(--yellow);
}
}
&__name {
flex: 1;
}
&__actions {
flex-shrink: 0;
justify-self: flex-end;
display: flex;
align-items: center;
gap: var(--space-1);
visibility: hidden;
}
&__content {
padding-block: var(--space-2);
}
&__client {
&:not(:last-child) {
margin-bottom: var(--space-1);
}
}
}
</style>

View File

@@ -24,8 +24,6 @@ const bem = useBem('chat-attachment')
<style lang="scss">
.chat-attachment {
--stripe-width: 20px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -39,24 +37,9 @@ const bem = useBem('chat-attachment')
color: var(--grey-3);
&.is-loading {
background-image: repeating-linear-gradient(
-45deg,
transparent 25%,
transparent 50%,
var(--grey-2) 50%,
var(--grey-2) 75%
);
background-size: var(--stripe-width) var(--stripe-width);
animation: loading 500ms infinite linear;
}
}
--stripe-width: 20px;
@keyframes loading {
from {
background-position-x: 0;
}
to {
background-position-x: var(--stripe-width);
@include diagonal-stripes;
}
}
</style>

View File

@@ -5,6 +5,7 @@
class="chat-input__add-attachment"
type="secondary"
:icon="Plus"
size="lg"
@click="fileUploadApi.openFilePicker()"
/>
@@ -18,6 +19,7 @@
<ChadButton
class="chat-input__send"
:loading="isUploading"
size="lg"
@click="sendMessage()"
>
Send

View File

@@ -24,8 +24,10 @@
</template>
<script setup lang="ts">
import type { ChatMessage } from '@shared/api/generated-chad-api.ts'
import ChadSpinner from '@shared/components/ui/Spinner.vue'
import ChadToBottom from '@shared/components/ui/ToBottom.vue'
import { useClients } from '@shared/composables/use-clients.ts'
import { useEventBus } from '@shared/composables/use-event-bus.ts'
import { useChatScroll } from '@widgets/chat/composables/use-chat-scroll.ts'
import ChatMessageGroup from '@widgets/chat/ui/ChatMessageGroup.vue'
@@ -33,6 +35,7 @@ import { onUnmounted, ref, useTemplateRef, watch } from 'vue'
import { useChatHistory } from '../composables/use-chat-history'
const eventBus = useEventBus()
const { self } = useClients()
const scrollRef = useTemplateRef('scroll')
@@ -64,8 +67,8 @@ onUnmounted(() => {
eventBus.off('chat:new-message', onNewChatMessage)
})
function onNewChatMessage() {
if (!arrivedState.start) {
function onNewChatMessage(message: ChatMessage) {
if (!arrivedState.start && message.senderUsername !== self.value?.username) {
hasUnreadMessages.value = true
}
}

View File

@@ -23,7 +23,7 @@ export default defineConfig({
preprocessorOptions: {
scss: {
additionalData: `
@use "@shared/styles/mixins.scss" as *;
@use "@shared/styles/mixins" as *;
`,
},
},

View File

@@ -1210,6 +1210,11 @@
resolved "https://registry.yarnpkg.com/@zag-js/anatomy/-/anatomy-1.41.1.tgz#9cb2a4c6506fd14bbd3387e523885257ed1e37d9"
integrity sha512-wBQVpl8TC9O5AjeJrnmNdJWEUYorTi7iklOcySeXIeaz6D7Y0YY0YbEOSFNsRTpn/NQHwkPejf3i5qkKavNHXw==
"@zag-js/anatomy@1.41.2":
version "1.41.2"
resolved "https://registry.yarnpkg.com/@zag-js/anatomy/-/anatomy-1.41.2.tgz#16569b7eb743cb4459fbaf218bdcd5fd72cea32d"
integrity sha512-Fm9hqdrvaCzCsdcf19G8WZxYtHElKltkGHdhqMEt4XU+ULTr1DK7KbOtDDv9J27CuzqSLALUz5QfRjPftoKHwg==
"@zag-js/aria-hidden@1.41.1":
version "1.41.1"
resolved "https://registry.yarnpkg.com/@zag-js/aria-hidden/-/aria-hidden-1.41.1.tgz#69c7c695c718baaaad4db9ae8ddae1c17d5bbfcb"
@@ -1255,6 +1260,14 @@
"@zag-js/dom-query" "1.41.1"
"@zag-js/utils" "1.41.1"
"@zag-js/core@1.41.2":
version "1.41.2"
resolved "https://registry.yarnpkg.com/@zag-js/core/-/core-1.41.2.tgz#d315e84ac55ec4038e5f40d870b3987613882fb3"
integrity sha512-xXTN3zKwOtMI4+5dG2cG+T1B4WR3X9alXiYaPJKaGd4N2eYRj9JEPte3Hv9gtFm+RM0b9VIwksHEg25rqE4apw==
dependencies:
"@zag-js/dom-query" "1.41.2"
"@zag-js/utils" "1.41.2"
"@zag-js/dialog@^1.41.1":
version "1.41.1"
resolved "https://registry.yarnpkg.com/@zag-js/dialog/-/dialog-1.41.1.tgz#12d3e5e341064103184dbfeeb1e77c0f7f0036a6"
@@ -1293,6 +1306,13 @@
dependencies:
"@zag-js/types" "1.41.1"
"@zag-js/dom-query@1.41.2":
version "1.41.2"
resolved "https://registry.yarnpkg.com/@zag-js/dom-query/-/dom-query-1.41.2.tgz#fad51d82f548a55c9116c76370224f0f3fc5f656"
integrity sha512-+eBk1nlJA312mNmY/GSThLRwcCRqMIL+A1pLsWvTlQLQjmH1/UxoAuv6l2yvRCT33XmC8FBlBIKnXhOCpDvIZA==
dependencies:
"@zag-js/types" "1.41.2"
"@zag-js/file-upload@^1.41.0":
version "1.41.0"
resolved "https://registry.yarnpkg.com/@zag-js/file-upload/-/file-upload-1.41.0.tgz#6ad2deb4d5edc9db1c46fda6823be3a9d404b625"
@@ -1320,6 +1340,13 @@
dependencies:
"@zag-js/dom-query" "1.41.1"
"@zag-js/focus-visible@1.41.1":
version "1.41.1"
resolved "https://registry.yarnpkg.com/@zag-js/focus-visible/-/focus-visible-1.41.1.tgz#5f27ded76a0ab81bd063e01165ecde68168a2ebf"
integrity sha512-uIPkVk7gTVFCEGuDTI/2f3tWEcbsX88SrjgIA31F/m3e5/ieDaeiTfq7pxn+naHwZKEhRNScAgwOpu2AQgc6ag==
dependencies:
"@zag-js/dom-query" "1.41.1"
"@zag-js/i18n-utils@1.41.0":
version "1.41.0"
resolved "https://registry.yarnpkg.com/@zag-js/i18n-utils/-/i18n-utils-1.41.0.tgz#da493ffac2482df38668a99d542d85a568516cc4"
@@ -1353,6 +1380,17 @@
dependencies:
"@zag-js/dom-query" "1.41.1"
"@zag-js/slider@^1.41.2":
version "1.41.2"
resolved "https://registry.yarnpkg.com/@zag-js/slider/-/slider-1.41.2.tgz#d5f5f5f39a925cdde39b301eaccbacced64fa78b"
integrity sha512-mKK2BwoDbIGxAdkdKkPZJA1SHtEQt3lS9hJ6WghefYU2vyd0BXoIKvcDV3xJOzly5LXYhH5cJITn6JtGK8353A==
dependencies:
"@zag-js/anatomy" "1.41.2"
"@zag-js/core" "1.41.2"
"@zag-js/dom-query" "1.41.2"
"@zag-js/types" "1.41.2"
"@zag-js/utils" "1.41.2"
"@zag-js/store@1.41.0":
version "1.41.0"
resolved "https://registry.yarnpkg.com/@zag-js/store/-/store-1.41.0.tgz#97a3ecc1bbb5fe8de51083e4dafaa0d0d3c2d741"
@@ -1360,6 +1398,18 @@
dependencies:
proxy-compare "3.0.1"
"@zag-js/switch@^1.41.1":
version "1.41.1"
resolved "https://registry.yarnpkg.com/@zag-js/switch/-/switch-1.41.1.tgz#81141dfbba50c151800cf4548782d458091b632a"
integrity sha512-69F4F/rGD8swQSPJY01J8To6qDVIWp87nfgNpsrUZ2CRcWxRCAjHqC3LwmVARg3pn8sa82jJPhw6VLJ81biXWQ==
dependencies:
"@zag-js/anatomy" "1.41.1"
"@zag-js/core" "1.41.1"
"@zag-js/dom-query" "1.41.1"
"@zag-js/focus-visible" "1.41.1"
"@zag-js/types" "1.41.1"
"@zag-js/utils" "1.41.1"
"@zag-js/toggle@^1.40.0":
version "1.41.0"
resolved "https://registry.yarnpkg.com/@zag-js/toggle/-/toggle-1.41.0.tgz#3245e651fc6f7ae2a2c4f86ae244ae90f3d3b517"
@@ -1385,6 +1435,13 @@
dependencies:
csstype "3.2.3"
"@zag-js/types@1.41.2":
version "1.41.2"
resolved "https://registry.yarnpkg.com/@zag-js/types/-/types-1.41.2.tgz#99e8eec960b6c6b5157e281be40cf380d0b7c8ed"
integrity sha512-L6CNvK06lIVpy0X8eG3kbDIx8Uuv+3KHElxXYSzRXSJ7/OLCv1sTRgEvnxNtdIWOrksGgxF4JtT7PXtoClGqNQ==
dependencies:
csstype "3.2.3"
"@zag-js/utils@1.41.0":
version "1.41.0"
resolved "https://registry.yarnpkg.com/@zag-js/utils/-/utils-1.41.0.tgz#61639369aeadf26f91fe009b4ddce926c5ba607c"
@@ -1395,6 +1452,11 @@
resolved "https://registry.yarnpkg.com/@zag-js/utils/-/utils-1.41.1.tgz#248fc28510d625034553fd9a4213147204fca323"
integrity sha512-IZGqDpQYvgCQlGcLTVCzWG5DEz318ZLVJhp8TtT9HPDNd+RJTcVHRja7z+vqQ0Su+wKZkuLlIh5gtraxQ+YX9Q==
"@zag-js/utils@1.41.2":
version "1.41.2"
resolved "https://registry.yarnpkg.com/@zag-js/utils/-/utils-1.41.2.tgz#1668137bc7b7bf5138203bac1feeeed0059c8e29"
integrity sha512-Yj8FSrR7vGA6ahUhjrThfHAF+PM2Y1Yv2lkXkqZZd60mPBhixcot1+SHOfEMV63JimQcWrmQ8QbeYYMmF+ZpLQ==
"@zag-js/vue@^1.40.0":
version "1.41.0"
resolved "https://registry.yarnpkg.com/@zag-js/vue/-/vue-1.41.0.tgz#7e5649c1d5e7979493987de66d25d3deda90976e"