Reorganize web folder structurally

This commit is contained in:
Халимов Рустам
2026-03-19 22:24:21 +03:00
parent 11ebbc853b
commit 4384233aa5
62 changed files with 0 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
import { useState, useEffect, useRef, useCallback } from 'react';
/**
* Debounced value — updates value after the specified delay.
*/
export function useDebouncedValue<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
/**
* Debounced callback — returns a function that delays execution.
*/
export function useDebouncedCallback<T extends (...args: unknown[]) => unknown>(
callback: T,
delay: number,
): (...args: Parameters<T>) => void {
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const callbackRef = useRef(callback);
callbackRef.current = callback;
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
return useCallback((...args: Parameters<T>) => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => callbackRef.current(...args), delay);
}, [delay]);
}
/**
* Returns an AbortController that auto-aborts on unmount or when reset is called.
*/
export function useAbortController() {
const controllerRef = useRef<AbortController | null>(null);
const getSignal = useCallback(() => {
if (controllerRef.current) controllerRef.current.abort();
controllerRef.current = new AbortController();
return controllerRef.current.signal;
}, []);
useEffect(() => {
return () => {
controllerRef.current?.abort();
};
}, []);
return getSignal;
}
+134
View File
@@ -0,0 +1,134 @@
// Notification sound using Web Audio API — generates a pleasant chime
let audioContext: AudioContext | null = null;
function getAudioContext(): AudioContext | null {
if (typeof window !== 'undefined' && navigator && 'userActivation' in navigator) {
if (!(navigator as any).userActivation.hasBeenActive) {
return null;
}
}
if (!audioContext) {
try {
const AudioCtx = window.AudioContext || (window as any).webkitAudioContext;
if (AudioCtx) {
audioContext = new AudioCtx();
}
} catch {
return null;
}
}
return audioContext;
}
export function playNotificationSound() {
try {
const ctx = getAudioContext();
if (!ctx) return;
if (ctx.state === 'suspended') {
ctx.resume();
}
const now = ctx.currentTime;
// Soft, warm notification — lower frequencies, triangle waves, gentle volume
// First note — warm mellow tone
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.type = 'triangle';
osc1.frequency.setValueAtTime(523.25, now); // C5
gain1.gain.setValueAtTime(0.08, now);
gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.3);
osc1.connect(gain1);
gain1.connect(ctx.destination);
osc1.start(now);
osc1.stop(now + 0.3);
// Second note — gentle higher tone
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.type = 'triangle';
osc2.frequency.setValueAtTime(659.25, now + 0.08); // E5
gain2.gain.setValueAtTime(0, now);
gain2.gain.setValueAtTime(0.06, now + 0.08);
gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.35);
osc2.connect(gain2);
gain2.connect(ctx.destination);
osc2.start(now + 0.08);
osc2.stop(now + 0.35);
} catch (e) {
// Audio context not supported — silent fail
}
}
// Muted chats stored in localStorage
const MUTED_KEY = 'knot_muted_chats';
export function getMutedChats(): Set<string> {
try {
const stored = localStorage.getItem(MUTED_KEY);
return stored ? new Set(JSON.parse(stored)) : new Set();
} catch {
return new Set();
}
}
export function toggleMuteChat(chatId: string): boolean {
const muted = getMutedChats();
if (muted.has(chatId)) {
muted.delete(chatId);
} else {
muted.add(chatId);
}
localStorage.setItem(MUTED_KEY, JSON.stringify([...muted]));
return muted.has(chatId);
}
export function isChatMuted(chatId: string): boolean {
return getMutedChats().has(chatId);
}
// Call ringtone
let callAudio: HTMLAudioElement | null = null;
export function playCallRingtone() {
try {
if (callAudio) {
callAudio.pause();
callAudio.currentTime = 0;
}
callAudio = new Audio('/sounds/call_sound.mp3');
callAudio.loop = true;
callAudio.volume = 0.5;
callAudio.play().catch(() => {});
} catch (e) {
// silent fail
}
}
export function stopCallRingtone() {
try {
if (callAudio) {
callAudio.pause();
callAudio.currentTime = 0;
callAudio = null;
}
} catch (e) {
// silent fail
}
}
// "Абонент недоступен" sound
export function playUnavailableSound(): Promise<void> {
return new Promise((resolve) => {
try {
const audio = new Audio('/sounds/abonent_nedostupen.mp3');
audio.volume = 0.7;
audio.onended = () => resolve();
audio.onerror = () => resolve();
audio.play().catch(() => resolve());
} catch (e) {
resolve();
}
});
}
+160
View File
@@ -0,0 +1,160 @@
import { clsx, type ClassValue } from 'clsx';
export function cn(...inputs: ClassValue[]) {
return clsx(inputs);
}
export function formatTime(date: string | Date, lang: string = 'ru'): string {
const d = new Date(date);
return d.toLocaleTimeString(lang === 'ru' ? 'ru-RU' : 'en-US', { hour: '2-digit', minute: '2-digit' });
}
export function formatDate(date: string | Date, lang: string = 'ru'): string {
const d = new Date(date);
const now = new Date();
const diff = now.getTime() - d.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return lang === 'ru' ? 'Сегодня' : 'Today';
if (days === 1) return lang === 'ru' ? 'Вчера' : 'Yesterday';
if (days < 7) {
const weekDaysRu = ['Воскресенье', 'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота'];
const weekDaysEn = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
return (lang === 'ru' ? weekDaysRu : weekDaysEn)[d.getDay()];
}
return d.toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', {
day: 'numeric',
month: 'long',
year: days > 365 ? 'numeric' : undefined,
});
}
export function formatLastSeen(date: string | Date, lang: string = 'ru'): string {
const d = new Date(date);
const now = new Date();
const diff = now.getTime() - d.getTime();
const minutes = Math.floor(diff / (1000 * 60));
if (minutes < 1) return lang === 'ru' ? 'только что' : 'just now';
if (minutes < 60) return lang === 'ru' ? `${minutes} мин. назад` : `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return lang === 'ru' ? `${hours} ч. назад` : `${hours}h ago`;
const at = lang === 'ru' ? ' в ' : ' at ';
return formatDate(date, lang) + at + formatTime(date, lang);
}
/**
* Strips markdown syntax (**bold**, *italic*, _italic_, ~strikethrough~, `code`)
* and returns plain text for use in previews.
*/
export function stripMarkdown(text: string): string {
if (!text) return text;
return text
.replace(/\*\*([\s\S]*?)\*\*/g, '$1')
.replace(/\*([\s\S]*?)\*/g, '$1')
.replace(/_([\s\S]*?)_/g, '$1')
.replace(/~([\s\S]*?)~/g, '$1')
.replace(/`([\s\S]*?)`/g, '$1');
}
export function getInitials(name: string): string {
return name
.split(' ')
.map((part) => part[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
export function generateAvatarColor(name: string): string {
const colors = [
'from-violet-500 to-purple-600',
'from-blue-500 to-indigo-600',
'from-emerald-500 to-teal-600',
'from-rose-500 to-pink-600',
'from-amber-500 to-orange-600',
'from-cyan-500 to-blue-600',
'from-fuchsia-500 to-purple-600',
'from-lime-500 to-green-600',
];
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
// Waveform cache so we don't decode the same audio twice
const waveformCache = new Map<string, number[]>();
/**
* Decodes an audio file from a URL and extracts normalized waveform peak values.
* Returns an array of `bars` values in [0, 1].
*/
export async function extractWaveform(url: string, bars: number = 28): Promise<number[]> {
const cached = waveformCache.get(url);
if (cached) return cached;
let audioCtx: any;
try {
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const OfflineCtx = window.OfflineAudioContext || (window as any).webkitOfflineAudioContext;
if (OfflineCtx) {
audioCtx = new OfflineCtx(1, 1, 44100);
} else {
audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
}
// We only need the buffer, so decode it
const audioBuffer = await audioCtx!.decodeAudioData(arrayBuffer);
// If it was a regular AudioContext, close it.
if (audioCtx.close) {
await audioCtx.close();
}
audioCtx = undefined;
const channelData = audioBuffer.getChannelData(0);
const samplesPerBar = Math.floor(channelData.length / bars);
const peaks: number[] = [];
for (let i = 0; i < bars; i++) {
let peak = 0;
const start = i * samplesPerBar;
// Sample a subset for performance
const step = Math.max(1, Math.floor(samplesPerBar / 200));
for (let j = 0; j < samplesPerBar; j += step) {
const abs = Math.abs(channelData[start + j] || 0);
if (abs > peak) peak = abs;
}
peaks.push(peak);
}
// Normalize to [0, 1]
const max = Math.max(...peaks, 0.01);
const normalized = peaks.map(p => p / max);
waveformCache.set(url, normalized);
return normalized;
} catch {
// Close leaked AudioContext if any
if (audioCtx && audioCtx.close) audioCtx.close().catch(() => {});
// On error, return uniform bars
return Array(bars).fill(0.5);
}
}
export function getMediaUrl(url: string | null | undefined): string {
if (!url) return '';
if (url.startsWith('http') || url.startsWith('blob:') || url.startsWith('data:')) return url;
// Use VITE_API_URL if defined, otherwise let it be a relative path which the browser
// will resolve against the current origin (port).
const baseUrl = import.meta.env.VITE_API_URL || '';
return `${baseUrl}${url.startsWith('/') ? '' : '/'}${url}`;
}