Initial vue frontend

This commit is contained in:
2025-08-12 01:05:59 +02:00
parent 64e50027ca
commit 58e0c10b4e
70 changed files with 16958 additions and 0 deletions

View File

@@ -0,0 +1,173 @@
<template>
<button
:type="type"
:disabled="disabled"
:class="[
'base-button',
`base-button--${variant}`,
`base-button--${size}`,
{ 'base-button--loading': loading }
]"
:aria-label="ariaLabel"
:aria-describedby="ariaDescribedby"
@click="$emit('click', $event)"
@keydown="handleKeydown"
>
<span v-if="loading" class="base-button__spinner" aria-hidden="true"></span>
<span :class="{ 'base-button__content--hidden': loading }">
<slot />
</span>
</button>
</template>
<script setup lang="ts">
interface Props {
type?: 'button' | 'submit' | 'reset'
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
loading?: boolean
ariaLabel?: string
ariaDescribedby?: string
}
withDefaults(defineProps<Props>(), {
type: 'button',
variant: 'primary',
size: 'md',
disabled: false,
loading: false
})
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
emit('click', event as any)
}
}
</script>
<style scoped>
.base-button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 8px;
font-family: inherit;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
outline: none;
text-decoration: none;
}
.base-button:focus-visible {
outline: 2px solid #646cff;
outline-offset: 2px;
}
.base-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Sizes */
.base-button--sm {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
}
.base-button--md {
padding: 0.75rem 1rem;
font-size: 1rem;
}
.base-button--lg {
padding: 1rem 1.5rem;
font-size: 1.125rem;
}
/* Variants */
.base-button--primary {
background-color: #646cff;
color: white;
}
.base-button--primary:hover:not(:disabled) {
background-color: #535bf2;
}
.base-button--secondary {
background-color: #f9f9f9;
color: #213547;
border-color: #d1d5db;
}
.base-button--secondary:hover:not(:disabled) {
background-color: #f3f4f6;
border-color: #9ca3af;
}
.base-button--danger {
background-color: #ef4444;
color: white;
}
.base-button--danger:hover:not(:disabled) {
background-color: #dc2626;
}
.base-button--ghost {
background-color: transparent;
color: #646cff;
}
.base-button--ghost:hover:not(:disabled) {
background-color: rgba(100, 108, 255, 0.1);
}
/* Loading state */
.base-button--loading {
cursor: wait;
}
.base-button__spinner {
position: absolute;
width: 1rem;
height: 1rem;
border: 2px solid transparent;
border-top: 2px solid currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.base-button__content--hidden {
opacity: 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.base-button--secondary {
background-color: #374151;
color: rgba(255, 255, 255, 0.87);
border-color: #4b5563;
}
.base-button--secondary:hover:not(:disabled) {
background-color: #4b5563;
border-color: #6b7280;
}
}
</style>

View File

@@ -0,0 +1,279 @@
<template>
<Teleport to="body">
<Transition name="dialog">
<div
v-if="show"
class="dialog-overlay"
@click="handleOverlayClick"
@keydown.esc="handleClose"
role="dialog"
:aria-labelledby="titleId"
:aria-describedby="contentId"
aria-modal="true"
>
<div
ref="dialogRef"
:class="[
'dialog',
`dialog--${size}`
]"
@click.stop
>
<div class="dialog__header" v-if="$slots.header || title">
<h2 :id="titleId" class="dialog__title">
<slot name="header">{{ title }}</slot>
</h2>
<button
v-if="closable"
class="dialog__close"
@click="handleClose"
aria-label="Close dialog"
type="button"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div :id="contentId" class="dialog__content">
<slot />
</div>
<div v-if="$slots.footer" class="dialog__footer">
<slot name="footer" />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, watch } from 'vue'
interface Props {
show: boolean
title?: string
size?: 'sm' | 'md' | 'lg' | 'xl'
closable?: boolean
closeOnOverlay?: boolean
}
const props = withDefaults(defineProps<Props>(), {
size: 'md',
closable: true,
closeOnOverlay: true
})
const emit = defineEmits<{
close: []
'update:show': [value: boolean]
}>()
const dialogRef = ref<HTMLDivElement>()
const titleId = computed(() => `dialog-title-${Math.random().toString(36).substr(2, 9)}`)
const contentId = computed(() => `dialog-content-${Math.random().toString(36).substr(2, 9)}`)
const handleClose = () => {
emit('close')
emit('update:show', false)
}
const handleOverlayClick = () => {
if (props.closeOnOverlay) {
handleClose()
}
}
// Focus management
let lastFocusedElement: HTMLElement | null = null
const trapFocus = (event: KeyboardEvent) => {
if (event.key !== 'Tab') return
const focusableElements = dialogRef.value?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
) as NodeListOf<HTMLElement>
if (!focusableElements || focusableElements.length === 0) return
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
if (event.shiftKey) {
if (document.activeElement === firstElement) {
event.preventDefault()
lastElement.focus()
}
} else {
if (document.activeElement === lastElement) {
event.preventDefault()
firstElement.focus()
}
}
}
watch(() => props.show, async (isVisible) => {
if (isVisible) {
lastFocusedElement = document.activeElement as HTMLElement
document.body.style.overflow = 'hidden'
document.addEventListener('keydown', trapFocus)
await nextTick()
// Focus first focusable element or the dialog itself
const firstFocusable = dialogRef.value?.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
) as HTMLElement
if (firstFocusable) {
firstFocusable.focus()
} else {
dialogRef.value?.focus()
}
} else {
document.body.style.overflow = ''
document.removeEventListener('keydown', trapFocus)
// Restore focus to the element that was focused before the dialog opened
if (lastFocusedElement) {
lastFocusedElement.focus()
lastFocusedElement = null
}
}
})
</script>
<style scoped>
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.dialog {
background: #ffffff;
border-radius: 12px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
outline: none;
}
.dialog--sm {
width: 100%;
max-width: 24rem;
}
.dialog--md {
width: 100%;
max-width: 32rem;
}
.dialog--lg {
width: 100%;
max-width: 48rem;
}
.dialog--xl {
width: 100%;
max-width: 64rem;
}
.dialog__header {
padding: 1.5rem 1.5rem 0;
display: flex;
align-items: center;
justify-content: space-between;
}
.dialog__title {
font-size: 1.25rem;
font-weight: 600;
color: #111827;
margin: 0;
}
.dialog__close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6b7280;
padding: 0.25rem;
line-height: 1;
transition: color 0.2s ease;
}
.dialog__close:hover {
color: #374151;
}
.dialog__close:focus-visible {
outline: 2px solid #646cff;
outline-offset: 2px;
border-radius: 4px;
}
.dialog__content {
padding: 1.5rem;
flex: 1;
overflow-y: auto;
}
.dialog__footer {
padding: 0 1.5rem 1.5rem;
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
/* Transitions */
.dialog-enter-active,
.dialog-leave-active {
transition: opacity 0.3s ease;
}
.dialog-enter-from,
.dialog-leave-to {
opacity: 0;
}
.dialog-enter-active .dialog,
.dialog-leave-active .dialog {
transition: transform 0.3s ease;
}
.dialog-enter-from .dialog,
.dialog-leave-to .dialog {
transform: scale(0.9);
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.dialog {
background: #1f2937;
}
.dialog__title {
color: rgba(255, 255, 255, 0.87);
}
.dialog__close {
color: #9ca3af;
}
.dialog__close:hover {
color: rgba(255, 255, 255, 0.87);
}
}
</style>

View File

@@ -0,0 +1,185 @@
<template>
<div class="base-input">
<label v-if="label" :for="inputId" class="base-input__label">
{{ label }}
<span v-if="required" class="base-input__required">*</span>
</label>
<div class="base-input__wrapper">
<input
:id="inputId"
ref="inputRef"
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:required="required"
:autocomplete="autocomplete"
:aria-invalid="error ? 'true' : 'false'"
:aria-describedby="error ? `${inputId}-error` : undefined"
:class="[
'base-input__field',
{ 'base-input__field--error': error }
]"
@input="handleInput"
@blur="$emit('blur', $event)"
@focus="$emit('focus', $event)"
@keydown="$emit('keydown', $event)"
@keyup="$emit('keyup', $event)"
/>
</div>
<div v-if="error" :id="`${inputId}-error`" class="base-input__error">
{{ error }}
</div>
<div v-else-if="helpText" class="base-input__help">
{{ helpText }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
interface Props {
modelValue: string | number
type?: string
label?: string
placeholder?: string
disabled?: boolean
readonly?: boolean
required?: boolean
autocomplete?: string
error?: string
helpText?: string
id?: string
}
const props = withDefaults(defineProps<Props>(), {
type: 'text',
disabled: false,
readonly: false,
required: false
})
const emit = defineEmits<{
'update:modelValue': [value: string | number]
blur: [event: FocusEvent]
focus: [event: FocusEvent]
keydown: [event: KeyboardEvent]
keyup: [event: KeyboardEvent]
}>()
const inputRef = ref<HTMLInputElement>()
const inputId = computed(() => props.id || `input-${Math.random().toString(36).substr(2, 9)}`)
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement
const value = props.type === 'number' ? parseFloat(target.value) || 0 : target.value
emit('update:modelValue', value)
}
const focus = () => {
inputRef.value?.focus()
}
defineExpose({
focus,
inputRef
})
</script>
<style scoped>
.base-input {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.base-input__label {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
}
.base-input__required {
color: #ef4444;
}
.base-input__wrapper {
position: relative;
}
.base-input__field {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
background-color: #ffffff;
color: #111827;
transition: all 0.2s ease;
outline: none;
}
.base-input__field:focus {
border-color: #646cff;
box-shadow: 0 0 0 3px rgba(100, 108, 255, 0.1);
}
.base-input__field:disabled {
background-color: #f9fafb;
color: #9ca3af;
cursor: not-allowed;
}
.base-input__field:readonly {
background-color: #f9fafb;
cursor: default;
}
.base-input__field--error {
border-color: #ef4444;
}
.base-input__field--error:focus {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
.base-input__error {
font-size: 0.875rem;
color: #ef4444;
}
.base-input__help {
font-size: 0.875rem;
color: #6b7280;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.base-input__label {
color: rgba(255, 255, 255, 0.87);
}
.base-input__field {
background-color: #374151;
color: rgba(255, 255, 255, 0.87);
border-color: #4b5563;
}
.base-input__field:disabled,
.base-input__field:readonly {
background-color: #1f2937;
color: #9ca3af;
}
.base-input__help {
color: #9ca3af;
}
}
</style>

View File

@@ -0,0 +1,228 @@
<template>
<div class="base-textarea">
<label v-if="label" :for="textareaId" class="base-textarea__label">
{{ label }}
<span v-if="required" class="base-textarea__required">*</span>
</label>
<div class="base-textarea__wrapper">
<textarea
:id="textareaId"
ref="textareaRef"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:required="required"
:rows="rows"
:maxlength="maxlength"
:aria-invalid="error ? 'true' : 'false'"
:aria-describedby="error ? `${textareaId}-error` : undefined"
:class="[
'base-textarea__field',
{ 'base-textarea__field--error': error }
]"
@input="handleInput"
@blur="$emit('blur', $event)"
@focus="$emit('focus', $event)"
@keydown="handleKeydown"
@keyup="$emit('keyup', $event)"
/>
</div>
<div v-if="showCharCount && maxlength" class="base-textarea__char-count">
{{ modelValue.length }}/{{ maxlength }}
</div>
<div v-if="error" :id="`${textareaId}-error`" class="base-textarea__error">
{{ error }}
</div>
<div v-else-if="helpText" class="base-textarea__help">
{{ helpText }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
interface Props {
modelValue: string
label?: string
placeholder?: string
disabled?: boolean
readonly?: boolean
required?: boolean
rows?: number
maxlength?: number
showCharCount?: boolean
error?: string
helpText?: string
id?: string
autoResize?: boolean
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
readonly: false,
required: false,
rows: 3,
showCharCount: false,
autoResize: false
})
const emit = defineEmits<{
'update:modelValue': [value: string]
blur: [event: FocusEvent]
focus: [event: FocusEvent]
keydown: [event: KeyboardEvent]
keyup: [event: KeyboardEvent]
submit: []
}>()
const textareaRef = ref<HTMLTextAreaElement>()
const textareaId = computed(() => props.id || `textarea-${Math.random().toString(36).substr(2, 9)}`)
const handleInput = (event: Event) => {
const target = event.target as HTMLTextAreaElement
emit('update:modelValue', target.value)
if (props.autoResize) {
autoResize(target)
}
}
const handleKeydown = (event: KeyboardEvent) => {
emit('keydown', event)
// Submit on Ctrl+Enter or Cmd+Enter
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
event.preventDefault()
emit('submit')
}
}
const autoResize = (textarea: HTMLTextAreaElement) => {
textarea.style.height = 'auto'
textarea.style.height = textarea.scrollHeight + 'px'
}
const focus = () => {
textareaRef.value?.focus()
}
const selectAll = () => {
textareaRef.value?.select()
}
defineExpose({
focus,
selectAll,
textareaRef
})
</script>
<style scoped>
.base-textarea {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.base-textarea__label {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
}
.base-textarea__required {
color: #ef4444;
}
.base-textarea__wrapper {
position: relative;
}
.base-textarea__field {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
background-color: #ffffff;
color: #111827;
transition: all 0.2s ease;
outline: none;
resize: vertical;
min-height: 3rem;
}
.base-textarea__field:focus {
border-color: #646cff;
box-shadow: 0 0 0 3px rgba(100, 108, 255, 0.1);
}
.base-textarea__field:disabled {
background-color: #f9fafb;
color: #9ca3af;
cursor: not-allowed;
resize: none;
}
.base-textarea__field:readonly {
background-color: #f9fafb;
cursor: default;
resize: none;
}
.base-textarea__field--error {
border-color: #ef4444;
}
.base-textarea__field--error:focus {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
.base-textarea__char-count {
font-size: 0.75rem;
color: #6b7280;
text-align: right;
}
.base-textarea__error {
font-size: 0.875rem;
color: #ef4444;
}
.base-textarea__help {
font-size: 0.875rem;
color: #6b7280;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.base-textarea__label {
color: rgba(255, 255, 255, 0.87);
}
.base-textarea__field {
background-color: #374151;
color: rgba(255, 255, 255, 0.87);
border-color: #4b5563;
}
.base-textarea__field:disabled,
.base-textarea__field:readonly {
background-color: #1f2937;
color: #9ca3af;
}
.base-textarea__help,
.base-textarea__char-count {
color: #9ca3af;
}
}
</style>

View File

@@ -0,0 +1,121 @@
<template>
<span class="icon" :class="[`icon-${name}`, sizeClass]">
<!-- Microphone -->
<svg v-if="name === 'microphone'" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C13.1 2 14 2.9 14 4V10C14 11.1 13.1 12 12 12S10 11.1 10 10V4C10 2.9 10.9 2 12 2M19 10V12C19 15.3 16.3 18 13 18V22H11V18C7.7 18 5 15.3 5 12V10H7V12C7 14.2 8.8 16 11 16H13C15.2 16 17 14.2 17 12V10H19Z"/>
</svg>
<!-- Stop -->
<svg v-else-if="name === 'stop'" viewBox="0 0 24 24" fill="currentColor">
<path d="M18,18H6V6H18V18Z"/>
</svg>
<!-- Play -->
<svg v-else-if="name === 'play'" viewBox="0 0 24 24" fill="currentColor">
<path d="M8,5.14V19.14L19,12.14L8,5.14Z"/>
</svg>
<!-- Send -->
<svg v-else-if="name === 'send'" viewBox="0 0 24 24" fill="currentColor">
<path d="M2,21L23,12L2,3V10L17,12L2,14V21Z"/>
</svg>
<!-- Trash -->
<svg v-else-if="name === 'trash'" viewBox="0 0 24 24" fill="currentColor">
<path d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"/>
</svg>
<!-- Warning -->
<svg v-else-if="name === 'warning'" viewBox="0 0 24 24" fill="currentColor">
<path d="M13,13H11V7H13M13,17H11V15H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z"/>
</svg>
<!-- Info -->
<svg v-else-if="name === 'info'" viewBox="0 0 24 24" fill="currentColor">
<path d="M13,9H11V7H13M13,17H11V11H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z"/>
</svg>
<!-- Camera -->
<svg v-else-if="name === 'camera'" viewBox="0 0 24 24" fill="currentColor">
<path d="M4,4H7L9,2H15L17,4H20A2,2 0 0,1 22,6V18A2,2 0 0,1 20,20H4A2,2 0 0,1 2,18V6A2,2 0 0,1 4,4M12,7A5,5 0 0,0 7,12A5,5 0 0,0 12,17A5,5 0 0,0 17,12A5,5 0 0,0 12,7M12,9A3,3 0 0,1 15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9Z"/>
</svg>
<!-- Upload -->
<svg v-else-if="name === 'upload'" viewBox="0 0 24 24" fill="currentColor">
<path d="M9,16V10H5L12,3L19,10H15V16H9M5,20V18H19V20H5Z"/>
</svg>
<!-- Close -->
<svg v-else-if="name === 'close'" viewBox="0 0 24 24" fill="currentColor">
<path d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"/>
</svg>
<!-- Search -->
<svg v-else-if="name === 'search'" viewBox="0 0 24 24" fill="currentColor">
<path d="M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z"/>
</svg>
<!-- Plus -->
<svg v-else-if="name === 'plus'" viewBox="0 0 24 24" fill="currentColor">
<path d="M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z"/>
</svg>
<!-- Settings -->
<svg v-else-if="name === 'settings'" viewBox="0 0 24 24" fill="currentColor">
<path d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.22,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.22,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.68 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z"/>
</svg>
<!-- Default fallback -->
<svg v-else viewBox="0 0 24 24" fill="currentColor">
<path d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z"/>
</svg>
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
name: string
size?: 'sm' | 'md' | 'lg' | 'xl'
}
const props = withDefaults(defineProps<Props>(), {
size: 'md'
})
const sizeClass = computed(() => `icon-${props.size}`)
</script>
<style scoped>
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon svg {
width: 100%;
height: 100%;
}
.icon-sm {
width: 1rem;
height: 1rem;
}
.icon-md {
width: 1.25rem;
height: 1.25rem;
}
.icon-lg {
width: 1.5rem;
height: 1.5rem;
}
.icon-xl {
width: 2rem;
height: 2rem;
}
</style>

View File

@@ -0,0 +1,70 @@
<template>
<header class="chat-header">
<h2 class="chat-title">{{ channelName }}</h2>
<div class="chat-actions">
<BaseButton
variant="ghost"
size="sm"
@click="$emit('search')"
aria-label="Search messages"
>
🔍
</BaseButton>
</div>
</header>
</template>
<script setup lang="ts">
import BaseButton from '@/components/base/BaseButton.vue'
interface Props {
channelName: string
}
defineProps<Props>()
defineEmits<{
search: []
}>()
</script>
<style scoped>
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
background: white;
border-bottom: 1px solid #e5e7eb;
min-height: 64px;
}
.chat-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #111827;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.chat-header {
background: #1f2937;
border-bottom-color: #374151;
}
.chat-title {
color: rgba(255, 255, 255, 0.87);
}
}
</style>

View File

@@ -0,0 +1,106 @@
<template>
<div class="file-attachment">
<!-- Image files -->
<ImageMessage
v-if="isImageFile"
:file="file"
/>
<!-- Audio/voice files -->
<VoiceMessage
v-else-if="isAudioFile"
:file="file"
/>
<!-- Other files -->
<FileMessage
v-else
:file="file"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ImageMessage from './ImageMessage.vue'
import VoiceMessage from './VoiceMessage.vue'
import FileMessage from './FileMessage.vue'
import type { FileAttachment } from '@/types'
interface Props {
file: FileAttachment
}
const props = defineProps<Props>()
const fileExtension = computed(() => {
return props.file.original_name.split('.').pop()?.toLowerCase() || ''
})
const isImageFile = computed(() => {
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp']
return imageExtensions.includes(fileExtension.value)
})
const isAudioFile = computed(() => {
const audioExtensions = ['mp3', 'wav', 'webm', 'ogg', 'aac', 'm4a']
return audioExtensions.includes(fileExtension.value)
})
</script>
<style scoped>
.file-attachment {
margin: 0.25rem 0;
}
.file-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 6px;
text-decoration: none;
color: #374151;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.file-link:hover {
background: #e5e7eb;
border-color: #9ca3af;
}
.file-name {
flex: 1;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
font-size: 0.75rem;
color: #6b7280;
font-weight: 400;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.file-link {
background: #374151;
border-color: #4b5563;
color: rgba(255, 255, 255, 0.87);
}
.file-link:hover {
background: #4b5563;
border-color: #6b7280;
}
.file-size {
color: rgba(255, 255, 255, 0.6);
}
}
</style>

View File

@@ -0,0 +1,458 @@
<template>
<div class="file-message">
<div
class="file-container"
@click="handleFileClick"
:class="{ 'clickable': isPreviewable }"
>
<div class="file-icon">
<Icon :name="fileIcon" size="md" />
</div>
<div class="file-info">
<div class="file-name">{{ file.original_name }}</div>
<div class="file-meta">
<span class="file-size">{{ formatFileSize(file.file_size) }}</span>
<span class="file-type">{{ file.file_type }}</span>
</div>
<div v-if="isPreviewable" class="preview-hint">
Click to preview
</div>
</div>
<button
@click.stop="downloadFile"
class="download-button"
title="Download"
>
<Icon name="download" size="sm" />
</button>
</div>
<!-- File preview modal -->
<teleport to="body">
<div
v-if="showPreview"
class="file-modal"
@click="showPreview = false"
@keydown.escape="showPreview = false"
>
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>{{ file.original_name }}</h3>
<button @click="showPreview = false" class="close-button">
<Icon name="x" size="sm" />
</button>
</div>
<div class="preview-container">
<!-- Text preview -->
<div v-if="isTextFile && previewContent" class="text-preview">
<pre>{{ previewContent }}</pre>
</div>
<!-- PDF preview -->
<iframe
v-else-if="isPdfFile"
:src="fileUrl"
class="pdf-preview"
></iframe>
<!-- Generic file info -->
<div v-else class="file-details">
<Icon :name="fileIcon" size="xl" />
<p>Cannot preview this file type</p>
<button @click="downloadFile" class="download-file-button">
<Icon name="download" size="sm" />
Download File
</button>
</div>
</div>
</div>
</div>
</teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { apiService } from '@/services/api'
import Icon from '@/components/base/Icon.vue'
import type { FileAttachment } from '@/types'
interface Props {
file: FileAttachment
}
const props = defineProps<Props>()
const showPreview = ref(false)
const previewContent = ref<string>('')
const loading = ref(false)
const fileUrl = computed(() => apiService.getFileUrl(props.file.file_path))
const fileExtension = computed(() => {
return props.file.original_name.split('.').pop()?.toLowerCase() || ''
})
const fileIcon = computed(() => {
const ext = fileExtension.value
if (['pdf'].includes(ext)) {
return 'file-text'
} else if (['doc', 'docx'].includes(ext)) {
return 'file-text'
} else if (['xls', 'xlsx'].includes(ext)) {
return 'table'
} else if (['zip', 'rar', '7z'].includes(ext)) {
return 'archive'
} else if (['txt', 'md', 'json', 'xml', 'csv'].includes(ext)) {
return 'file-text'
} else {
return 'file'
}
})
const isTextFile = computed(() => {
const textExtensions = ['txt', 'md', 'json', 'xml', 'csv', 'log', 'js', 'ts', 'css', 'html']
return textExtensions.includes(fileExtension.value)
})
const isPdfFile = computed(() => {
return fileExtension.value === 'pdf'
})
const isPreviewable = computed(() => {
return isTextFile.value || isPdfFile.value
})
const handleFileClick = async () => {
if (!isPreviewable.value) {
downloadFile()
return
}
if (isTextFile.value && !previewContent.value) {
await loadTextPreview()
}
showPreview.value = true
}
const loadTextPreview = async () => {
try {
loading.value = true
const response = await fetch(fileUrl.value)
const text = await response.text()
// Limit preview size to prevent UI issues
if (text.length > 50000) {
previewContent.value = text.slice(0, 50000) + '\n\n... (file truncated for preview)'
} else {
previewContent.value = text
}
} catch (error) {
console.error('Failed to load file preview:', error)
previewContent.value = 'Error loading file preview'
} finally {
loading.value = false
}
}
const downloadFile = async () => {
try {
const response = await fetch(fileUrl.value)
const blob = await response.blob()
const blobUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = blobUrl
link.download = props.file.original_name
link.click()
// Clean up the blob URL after download
setTimeout(() => URL.revokeObjectURL(blobUrl), 100)
} catch (error) {
console.error('Failed to download file:', error)
// Fallback to direct link
const link = document.createElement('a')
link.href = fileUrl.value
link.download = props.file.original_name
link.target = '_blank'
link.click()
}
}
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
</script>
<style scoped>
.file-message {
margin: 0.5rem 0;
max-width: 400px;
}
.file-container {
display: flex;
align-items: center;
gap: 0.75rem;
background: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 0.75rem;
transition: all 0.2s ease;
}
.file-container.clickable {
cursor: pointer;
}
.file-container.clickable:hover {
background: #e5e7eb;
border-color: #3b82f6;
transform: translateY(-1px);
}
.file-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
background: #3b82f6;
color: white;
border-radius: 8px;
flex-shrink: 0;
}
.file-info {
flex: 1;
min-width: 0;
}
.file-name {
font-weight: 500;
color: #374151;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 0.25rem;
}
.file-meta {
display: flex;
gap: 0.5rem;
font-size: 0.75rem;
color: #6b7280;
}
.preview-hint {
font-size: 0.75rem;
color: #3b82f6;
margin-top: 0.25rem;
}
.download-button {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: transparent;
color: #6b7280;
border: 1px solid #d1d5db;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.download-button:hover {
background: #f9fafb;
color: #374151;
border-color: #9ca3af;
}
/* Modal styles */
.file-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
cursor: pointer;
}
.modal-content {
background: white;
border-radius: 12px;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
cursor: default;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h3 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #374151;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.close-button {
background: transparent;
border: none;
cursor: pointer;
padding: 0.25rem;
color: #6b7280;
}
.close-button:hover {
color: #374151;
}
.preview-container {
flex: 1;
overflow: auto;
padding: 1rem;
}
.text-preview {
max-height: 70vh;
overflow: auto;
}
.text-preview pre {
white-space: pre-wrap;
word-wrap: break-word;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.875rem;
line-height: 1.4;
color: #374151;
margin: 0;
}
.pdf-preview {
width: 100%;
height: 70vh;
border: none;
}
.file-details {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
color: #6b7280;
}
.download-file-button {
display: flex;
align-items: center;
gap: 0.5rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
padding: 0.75rem 1.5rem;
cursor: pointer;
margin-top: 1rem;
transition: background 0.2s ease;
}
.download-file-button:hover {
background: #2563eb;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.file-container {
background: #374151;
border-color: #4b5563;
}
.file-container.clickable:hover {
background: #4b5563;
border-color: #60a5fa;
}
.file-name {
color: rgba(255, 255, 255, 0.87);
}
.file-meta {
color: rgba(255, 255, 255, 0.6);
}
.preview-hint {
color: #60a5fa;
}
.download-button {
color: rgba(255, 255, 255, 0.6);
border-color: #4b5563;
}
.download-button:hover {
background: #4b5563;
color: rgba(255, 255, 255, 0.87);
border-color: #6b7280;
}
.modal-content {
background: #1f2937;
}
.modal-header {
border-bottom-color: #374151;
}
.modal-header h3 {
color: rgba(255, 255, 255, 0.87);
}
.close-button {
color: rgba(255, 255, 255, 0.6);
}
.close-button:hover {
color: rgba(255, 255, 255, 0.87);
}
.text-preview pre {
color: rgba(255, 255, 255, 0.87);
}
.file-details {
color: rgba(255, 255, 255, 0.6);
}
}
</style>

View File

@@ -0,0 +1,256 @@
<template>
<div class="image-message">
<div
class="image-thumbnail"
@click="showFullSize = true"
:style="{ cursor: 'pointer' }"
>
<img
:src="imageUrl"
:alt="file.original_name"
class="thumbnail"
@error="imageError = true"
/>
<div class="image-overlay">
<Icon name="search" size="sm" />
</div>
</div>
<div class="image-info">
<span class="image-name">{{ file.original_name }}</span>
<span class="image-size">{{ formatFileSize(file.file_size) }}</span>
</div>
<!-- Full-size image modal -->
<teleport to="body">
<div
v-if="showFullSize"
class="image-modal"
@click="showFullSize = false"
@keydown.escape="showFullSize = false"
>
<div class="modal-content" @click.stop>
<img
:src="imageUrl"
:alt="file.original_name"
class="full-image"
/>
<div class="modal-actions">
<button @click="downloadImage" class="action-button">
<Icon name="download" size="sm" />
Download
</button>
<button @click="showFullSize = false" class="action-button">
<Icon name="x" size="sm" />
Close
</button>
</div>
</div>
</div>
</teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { apiService } from '@/services/api'
import Icon from '@/components/base/Icon.vue'
import type { FileAttachment } from '@/types'
interface Props {
file: FileAttachment
}
const props = defineProps<Props>()
const showFullSize = ref(false)
const imageError = ref(false)
const imageUrl = computed(() => apiService.getFileUrl(props.file.file_path))
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
const downloadImage = async () => {
try {
const response = await fetch(imageUrl.value)
const blob = await response.blob()
const blobUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = blobUrl
link.download = props.file.original_name
link.click()
// Clean up the blob URL after download
setTimeout(() => URL.revokeObjectURL(blobUrl), 100)
} catch (error) {
console.error('Failed to download image:', error)
// Fallback to direct link
const link = document.createElement('a')
link.href = imageUrl.value
link.download = props.file.original_name
link.target = '_blank'
link.click()
}
}
// Close modal on escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && showFullSize.value) {
showFullSize.value = false
}
})
</script>
<style scoped>
.image-message {
margin: 0.5rem 0;
max-width: 300px;
}
.image-thumbnail {
position: relative;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e5e7eb;
transition: all 0.2s ease;
}
.image-thumbnail:hover {
border-color: #3b82f6;
transform: scale(1.02);
}
.image-thumbnail:hover .image-overlay {
opacity: 1;
}
.thumbnail {
width: 100%;
height: auto;
max-height: 200px;
object-fit: cover;
display: block;
}
.image-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 0.5rem;
border-radius: 50%;
opacity: 0;
transition: opacity 0.2s ease;
}
.image-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: #f9fafb;
font-size: 0.75rem;
}
.image-name {
font-weight: 500;
color: #374151;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.image-size {
color: #6b7280;
margin-left: 0.5rem;
}
/* Modal styles */
.image-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
cursor: pointer;
}
.modal-content {
position: relative;
max-width: 90vw;
max-height: 90vh;
cursor: default;
}
.full-image {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
border-radius: 8px;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: center;
margin-top: 1rem;
}
.action-button {
display: flex;
align-items: center;
gap: 0.5rem;
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
padding: 0.5rem 1rem;
cursor: pointer;
transition: all 0.2s ease;
}
.action-button:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.3);
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.image-thumbnail {
border-color: #4b5563;
}
.image-thumbnail:hover {
border-color: #60a5fa;
}
.image-info {
background: #374151;
}
.image-name {
color: rgba(255, 255, 255, 0.87);
}
.image-size {
color: rgba(255, 255, 255, 0.6);
}
}
</style>

View File

@@ -0,0 +1,73 @@
<template>
<div class="input-actions">
<BaseButton
variant="ghost"
size="sm"
@click="$emit('file-upload')"
aria-label="Upload file"
:disabled="disabled"
>
📎
</BaseButton>
<BaseButton
variant="ghost"
size="sm"
@click="$emit('camera')"
aria-label="Take photo"
:disabled="disabled"
>
📷
</BaseButton>
<BaseButton
variant="ghost"
size="sm"
@click="$emit('voice')"
aria-label="Record voice message"
:disabled="disabled"
>
🎤
</BaseButton>
<BaseButton
variant="primary"
size="sm"
@click="$emit('send')"
:disabled="!canSend || disabled"
aria-label="Send message"
>
Send
</BaseButton>
</div>
</template>
<script setup lang="ts">
import BaseButton from '@/components/base/BaseButton.vue'
interface Props {
disabled?: boolean
canSend?: boolean
}
withDefaults(defineProps<Props>(), {
disabled: false,
canSend: false
})
defineEmits<{
'file-upload': []
'camera': []
'voice': []
'send': []
}>()
</script>
<style scoped>
.input-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,97 @@
<template>
<div class="message-input-container">
<div class="message-input">
<BaseTextarea
v-model="messageText"
placeholder="Type a message..."
:rows="1"
auto-resize
@keydown="handleInputKeydown"
@submit="handleSubmit"
ref="textareaRef"
/>
<InputActions
:disabled="isDisabled"
:can-send="canSend"
@file-upload="$emit('file-upload')"
@camera="$emit('camera')"
@voice="$emit('voice')"
@send="handleSubmit"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useAppStore } from '@/stores/app'
import { useAudio } from '@/composables/useAudio'
import BaseTextarea from '@/components/base/BaseTextarea.vue'
import InputActions from './InputActions.vue'
const emit = defineEmits<{
'send-message': [content: string]
'file-upload': []
'camera': []
'voice': []
}>()
const appStore = useAppStore()
const { playWater, playSent } = useAudio()
const messageText = ref('')
const textareaRef = ref()
const currentChannelId = computed(() => appStore.currentChannelId)
const isDisabled = computed(() => !currentChannelId.value)
const canSend = computed(() => messageText.value.trim().length > 0 && !!currentChannelId.value)
const handleInputKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
handleSubmit()
}
}
const handleSubmit = () => {
if (!canSend.value) return
const content = messageText.value.trim()
messageText.value = ''
playWater()
emit('send-message', content)
}
const focus = () => {
textareaRef.value?.focus()
}
defineExpose({
focus
})
</script>
<style scoped>
.message-input-container {
padding: 1rem;
background: white;
border-top: 1px solid #e5e7eb;
}
.message-input {
display: flex;
align-items: flex-end;
gap: 0.75rem;
max-width: 100%;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.message-input-container {
background: #1f2937;
border-top-color: #374151;
}
}
</style>

View File

@@ -0,0 +1,277 @@
<template>
<div
:class="[
'message',
{ 'message--unsent': isUnsent }
]"
:data-message-id="message.id"
:tabindex="tabindex || 0"
:aria-label="messageAriaLabel"
role="listitem"
@keydown="handleKeydown"
>
<div class="message__content">
{{ message.content }}
</div>
<!-- File Attachment -->
<div v-if="hasFileAttachment && fileAttachment" class="message__files">
<FileAttachment :file="fileAttachment" />
</div>
<div class="message__meta">
<time v-if="!isUnsent && 'created_at' in message" class="message__time">
{{ formatTime(message.created_at) }}
</time>
<span v-else class="message__status">Sending...</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useAudio } from '@/composables/useAudio'
import { useToastStore } from '@/stores/toast'
import { useAppStore } from '@/stores/app'
import FileAttachment from './FileAttachment.vue'
import type { ExtendedMessage, UnsentMessage, FileAttachment as FileAttachmentType } from '@/types'
interface Props {
message: ExtendedMessage | UnsentMessage
isUnsent?: boolean
tabindex?: number
}
const props = withDefaults(defineProps<Props>(), {
isUnsent: false
})
// Debug message structure (removed for production)
const { speak, playSound } = useAudio()
const toastStore = useToastStore()
const appStore = useAppStore()
// Check if message has a file attachment
const hasFileAttachment = computed(() => {
return 'fileId' in props.message && !!props.message.fileId
})
// Create FileAttachment object from flattened message data
const fileAttachment = computed((): FileAttachmentType | null => {
if (!hasFileAttachment.value || !('fileId' in props.message)) return null
return {
id: props.message.fileId!,
channel_id: props.message.channel_id,
message_id: props.message.id,
file_path: props.message.filePath!,
file_type: props.message.fileType!,
file_size: props.message.fileSize!,
original_name: props.message.originalName!,
created_at: props.message.fileCreatedAt || props.message.created_at
}
})
const formatTime = (timestamp: string): string => {
return new Date(timestamp).toLocaleTimeString()
}
// Create comprehensive aria-label for screen readers
const messageAriaLabel = computed(() => {
let label = ''
// Add message content
if (props.message.content) {
label += props.message.content
}
// Add file attachment info if present
if (hasFileAttachment.value && fileAttachment.value) {
const file = fileAttachment.value
const fileType = getFileType(file.original_name)
label += `. Has ${fileType} attachment: ${file.original_name}`
}
// Add timestamp
if ('created_at' in props.message && props.message.created_at) {
const time = formatTime(props.message.created_at)
label += `. Sent at ${time}`
}
// Add status for unsent messages
if (props.isUnsent) {
label += '. Message is sending'
}
return label
})
// Helper to determine file type for better description
const getFileType = (filename: string): string => {
const ext = filename.split('.').pop()?.toLowerCase()
if (!ext) return 'file'
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext)) {
return 'image'
} else if (['mp3', 'wav', 'webm', 'ogg', 'aac', 'm4a'].includes(ext)) {
return 'voice'
} else if (['pdf'].includes(ext)) {
return 'PDF document'
} else if (['doc', 'docx'].includes(ext)) {
return 'Word document'
} else if (['txt', 'md'].includes(ext)) {
return 'text document'
} else {
return 'file'
}
}
const handleKeydown = (event: KeyboardEvent) => {
// Don't interfere with normal keyboard shortcuts (Ctrl+C, Ctrl+V, etc.)
if (event.ctrlKey || event.metaKey || event.altKey) {
return
}
if (event.key === 'c') {
// Copy message content (only when no modifiers are pressed)
navigator.clipboard.writeText(props.message.content)
playSound('copy')
toastStore.success('Message copied to clipboard')
} else if (event.key === 'r') {
// Read message aloud (only when no modifiers are pressed)
if (appStore.settings.ttsEnabled) {
speak(props.message.content)
toastStore.info('Reading message')
} else {
toastStore.info('Text-to-speech is disabled')
}
}
}
</script>
<style scoped>
.message {
padding: 0.75rem 1rem;
border: 1px solid transparent;
border-radius: 8px;
margin-bottom: 0.5rem;
transition: all 0.2s ease;
}
.message:hover,
.message:focus {
background: rgba(0, 0, 0, 0.02);
border-color: #e5e7eb;
outline: none;
}
.message:focus {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
border-color: #3b82f6;
}
.message--unsent {
opacity: 0.7;
background: #fef3c7;
border-color: #fbbf24;
}
.message--highlighted {
background: #dbeafe;
border-color: #3b82f6;
animation: highlight-fade 2s ease-out;
}
@keyframes highlight-fade {
0% {
background: #bfdbfe;
border-color: #2563eb;
}
100% {
background: #dbeafe;
border-color: #3b82f6;
}
}
.message__content {
font-size: 0.875rem;
line-height: 1.5;
color: #111827;
margin-bottom: 0.5rem;
white-space: pre-wrap;
word-wrap: break-word;
}
.message__files {
margin: 0.5rem 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.message__meta {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
}
.message__time {
font-size: 0.75rem;
color: #6b7280;
}
.message__status {
font-size: 0.75rem;
color: #f59e0b;
font-weight: 500;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.message:hover,
.message:focus {
background: rgba(255, 255, 255, 0.05);
border-color: #374151;
}
.message:focus {
border-color: #60a5fa;
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.1);
}
.message--unsent {
background: #451a03;
border-color: #92400e;
}
.message--highlighted {
background: #1e3a8a;
border-color: #60a5fa;
}
@keyframes highlight-fade {
0% {
background: #1e40af;
border-color: #3b82f6;
}
100% {
background: #1e3a8a;
border-color: #60a5fa;
}
}
.message__content {
color: rgba(255, 255, 255, 0.87);
}
.message__time {
color: rgba(255, 255, 255, 0.6);
}
.message__status {
color: #fbbf24;
}
}
</style>

View File

@@ -0,0 +1,250 @@
<template>
<div
class="messages-container"
ref="containerRef"
@keydown="handleKeydown"
tabindex="0"
role="list"
:aria-label="messagesAriaLabel"
:aria-description="navigationHint"
>
<div class="messages" role="presentation">
<!-- Regular Messages -->
<MessageItem
v-for="(message, index) in messages"
:key="message.id"
:message="message"
:tabindex="index === focusedMessageIndex ? 0 : -1"
:data-message-index="index"
@focus="focusedMessageIndex = index"
/>
<!-- Unsent Messages -->
<MessageItem
v-for="(unsentMsg, index) in unsentMessages"
:key="unsentMsg.id"
:message="unsentMsg"
:is-unsent="true"
:tabindex="(messages.length + index) === focusedMessageIndex ? 0 : -1"
:data-message-index="messages.length + index"
@focus="focusedMessageIndex = messages.length + index"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick, watch, computed } from 'vue'
import MessageItem from './MessageItem.vue'
import type { ExtendedMessage, UnsentMessage } from '@/types'
interface Props {
messages: ExtendedMessage[]
unsentMessages: UnsentMessage[]
}
const emit = defineEmits<{
'message-selected': [message: ExtendedMessage | UnsentMessage, index: number]
}>()
const props = defineProps<Props>()
const containerRef = ref<HTMLElement>()
const focusedMessageIndex = ref(0)
// Combined messages array for easier navigation
const allMessages = computed(() => [...props.messages, ...props.unsentMessages])
const totalMessages = computed(() => allMessages.value.length)
// ARIA labels for screen readers
const messagesAriaLabel = computed(() => {
const total = totalMessages.value
const current = focusedMessageIndex.value + 1
if (total === 0) {
return 'Messages list, no messages'
} else if (total === 1) {
return 'Messages list, 1 message'
} else {
return `Messages list, ${total} messages, currently focused on message ${current} of ${total}`
}
})
const navigationHint = 'Use arrow keys to navigate, Page Up/Down to jump 10 messages, Home/End for first/last, Enter to select'
// Keyboard navigation
const handleKeydown = (event: KeyboardEvent) => {
if (totalMessages.value === 0) return
let newIndex = focusedMessageIndex.value
switch (event.key) {
case 'ArrowUp':
event.preventDefault()
newIndex = Math.max(0, focusedMessageIndex.value - 1)
break
case 'ArrowDown':
event.preventDefault()
newIndex = Math.min(totalMessages.value - 1, focusedMessageIndex.value + 1)
break
case 'PageUp':
event.preventDefault()
newIndex = Math.max(0, focusedMessageIndex.value - 10)
break
case 'PageDown':
event.preventDefault()
newIndex = Math.min(totalMessages.value - 1, focusedMessageIndex.value + 10)
break
case 'Home':
event.preventDefault()
newIndex = 0
break
case 'End':
event.preventDefault()
newIndex = totalMessages.value - 1
break
case 'Enter':
case ' ':
event.preventDefault()
selectCurrentMessage()
return
default:
return
}
if (newIndex !== focusedMessageIndex.value) {
focusMessage(newIndex)
}
}
const focusMessage = (index: number) => {
focusedMessageIndex.value = index
nextTick(() => {
const messageElement = containerRef.value?.querySelector(`[data-message-index="${index}"]`) as HTMLElement
if (messageElement) {
messageElement.focus()
messageElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
})
}
const selectCurrentMessage = () => {
const currentMessage = allMessages.value[focusedMessageIndex.value]
if (currentMessage) {
emit('message-selected', currentMessage, focusedMessageIndex.value)
}
}
// Method to focus a specific message (for external use, like search results)
const focusMessageById = (messageId: string | number) => {
const index = allMessages.value.findIndex(msg => msg.id === messageId)
if (index !== -1) {
focusMessage(index)
}
}
const scrollToBottom = () => {
nextTick(() => {
if (containerRef.value) {
containerRef.value.scrollTop = containerRef.value.scrollHeight
}
})
}
// Watch for new messages and auto-scroll
watch(() => [props.messages.length, props.unsentMessages.length], () => {
// When new messages arrive, focus the last message and scroll to bottom
if (totalMessages.value > 0) {
focusedMessageIndex.value = totalMessages.value - 1
}
scrollToBottom()
})
// Reset focus when messages change significantly
watch(() => totalMessages.value, (newTotal) => {
if (focusedMessageIndex.value >= newTotal) {
focusedMessageIndex.value = Math.max(0, newTotal - 1)
}
})
onMounted(() => {
scrollToBottom()
// Focus the last message on mount
if (totalMessages.value > 0) {
focusedMessageIndex.value = totalMessages.value - 1
}
})
defineExpose({
scrollToBottom,
focusMessageById
})
</script>
<style scoped>
.messages-container {
flex: 1;
overflow-y: auto;
padding: 1rem;
background: #fafafa;
}
.messages-container:focus {
outline: 2px solid #3b82f6;
outline-offset: -2px;
}
.messages {
display: flex;
flex-direction: column;
min-height: 100%;
}
/* Scrollbar styling */
.messages-container::-webkit-scrollbar {
width: 8px;
}
.messages-container::-webkit-scrollbar-track {
background: #f1f5f9;
}
.messages-container::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.messages-container::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.messages-container {
background: #111827;
}
.messages-container:focus {
outline-color: #60a5fa;
}
.messages-container::-webkit-scrollbar-track {
background: #1f2937;
}
.messages-container::-webkit-scrollbar-thumb {
background: #4b5563;
}
.messages-container::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
}
</style>

View File

@@ -0,0 +1,318 @@
<template>
<div class="voice-message">
<div class="voice-player">
<button
@click="togglePlayback"
class="play-button"
:disabled="loading"
>
<Icon :name="isPlaying ? 'pause' : 'play'" size="sm" />
</button>
<div class="voice-info">
<div class="voice-waveform">
<div class="progress-bar">
<div
class="progress"
:style="{ width: `${progress}%` }"
></div>
</div>
</div>
<div class="voice-meta" aria-live="off">
<span class="duration">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
<span class="file-size">{{ formatFileSize(file.file_size) }}</span>
</div>
</div>
<button
@click="downloadVoice"
class="download-button"
title="Download"
>
<Icon name="download" size="sm" />
</button>
</div>
<div class="voice-filename">
{{ file.original_name }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onUnmounted } from 'vue'
import { apiService } from '@/services/api'
import Icon from '@/components/base/Icon.vue'
import type { FileAttachment } from '@/types'
interface Props {
file: FileAttachment
}
const props = defineProps<Props>()
const isPlaying = ref(false)
const loading = ref(false)
const currentTime = ref(0)
const duration = ref(0)
let audio: HTMLAudioElement | null = null
const audioUrl = computed(() => apiService.getFileUrl(props.file.file_path))
const progress = computed(() => {
return duration.value > 0 ? (currentTime.value / duration.value) * 100 : 0
})
const togglePlayback = async () => {
if (!audio) {
await initAudio()
}
if (!audio) return
if (isPlaying.value) {
audio.pause()
} else {
await audio.play()
}
}
const initAudio = async () => {
try {
loading.value = true
audio = new Audio(audioUrl.value)
audio.addEventListener('loadedmetadata', () => {
const audioDuration = audio!.duration
duration.value = isFinite(audioDuration) && !isNaN(audioDuration) ? audioDuration : 0
})
audio.addEventListener('timeupdate', () => {
currentTime.value = audio!.currentTime
})
audio.addEventListener('play', () => {
isPlaying.value = true
})
audio.addEventListener('pause', () => {
isPlaying.value = false
})
audio.addEventListener('ended', () => {
isPlaying.value = false
currentTime.value = 0
})
await audio.load()
} catch (error) {
console.error('Failed to load audio:', error)
} finally {
loading.value = false
}
}
const formatTime = (seconds: number): string => {
if (!isFinite(seconds) || isNaN(seconds)) {
return '0:00'
}
const minutes = Math.floor(seconds / 60)
const remainingSeconds = Math.floor(seconds % 60)
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
}
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
const downloadVoice = async () => {
try {
const response = await fetch(audioUrl.value)
const blob = await response.blob()
const blobUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = blobUrl
link.download = props.file.original_name
link.click()
// Clean up the blob URL after download
setTimeout(() => URL.revokeObjectURL(blobUrl), 100)
} catch (error) {
console.error('Failed to download voice message:', error)
// Fallback to direct link
const link = document.createElement('a')
link.href = audioUrl.value
link.download = props.file.original_name
link.target = '_blank'
link.click()
}
}
// Cleanup on component unmount
onUnmounted(() => {
if (audio) {
audio.pause()
audio.src = ''
audio = null
}
})
</script>
<style scoped>
.voice-message {
margin: 0.5rem 0;
max-width: 350px;
}
.voice-player {
display: flex;
align-items: center;
gap: 0.75rem;
background: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 0.75rem;
}
.play-button {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: #3b82f6;
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.play-button:hover:not(:disabled) {
background: #2563eb;
transform: scale(1.05);
}
.play-button:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.voice-info {
flex: 1;
min-width: 0;
}
.voice-waveform {
margin-bottom: 0.5rem;
}
.progress-bar {
height: 4px;
background: #e5e7eb;
border-radius: 2px;
overflow: hidden;
}
.progress {
height: 100%;
background: #3b82f6;
transition: width 0.1s ease;
}
.voice-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.75rem;
}
.duration {
color: #374151;
font-weight: 500;
}
.file-size {
color: #6b7280;
}
.download-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: transparent;
color: #6b7280;
border: 1px solid #d1d5db;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.download-button:hover {
background: #f9fafb;
color: #374151;
border-color: #9ca3af;
}
.voice-filename {
margin-top: 0.5rem;
font-size: 0.75rem;
color: #6b7280;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.voice-player {
background: #374151;
border-color: #4b5563;
}
.progress-bar {
background: #4b5563;
}
.progress {
background: #60a5fa;
}
.duration {
color: rgba(255, 255, 255, 0.87);
}
.file-size {
color: rgba(255, 255, 255, 0.6);
}
.download-button {
color: rgba(255, 255, 255, 0.6);
border-color: #4b5563;
}
.download-button:hover {
background: #4b5563;
color: rgba(255, 255, 255, 0.87);
border-color: #6b7280;
}
.voice-filename {
color: rgba(255, 255, 255, 0.6);
}
}
</style>

View File

@@ -0,0 +1,512 @@
<template>
<div class="camera-capture-dialog">
<div class="camera-container">
<!-- Camera Feed -->
<div class="camera-feed" v-if="!capturedImage">
<video
ref="videoElement"
autoplay
playsinline
muted
:class="{ 'mirrored': isFrontCamera }"
></video>
<!-- Camera Controls Overlay -->
<div class="camera-overlay">
<div class="camera-info">
<div class="camera-status" :class="{ 'active': isStreaming }">
<Icon name="camera" />
<span v-if="isStreaming">Camera Active</span>
<span v-else>Camera Inactive</span>
</div>
</div>
<!-- Switch Camera Button -->
<BaseButton
v-if="availableCameras.length > 1"
@click="switchCamera"
variant="secondary"
size="sm"
class="switch-camera-btn"
:disabled="!isStreaming"
>
<Icon name="camera" />
Switch
</BaseButton>
</div>
</div>
<!-- Captured Image Preview -->
<div class="image-preview" v-if="capturedImage">
<img
:src="capturedImage"
alt="Captured photo"
class="captured-photo"
/>
</div>
<!-- Error Message -->
<div class="error-message" v-if="errorMessage">
<Icon name="warning" />
{{ errorMessage }}
</div>
<!-- Camera Permission Info -->
<div class="permission-info" v-if="!hasPermission && !errorMessage">
<Icon name="info" />
<p>Camera access is required to take photos. Please grant permission when prompted.</p>
</div>
</div>
<!-- Capture Controls -->
<div class="capture-controls">
<div class="capture-buttons" v-if="!capturedImage">
<BaseButton
@click="capturePhoto"
variant="primary"
size="lg"
:disabled="!isStreaming"
class="capture-btn"
>
<Icon name="camera" />
Take Photo
</BaseButton>
</div>
<div class="review-buttons" v-if="capturedImage">
<BaseButton
@click="retakePhoto"
variant="secondary"
>
<Icon name="camera" />
Retake
</BaseButton>
<BaseButton
@click="sendPhoto"
variant="primary"
:disabled="isSending"
:loading="isSending"
>
<Icon name="send" />
Send Photo
</BaseButton>
</div>
</div>
<!-- Dialog Actions -->
<div class="dialog-actions">
<BaseButton
@click="closeDialog"
variant="secondary"
>
Cancel
</BaseButton>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useAppStore } from '@/stores/app'
import { useToastStore } from '@/stores/toast'
import { apiService } from '@/services/api'
import BaseButton from '@/components/base/BaseButton.vue'
import Icon from '@/components/base/Icon.vue'
const emit = defineEmits<{
close: []
sent: []
}>()
const appStore = useAppStore()
const toastStore = useToastStore()
// Refs
const videoElement = ref<HTMLVideoElement>()
const capturedImage = ref<string>()
const isStreaming = ref(false)
const hasPermission = ref(false)
const isSending = ref(false)
const errorMessage = ref('')
const availableCameras = ref<MediaDeviceInfo[]>([])
const currentCameraIndex = ref(0)
const isFrontCamera = ref(true)
// Stream management
let currentStream: MediaStream | null = null
// Methods
const initializeCamera = async () => {
try {
errorMessage.value = ''
// Get available cameras
const devices = await navigator.mediaDevices.enumerateDevices()
availableCameras.value = devices.filter(device => device.kind === 'videoinput')
if (availableCameras.value.length === 0) {
throw new Error('No cameras found')
}
// Start with front camera if available
const frontCamera = availableCameras.value.find(camera =>
camera.label.toLowerCase().includes('front') ||
camera.label.toLowerCase().includes('user')
)
if (frontCamera) {
currentCameraIndex.value = availableCameras.value.indexOf(frontCamera)
isFrontCamera.value = true
} else {
currentCameraIndex.value = 0
isFrontCamera.value = false
}
await startCamera()
hasPermission.value = true
} catch (error) {
console.error('Failed to initialize camera:', error)
errorMessage.value = 'Failed to access camera. Please check permissions and try again.'
hasPermission.value = false
}
}
const startCamera = async () => {
try {
// Stop current stream if exists
if (currentStream) {
currentStream.getTracks().forEach(track => track.stop())
}
const constraints: MediaStreamConstraints = {
video: {
deviceId: availableCameras.value[currentCameraIndex.value]?.deviceId,
width: { ideal: 1280 },
height: { ideal: 720 },
facingMode: isFrontCamera.value ? 'user' : 'environment'
}
}
currentStream = await navigator.mediaDevices.getUserMedia(constraints)
if (videoElement.value) {
videoElement.value.srcObject = currentStream
isStreaming.value = true
}
} catch (error) {
console.error('Failed to start camera:', error)
throw error
}
}
const switchCamera = async () => {
if (availableCameras.value.length <= 1) return
currentCameraIndex.value = (currentCameraIndex.value + 1) % availableCameras.value.length
// Determine if this is likely a front camera
const currentCamera = availableCameras.value[currentCameraIndex.value]
isFrontCamera.value = currentCamera.label.toLowerCase().includes('front') ||
currentCamera.label.toLowerCase().includes('user')
try {
await startCamera()
} catch (error) {
console.error('Failed to switch camera:', error)
toastStore.error('Failed to switch camera')
}
}
const capturePhoto = () => {
if (!videoElement.value || !isStreaming.value) return
try {
// Create canvas to capture frame
const canvas = document.createElement('canvas')
const video = videoElement.value
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Failed to get canvas context')
// Flip horizontally for front camera
if (isFrontCamera.value) {
ctx.scale(-1, 1)
ctx.drawImage(video, -canvas.width, 0, canvas.width, canvas.height)
} else {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
}
// Convert to data URL
capturedImage.value = canvas.toDataURL('image/jpeg', 0.8)
// Stop camera stream
stopCamera()
toastStore.success('Photo captured!')
} catch (error) {
console.error('Failed to capture photo:', error)
toastStore.error('Failed to capture photo')
}
}
const retakePhoto = () => {
capturedImage.value = undefined
initializeCamera()
}
const sendPhoto = async () => {
if (!capturedImage.value) return
isSending.value = true
errorMessage.value = ''
try {
// Create a message first to attach the photo to
const message = await apiService.createMessage(appStore.currentChannelId!, 'Photo')
// Convert data URL to blob
const response = await fetch(capturedImage.value)
const blob = await response.blob()
// Create file from blob
const file = new File([blob], `photo-${Date.now()}.jpg`, {
type: 'image/jpeg'
})
// Upload photo
await apiService.uploadFile(appStore.currentChannelId!, message.id, file)
toastStore.success('Photo sent!')
emit('sent')
emit('close')
} catch (error) {
console.error('Failed to send photo:', error)
errorMessage.value = 'Failed to send photo. Please try again.'
toastStore.error('Failed to send photo')
} finally {
isSending.value = false
}
}
const stopCamera = () => {
if (currentStream) {
currentStream.getTracks().forEach(track => track.stop())
currentStream = null
}
isStreaming.value = false
}
const closeDialog = () => {
stopCamera()
emit('close')
}
// Lifecycle
onMounted(() => {
initializeCamera()
})
onUnmounted(() => {
stopCamera()
})
</script>
<style scoped>
.camera-capture-dialog {
padding: 1rem 0;
min-width: 500px;
max-width: 600px;
}
.camera-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.camera-feed {
position: relative;
width: 100%;
max-width: 500px;
border-radius: 12px;
overflow: hidden;
background: #000;
aspect-ratio: 16/9;
}
.camera-feed video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.camera-feed video.mirrored {
transform: scaleX(-1);
}
.camera-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 1rem;
background: linear-gradient(to bottom, rgba(0,0,0,0.3), transparent);
}
.camera-info {
flex: 1;
}
.camera-status {
display: flex;
align-items: center;
gap: 0.5rem;
color: rgba(255, 255, 255, 0.8);
font-size: 0.875rem;
padding: 0.5rem 0.75rem;
background: rgba(0, 0, 0, 0.5);
border-radius: 20px;
backdrop-filter: blur(8px);
}
.camera-status.active {
color: #10b981;
}
.switch-camera-btn {
background: rgba(0, 0, 0, 0.5) !important;
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.2) !important;
color: white !important;
}
.image-preview {
width: 100%;
max-width: 500px;
border-radius: 12px;
overflow: hidden;
aspect-ratio: 16/9;
}
.captured-photo {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.error-message {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
color: #dc2626;
font-weight: 500;
max-width: 500px;
}
.permission-info {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 8px;
color: #0369a1;
max-width: 500px;
}
.permission-info p {
margin: 0;
font-size: 0.875rem;
}
.capture-controls {
display: flex;
justify-content: center;
margin-bottom: 2rem;
}
.capture-buttons, .review-buttons {
display: flex;
gap: 1rem;
}
.capture-btn {
padding: 1rem 2rem;
font-size: 1.125rem;
font-weight: 600;
border-radius: 50px;
min-width: 160px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.error-message {
background: #7f1d1d;
border-color: #991b1b;
color: #fca5a5;
}
.permission-info {
background: #1e3a8a;
border-color: #3b82f6;
color: #93c5fd;
}
.dialog-actions {
border-top-color: #374151;
}
}
/* Mobile responsiveness */
@media (max-width: 640px) {
.camera-capture-dialog {
min-width: unset;
max-width: unset;
width: 100%;
}
.camera-feed, .image-preview {
max-width: 100%;
}
.camera-overlay {
padding: 0.75rem;
}
.capture-btn {
padding: 0.875rem 1.5rem;
font-size: 1rem;
min-width: 140px;
}
.capture-buttons, .review-buttons {
flex-direction: column;
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,465 @@
<template>
<div class="channel-info-dialog">
<div class="info-section">
<BaseInput
v-model="channelName"
label="Channel name"
placeholder="Enter channel name"
ref="nameInput"
/>
<BaseInput
v-model="channelIdDisplay"
label="Channel ID (for API use)"
readonly
/>
</div>
<div class="actions-section">
<div class="action-group">
<h3>Channel Actions</h3>
<BaseButton
@click="makeDefault"
variant="secondary"
:disabled="isDefault"
>
{{ isDefault ? 'Already Default' : 'Make Default Channel' }}
</BaseButton>
<BaseButton
@click="showMergeDialog = true"
variant="secondary"
:disabled="availableChannels.length === 0"
>
Merge Channel
</BaseButton>
<BaseButton
@click="showDeleteConfirm = true"
variant="danger"
>
Delete Channel
</BaseButton>
</div>
</div>
<div class="dialog-actions">
<BaseButton @click="cancel" variant="secondary">
Cancel
</BaseButton>
<BaseButton @click="save" :loading="saving">
Save Changes
</BaseButton>
</div>
<!-- Merge Channel Dialog -->
<BaseDialog v-model:show="showMergeDialog" title="Merge Channel" size="md">
<div class="merge-dialog">
<p class="merge-warning">
This will move all messages from "{{ channel.name }}" into the selected target channel,
then delete this channel. This action cannot be undone.
</p>
<div class="merge-form">
<label for="target-channel">Merge into:</label>
<select
id="target-channel"
v-model="selectedTargetChannel"
class="target-select"
>
<option value="">Select target channel...</option>
<option
v-for="ch in availableChannels"
:key="ch.id"
:value="ch.id"
>
{{ ch.name }}
</option>
</select>
</div>
<div class="merge-actions">
<BaseButton @click="showMergeDialog = false" variant="secondary">
Cancel
</BaseButton>
<BaseButton
@click="performMerge"
variant="danger"
:disabled="!selectedTargetChannel"
:loading="merging"
>
Merge Channels
</BaseButton>
</div>
</div>
</BaseDialog>
<!-- Delete Confirmation Dialog -->
<BaseDialog v-model:show="showDeleteConfirm" title="Delete Channel" size="md">
<div class="delete-dialog">
<p class="delete-warning">
Are you sure you want to delete "{{ channel.name }}"?
This will permanently delete all messages in this channel.
This action cannot be undone.
</p>
<div class="delete-actions">
<BaseButton @click="showDeleteConfirm = false" variant="secondary">
Cancel
</BaseButton>
<BaseButton
@click="performDelete"
variant="danger"
:loading="deleting"
>
Delete Channel
</BaseButton>
</div>
</div>
</BaseDialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useAppStore } from '@/stores/app'
import { useToastStore } from '@/stores/toast'
import { apiService } from '@/services/api'
import { syncService } from '@/services/sync'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseDialog from '@/components/base/BaseDialog.vue'
import type { Channel } from '@/types'
interface Props {
channel: Channel
}
const emit = defineEmits<{
close: []
'channel-updated': [channel: Channel]
'channel-deleted': [channelId: number]
'channel-merged': [sourceId: number, targetId: number]
}>()
const props = defineProps<Props>()
const appStore = useAppStore()
const toastStore = useToastStore()
// Form state
const channelName = ref(props.channel.name)
const channelIdDisplay = ref(props.channel.id.toString())
const saving = ref(false)
// Dialog states
const showMergeDialog = ref(false)
const showDeleteConfirm = ref(false)
const selectedTargetChannel = ref<number | null>(null)
const merging = ref(false)
const deleting = ref(false)
// Input ref for focus
const nameInput = ref()
// Computed properties
const isDefault = computed(() =>
appStore.settings.defaultChannelId === props.channel.id
)
const availableChannels = computed(() =>
appStore.channels.filter(ch => ch.id !== props.channel.id)
)
// Actions
const makeDefault = async () => {
try {
await appStore.updateSettings({ defaultChannelId: props.channel.id })
toastStore.success(`${props.channel.name} is now the default channel`)
} catch (error) {
console.error('Failed to set default channel:', error)
toastStore.error('Failed to set default channel')
}
}
const save = async () => {
if (!channelName.value.trim()) {
toastStore.error('Channel name is required')
return
}
try {
saving.value = true
// Try online update first
try {
await apiService.updateChannel(props.channel.id, channelName.value.trim())
// Update local store
const updatedChannel = { ...props.channel, name: channelName.value.trim() }
const channelIndex = appStore.channels.findIndex(ch => ch.id === props.channel.id)
if (channelIndex !== -1) {
appStore.channels[channelIndex] = updatedChannel
await appStore.saveState()
}
emit('channel-updated', updatedChannel)
toastStore.success('Channel updated successfully')
} catch (error) {
// Offline fallback - update locally only
console.log('Offline mode: updating channel locally')
const updatedChannel = { ...props.channel, name: channelName.value.trim() }
const channelIndex = appStore.channels.findIndex(ch => ch.id === props.channel.id)
if (channelIndex !== -1) {
appStore.channels[channelIndex] = updatedChannel
await appStore.saveState()
}
emit('channel-updated', updatedChannel)
toastStore.success('Channel updated locally (will sync when online)')
}
emit('close')
} catch (error) {
console.error('Failed to update channel:', error)
toastStore.error('Failed to update channel')
} finally {
saving.value = false
}
}
const performMerge = async () => {
if (!selectedTargetChannel.value) return
try {
merging.value = true
// Try online merge first
try {
await apiService.mergeChannels(props.channel.id, selectedTargetChannel.value)
// Remove source channel from local store
appStore.channels = appStore.channels.filter(ch => ch.id !== props.channel.id)
// Clear messages for the merged channel
delete appStore.messages[props.channel.id]
await appStore.saveState()
emit('channel-merged', props.channel.id, selectedTargetChannel.value)
toastStore.success('Channels merged successfully')
// Switch to target channel if we were on the source channel
if (appStore.currentChannelId === props.channel.id) {
await appStore.setCurrentChannel(selectedTargetChannel.value)
}
} catch (error) {
// For merge, we can't do offline fallback easily since it affects multiple channels
console.error('Failed to merge channels:', error)
toastStore.error('Failed to merge channels - this requires an internet connection')
}
showMergeDialog.value = false
emit('close')
} catch (error) {
console.error('Failed to merge channels:', error)
toastStore.error('Failed to merge channels')
} finally {
merging.value = false
}
}
const performDelete = async () => {
try {
deleting.value = true
// Try online delete first
try {
await apiService.deleteChannel(props.channel.id)
// Remove from local store
appStore.channels = appStore.channels.filter(ch => ch.id !== props.channel.id)
delete appStore.messages[props.channel.id]
await appStore.saveState()
emit('channel-deleted', props.channel.id)
toastStore.success('Channel deleted successfully')
// Switch to first available channel if we were on the deleted channel
if (appStore.currentChannelId === props.channel.id && appStore.channels.length > 0) {
await appStore.setCurrentChannel(appStore.channels[0].id)
}
} catch (error) {
// For delete, we can't do offline fallback easily since it affects server state
console.error('Failed to delete channel:', error)
toastStore.error('Failed to delete channel - this requires an internet connection')
}
showDeleteConfirm.value = false
emit('close')
} catch (error) {
console.error('Failed to delete channel:', error)
toastStore.error('Failed to delete channel')
} finally {
deleting.value = false
}
}
const cancel = () => {
emit('close')
}
onMounted(() => {
nameInput.value?.focus()
})
</script>
<style scoped>
.channel-info-dialog {
padding: 1rem 0;
display: flex;
flex-direction: column;
gap: 2rem;
min-width: 400px;
}
.info-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.actions-section {
border-top: 1px solid #e5e7eb;
padding-top: 1.5rem;
}
.action-group h3 {
margin: 0 0 1rem 0;
font-size: 1rem;
font-weight: 600;
color: #374151;
}
.action-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
border-top: 1px solid #e5e7eb;
padding-top: 1.5rem;
}
/* Merge Dialog Styles */
.merge-dialog {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.merge-warning {
padding: 1rem;
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 6px;
color: #92400e;
margin: 0;
line-height: 1.5;
}
.merge-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.merge-form label {
font-weight: 500;
color: #374151;
}
.target-select {
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 6px;
background: white;
color: #111827;
font-size: 0.875rem;
}
.target-select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.merge-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
/* Delete Dialog Styles */
.delete-dialog {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.delete-warning {
padding: 1rem;
background: #fef2f2;
border: 1px solid #fca5a5;
border-radius: 6px;
color: #dc2626;
margin: 0;
line-height: 1.5;
}
.delete-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.actions-section {
border-top-color: #374151;
}
.action-group h3 {
color: rgba(255, 255, 255, 0.87);
}
.dialog-actions {
border-top-color: #374151;
}
.merge-warning {
background: #451a03;
border-color: #92400e;
color: #fbbf24;
}
.delete-warning {
background: #450a0a;
border-color: #dc2626;
color: #fca5a5;
}
.merge-form label {
color: rgba(255, 255, 255, 0.87);
}
.target-select {
background: #374151;
color: rgba(255, 255, 255, 0.87);
border-color: #4b5563;
}
.target-select:focus {
border-color: #60a5fa;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1);
}
}
</style>

View File

@@ -0,0 +1,96 @@
<template>
<div class="create-channel-dialog">
<form @submit.prevent="handleSubmit" class="channel-form">
<BaseInput
v-model="channelName"
label="Channel Name"
placeholder="Enter channel name"
required
:error="error"
:disabled="isLoading"
ref="nameInput"
/>
<div class="form-actions">
<BaseButton
type="button"
variant="secondary"
@click="$emit('cancel')"
:disabled="isLoading"
>
Cancel
</BaseButton>
<BaseButton
type="submit"
:loading="isLoading"
:disabled="!channelName.trim()"
>
Create Channel
</BaseButton>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAppStore } from '@/stores/app'
import { useToastStore } from '@/stores/toast'
import { apiService } from '@/services/api'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseButton from '@/components/base/BaseButton.vue'
const emit = defineEmits<{
cancel: []
created: [channelId: number]
}>()
const appStore = useAppStore()
const toastStore = useToastStore()
const channelName = ref('')
const error = ref('')
const isLoading = ref(false)
const nameInput = ref()
const handleSubmit = async () => {
if (!channelName.value.trim()) return
isLoading.value = true
error.value = ''
try {
const newChannel = await apiService.createChannel(channelName.value.trim())
appStore.addChannel(newChannel)
toastStore.success(`Channel "${newChannel.name}" created successfully!`)
emit('created', newChannel.id)
} catch (err) {
console.error('Failed to create channel:', err)
error.value = 'Failed to create channel. Please try again.'
} finally {
isLoading.value = false
}
}
onMounted(() => {
nameInput.value?.focus()
})
</script>
<style scoped>
.create-channel-dialog {
padding: 1rem 0;
}
.channel-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
</style>

View File

@@ -0,0 +1,349 @@
<template>
<div class="file-upload-dialog">
<div class="upload-area"
:class="{ 'upload-area--dragging': isDragging }"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop">
<input
type="file"
ref="fileInput"
@change="handleFileSelect"
class="file-input"
:disabled="isUploading"
multiple
/>
<div v-if="!selectedFiles.length" class="upload-prompt">
<div class="upload-icon">📎</div>
<p>Click to select files or drag and drop</p>
<p class="upload-hint">All file types supported</p>
</div>
<div v-else class="selected-files">
<h4>Selected Files:</h4>
<div class="file-list">
<div v-for="(file, index) in selectedFiles" :key="index" class="file-item">
<span class="file-name">{{ file.name }}</span>
<span class="file-size">{{ formatFileSize(file.size) }}</span>
<button
@click="removeFile(index)"
class="remove-file"
:disabled="isUploading"
aria-label="Remove file"
>
×
</button>
</div>
</div>
</div>
</div>
<div v-if="uploadProgress.length > 0" class="upload-progress">
<div v-for="(progress, index) in uploadProgress" :key="index" class="progress-item">
<div class="progress-label">{{ selectedFiles[index]?.name }}</div>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: `${progress}%` }"></div>
</div>
<div class="progress-text">{{ progress }}%</div>
</div>
</div>
<div class="dialog-actions">
<BaseButton
variant="secondary"
@click="$emit('cancel')"
:disabled="isUploading"
>
Cancel
</BaseButton>
<BaseButton
@click="uploadFiles"
:loading="isUploading"
:disabled="selectedFiles.length === 0"
>
Upload {{ selectedFiles.length }} file{{ selectedFiles.length === 1 ? '' : 's' }}
</BaseButton>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useAppStore } from '@/stores/app'
import { useToastStore } from '@/stores/toast'
import { apiService } from '@/services/api'
import BaseButton from '@/components/base/BaseButton.vue'
const emit = defineEmits<{
cancel: []
uploaded: []
}>()
const appStore = useAppStore()
const toastStore = useToastStore()
const fileInput = ref<HTMLInputElement>()
const selectedFiles = ref<File[]>([])
const uploadProgress = ref<number[]>([])
const isDragging = ref(false)
const isUploading = ref(false)
const error = ref('')
const handleDragOver = () => {
isDragging.value = true
}
const handleDragLeave = () => {
isDragging.value = false
}
const handleDrop = (event: DragEvent) => {
isDragging.value = false
const files = Array.from(event.dataTransfer?.files || [])
addFiles(files)
}
const handleFileSelect = (event: Event) => {
const files = Array.from((event.target as HTMLInputElement).files || [])
addFiles(files)
}
const addFiles = (files: File[]) => {
selectedFiles.value.push(...files)
uploadProgress.value = new Array(selectedFiles.value.length).fill(0)
}
const removeFile = (index: number) => {
selectedFiles.value.splice(index, 1)
uploadProgress.value.splice(index, 1)
}
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const uploadFiles = async () => {
if (!appStore.currentChannelId || selectedFiles.value.length === 0) return
isUploading.value = true
error.value = ''
try {
// Create a message first to attach files to
const message = await apiService.createMessage(appStore.currentChannelId,
`Uploaded ${selectedFiles.value.length} file${selectedFiles.value.length === 1 ? '' : 's'}`)
// Upload each file
for (let i = 0; i < selectedFiles.value.length; i++) {
const file = selectedFiles.value[i]
try {
await apiService.uploadFile(appStore.currentChannelId, message.id, file)
uploadProgress.value[i] = 100
} catch (fileError) {
console.error(`Failed to upload ${file.name}:`, fileError)
toastStore.error(`Failed to upload ${file.name}`)
uploadProgress.value[i] = 0
}
}
toastStore.success('Files uploaded successfully!')
emit('uploaded')
} catch (err) {
console.error('Upload failed:', err)
error.value = 'Upload failed. Please try again.'
} finally {
isUploading.value = false
}
}
</script>
<style scoped>
.file-upload-dialog {
padding: 1rem 0;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.upload-area {
border: 2px dashed #d1d5db;
border-radius: 12px;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.upload-area:hover,
.upload-area--dragging {
border-color: #646cff;
background: #f8faff;
}
.file-input {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.upload-prompt {
pointer-events: none;
}
.upload-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.upload-hint {
font-size: 0.875rem;
color: #6b7280;
margin: 0;
}
.selected-files h4 {
margin: 0 0 1rem 0;
font-size: 1rem;
font-weight: 600;
}
.file-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
text-align: left;
}
.file-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem;
background: #f9fafb;
border-radius: 6px;
}
.file-name {
flex: 1;
font-weight: 500;
word-break: break-all;
}
.file-size {
font-size: 0.875rem;
color: #6b7280;
}
.remove-file {
background: #ef4444;
color: white;
border: none;
border-radius: 50%;
width: 1.5rem;
height: 1.5rem;
cursor: pointer;
font-size: 1rem;
line-height: 1;
}
.remove-file:hover:not(:disabled) {
background: #dc2626;
}
.upload-progress {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.progress-item {
display: flex;
align-items: center;
gap: 0.75rem;
}
.progress-label {
flex: 1;
font-size: 0.875rem;
font-weight: 500;
}
.progress-bar {
flex: 2;
height: 0.5rem;
background: #e5e7eb;
border-radius: 0.25rem;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #646cff;
transition: width 0.3s ease;
}
.progress-text {
font-size: 0.875rem;
color: #6b7280;
min-width: 3rem;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.error-message {
padding: 0.75rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 6px;
color: #dc2626;
font-size: 0.875rem;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.upload-area {
border-color: #4b5563;
}
.upload-area:hover,
.upload-area--dragging {
border-color: #646cff;
background: #1e293b;
}
.file-item {
background: #374151;
}
.progress-bar {
background: #4b5563;
}
.error-message {
background: #422006;
border-color: #92400e;
color: #fbbf24;
}
}
</style>

View File

@@ -0,0 +1,308 @@
<template>
<div class="search-dialog">
<div class="search-form">
<BaseInput
v-model="searchQuery"
placeholder="Search messages..."
@keydown.enter="performSearch"
ref="searchInput"
/>
<div class="search-filters">
<select
v-model="selectedChannelId"
class="channel-filter"
>
<option :value="null">All channels</option>
<option
v-for="channel in appStore.channels"
:key="channel.id"
:value="channel.id"
>
{{ channel.name }}
</option>
</select>
<BaseButton
@click="performSearch"
:loading="isSearching"
:disabled="!searchQuery.trim()"
>
Search
</BaseButton>
</div>
</div>
<div v-if="isSearching" class="search-loading">
Searching...
</div>
<div v-else-if="searchResults.length > 0" class="search-results">
<div class="results-header">
Found {{ searchResults.length }} result{{ searchResults.length === 1 ? '' : 's' }}
</div>
<div class="results-list">
<div
v-for="result in searchResults"
:key="`${result.channel_id}-${result.id}`"
class="result-item"
@click="goToMessage(result)"
tabindex="0"
@keydown.enter="goToMessage(result)"
>
<div class="result-channel">
{{ getChannelName(result.channel_id) }}
</div>
<div class="result-content">
{{ result.content }}
</div>
<div class="result-time">
{{ formatTime(result.created_at) }}
</div>
</div>
</div>
</div>
<div v-else-if="hasSearched && searchResults.length === 0" class="no-results">
No messages found for "{{ searchQuery }}"
</div>
<div v-if="error" class="search-error">
{{ error }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAppStore } from '@/stores/app'
import { useToastStore } from '@/stores/toast'
import { apiService } from '@/services/api'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import type { Message, ExtendedMessage } from '@/types'
const emit = defineEmits<{
close: []
'select-message': [message: ExtendedMessage]
}>()
const appStore = useAppStore()
const toastStore = useToastStore()
const searchQuery = ref('')
const selectedChannelId = ref<number | null>(null)
const searchResults = ref<ExtendedMessage[]>([])
const isSearching = ref(false)
const hasSearched = ref(false)
const error = ref('')
const searchInput = ref()
const performSearch = async () => {
if (!searchQuery.value.trim()) return
isSearching.value = true
error.value = ''
try {
const response = await apiService.search(
searchQuery.value.trim(),
selectedChannelId.value || undefined
)
// Transform search results to match expected format
searchResults.value = response.results.map((result: any) => ({
...result,
channel_id: result.channelId || result.channel_id,
created_at: result.createdAt || result.created_at
})) as ExtendedMessage[]
console.log('Search results transformed:', searchResults.value)
hasSearched.value = true
} catch (err) {
console.error('Search failed:', err)
error.value = 'Search failed. Please try again.'
toastStore.error('Search failed')
} finally {
isSearching.value = false
}
}
const goToMessage = (message: ExtendedMessage) => {
emit('select-message', message)
emit('close')
}
const getChannelName = (channelId: number): string => {
if (!channelId) return 'Unknown Channel'
const channel = appStore.channels.find(c => c.id === channelId)
return channel?.name || `Channel ${channelId}`
}
const formatTime = (timestamp: string): string => {
if (!timestamp) return 'Unknown time'
const date = new Date(timestamp)
if (isNaN(date.getTime())) {
return 'Invalid date'
}
return date.toLocaleString()
}
onMounted(() => {
searchInput.value?.focus()
})
</script>
<style scoped>
.search-dialog {
padding: 1rem 0;
display: flex;
flex-direction: column;
gap: 1.5rem;
min-height: 400px;
}
.search-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.search-filters {
display: flex;
gap: 0.75rem;
align-items: flex-end;
}
.channel-filter {
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 6px;
background: white;
color: #111827;
font-size: 0.875rem;
min-width: 150px;
}
.channel-filter:focus {
outline: none;
border-color: #646cff;
box-shadow: 0 0 0 3px rgba(100, 108, 255, 0.1);
}
.search-loading {
display: flex;
justify-content: center;
padding: 2rem;
color: #6b7280;
}
.search-results {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
}
.results-header {
font-weight: 600;
color: #374151;
padding-bottom: 0.5rem;
border-bottom: 1px solid #e5e7eb;
}
.results-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 300px;
overflow-y: auto;
}
.result-item {
padding: 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.result-item:hover,
.result-item:focus {
background: #f9fafb;
border-color: #646cff;
outline: none;
}
.result-channel {
font-size: 0.75rem;
font-weight: 600;
color: #646cff;
margin-bottom: 0.25rem;
}
.result-content {
color: #111827;
margin-bottom: 0.5rem;
line-height: 1.4;
}
.result-time {
font-size: 0.75rem;
color: #6b7280;
}
.no-results {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
color: #6b7280;
font-style: italic;
}
.search-error {
padding: 0.75rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 6px;
color: #dc2626;
font-size: 0.875rem;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.channel-filter {
background: #374151;
color: rgba(255, 255, 255, 0.87);
border-color: #4b5563;
}
.results-header {
color: rgba(255, 255, 255, 0.87);
border-bottom-color: #374151;
}
.result-item {
border-color: #374151;
}
.result-item:hover,
.result-item:focus {
background: #374151;
}
.result-content {
color: rgba(255, 255, 255, 0.87);
}
.search-error {
background: #422006;
border-color: #92400e;
color: #fbbf24;
}
}
</style>

View File

@@ -0,0 +1,364 @@
<template>
<div class="settings-dialog">
<form @submit.prevent="handleSave" class="settings-form">
<div class="setting-group">
<h3>Audio Settings</h3>
<label class="setting-item">
<input
type="checkbox"
v-model="localSettings.soundEnabled"
class="checkbox"
/>
<span>Enable sound effects</span>
</label>
<label class="setting-item">
<input
type="checkbox"
v-model="localSettings.speechEnabled"
class="checkbox"
/>
<span>Enable speech synthesis (deprecated)</span>
</label>
</div>
<div class="setting-group">
<h3>Text-to-Speech</h3>
<label class="setting-item">
<input
type="checkbox"
v-model="localSettings.ttsEnabled"
class="checkbox"
/>
<span>Enable text-to-speech announcements</span>
</label>
<div class="setting-item" v-if="localSettings.ttsEnabled">
<label for="voice-select">Voice</label>
<select
id="voice-select"
v-model="selectedVoiceURI"
class="select"
@change="handleVoiceChange"
>
<option value="" disabled>Select a voice...</option>
<option
v-for="voice in availableVoices"
:key="voice.voiceURI"
:value="voice.voiceURI"
>
{{ voice.name }} ({{ voice.lang }})
</option>
</select>
</div>
<div class="setting-item" v-if="localSettings.ttsEnabled">
<label for="rate-slider">Speech Rate: {{ localSettings.ttsRate.toFixed(1) }}</label>
<input
id="rate-slider"
type="range"
min="0.5"
max="2"
step="0.1"
v-model.number="localSettings.ttsRate"
class="slider"
/>
</div>
<div class="setting-item" v-if="localSettings.ttsEnabled">
<label for="pitch-slider">Speech Pitch: {{ localSettings.ttsPitch.toFixed(1) }}</label>
<input
id="pitch-slider"
type="range"
min="0"
max="2"
step="0.1"
v-model.number="localSettings.ttsPitch"
class="slider"
/>
</div>
<div class="setting-item" v-if="localSettings.ttsEnabled">
<label for="volume-slider">Speech Volume: {{ localSettings.ttsVolume.toFixed(1) }}</label>
<input
id="volume-slider"
type="range"
min="0"
max="1"
step="0.1"
v-model.number="localSettings.ttsVolume"
class="slider"
/>
</div>
<div class="setting-item" v-if="localSettings.ttsEnabled">
<BaseButton
type="button"
variant="secondary"
size="sm"
@click="testSpeech"
:disabled="!selectedVoiceURI"
>
Test Speech
</BaseButton>
</div>
</div>
<div class="setting-group">
<h3>Appearance</h3>
<div class="setting-item">
<label for="theme-select">Theme</label>
<select
id="theme-select"
v-model="localSettings.theme"
class="select"
>
<option value="auto">Auto (System)</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
</div>
<div class="setting-group" v-if="appStore.channels.length > 0">
<h3>Default Channel</h3>
<div class="setting-item">
<label for="default-channel-select">Default Channel</label>
<select
id="default-channel-select"
v-model="localSettings.defaultChannelId"
class="select"
>
<option :value="null">None</option>
<option
v-for="channel in appStore.channels"
:key="channel.id"
:value="channel.id"
>
{{ channel.name }}
</option>
</select>
</div>
</div>
<div class="form-actions">
<BaseButton
type="button"
variant="secondary"
@click="$emit('close')"
>
Cancel
</BaseButton>
<BaseButton
type="submit"
:loading="isSaving"
>
Save Settings
</BaseButton>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useAppStore } from '@/stores/app'
import { useToastStore } from '@/stores/toast'
import { useAudio } from '@/composables/useAudio'
import BaseButton from '@/components/base/BaseButton.vue'
import type { AppSettings } from '@/types'
const emit = defineEmits<{
close: []
}>()
const appStore = useAppStore()
const toastStore = useToastStore()
const { availableVoices, speak, setVoice } = useAudio()
const isSaving = ref(false)
const selectedVoiceURI = ref('')
const localSettings = reactive<AppSettings>({
soundEnabled: true,
speechEnabled: true,
ttsEnabled: true,
ttsRate: 1,
ttsPitch: 1,
ttsVolume: 1,
selectedVoiceURI: null,
defaultChannelId: null,
theme: 'auto'
})
const handleVoiceChange = () => {
const voice = availableVoices.value.find(v => v.voiceURI === selectedVoiceURI.value)
if (voice) {
setVoice(voice)
localSettings.selectedVoiceURI = voice.voiceURI
}
}
const testSpeech = async () => {
try {
await speak('This is a test of the text-to-speech system.', {
rate: localSettings.ttsRate,
pitch: localSettings.ttsPitch,
volume: localSettings.ttsVolume
})
} catch (error) {
toastStore.error('Speech test failed')
}
}
const handleSave = async () => {
isSaving.value = true
try {
await appStore.updateSettings(localSettings)
toastStore.success('Settings saved successfully!')
emit('close')
} catch (error) {
console.error('Failed to save settings:', error)
toastStore.error('Failed to save settings')
} finally {
isSaving.value = false
}
}
onMounted(() => {
// Copy current settings to local state
Object.assign(localSettings, appStore.settings)
// Set up voice selection
selectedVoiceURI.value = appStore.settings.selectedVoiceURI || ''
})
</script>
<style scoped>
.settings-dialog {
padding: 1rem 0;
}
.settings-form {
display: flex;
flex-direction: column;
gap: 2rem;
}
.setting-group {
display: flex;
flex-direction: column;
gap: 1rem;
}
.setting-group h3 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #374151;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 0.5rem;
}
.setting-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0;
}
.setting-item label {
font-weight: 500;
color: #374151;
}
.checkbox {
width: 1.25rem;
height: 1.25rem;
accent-color: #646cff;
cursor: pointer;
}
.select {
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 6px;
background: white;
color: #111827;
font-size: 0.875rem;
min-width: 150px;
cursor: pointer;
}
.select:focus {
outline: none;
border-color: #646cff;
box-shadow: 0 0 0 3px rgba(100, 108, 255, 0.1);
}
.slider {
width: 100%;
max-width: 200px;
height: 4px;
border-radius: 2px;
background: #e5e7eb;
outline: none;
cursor: pointer;
appearance: none;
}
.slider::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #646cff;
cursor: pointer;
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #646cff;
cursor: pointer;
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.setting-group h3 {
color: rgba(255, 255, 255, 0.87);
border-bottom-color: #374151;
}
.setting-item label {
color: rgba(255, 255, 255, 0.87);
}
.select {
background: #374151;
color: rgba(255, 255, 255, 0.87);
border-color: #4b5563;
}
.form-actions {
border-top-color: #374151;
}
}
</style>

View File

@@ -0,0 +1,465 @@
<template>
<div class="voice-recording-dialog">
<div class="recording-container">
<!-- Recording Status -->
<div class="recording-status">
<div class="status-indicator" :class="{ 'recording': recording.isRecording, 'has-recording': recording.blob }">
<div class="pulse" v-if="recording.isRecording"></div>
<Icon name="microphone" />
</div>
<div class="status-text">
<h3 v-if="recording.isRecording">Recording...</h3>
<h3 v-else-if="recording.blob">Recording Complete</h3>
<h3 v-else>Ready to Record</h3>
<p class="duration">{{ recordingDurationFormatted }}</p>
</div>
</div>
<!-- Waveform Visualization (placeholder) -->
<div class="waveform" v-if="recording.isRecording">
<div class="wave-bar" v-for="i in 20" :key="i" :style="{ height: getWaveHeight(i) + 'px' }"></div>
</div>
<!-- Playback Controls -->
<div class="playback-controls" v-if="recording.blob">
<div class="progress-bar">
<div class="progress" :style="{ width: playbackProgress + '%' }"></div>
</div>
<div class="playback-time">
{{ formatTime(recording.currentTime) }} / {{ formatTime(recording.duration) }}
</div>
</div>
<!-- Control Buttons -->
<div class="controls">
<BaseButton
v-if="!recording.isRecording && !recording.blob"
@click="startRecording"
variant="primary"
size="lg"
:disabled="!canRecord"
class="record-btn"
>
<Icon name="microphone" />
Start Recording
</BaseButton>
<BaseButton
v-if="recording.isRecording"
@click="stopRecording"
variant="danger"
size="lg"
class="stop-btn"
>
<Icon name="stop" />
Stop Recording
</BaseButton>
<div class="playback-buttons" v-if="recording.blob && !recording.isRecording">
<BaseButton
@click="playRecording"
variant="secondary"
:disabled="recording.isPlaying"
>
<Icon name="play" />
Play
</BaseButton>
<BaseButton
@click="clearRecording"
variant="secondary"
>
<Icon name="trash" />
Clear
</BaseButton>
<BaseButton
@click="startRecording"
variant="secondary"
>
<Icon name="microphone" />
Re-record
</BaseButton>
</div>
</div>
<!-- Error Message -->
<div class="error-message" v-if="errorMessage">
<Icon name="warning" />
{{ errorMessage }}
</div>
<!-- Microphone Permission Info -->
<div class="permission-info" v-if="!canRecord">
<Icon name="info" />
<p>Microphone access is required for voice recording. Please grant permission when prompted.</p>
</div>
</div>
<!-- Dialog Actions -->
<div class="dialog-actions">
<BaseButton
@click="$emit('close')"
variant="secondary"
>
Cancel
</BaseButton>
<BaseButton
@click="sendVoiceMessage"
variant="primary"
:disabled="!recording.blob || isSending"
:loading="isSending"
>
<Icon name="send" />
Send Voice Message
</BaseButton>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useAudio } from '@/composables/useAudio'
import { useAppStore } from '@/stores/app'
import { useToastStore } from '@/stores/toast'
import { apiService } from '@/services/api'
import BaseButton from '@/components/base/BaseButton.vue'
import Icon from '@/components/base/Icon.vue'
const emit = defineEmits<{
close: []
sent: []
}>()
const appStore = useAppStore()
const toastStore = useToastStore()
const {
recording,
canRecord,
recordingDurationFormatted,
startRecording: startAudioRecording,
stopRecording: stopAudioRecording,
playRecording,
clearRecording
} = useAudio()
const isSending = ref(false)
const errorMessage = ref('')
const waveAnimation = ref<number[]>([])
// Computed
const playbackProgress = computed(() => {
if (!recording.value.duration) return 0
return (recording.value.currentTime / recording.value.duration) * 100
})
// Methods
const startRecording = async () => {
errorMessage.value = ''
const success = await startAudioRecording()
if (!success) {
errorMessage.value = 'Failed to start recording. Please check microphone permissions.'
} else {
startWaveAnimation()
}
}
const stopRecording = () => {
stopAudioRecording()
stopWaveAnimation()
}
const sendVoiceMessage = async () => {
if (!recording.value.blob) return
isSending.value = true
errorMessage.value = ''
try {
// Create a message first to attach the voice file to
const message = await apiService.createMessage(appStore.currentChannelId!, 'Voice message')
// Create file from blob
const file = new File([recording.value.blob!], `voice-${Date.now()}.webm`, {
type: 'audio/webm;codecs=opus'
})
// Upload voice file
await apiService.uploadFile(appStore.currentChannelId!, message.id, file)
toastStore.success('Voice message sent!')
clearRecording()
emit('sent')
emit('close')
} catch (error) {
console.error('Failed to send voice message:', error)
errorMessage.value = 'Failed to send voice message. Please try again.'
toastStore.error('Failed to send voice message')
} finally {
isSending.value = false
}
}
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
// Waveform animation
let animationInterval: number | null = null
const startWaveAnimation = () => {
waveAnimation.value = Array.from({ length: 20 }, () => Math.random() * 40 + 10)
animationInterval = setInterval(() => {
waveAnimation.value = waveAnimation.value.map(() => Math.random() * 40 + 10)
}, 150)
}
const stopWaveAnimation = () => {
if (animationInterval) {
clearInterval(animationInterval)
animationInterval = null
}
}
const getWaveHeight = (index: number): number => {
return waveAnimation.value[index] || 20
}
// Cleanup
onUnmounted(() => {
stopWaveAnimation()
})
// Initialize
onMounted(() => {
// Clear any existing recording when dialog opens
if (recording.value.blob) {
clearRecording()
}
})
</script>
<style scoped>
.voice-recording-dialog {
padding: 1rem 0;
min-width: 400px;
}
.recording-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
margin-bottom: 2rem;
}
.recording-status {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.status-indicator {
position: relative;
width: 80px;
height: 80px;
border-radius: 50%;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
color: #6b7280;
transition: all 0.3s ease;
}
.status-indicator.recording {
background: #dc2626;
color: white;
}
.status-indicator.has-recording {
background: #059669;
color: white;
}
.pulse {
position: absolute;
inset: -10px;
border-radius: 50%;
border: 2px solid #dc2626;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(1.5);
}
}
.status-text {
text-align: center;
}
.status-text h3 {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
font-weight: 600;
color: #111827;
}
.duration {
margin: 0;
font-size: 1.5rem;
font-weight: 500;
color: #4b5563;
}
.waveform {
display: flex;
align-items: end;
gap: 3px;
height: 60px;
padding: 0 1rem;
}
.wave-bar {
width: 4px;
background: linear-gradient(to top, #dc2626, #f87171);
border-radius: 2px;
transition: height 0.1s ease;
min-height: 4px;
}
.playback-controls {
width: 100%;
max-width: 300px;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.progress-bar {
width: 100%;
height: 6px;
background: #e5e7eb;
border-radius: 3px;
overflow: hidden;
}
.progress {
height: 100%;
background: #059669;
transition: width 0.1s ease;
}
.playback-time {
text-align: center;
font-size: 0.875rem;
color: #6b7280;
}
.controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.record-btn {
padding: 1rem 2rem;
font-size: 1.125rem;
font-weight: 600;
}
.stop-btn {
padding: 1rem 2rem;
font-size: 1.125rem;
font-weight: 600;
}
.playback-buttons {
display: flex;
gap: 0.75rem;
}
.error-message {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
color: #dc2626;
font-weight: 500;
}
.permission-info {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 8px;
color: #0369a1;
}
.permission-info p {
margin: 0;
font-size: 0.875rem;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.status-text h3 {
color: rgba(255, 255, 255, 0.87);
}
.duration {
color: rgba(255, 255, 255, 0.6);
}
.playback-time {
color: rgba(255, 255, 255, 0.6);
}
.progress-bar {
background: #374151;
}
.error-message {
background: #7f1d1d;
border-color: #991b1b;
color: #fca5a5;
}
.permission-info {
background: #1e3a8a;
border-color: #3b82f6;
color: #93c5fd;
}
.dialog-actions {
border-top-color: #374151;
}
}
</style>

View File

@@ -0,0 +1,76 @@
<template>
<div class="channel-list-container">
<ul class="channel-list" role="list">
<ChannelListItem
v-for="channel in channels"
:key="channel.id"
:channel="channel"
:is-active="channel.id === currentChannelId"
:unread-count="unreadCounts[channel.id]"
@select="$emit('select-channel', $event)"
/>
</ul>
</div>
</template>
<script setup lang="ts">
import ChannelListItem from './ChannelListItem.vue'
import type { Channel } from '@/types'
interface Props {
channels: Channel[]
currentChannelId: number | null
unreadCounts: Record<number, number>
}
defineProps<Props>()
defineEmits<{
'select-channel': [channelId: number]
}>()
</script>
<style scoped>
.channel-list-container {
flex: 1;
overflow-y: auto;
padding: 0.5rem 0;
}
.channel-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
/* Scrollbar styling */
.channel-list-container::-webkit-scrollbar {
width: 6px;
}
.channel-list-container::-webkit-scrollbar-track {
background: transparent;
}
.channel-list-container::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
.channel-list-container::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.channel-list-container::-webkit-scrollbar-thumb {
background: #4b5563;
}
.channel-list-container::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
}
</style>

View File

@@ -0,0 +1,146 @@
<template>
<li
:class="[
'channel-item',
{ 'channel-item--active': isActive }
]"
>
<div class="channel-wrapper">
<button
class="channel-button"
@click="$emit('select', channel.id)"
:aria-pressed="isActive"
:aria-label="`Select channel ${channel.name}`"
>
<span class="channel-name">{{ channel.name }}</span>
<span v-if="unreadCount" class="channel-unread">
{{ unreadCount }}
</span>
</button>
<button
class="channel-info-button"
@click.stop="$emit('info', channel)"
:aria-label="`Channel info for ${channel.name}`"
title="Channel info"
>
</button>
</div>
</li>
</template>
<script setup lang="ts">
import type { Channel } from '@/types'
interface Props {
channel: Channel
isActive: boolean
unreadCount?: number
}
defineProps<Props>()
defineEmits<{
select: [channelId: number]
}>()
</script>
<style scoped>
.channel-item {
list-style: none;
margin: 0;
}
.channel-button {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.75rem 1rem;
background: none;
border: none;
text-align: left;
color: #6b7280;
cursor: pointer;
transition: all 0.2s ease;
border-radius: 6px;
margin: 0 0.5rem 0.25rem 0.5rem;
}
.channel-button:hover {
background: rgba(0, 0, 0, 0.05);
color: #374151;
}
.channel-button:focus {
outline: none;
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.channel-item--active .channel-button {
background: #3b82f6;
color: white;
}
.channel-item--active .channel-button:hover {
background: #2563eb;
}
.channel-name {
flex: 1;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-unread {
background: #ef4444;
color: white;
font-size: 0.75rem;
font-weight: 600;
padding: 0.125rem 0.375rem;
border-radius: 10px;
min-width: 1.25rem;
height: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.channel-item--active .channel-unread {
background: rgba(255, 255, 255, 0.9);
color: #3b82f6;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.channel-button {
color: rgba(255, 255, 255, 0.6);
}
.channel-button:hover {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.87);
}
.channel-button:focus {
background: rgba(96, 165, 250, 0.1);
color: #60a5fa;
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2);
}
.channel-item--active .channel-button {
background: #3b82f6;
color: white;
}
.channel-item--active .channel-button:hover {
background: #2563eb;
}
}
</style>

View File

@@ -0,0 +1,136 @@
<template>
<aside class="sidebar">
<div class="sidebar__header">
<h1 class="sidebar__title">Notebrook</h1>
<BaseButton
variant="ghost"
size="sm"
@click="$emit('create-channel')"
aria-label="Create new channel"
>
+
</BaseButton>
</div>
<div class="sidebar__content">
<ChannelList
:channels="channels"
:current-channel-id="currentChannelId"
:unread-counts="unreadCounts"
@select-channel="$emit('select-channel', $event)"
/>
</div>
<div class="sidebar__footer">
<BaseButton
variant="ghost"
size="sm"
@click="$emit('settings')"
aria-label="Open settings"
>
</BaseButton>
</div>
</aside>
</template>
<script setup lang="ts">
import BaseButton from '@/components/base/BaseButton.vue'
import ChannelList from './ChannelList.vue'
import type { Channel } from '@/types'
interface Props {
channels: Channel[]
currentChannelId: number | null
unreadCounts: Record<number, number>
}
defineProps<Props>()
defineEmits<{
'create-channel': []
'select-channel': [channelId: number]
'settings': []
}>()
</script>
<style scoped>
.sidebar {
width: 300px;
background: #f9fafb;
border-right: 1px solid #e5e7eb;
display: flex;
flex-direction: column;
height: 100vh;
}
.sidebar__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
background: white;
flex-shrink: 0;
}
.sidebar__title {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: #111827;
}
.sidebar__content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar__footer {
padding: 1rem;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
display: flex;
justify-content: center;
flex-shrink: 0;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.sidebar {
background: #1f2937;
border-right-color: #374151;
}
.sidebar__header {
background: #1f2937;
border-bottom-color: #374151;
}
.sidebar__title {
color: rgba(255, 255, 255, 0.87);
}
.sidebar__footer {
background: #1f2937;
border-top-color: #374151;
}
}
/* Responsive design */
@media (max-width: 768px) {
.sidebar {
width: 250px;
}
.sidebar__header {
padding: 1rem;
}
.sidebar__title {
font-size: 1.125rem;
}
}
</style>