feat: Implement SearchPanel, various mobile UI screens, and foundational shared components across applications.
All checks were successful
Build and Release / build-and-release (push) Successful in 15m35s
All checks were successful
Build and Release / build-and-release (push) Successful in 15m35s
This commit is contained in:
1
TODO.md
1
TODO.md
@@ -17,4 +17,3 @@
|
||||
|
||||
# Android
|
||||
|
||||
- On android when i minimize the app it shows im idle. Can we make it so it shows im offline?
|
||||
@@ -8,7 +8,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 27
|
||||
versionName "1.0.37"
|
||||
versionName "1.0.38"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@discord-clone/android",
|
||||
"private": true,
|
||||
"version": "1.0.36",
|
||||
"version": "1.0.38",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"cap:sync": "npx cap sync",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@discord-clone/electron",
|
||||
"private": true,
|
||||
"version": "1.0.37",
|
||||
"version": "1.0.38",
|
||||
"description": "Brycord - Electron app",
|
||||
"author": "Moyettes",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@discord-clone/web",
|
||||
"private": true,
|
||||
"version": "1.0.37",
|
||||
"version": "1.0.38",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
1328
logs/log.txt
1328
logs/log.txt
File diff suppressed because it is too large
Load Diff
611
logs/log2.txt
611
logs/log2.txt
@@ -1,611 +0,0 @@
|
||||
2026-02-22T18:24:19.1151666Z e0b0e1a10f48(version:v0.2.13) received task 97 of job build-and-release, be triggered by event: push
|
||||
2026-02-22T18:24:19.1154469Z workflow prepared
|
||||
2026-02-22T18:24:19.1154948Z evaluating expression 'success()'
|
||||
2026-02-22T18:24:19.1155512Z expression 'success()' evaluated to 'true'
|
||||
2026-02-22T18:24:19.1155614Z 🚀 Start image=moyettes/eb
|
||||
2026-02-22T18:24:19.1199380Z 🐳 docker pull image=moyettes/eb platform= username= forcePull=false
|
||||
2026-02-22T18:24:19.1199576Z 🐳 docker pull moyettes/eb
|
||||
2026-02-22T18:24:19.1206004Z Image exists? true
|
||||
2026-02-22T18:24:19.1285918Z Cleaning up network for job build-and-release, and network name is: GITEA-ACTIONS-TASK-97_WORKFLOW-Build-and-Release_JOB-build-and-release-build-and-release-network
|
||||
2026-02-22T18:24:20.6844953Z 🐳 docker create image=moyettes/eb platform= entrypoint=["/bin/sleep" "10800"] cmd=[] network="GITEA-ACTIONS-TASK-97_WORKFLOW-Build-and-Release_JOB-build-and-release-build-and-release-network"
|
||||
2026-02-22T18:24:20.6919431Z Custom container.Config from options ==> &{Hostname: Domainname: User: AttachStdin:false AttachStdout:true AttachStderr:true ExposedPorts:map[] Tty:false OpenStdin:false StdinOnce:false Env:[] Cmd:[] Healthcheck:<nil> ArgsEscaped:false Image: Volumes:map[] WorkingDir: Entrypoint:[] NetworkDisabled:false MacAddress: OnBuild:[] Labels:map[] StopSignal: StopTimeout:<nil> Shell:[]}
|
||||
2026-02-22T18:24:20.6919707Z Merged container.Config ==> &{Hostname: Domainname: User: AttachStdin:false AttachStdout:true AttachStderr:true ExposedPorts:map[] Tty:false OpenStdin:false StdinOnce:false Env:[RUNNER_TOOL_CACHE=/opt/hostedtoolcache RUNNER_OS=Linux RUNNER_ARCH=X64 RUNNER_TEMP=/tmp LANG=C.UTF-8] Cmd:[] Healthcheck:<nil> ArgsEscaped:false Image:moyettes/eb Volumes:map[] WorkingDir:/workspace/Moyettes/DiscordClone Entrypoint:[/bin/sleep 10800] NetworkDisabled:false MacAddress: OnBuild:[] Labels:map[] StopSignal: StopTimeout:<nil> Shell:[]}
|
||||
2026-02-22T18:24:20.6919931Z Custom container.HostConfig from options ==> &{Binds:[] ContainerIDFile: LogConfig:{Type: Config:map[]} NetworkMode:GITEA-ACTIONS-TASK-97_WORKFLOW-Build-and-Release_JOB-build-and-release-build-and-release-network PortBindings:map[] RestartPolicy:{Name:no MaximumRetryCount:0} AutoRemove:false VolumeDriver: VolumesFrom:[] ConsoleSize:[0 0] Annotations:map[] CapAdd:[] CapDrop:[] CgroupnsMode: DNS:[] DNSOptions:[] DNSSearch:[] ExtraHosts:[] GroupAdd:[] IpcMode: Cgroup: Links:[] OomScoreAdj:0 PidMode: Privileged:false PublishAllPorts:false ReadonlyRootfs:false SecurityOpt:[] StorageOpt:map[] Tmpfs:map[] UTSMode: UsernsMode: ShmSize:0 Sysctls:map[] Runtime: Isolation: Resources:{CPUShares:0 Memory:0 NanoCPUs:0 CgroupParent: BlkioWeight:0 BlkioWeightDevice:[] BlkioDeviceReadBps:[] BlkioDeviceWriteBps:[] BlkioDeviceReadIOps:[] BlkioDeviceWriteIOps:[] CPUPeriod:0 CPUQuota:0 CPURealtimePeriod:0 CPURealtimeRuntime:0 CpusetCpus: CpusetMems: Devices:[] DeviceCgroupRules:[] DeviceRequests:[] KernelMemory:0 KernelMemoryTCP:0 MemoryReservation:0 MemorySwap:0 MemorySwappiness:0xc0002ec948 OomKillDisable:0xc0002ec843 PidsLimit:0xc0002ec9a8 Ulimits:[] CPUCount:0 CPUPercent:0 IOMaximumIOps:0 IOMaximumBandwidth:0} Mounts:[] MaskedPaths:[] ReadonlyPaths:[] Init:<nil>}
|
||||
2026-02-22T18:24:20.6920169Z --network and --net in the options will be ignored.
|
||||
2026-02-22T18:24:20.6920322Z Merged container.HostConfig ==> &{Binds:[/var/run/docker.sock:/var/run/docker.sock] ContainerIDFile: LogConfig:{Type: Config:map[]} NetworkMode:GITEA-ACTIONS-TASK-97_WORKFLOW-Build-and-Release_JOB-build-and-release-build-and-release-network PortBindings:map[] RestartPolicy:{Name:no MaximumRetryCount:0} AutoRemove:true VolumeDriver: VolumesFrom:[] ConsoleSize:[0 0] Annotations:map[] CapAdd:[] CapDrop:[] CgroupnsMode: DNS:[] DNSOptions:[] DNSSearch:[] ExtraHosts:[] GroupAdd:[] IpcMode: Cgroup: Links:[] OomScoreAdj:0 PidMode: Privileged:false PublishAllPorts:false ReadonlyRootfs:false SecurityOpt:[] StorageOpt:map[] Tmpfs:map[] UTSMode: UsernsMode: ShmSize:0 Sysctls:map[] Runtime: Isolation: Resources:{CPUShares:0 Memory:0 NanoCPUs:0 CgroupParent: BlkioWeight:0 BlkioWeightDevice:[] BlkioDeviceReadBps:[] BlkioDeviceWriteBps:[] BlkioDeviceReadIOps:[] BlkioDeviceWriteIOps:[] CPUPeriod:0 CPUQuota:0 CPURealtimePeriod:0 CPURealtimeRuntime:0 CpusetCpus: CpusetMems: Devices:[] DeviceCgroupRules:[] DeviceRequests:[] KernelMemory:0 KernelMemoryTCP:0 MemoryReservation:0 MemorySwap:0 MemorySwappiness:0xc0002ec948 OomKillDisable:0xc0002ec843 PidsLimit:0xc0002ec9a8 Ulimits:[] CPUCount:0 CPUPercent:0 IOMaximumIOps:0 IOMaximumBandwidth:0} Mounts:[{Type:volume Source:act-toolcache Target:/opt/hostedtoolcache ReadOnly:false Consistency: BindOptions:<nil> VolumeOptions:<nil> TmpfsOptions:<nil> ClusterOptions:<nil>} {Type:volume Source:GITEA-ACTIONS-TASK-97_WORKFLOW-Build-and-Release_JOB-build-and-release-env Target:/var/run/act ReadOnly:false Consistency: BindOptions:<nil> VolumeOptions:<nil> TmpfsOptions:<nil> ClusterOptions:<nil>} {Type:volume Source:GITEA-ACTIONS-TASK-97_WORKFLOW-Build-and-Release_JOB-build-and-release Target:/workspace/Moyettes/DiscordClone ReadOnly:false Consistency: BindOptions:<nil> VolumeOptions:<nil> TmpfsOptions:<nil> ClusterOptions:<nil>}] MaskedPaths:[] ReadonlyPaths:[] Init:<nil>}
|
||||
2026-02-22T18:24:23.0180031Z Created container name=GITEA-ACTIONS-TASK-97_WORKFLOW-Build-and-Release_JOB-build-and-release id=476c8d61de5006daf47cd044eca820c0f99b08acfc7b846a97d8d1560814378e from image moyettes/eb (platform: )
|
||||
2026-02-22T18:24:23.0180328Z ENV ==> [RUNNER_TOOL_CACHE=/opt/hostedtoolcache RUNNER_OS=Linux RUNNER_ARCH=X64 RUNNER_TEMP=/tmp LANG=C.UTF-8]
|
||||
2026-02-22T18:24:23.0180410Z 🐳 docker run image=moyettes/eb platform= entrypoint=["/bin/sleep" "10800"] cmd=[] network="GITEA-ACTIONS-TASK-97_WORKFLOW-Build-and-Release_JOB-build-and-release-build-and-release-network"
|
||||
2026-02-22T18:24:23.0180491Z Starting container: 476c8d61de5006daf47cd044eca820c0f99b08acfc7b846a97d8d1560814378e
|
||||
2026-02-22T18:24:39.2493696Z Started container: 476c8d61de5006daf47cd044eca820c0f99b08acfc7b846a97d8d1560814378e
|
||||
2026-02-22T18:24:39.3080025Z Writing entry to tarball workflow/event.json len:5124
|
||||
2026-02-22T18:24:39.3080335Z Writing entry to tarball workflow/envs.txt len:0
|
||||
2026-02-22T18:24:39.3080410Z Extracting content to '/var/run/act/'
|
||||
2026-02-22T18:24:39.3144112Z ☁ git clone 'https://github.com/actions/checkout' # ref=v4
|
||||
2026-02-22T18:24:39.3144257Z cloning https://github.com/actions/checkout to /root/.cache/act/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab
|
||||
2026-02-22T18:24:39.6397485Z Non-terminating error while running 'git clone': some refs were not updated
|
||||
2026-02-22T18:24:39.6437468Z ☁ git clone 'https://github.com/actions/cache' # ref=v4
|
||||
2026-02-22T18:24:39.6437622Z cloning https://github.com/actions/cache to /root/.cache/act/6b4e4eb40e21c1bd02cb00a273f4d79af7c42205c1390e4e65c594ecd7a3696e
|
||||
2026-02-22T18:24:39.7994895Z Non-terminating error while running 'git clone': some refs were not updated
|
||||
2026-02-22T18:24:39.8106683Z evaluating expression ''
|
||||
2026-02-22T18:24:39.8107099Z expression '' evaluated to 'true'
|
||||
2026-02-22T18:24:39.8107195Z ⭐ Run Main Checkout repository
|
||||
2026-02-22T18:24:39.8107327Z Writing entry to tarball workflow/outputcmd.txt len:0
|
||||
2026-02-22T18:24:39.8107460Z Writing entry to tarball workflow/statecmd.txt len:0
|
||||
2026-02-22T18:24:39.8107544Z Writing entry to tarball workflow/pathcmd.txt len:0
|
||||
2026-02-22T18:24:39.8107630Z Writing entry to tarball workflow/envs.txt len:0
|
||||
2026-02-22T18:24:39.8107761Z Writing entry to tarball workflow/SUMMARY.md len:0
|
||||
2026-02-22T18:24:39.8107852Z Extracting content to '/var/run/act'
|
||||
2026-02-22T18:24:39.8148668Z expression '${{ github.token }}' rewritten to 'format('{0}', github.token)'
|
||||
2026-02-22T18:24:39.8148813Z evaluating expression 'format('{0}', github.token)'
|
||||
2026-02-22T18:24:39.8149058Z expression 'format('{0}', github.token)' evaluated to '%!t(string=***)'
|
||||
2026-02-22T18:24:39.8149363Z expression '${{ github.repository }}' rewritten to 'format('{0}', github.repository)'
|
||||
2026-02-22T18:24:39.8149445Z evaluating expression 'format('{0}', github.repository)'
|
||||
2026-02-22T18:24:39.8149608Z expression 'format('{0}', github.repository)' evaluated to '%!t(string=Moyettes/DiscordClone)'
|
||||
2026-02-22T18:24:39.8149736Z type=remote-action actionDir=/root/.cache/act/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab actionPath= workdir=/workspace/Moyettes/DiscordClone actionCacheDir=/root/.cache/act actionName=c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab containerActionDir=/var/run/act/actions/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab
|
||||
2026-02-22T18:24:39.8149871Z /var/run/act/actions/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab
|
||||
2026-02-22T18:24:39.8150013Z 🐳 docker cp src=/root/.cache/act/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/ dst=/var/run/act/actions/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/
|
||||
2026-02-22T18:24:39.8151040Z Writing tarball /tmp/act144515510 from /root/.cache/act/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/
|
||||
2026-02-22T18:24:39.8151159Z Stripping prefix:/root/.cache/act/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/ src:/root/.cache/act/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/
|
||||
2026-02-22T18:24:39.8524368Z Extracting content from '/tmp/act144515510' to '/var/run/act/actions/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/'
|
||||
2026-02-22T18:24:39.8921095Z executing remote job container: [node /var/run/act/actions/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/dist/index.js]
|
||||
2026-02-22T18:24:39.8921429Z 🐳 docker exec cmd=[node /var/run/act/actions/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/dist/index.js] user= workdir=
|
||||
2026-02-22T18:24:39.8921512Z Exec command '[node /var/run/act/actions/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/dist/index.js]'
|
||||
2026-02-22T18:24:39.8921727Z Working directory '/workspace/Moyettes/DiscordClone'
|
||||
2026-02-22T18:24:39.9825906Z ::add-matcher::/run/act/actions/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/dist/problem-matcher.json
|
||||
2026-02-22T18:24:39.9825992Z ::add-matcher::/run/act/actions/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/dist/problem-matcher.json
|
||||
2026-02-22T18:24:39.9827546Z Syncing repository: Moyettes/DiscordClone
|
||||
2026-02-22T18:24:39.9828977Z ::group::Getting Git version info
|
||||
2026-02-22T18:24:39.9829179Z Working directory is '/workspace/Moyettes/DiscordClone'
|
||||
2026-02-22T18:24:39.9843627Z [command]/usr/bin/git version
|
||||
2026-02-22T18:24:39.9876792Z git version 2.34.1
|
||||
2026-02-22T18:24:39.9893034Z ::endgroup::
|
||||
2026-02-22T18:24:39.9900583Z Copying '/root/.gitconfig' to '/tmp/13a3563f-2afc-454a-be82-84e8c97f13b5/.gitconfig'
|
||||
2026-02-22T18:24:39.9906566Z Temporarily overriding HOME='/tmp/13a3563f-2afc-454a-be82-84e8c97f13b5' before making global git config changes
|
||||
2026-02-22T18:24:39.9906722Z Adding repository directory to the temporary git global config as a safe directory
|
||||
2026-02-22T18:24:39.9910486Z [command]/usr/bin/git config --global --add safe.directory /workspace/Moyettes/DiscordClone
|
||||
2026-02-22T18:24:39.9930061Z Deleting the contents of '/workspace/Moyettes/DiscordClone'
|
||||
2026-02-22T18:24:39.9931500Z ::group::Initializing the repository
|
||||
2026-02-22T18:24:39.9932961Z [command]/usr/bin/git init /workspace/Moyettes/DiscordClone
|
||||
2026-02-22T18:24:39.9951790Z hint: Using 'master' as the name for the initial branch. This default branch name
|
||||
2026-02-22T18:24:39.9951851Z hint: is subject to change. To configure the initial branch name to use in all
|
||||
2026-02-22T18:24:39.9951902Z hint: of your new repositories, which will suppress this warning, call:
|
||||
2026-02-22T18:24:39.9951956Z hint:
|
||||
2026-02-22T18:24:39.9951999Z hint: git config --global init.defaultBranch <name>
|
||||
2026-02-22T18:24:39.9952045Z hint:
|
||||
2026-02-22T18:24:39.9952086Z hint: Names commonly chosen instead of 'master' are 'main', 'trunk' and
|
||||
2026-02-22T18:24:39.9952134Z hint: 'development'. The just-created branch can be renamed via this command:
|
||||
2026-02-22T18:24:39.9952193Z hint:
|
||||
2026-02-22T18:24:39.9952254Z hint: git branch -m <name>
|
||||
2026-02-22T18:24:39.9953884Z Initialized empty Git repository in /workspace/Moyettes/DiscordClone/.git/
|
||||
2026-02-22T18:24:39.9958929Z [command]/usr/bin/git remote add origin http://192.168.125.15:4000/Moyettes/DiscordClone
|
||||
2026-02-22T18:24:39.9974658Z ::endgroup::
|
||||
2026-02-22T18:24:39.9974751Z ::group::Disabling automatic garbage collection
|
||||
2026-02-22T18:24:39.9976776Z [command]/usr/bin/git config --local gc.auto 0
|
||||
2026-02-22T18:24:39.9990144Z ::endgroup::
|
||||
2026-02-22T18:24:39.9990226Z ::group::Setting up auth
|
||||
2026-02-22T18:24:39.9993042Z [command]/usr/bin/git config --local --name-only --get-regexp core\.sshCommand
|
||||
2026-02-22T18:24:40.0008870Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'core\.sshCommand' && git config --local --unset-all 'core.sshCommand' || :"
|
||||
2026-02-22T18:24:40.0175272Z [command]/usr/bin/git config --local --name-only --get-regexp http\.http\:\/\/192\.168\.125\.15\:4000\/\.extraheader
|
||||
2026-02-22T18:24:40.0190112Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'http\.http\:\/\/192\.168\.125\.15\:4000\/\.extraheader' && git config --local --unset-all 'http.http://192.168.125.15:4000/.extraheader' || :"
|
||||
2026-02-22T18:24:40.0332525Z [command]/usr/bin/git config --local --name-only --get-regexp ^includeIf\.gitdir:
|
||||
2026-02-22T18:24:40.0346462Z [command]/usr/bin/git submodule foreach --recursive git config --local --show-origin --name-only --get-regexp remote.origin.url
|
||||
2026-02-22T18:24:40.0492696Z [command]/usr/bin/git config --local http.http://192.168.125.15:4000/.extraheader AUTHORIZATION: basic ***
|
||||
2026-02-22T18:24:40.0508589Z ::endgroup::
|
||||
2026-02-22T18:24:40.0508709Z ::group::Fetching the repository
|
||||
2026-02-22T18:24:40.0511771Z [command]/usr/bin/git -c protocol.version=2 fetch --no-tags --prune --no-recurse-submodules --depth=1 origin +fee34f02364d5b90ed3ecfbfb65db744e61cff35:refs/remotes/origin/main
|
||||
2026-02-22T18:24:41.1339161Z From http://192.168.125.15:4000/Moyettes/DiscordClone
|
||||
2026-02-22T18:24:41.1339550Z * [new ref] fee34f02364d5b90ed3ecfbfb65db744e61cff35 -> origin/main
|
||||
2026-02-22T18:24:41.1351696Z ::endgroup::
|
||||
2026-02-22T18:24:41.1351803Z ::group::Determining the checkout info
|
||||
2026-02-22T18:24:41.1351925Z ::endgroup::
|
||||
2026-02-22T18:24:41.1359598Z [command]/usr/bin/git sparse-checkout disable
|
||||
2026-02-22T18:24:41.1379913Z [command]/usr/bin/git config --local --unset-all extensions.worktreeConfig
|
||||
2026-02-22T18:24:41.1393889Z ::group::Checking out the ref
|
||||
2026-02-22T18:24:41.1395930Z [command]/usr/bin/git checkout --progress --force -B main refs/remotes/origin/main
|
||||
2026-02-22T18:24:41.2311099Z Switched to a new branch 'main'
|
||||
2026-02-22T18:24:41.2311372Z Branch 'main' set up to track remote branch 'main' from 'origin'.
|
||||
2026-02-22T18:24:41.2317056Z ::endgroup::
|
||||
2026-02-22T18:24:41.2344857Z [command]/usr/bin/git log -1 --format=%H
|
||||
2026-02-22T18:24:41.2360982Z fee34f02364d5b90ed3ecfbfb65db744e61cff35
|
||||
2026-02-22T18:24:41.2368952Z ::remove-matcher owner=checkout-git::
|
||||
2026-02-22T18:24:41.6092721Z (node:169) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.
|
||||
2026-02-22T18:24:41.6092918Z (Use `node --trace-deprecation ...` to show where the warning was created)
|
||||
2026-02-22T18:25:01.6665289Z ::warning::Failed to restore: getCacheEntry failed: Request timeout: /_apis/artifactcache/cache?keys=npm-electron-134bff4a9275926d53e3bf1904c23c6d759a3e9f2e4078e45534b44b35a3bf70%252Cnpm-electron-&version=f2531268ab9c19c75ce7b3eb23cc11c7f69fd3cf796834d4881591e430a373ff
|
||||
2026-02-22T18:25:01.6666495Z Cache not found for input keys: npm-electron-134bff4a9275926d53e3bf1904c23c6d759a3e9f2e4078e45534b44b35a3bf70, npm-electron-
|
||||
2026-02-22T18:25:04.7732694Z npm warn deprecated tar@6.2.1: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
2026-02-22T18:25:04.8375071Z npm warn deprecated sourcemap-codec@1.4.8: Please use @jridgewell/sourcemap-codec instead
|
||||
2026-02-22T18:25:05.1190751Z npm warn deprecated npmlog@6.0.2: This package is no longer supported.
|
||||
2026-02-22T18:25:05.3333621Z npm warn deprecated lodash.isequal@4.5.0: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
|
||||
2026-02-22T18:25:05.4953489Z npm warn deprecated inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
|
||||
2026-02-22T18:25:05.7402389Z npm warn deprecated glob@7.2.3: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
2026-02-22T18:25:05.7562188Z npm warn deprecated gauge@4.0.4: This package is no longer supported.
|
||||
2026-02-22T18:25:05.9472737Z npm warn deprecated boolean@3.2.0: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
|
||||
2026-02-22T18:25:05.9889854Z npm warn deprecated are-we-there-yet@3.0.1: This package is no longer supported.
|
||||
2026-02-22T18:25:06.4312265Z npm warn deprecated @npmcli/move-file@2.0.1: This functionality has been moved to @npmcli/fs
|
||||
2026-02-22T18:25:07.5674864Z npm warn deprecated source-map@0.8.0-beta.0: The work that was done in this beta branch won't be included in future versions
|
||||
2026-02-22T18:25:07.6125546Z npm warn deprecated glob@11.1.0: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
2026-02-22T18:25:07.6628274Z npm warn deprecated glob@9.3.5: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
2026-02-22T18:25:07.6926905Z npm warn deprecated rimraf@3.0.2: Rimraf versions prior to v4 are no longer supported
|
||||
2026-02-22T18:25:07.8119261Z npm warn deprecated glob@10.5.0: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
2026-02-22T18:25:07.8308730Z npm warn deprecated rimraf@3.0.2: Rimraf versions prior to v4 are no longer supported
|
||||
2026-02-22T18:25:07.8573021Z npm warn deprecated glob@8.1.0: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
2026-02-22T18:25:07.8663811Z npm warn deprecated glob@7.2.3: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
2026-02-22T18:25:07.8757391Z npm warn deprecated rimraf@3.0.2: Rimraf versions prior to v4 are no longer supported
|
||||
2026-02-22T18:25:10.1036978Z
|
||||
2026-02-22T18:25:10.1037318Z added 923 packages, and audited 929 packages in 8s
|
||||
2026-02-22T18:25:10.1037551Z
|
||||
2026-02-22T18:25:10.1037625Z 259 packages are looking for funding
|
||||
2026-02-22T18:25:10.1037816Z run `npm fund` for details
|
||||
2026-02-22T18:25:10.1265827Z
|
||||
2026-02-22T18:25:10.1266279Z 25 vulnerabilities (2 moderate, 23 high)
|
||||
2026-02-22T18:25:10.1266395Z
|
||||
2026-02-22T18:25:10.1266467Z To address issues that do not require attention, run:
|
||||
2026-02-22T18:25:10.1266587Z npm audit fix
|
||||
2026-02-22T18:25:10.1266656Z
|
||||
2026-02-22T18:25:10.1266722Z To address all issues (including breaking changes), run:
|
||||
2026-02-22T18:25:10.1266796Z npm audit fix --force
|
||||
2026-02-22T18:25:10.1266867Z
|
||||
2026-02-22T18:25:10.1266957Z Run `npm audit` for details.
|
||||
2026-02-22T18:25:10.1464890Z --- app-builder diagnostics ---
|
||||
2026-02-22T18:25:10.1679924Z -rwxr-xr-x 1 root root 18116608 Feb 22 18:25 node_modules/app-builder-bin/linux/x64/app-builder
|
||||
2026-02-22T18:25:10.2788131Z not a dynamic executable
|
||||
2026-02-22T18:25:10.3000273Z 3.5.10
|
||||
2026-02-22T18:25:10.3431920Z 3.5.10
|
||||
2026-02-22T18:25:10.5573080Z
|
||||
2026-02-22T18:25:10.5573589Z > @***/electron@1.0.37 build
|
||||
2026-02-22T18:25:10.5573668Z > vite build
|
||||
2026-02-22T18:25:10.5573716Z
|
||||
2026-02-22T18:25:10.7182083Z [36mvite v7.3.1 [32mbuilding client environment for production...[36m[39m
|
||||
2026-02-22T18:25:10.7514080Z transforming...
|
||||
2026-02-22T18:25:11.1579107Z [33mThe glob option "as" has been deprecated in favour of "query". Please update `as: 'url'` to `query: '?url', import: 'default'`.[39m
|
||||
2026-02-22T18:25:11.1682512Z [33m[plugin vite:esbuild] ../../packages/shared/src/components/ScreenShareModal.jsx: [33mDuplicate key "width" in object literal[33m
|
||||
2026-02-22T18:25:11.1682810Z 104 | }}
|
||||
2026-02-22T18:25:11.1682901Z 105 | >
|
||||
2026-02-22T18:25:11.1682948Z 106 | ...le={{ position: 'relative', width: '100%', height: '250px', width: '450px', borderRadius: '8px', overflow: 'hidden...
|
||||
2026-02-22T18:25:11.1683023Z | ^
|
||||
2026-02-22T18:25:11.1683065Z 107 | {/* Thumbnail/Placeholder */}
|
||||
2026-02-22T18:25:11.1683129Z 108 | {item.thumbnail ? (
|
||||
2026-02-22T18:25:11.1683176Z [39m
|
||||
2026-02-22T18:25:13.1140514Z [32m✓[39m 4668 modules transformed.
|
||||
2026-02-22T18:25:13.6896511Z rendering chunks...
|
||||
2026-02-22T18:25:13.7330742Z computing gzip size...
|
||||
2026-02-22T18:25:13.8835603Z [2mdist-react/[22m[32mindex.html [39m[1m[2m 0.63 kB[22m[1m[22m[2m │ gzip: 0.39 kB[22m
|
||||
2026-02-22T18:25:13.8835922Z [2mdist-react/[22m[2massets/[22m[32mwoman_teacher-CXwESYU3.svg [39m[1m[2m 4.10 kB[22m[1m[22m[2m │ gzip: 1.60 kB[22m
|
||||
2026-02-22T18:25:13.8836000Z [2mdist-react/[22m[2massets/[22m[32mwoman_teacher_tone2-DWJ6rjnf.svg [39m[1m[2m 4.10 kB[22m[1m[22m[2m │ gzip: 1.60 kB[22m
|
||||
2026-02-22T18:25:13.8836057Z [2mdist-react/[22m[2massets/[22m[32mwoman_teacher_tone3-BvnMOsM7.svg [39m[1m[2m 4.10 kB[22m[1m[22m[2m │ gzip: 1.60 kB[22m
|
||||
2026-02-22T18:25:13.8836204Z [2mdist-react/[22m[2massets/[22m[32mwoman_teacher_tone4-C9bkU449.svg [39m[1m[2m 4.10 kB[22m[1m[22m[2m │ gzip: 1.60 kB[22m
|
||||
2026-02-22T18:25:13.8836307Z [2mdist-react/[22m[2massets/[22m[32mbubble_tea-Cy1d5egt.svg [39m[1m[2m 4.10 kB[22m[1m[22m[2m │ gzip: 1.92 kB[22m
|
||||
2026-02-22T18:25:13.8836358Z [2mdist-react/[22m[2massets/[22m[32mflag_gq-B3TFx5qI.svg [39m[1m[2m 4.11 kB[22m[1m[22m[2m │ gzip: 1.68 kB[22m
|
||||
2026-02-22T18:25:13.8836416Z [2mdist-react/[22m[2massets/[22m[32mperson_in_lotus_position_tone1-MK18jaNb.svg [39m[1m[2m 4.11 kB[22m[1m[22m[2m │ gzip: 1.73 kB[22m
|
||||
2026-02-22T18:25:13.8836466Z [2mdist-react/[22m[2massets/[22m[32mperson_in_lotus_position_tone2-Dzm_xqT2.svg [39m[1m[2m 4.11 kB[22m[1m[22m[2m │ gzip: 1.73 kB[22m
|
||||
2026-02-22T18:25:13.8836517Z [2mdist-react/[22m[2massets/[22m[32mperson_in_lotus_position_tone3-DUxbd8tQ.svg [39m[1m[2m 4.11 kB[22m[1m[22m[2m │ gzip: 1.73 kB[22m
|
||||
2026-02-22T18:25:13.8836575Z [2mdist-react/[22m[2massets/[22m[32mperson_in_lotus_position_tone5-CinKf4VL.svg [39m[1m[2m 4.11 kB[22m[1m[22m[2m │ gzip: 1.72 kB[22m
|
||||
2026-02-22T18:25:13.8836623Z [2mdist-react/[22m[2massets/[22m[32mperson_in_lotus_position-9VFgclqE.svg [39m[1m[2m 4.11 kB[22m[1m[22m[2m │ gzip: 1.73 kB[22m
|
||||
2026-02-22T18:25:13.8836670Z [2mdist-react/[22m[2massets/[22m[32mperson_in_lotus_position_tone4-BtjpxNGo.svg [39m[1m[2m 4.11 kB[22m[1m[22m[2m │ gzip: 1.72 kB[22m
|
||||
2026-02-22T18:25:13.8836722Z [2mdist-react/[22m[2massets/[22m[32mman_feeding_baby_tone5-DOWKsCGX.svg [39m[1m[2m 4.11 kB[22m[1m[22m[2m │ gzip: 1.83 kB[22m
|
||||
2026-02-22T18:25:13.8836777Z [2mdist-react/[22m[2massets/[22m[32mman_feeding_baby-BLUtslbF.svg [39m[1m[2m 4.13 kB[22m[1m[22m[2m │ gzip: 1.83 kB[22m
|
||||
2026-02-22T18:25:13.8836875Z [2mdist-react/[22m[2massets/[22m[32mman_feeding_baby_tone1-lg4dBAV2.svg [39m[1m[2m 4.13 kB[22m[1m[22m[2m │ gzip: 1.83 kB[22m
|
||||
2026-02-22T18:25:13.8836925Z [2mdist-react/[22m[2massets/[22m[32mman_feeding_baby_tone2-BuF25R9x.svg [39m[1m[2m 4.13 kB[22m[1m[22m[2m │ gzip: 1.83 kB[22m
|
||||
2026-02-22T18:25:13.8836978Z [2mdist-react/[22m[2massets/[22m[32mman_feeding_baby_tone3-DEYxzqY-.svg [39m[1m[2m 4.13 kB[22m[1m[22m[2m │ gzip: 1.83 kB[22m
|
||||
2026-02-22T18:25:13.8837026Z [2mdist-react/[22m[2massets/[22m[32mman_feeding_baby_tone4-IRS8MZPe.svg [39m[1m[2m 4.13 kB[22m[1m[22m[2m │ gzip: 1.83 kB[22m
|
||||
2026-02-22T18:25:13.8837091Z [2mdist-react/[22m[2massets/[22m[32mwoman_zombie-Cn4gQ0af.svg [39m[1m[2m 4.15 kB[22m[1m[22m[2m │ gzip: 1.73 kB[22m
|
||||
2026-02-22T18:25:13.8837140Z [2mdist-react/[22m[2massets/[22m[32mhiking_boot-CPXD60gE.svg [39m[1m[2m 4.17 kB[22m[1m[22m[2m │ gzip: 1.76 kB[22m
|
||||
2026-02-22T18:25:13.8837196Z [2mdist-react/[22m[2massets/[22m[32mjapanese_ogre-BsqNvmIl.svg [39m[1m[2m 4.22 kB[22m[1m[22m[2m │ gzip: 1.85 kB[22m
|
||||
2026-02-22T18:25:13.8837245Z [2mdist-react/[22m[2massets/[22m[32mwoman_police_officer_tone5-CuQMgf5h.svg [39m[1m[2m 4.23 kB[22m[1m[22m[2m │ gzip: 1.72 kB[22m
|
||||
2026-02-22T18:25:13.8837299Z [2mdist-react/[22m[2massets/[22m[32mwoman_police_officer-D6jKOTyC.svg [39m[1m[2m 4.25 kB[22m[1m[22m[2m │ gzip: 1.73 kB[22m
|
||||
2026-02-22T18:25:13.8837348Z [2mdist-react/[22m[2massets/[22m[32mwoman_police_officer_tone1-A8sdhmvt.svg [39m[1m[2m 4.25 kB[22m[1m[22m[2m │ gzip: 1.73 kB[22m
|
||||
2026-02-22T18:25:13.8837398Z [2mdist-react/[22m[2massets/[22m[32mwoman_police_officer_tone2-DaHNn5-D.svg [39m[1m[2m 4.25 kB[22m[1m[22m[2m │ gzip: 1.73 kB[22m
|
||||
2026-02-22T18:25:13.8837448Z [2mdist-react/[22m[2massets/[22m[32mwoman_police_officer_tone3-DXZ2OdUI.svg [39m[1m[2m 4.25 kB[22m[1m[22m[2m │ gzip: 1.73 kB[22m
|
||||
2026-02-22T18:25:13.8837497Z [2mdist-react/[22m[2massets/[22m[32mwoman_police_officer_tone4--Oe8w2XD.svg [39m[1m[2m 4.25 kB[22m[1m[22m[2m │ gzip: 1.72 kB[22m
|
||||
2026-02-22T18:25:13.8837559Z [2mdist-react/[22m[2massets/[22m[32mferris_wheel-DvW0t9g3.svg [39m[1m[2m 4.25 kB[22m[1m[22m[2m │ gzip: 1.19 kB[22m
|
||||
2026-02-22T18:25:13.8837608Z [2mdist-react/[22m[2massets/[22m[32mman_teacher_tone5-Bk9uZHaS.svg [39m[1m[2m 4.26 kB[22m[1m[22m[2m │ gzip: 1.75 kB[22m
|
||||
2026-02-22T18:25:13.8837748Z [2mdist-react/[22m[2massets/[22m[32mman_teacher-BRypTuYs.svg [39m[1m[2m 4.27 kB[22m[1m[22m[2m │ gzip: 1.75 kB[22m
|
||||
2026-02-22T18:25:13.8837808Z [2mdist-react/[22m[2massets/[22m[32mman_teacher_tone1-jNO2AiRD.svg [39m[1m[2m 4.27 kB[22m[1m[22m[2m │ gzip: 1.75 kB[22m
|
||||
2026-02-22T18:25:13.8837872Z [2mdist-react/[22m[2massets/[22m[32mman_teacher_tone2-rQoIFfFz.svg [39m[1m[2m 4.27 kB[22m[1m[22m[2m │ gzip: 1.75 kB[22m
|
||||
2026-02-22T18:25:13.8837924Z [2mdist-react/[22m[2massets/[22m[32mman_teacher_tone3-BEE8k6p5.svg [39m[1m[2m 4.27 kB[22m[1m[22m[2m │ gzip: 1.75 kB[22m
|
||||
2026-02-22T18:25:13.8837982Z [2mdist-react/[22m[2massets/[22m[32mman_teacher_tone4-C4j4RIq3.svg [39m[1m[2m 4.27 kB[22m[1m[22m[2m │ gzip: 1.74 kB[22m
|
||||
2026-02-22T18:25:13.8838043Z [2mdist-react/[22m[2massets/[22m[32msari-BSRA0_R3.svg [39m[1m[2m 4.27 kB[22m[1m[22m[2m │ gzip: 1.77 kB[22m
|
||||
2026-02-22T18:25:13.8838102Z [2mdist-react/[22m[2massets/[22m[32mgloves-BcY_RgAR.svg [39m[1m[2m 4.31 kB[22m[1m[22m[2m │ gzip: 1.62 kB[22m
|
||||
2026-02-22T18:25:13.8838160Z [2mdist-react/[22m[2massets/[22m[32mmoon_cake-BQr_VKRq.svg [39m[1m[2m 4.31 kB[22m[1m[22m[2m │ gzip: 1.94 kB[22m
|
||||
2026-02-22T18:25:13.8838220Z [2mdist-react/[22m[2massets/[22m[32mman_zombie-D5T1AZ12.svg [39m[1m[2m 4.34 kB[22m[1m[22m[2m │ gzip: 1.78 kB[22m
|
||||
2026-02-22T18:25:13.8838396Z [2mdist-react/[22m[2massets/[22m[32mwoman_surfing_tone1-Mj4OdRWf.svg [39m[1m[2m 4.35 kB[22m[1m[22m[2m │ gzip: 1.94 kB[22m
|
||||
2026-02-22T18:25:13.8838458Z [2mdist-react/[22m[2massets/[22m[32mwoman_surfing-22i7hQgf.svg [39m[1m[2m 4.38 kB[22m[1m[22m[2m │ gzip: 1.95 kB[22m
|
||||
2026-02-22T18:25:13.8838519Z [2mdist-react/[22m[2massets/[22m[32mwoman_surfing_tone2-BcJfdMyX.svg [39m[1m[2m 4.38 kB[22m[1m[22m[2m │ gzip: 1.95 kB[22m
|
||||
2026-02-22T18:25:13.8838578Z [2mdist-react/[22m[2massets/[22m[32mwoman_surfing_tone3-D1NUtDA8.svg [39m[1m[2m 4.38 kB[22m[1m[22m[2m │ gzip: 1.95 kB[22m
|
||||
2026-02-22T18:25:13.8838634Z [2mdist-react/[22m[2massets/[22m[32mwoman_surfing_tone4-ByYdFJZp.svg [39m[1m[2m 4.38 kB[22m[1m[22m[2m │ gzip: 1.95 kB[22m
|
||||
2026-02-22T18:25:13.8838702Z [2mdist-react/[22m[2massets/[22m[32mwoman_surfing_tone5-CrN9a9WS.svg [39m[1m[2m 4.38 kB[22m[1m[22m[2m │ gzip: 1.95 kB[22m
|
||||
2026-02-22T18:25:13.8838760Z [2mdist-react/[22m[2massets/[22m[32mperson_with_probing_cane-DRcmbgmz.svg [39m[1m[2m 4.38 kB[22m[1m[22m[2m │ gzip: 1.91 kB[22m
|
||||
2026-02-22T18:25:13.8838819Z [2mdist-react/[22m[2massets/[22m[32mperson_with_probing_cane_tone1-zKjrapc7.svg [39m[1m[2m 4.38 kB[22m[1m[22m[2m │ gzip: 1.90 kB[22m
|
||||
2026-02-22T18:25:13.8838880Z [2mdist-react/[22m[2massets/[22m[32mperson_with_probing_cane_tone2-CY2wYkQb.svg [39m[1m[2m 4.38 kB[22m[1m[22m[2m │ gzip: 1.91 kB[22m
|
||||
2026-02-22T18:25:13.8838937Z [2mdist-react/[22m[2massets/[22m[32mperson_with_probing_cane_tone3-CdJAKQXv.svg [39m[1m[2m 4.38 kB[22m[1m[22m[2m │ gzip: 1.91 kB[22m
|
||||
2026-02-22T18:25:13.8838994Z [2mdist-react/[22m[2massets/[22m[32mperson_with_probing_cane_tone5-DFAMgo57.svg [39m[1m[2m 4.38 kB[22m[1m[22m[2m │ gzip: 1.91 kB[22m
|
||||
2026-02-22T18:25:13.8839052Z [2mdist-react/[22m[2massets/[22m[32mperson_with_probing_cane_tone4-DXYc5Dlc.svg [39m[1m[2m 4.38 kB[22m[1m[22m[2m │ gzip: 1.91 kB[22m
|
||||
2026-02-22T18:25:13.8839110Z [2mdist-react/[22m[2massets/[22m[32mskier-BTSq18N5.svg [39m[1m[2m 4.39 kB[22m[1m[22m[2m │ gzip: 1.75 kB[22m
|
||||
2026-02-22T18:25:13.8839169Z [2mdist-react/[22m[2massets/[22m[32mwoman_singer_tone5-Co-5wXNK.svg [39m[1m[2m 4.40 kB[22m[1m[22m[2m │ gzip: 1.84 kB[22m
|
||||
2026-02-22T18:25:13.8839229Z [2mdist-react/[22m[2massets/[22m[32mwoman_singer_tone1-DbW2lM_k.svg [39m[1m[2m 4.42 kB[22m[1m[22m[2m │ gzip: 1.85 kB[22m
|
||||
2026-02-22T18:25:13.8839286Z [2mdist-react/[22m[2massets/[22m[32mman_with_probing_cane-BarJlRlV.svg [39m[1m[2m 4.45 kB[22m[1m[22m[2m │ gzip: 1.94 kB[22m
|
||||
2026-02-22T18:25:13.8839342Z [2mdist-react/[22m[2massets/[22m[32mman_with_probing_cane_tone1-D50RTI5B.svg [39m[1m[2m 4.45 kB[22m[1m[22m[2m │ gzip: 1.94 kB[22m
|
||||
2026-02-22T18:25:13.8839403Z [2mdist-react/[22m[2massets/[22m[32mman_with_probing_cane_tone2-CIeZuoUa.svg [39m[1m[2m 4.45 kB[22m[1m[22m[2m │ gzip: 1.94 kB[22m
|
||||
2026-02-22T18:25:13.8839467Z [2mdist-react/[22m[2massets/[22m[32mman_with_probing_cane_tone3-GUZ14VpU.svg [39m[1m[2m 4.45 kB[22m[1m[22m[2m │ gzip: 1.94 kB[22m
|
||||
2026-02-22T18:25:13.8839527Z [2mdist-react/[22m[2massets/[22m[32mman_with_probing_cane_tone4-Bd6iA8-d.svg [39m[1m[2m 4.45 kB[22m[1m[22m[2m │ gzip: 1.94 kB[22m
|
||||
2026-02-22T18:25:13.8839586Z [2mdist-react/[22m[2massets/[22m[32mman_with_probing_cane_tone5-BCpRNGU4.svg [39m[1m[2m 4.45 kB[22m[1m[22m[2m │ gzip: 1.94 kB[22m
|
||||
2026-02-22T18:25:13.8839644Z [2mdist-react/[22m[2massets/[22m[32mwoman_singer-skPaDBsj.svg [39m[1m[2m 4.45 kB[22m[1m[22m[2m │ gzip: 1.84 kB[22m
|
||||
2026-02-22T18:25:13.8839706Z [2mdist-react/[22m[2massets/[22m[32mwoman_singer_tone2-Bc-xqa4S.svg [39m[1m[2m 4.45 kB[22m[1m[22m[2m │ gzip: 1.85 kB[22m
|
||||
2026-02-22T18:25:13.8839763Z [2mdist-react/[22m[2massets/[22m[32mwoman_singer_tone3-CNRJeino.svg [39m[1m[2m 4.45 kB[22m[1m[22m[2m │ gzip: 1.85 kB[22m
|
||||
2026-02-22T18:25:13.8839836Z [2mdist-react/[22m[2massets/[22m[32mwoman_singer_tone4-CEfcWjkD.svg [39m[1m[2m 4.45 kB[22m[1m[22m[2m │ gzip: 1.84 kB[22m
|
||||
2026-02-22T18:25:13.8839896Z [2mdist-react/[22m[2massets/[22m[32mflag_sz-CsAySmAn.svg [39m[1m[2m 4.46 kB[22m[1m[22m[2m │ gzip: 1.66 kB[22m
|
||||
2026-02-22T18:25:13.8839958Z [2mdist-react/[22m[2massets/[22m[32msnowman2-CeWFCRvE.svg [39m[1m[2m 4.48 kB[22m[1m[22m[2m │ gzip: 1.15 kB[22m
|
||||
2026-02-22T18:25:13.8840013Z [2mdist-react/[22m[2massets/[22m[32mman_surfing-fqnQ3hm1.svg [39m[1m[2m 4.49 kB[22m[1m[22m[2m │ gzip: 1.97 kB[22m
|
||||
2026-02-22T18:25:13.8840070Z [2mdist-react/[22m[2massets/[22m[32mman_surfing_tone2-CDUKGUjg.svg [39m[1m[2m 4.49 kB[22m[1m[22m[2m │ gzip: 1.97 kB[22m
|
||||
2026-02-22T18:25:13.8840123Z [2mdist-react/[22m[2massets/[22m[32mman_surfing_tone1-BeC3CjNB.svg [39m[1m[2m 4.49 kB[22m[1m[22m[2m │ gzip: 1.96 kB[22m
|
||||
2026-02-22T18:25:13.8840184Z [2mdist-react/[22m[2massets/[22m[32mman_surfing_tone3-Dt-HUBR5.svg [39m[1m[2m 4.49 kB[22m[1m[22m[2m │ gzip: 1.97 kB[22m
|
||||
2026-02-22T18:25:13.8840241Z [2mdist-react/[22m[2massets/[22m[32mman_surfing_tone4-PI8ASA2j.svg [39m[1m[2m 4.49 kB[22m[1m[22m[2m │ gzip: 1.97 kB[22m
|
||||
2026-02-22T18:25:13.8840302Z [2mdist-react/[22m[2massets/[22m[32mman_surfing_tone5-CyvTB2HT.svg [39m[1m[2m 4.49 kB[22m[1m[22m[2m │ gzip: 1.97 kB[22m
|
||||
2026-02-22T18:25:13.8840359Z [2mdist-react/[22m[2massets/[22m[32mmotorized_wheelchair-DYMoavTH.svg [39m[1m[2m 4.50 kB[22m[1m[22m[2m │ gzip: 1.63 kB[22m
|
||||
2026-02-22T18:25:13.8840422Z [2mdist-react/[22m[2massets/[22m[32mwoman_feeding_baby_tone5-B6CmkSrw.svg [39m[1m[2m 4.54 kB[22m[1m[22m[2m │ gzip: 2.01 kB[22m
|
||||
2026-02-22T18:25:13.8840597Z [2mdist-react/[22m[2massets/[22m[32mflag_ht-nORDdDQL.svg [39m[1m[2m 4.54 kB[22m[1m[22m[2m │ gzip: 1.88 kB[22m
|
||||
2026-02-22T18:25:13.8840655Z [2mdist-react/[22m[2massets/[22m[32mwoman_feeding_baby-p-8aPRtV.svg [39m[1m[2m 4.56 kB[22m[1m[22m[2m │ gzip: 2.01 kB[22m
|
||||
2026-02-22T18:25:13.8840706Z [2mdist-react/[22m[2massets/[22m[32mwoman_feeding_baby_tone1-BezQI8D_.svg [39m[1m[2m 4.56 kB[22m[1m[22m[2m │ gzip: 2.01 kB[22m
|
||||
2026-02-22T18:25:13.8840763Z [2mdist-react/[22m[2massets/[22m[32mwoman_feeding_baby_tone2-CwnQLRQK.svg [39m[1m[2m 4.56 kB[22m[1m[22m[2m │ gzip: 2.01 kB[22m
|
||||
2026-02-22T18:25:13.8840826Z [2mdist-react/[22m[2massets/[22m[32mwoman_feeding_baby_tone3-DlgTa1f-.svg [39m[1m[2m 4.56 kB[22m[1m[22m[2m │ gzip: 2.01 kB[22m
|
||||
2026-02-22T18:25:13.8840885Z [2mdist-react/[22m[2massets/[22m[32mwoman_feeding_baby_tone4-DVay5Top.svg [39m[1m[2m 4.56 kB[22m[1m[22m[2m │ gzip: 2.01 kB[22m
|
||||
2026-02-22T18:25:13.8840943Z [2mdist-react/[22m[2massets/[22m[32mflag_ad-CYOJPtjR.svg [39m[1m[2m 4.59 kB[22m[1m[22m[2m │ gzip: 1.79 kB[22m
|
||||
2026-02-22T18:25:13.8840999Z [2mdist-react/[22m[2massets/[22m[32mwoman_with_probing_cane-DyJEphms.svg [39m[1m[2m 4.60 kB[22m[1m[22m[2m │ gzip: 2.00 kB[22m
|
||||
2026-02-22T18:25:13.8841059Z [2mdist-react/[22m[2massets/[22m[32mwoman_with_probing_cane_tone2-GEDDmrTN.svg [39m[1m[2m 4.60 kB[22m[1m[22m[2m │ gzip: 2.00 kB[22m
|
||||
2026-02-22T18:25:13.8841160Z [2mdist-react/[22m[2massets/[22m[32mwoman_with_probing_cane_tone1-DuB7wHWP.svg [39m[1m[2m 4.60 kB[22m[1m[22m[2m │ gzip: 2.00 kB[22m
|
||||
2026-02-22T18:25:13.8841219Z [2mdist-react/[22m[2massets/[22m[32mwoman_with_probing_cane_tone3-CeM4gv4f.svg [39m[1m[2m 4.60 kB[22m[1m[22m[2m │ gzip: 2.00 kB[22m
|
||||
2026-02-22T18:25:13.8841281Z [2mdist-react/[22m[2massets/[22m[32mwoman_with_probing_cane_tone4-BaEnBpNy.svg [39m[1m[2m 4.60 kB[22m[1m[22m[2m │ gzip: 2.00 kB[22m
|
||||
2026-02-22T18:25:13.8841337Z [2mdist-react/[22m[2massets/[22m[32mwoman_with_probing_cane_tone5-DimRSWot.svg [39m[1m[2m 4.60 kB[22m[1m[22m[2m │ gzip: 2.00 kB[22m
|
||||
2026-02-22T18:25:13.8841394Z [2mdist-react/[22m[2massets/[22m[32mperson_surfing_tone1-B_kCGm1b.svg [39m[1m[2m 4.62 kB[22m[1m[22m[2m │ gzip: 2.10 kB[22m
|
||||
2026-02-22T18:25:13.8841445Z [2mdist-react/[22m[2massets/[22m[32mperson_surfing-DEOz-TJs.svg [39m[1m[2m 4.62 kB[22m[1m[22m[2m │ gzip: 2.10 kB[22m
|
||||
2026-02-22T18:25:13.8841505Z [2mdist-react/[22m[2massets/[22m[32mperson_surfing_tone2-D35jmuhN.svg [39m[1m[2m 4.62 kB[22m[1m[22m[2m │ gzip: 2.11 kB[22m
|
||||
2026-02-22T18:25:13.8841565Z [2mdist-react/[22m[2massets/[22m[32mperson_surfing_tone3-C60a5Aj1.svg [39m[1m[2m 4.62 kB[22m[1m[22m[2m │ gzip: 2.10 kB[22m
|
||||
2026-02-22T18:25:13.8841624Z [2mdist-react/[22m[2massets/[22m[32mperson_surfing_tone5-CXXi5x8s.svg [39m[1m[2m 4.62 kB[22m[1m[22m[2m │ gzip: 2.10 kB[22m
|
||||
2026-02-22T18:25:13.8841671Z [2mdist-react/[22m[2massets/[22m[32mperson_surfing_tone4-nIpCJUpE.svg [39m[1m[2m 4.62 kB[22m[1m[22m[2m │ gzip: 2.10 kB[22m
|
||||
2026-02-22T18:25:13.8841725Z [2mdist-react/[22m[2massets/[22m[32mflag_tc-Dn_lC0KY.svg [39m[1m[2m 4.72 kB[22m[1m[22m[2m │ gzip: 1.94 kB[22m
|
||||
2026-02-22T18:25:13.8841772Z [2mdist-react/[22m[2massets/[22m[32mperson_golfing_tone2-BbPQ5nEE.svg [39m[1m[2m 4.72 kB[22m[1m[22m[2m │ gzip: 2.15 kB[22m
|
||||
2026-02-22T18:25:13.8841819Z [2mdist-react/[22m[2massets/[22m[32mperson_golfing-Mc5PuEC7.svg [39m[1m[2m 4.72 kB[22m[1m[22m[2m │ gzip: 2.15 kB[22m
|
||||
2026-02-22T18:25:13.8841865Z [2mdist-react/[22m[2massets/[22m[32mperson_golfing_tone4-DPEV2yNK.svg [39m[1m[2m 4.72 kB[22m[1m[22m[2m │ gzip: 2.15 kB[22m
|
||||
2026-02-22T18:25:13.8841912Z [2mdist-react/[22m[2massets/[22m[32mperson_golfing_tone1-DhUQwNf7.svg [39m[1m[2m 4.72 kB[22m[1m[22m[2m │ gzip: 2.15 kB[22m
|
||||
2026-02-22T18:25:13.8841967Z [2mdist-react/[22m[2massets/[22m[32mperson_golfing_tone5-Bgq3Ud_d.svg [39m[1m[2m 4.72 kB[22m[1m[22m[2m │ gzip: 2.15 kB[22m
|
||||
2026-02-22T18:25:13.8842017Z [2mdist-react/[22m[2massets/[22m[32mperson_golfing_tone3-CKRqu9yJ.svg [39m[1m[2m 4.72 kB[22m[1m[22m[2m │ gzip: 2.15 kB[22m
|
||||
2026-02-22T18:25:13.8842067Z [2mdist-react/[22m[2massets/[22m[32mflag_ki-Ccc3Xi24.svg [39m[1m[2m 4.77 kB[22m[1m[22m[2m │ gzip: 1.66 kB[22m
|
||||
2026-02-22T18:25:13.8842116Z [2mdist-react/[22m[2massets/[22m[32mflag_mo-PAf1BQIO.svg [39m[1m[2m 4.77 kB[22m[1m[22m[2m │ gzip: 2.24 kB[22m
|
||||
2026-02-22T18:25:13.8842162Z [2mdist-react/[22m[2massets/[22m[32mflag_sh-CT89bJZi.svg [39m[1m[2m 4.80 kB[22m[1m[22m[2m │ gzip: 1.67 kB[22m
|
||||
2026-02-22T18:25:13.8842218Z [2mdist-react/[22m[2massets/[22m[32mcrab-D6qU1zIW.svg [39m[1m[2m 4.87 kB[22m[1m[22m[2m │ gzip: 1.97 kB[22m
|
||||
2026-02-22T18:25:13.8842274Z [2mdist-react/[22m[2massets/[22m[32mcouple-KSrP6fk0.svg [39m[1m[2m 4.90 kB[22m[1m[22m[2m │ gzip: 2.04 kB[22m
|
||||
2026-02-22T18:25:13.8842333Z [2mdist-react/[22m[2massets/[22m[32mkiwi-BWXr7Vjo.svg [39m[1m[2m 4.91 kB[22m[1m[22m[2m │ gzip: 2.27 kB[22m
|
||||
2026-02-22T18:25:13.8842390Z [2mdist-react/[22m[2massets/[22m[32mhedgehog-CMNxZzfp.svg [39m[1m[2m 4.92 kB[22m[1m[22m[2m │ gzip: 1.82 kB[22m
|
||||
2026-02-22T18:25:13.8842453Z [2mdist-react/[22m[2massets/[22m[32mmoney_with_wings-BnGoAiwj.svg [39m[1m[2m 4.95 kB[22m[1m[22m[2m │ gzip: 2.07 kB[22m
|
||||
2026-02-22T18:25:13.8842510Z [2mdist-react/[22m[2massets/[22m[32mflag_kg-D_P2G_Do.svg [39m[1m[2m 4.98 kB[22m[1m[22m[2m │ gzip: 2.14 kB[22m
|
||||
2026-02-22T18:25:13.8842557Z [2mdist-react/[22m[2massets/[22m[32mmaracas-kQiWhg0J.svg [39m[1m[2m 4.99 kB[22m[1m[22m[2m │ gzip: 1.76 kB[22m
|
||||
2026-02-22T18:25:13.8842615Z [2mdist-react/[22m[2massets/[22m[32mx_ray-CWrdKTDm.svg [39m[1m[2m 4.99 kB[22m[1m[22m[2m │ gzip: 2.37 kB[22m
|
||||
2026-02-22T18:25:13.8842675Z [2mdist-react/[22m[2massets/[22m[32mpeople_holding_hands-BRZihiu5.svg [39m[1m[2m 5.03 kB[22m[1m[22m[2m │ gzip: 1.68 kB[22m
|
||||
2026-02-22T18:25:13.8842736Z [2mdist-react/[22m[2massets/[22m[32mbutterfly-AxzpD-Pg.svg [39m[1m[2m 5.04 kB[22m[1m[22m[2m │ gzip: 1.86 kB[22m
|
||||
2026-02-22T18:25:13.8842794Z [2mdist-react/[22m[2massets/[22m[32mflag_xk-D1vfCqOd.svg [39m[1m[2m 5.08 kB[22m[1m[22m[2m │ gzip: 2.15 kB[22m
|
||||
2026-02-22T18:25:13.8842848Z [2mdist-react/[22m[2massets/[22m[32mflag_tm-_4vioey7.svg [39m[1m[2m 5.13 kB[22m[1m[22m[2m │ gzip: 1.54 kB[22m
|
||||
2026-02-22T18:25:13.8842902Z [2mdist-react/[22m[2massets/[22m[32mtwo_men_holding_hands-BKJxHZb_.svg [39m[1m[2m 5.15 kB[22m[1m[22m[2m │ gzip: 1.50 kB[22m
|
||||
2026-02-22T18:25:13.8842960Z [2mdist-react/[22m[2massets/[22m[32mseal-Djs0F0U5.svg [39m[1m[2m 5.16 kB[22m[1m[22m[2m │ gzip: 2.18 kB[22m
|
||||
2026-02-22T18:25:13.8843016Z [2mdist-react/[22m[2massets/[22m[32mspeak_no_evil-EoRZCJhS.svg [39m[1m[2m 5.20 kB[22m[1m[22m[2m │ gzip: 2.27 kB[22m
|
||||
2026-02-22T18:25:13.8843136Z [2mdist-react/[22m[2massets/[22m[32mman_dancing_tone1-XI7g5maV.svg [39m[1m[2m 5.22 kB[22m[1m[22m[2m │ gzip: 2.00 kB[22m
|
||||
2026-02-22T18:25:13.8843196Z [2mdist-react/[22m[2massets/[22m[32mman_dancing-Dg-6O6t7.svg [39m[1m[2m 5.22 kB[22m[1m[22m[2m │ gzip: 2.00 kB[22m
|
||||
2026-02-22T18:25:13.8843251Z [2mdist-react/[22m[2massets/[22m[32mman_dancing_tone2-DBvANdsI.svg [39m[1m[2m 5.22 kB[22m[1m[22m[2m │ gzip: 2.00 kB[22m
|
||||
2026-02-22T18:25:13.8843309Z [2mdist-react/[22m[2massets/[22m[32mman_dancing_tone3-BK7ka3J3.svg [39m[1m[2m 5.22 kB[22m[1m[22m[2m │ gzip: 2.00 kB[22m
|
||||
2026-02-22T18:25:13.8843459Z [2mdist-react/[22m[2massets/[22m[32mman_dancing_tone5-CQh9niVO.svg [39m[1m[2m 5.22 kB[22m[1m[22m[2m │ gzip: 2.00 kB[22m
|
||||
2026-02-22T18:25:13.8843516Z [2mdist-react/[22m[2massets/[22m[32mman_dancing_tone4-D9FZRxV5.svg [39m[1m[2m 5.22 kB[22m[1m[22m[2m │ gzip: 2.00 kB[22m
|
||||
2026-02-22T18:25:13.8843572Z [2mdist-react/[22m[2massets/[22m[32mflag_pf-OA_PTTaZ.svg [39m[1m[2m 5.22 kB[22m[1m[22m[2m │ gzip: 1.81 kB[22m
|
||||
2026-02-22T18:25:13.8843628Z [2mdist-react/[22m[2massets/[22m[32mpie-DZ6nmSau.svg [39m[1m[2m 5.24 kB[22m[1m[22m[2m │ gzip: 1.98 kB[22m
|
||||
2026-02-22T18:25:13.8843685Z [2mdist-react/[22m[2massets/[22m[32mtwo_women_holding_hands-CnKtKnaZ.svg [39m[1m[2m 5.29 kB[22m[1m[22m[2m │ gzip: 1.71 kB[22m
|
||||
2026-02-22T18:25:13.8843734Z [2mdist-react/[22m[2massets/[22m[32mbrain-Czvux5Q4.svg [39m[1m[2m 5.32 kB[22m[1m[22m[2m │ gzip: 2.48 kB[22m
|
||||
2026-02-22T18:25:13.8843779Z [2mdist-react/[22m[2massets/[22m[32mlacrosse-DK95k1kF.svg [39m[1m[2m 5.33 kB[22m[1m[22m[2m │ gzip: 2.22 kB[22m
|
||||
2026-02-22T18:25:13.8843833Z [2mdist-react/[22m[2massets/[22m[32msee_no_evil-DnuksPIR.svg [39m[1m[2m 5.42 kB[22m[1m[22m[2m │ gzip: 2.38 kB[22m
|
||||
2026-02-22T18:25:13.8843887Z [2mdist-react/[22m[2massets/[22m[32mflag_vi-vzZjsoBi.svg [39m[1m[2m 5.43 kB[22m[1m[22m[2m │ gzip: 2.34 kB[22m
|
||||
2026-02-22T18:25:13.8843950Z [2mdist-react/[22m[2massets/[22m[32mdodo-CoZFlciJ.svg [39m[1m[2m 5.44 kB[22m[1m[22m[2m │ gzip: 2.31 kB[22m
|
||||
2026-02-22T18:25:13.8844004Z [2mdist-react/[22m[2massets/[22m[32mflag_hk-CzNuCBPg.svg [39m[1m[2m 5.46 kB[22m[1m[22m[2m │ gzip: 2.52 kB[22m
|
||||
2026-02-22T18:25:13.8844061Z [2mdist-react/[22m[2massets/[22m[32mspider_web-DPcv-q20.svg [39m[1m[2m 5.50 kB[22m[1m[22m[2m │ gzip: 2.43 kB[22m
|
||||
2026-02-22T18:25:13.8844115Z [2mdist-react/[22m[2massets/[22m[32mflag_bl-BoaeaHPp.svg [39m[1m[2m 5.58 kB[22m[1m[22m[2m │ gzip: 1.90 kB[22m
|
||||
2026-02-22T18:25:13.8844173Z [2mdist-react/[22m[2massets/[22m[32mflag_as-B43i20pO.svg [39m[1m[2m 5.61 kB[22m[1m[22m[2m │ gzip: 2.39 kB[22m
|
||||
2026-02-22T18:25:13.8844229Z [2mdist-react/[22m[2massets/[22m[32mflag_gp-DW1UVBGw.svg [39m[1m[2m 5.63 kB[22m[1m[22m[2m │ gzip: 2.05 kB[22m
|
||||
2026-02-22T18:25:13.8844293Z [2mdist-react/[22m[2massets/[22m[32mflag_ic-BrB5Xakj.svg [39m[1m[2m 5.63 kB[22m[1m[22m[2m │ gzip: 2.24 kB[22m
|
||||
2026-02-22T18:25:13.8844348Z [2mdist-react/[22m[2massets/[22m[32mman_golfing-DhSLN6KQ.svg [39m[1m[2m 5.64 kB[22m[1m[22m[2m │ gzip: 2.61 kB[22m
|
||||
2026-02-22T18:25:13.8844404Z [2mdist-react/[22m[2massets/[22m[32mman_golfing_tone1-DBE1f7b1.svg [39m[1m[2m 5.64 kB[22m[1m[22m[2m │ gzip: 2.61 kB[22m
|
||||
2026-02-22T18:25:13.8844461Z [2mdist-react/[22m[2massets/[22m[32mman_golfing_tone2-CNmTGsfk.svg [39m[1m[2m 5.64 kB[22m[1m[22m[2m │ gzip: 2.61 kB[22m
|
||||
2026-02-22T18:25:13.8844515Z [2mdist-react/[22m[2massets/[22m[32mman_golfing_tone3-vxj0o6sI.svg [39m[1m[2m 5.64 kB[22m[1m[22m[2m │ gzip: 2.61 kB[22m
|
||||
2026-02-22T18:25:13.8844579Z [2mdist-react/[22m[2massets/[22m[32mman_golfing_tone4-CsGYmisz.svg [39m[1m[2m 5.64 kB[22m[1m[22m[2m │ gzip: 2.61 kB[22m
|
||||
2026-02-22T18:25:13.8844633Z [2mdist-react/[22m[2massets/[22m[32mman_golfing_tone5-Cf_z4uyg.svg [39m[1m[2m 5.64 kB[22m[1m[22m[2m │ gzip: 2.60 kB[22m
|
||||
2026-02-22T18:25:13.8844694Z [2mdist-react/[22m[2massets/[22m[32mflag_bm-CzSakp_Z.svg [39m[1m[2m 5.67 kB[22m[1m[22m[2m │ gzip: 2.37 kB[22m
|
||||
2026-02-22T18:25:13.8844760Z [2mdist-react/[22m[2massets/[22m[32mmap-BGXvLkiw.svg [39m[1m[2m 5.68 kB[22m[1m[22m[2m │ gzip: 2.62 kB[22m
|
||||
2026-02-22T18:25:13.8844814Z [2mdist-react/[22m[2massets/[22m[32mpeople_wrestling-DjCLlDDS.svg [39m[1m[2m 5.71 kB[22m[1m[22m[2m │ gzip: 2.48 kB[22m
|
||||
2026-02-22T18:25:13.8844869Z [2mdist-react/[22m[2massets/[22m[32mtamale-2biJGrAo.svg [39m[1m[2m 5.74 kB[22m[1m[22m[2m │ gzip: 2.53 kB[22m
|
||||
2026-02-22T18:25:13.8844929Z [2mdist-react/[22m[2massets/[22m[32mmen_wrestling-BNuLmHCV.svg [39m[1m[2m 5.74 kB[22m[1m[22m[2m │ gzip: 2.47 kB[22m
|
||||
2026-02-22T18:25:13.8844987Z [2mdist-react/[22m[2massets/[22m[32mempty_nest-DGy7reBo.svg [39m[1m[2m 5.79 kB[22m[1m[22m[2m │ gzip: 2.71 kB[22m
|
||||
2026-02-22T18:25:13.8845041Z [2mdist-react/[22m[2massets/[22m[32mworm-CxRJMG1n.svg [39m[1m[2m 5.86 kB[22m[1m[22m[2m │ gzip: 2.77 kB[22m
|
||||
2026-02-22T18:25:13.8845099Z [2mdist-react/[22m[2massets/[22m[32mflag_bo-B7hNQ755.svg [39m[1m[2m 5.91 kB[22m[1m[22m[2m │ gzip: 2.22 kB[22m
|
||||
2026-02-22T18:25:13.8845146Z [2mdist-react/[22m[2massets/[22m[32mface_in_clouds-DBzCKo8S.svg [39m[1m[2m 5.94 kB[22m[1m[22m[2m │ gzip: 2.63 kB[22m
|
||||
2026-02-22T18:25:13.8845195Z [2mdist-react/[22m[2massets/[22m[32mwomen_wrestling-CARP3ZvF.svg [39m[1m[2m 5.96 kB[22m[1m[22m[2m │ gzip: 2.59 kB[22m
|
||||
2026-02-22T18:25:13.8845248Z [2mdist-react/[22m[2massets/[22m[32mman_lifting_weights_tone5-cnCH-jDP.svg [39m[1m[2m 6.00 kB[22m[1m[22m[2m │ gzip: 2.06 kB[22m
|
||||
2026-02-22T18:25:13.8845300Z [2mdist-react/[22m[2massets/[22m[32mman_lifting_weights_tone1-DGilOf2d.svg [39m[1m[2m 6.03 kB[22m[1m[22m[2m │ gzip: 2.06 kB[22m
|
||||
2026-02-22T18:25:13.8845350Z [2mdist-react/[22m[2massets/[22m[32mman_lifting_weights-DkiBT0IO.svg [39m[1m[2m 6.03 kB[22m[1m[22m[2m │ gzip: 2.07 kB[22m
|
||||
2026-02-22T18:25:13.8845398Z [2mdist-react/[22m[2massets/[22m[32mman_lifting_weights_tone3-DhF3q93u.svg [39m[1m[2m 6.03 kB[22m[1m[22m[2m │ gzip: 2.07 kB[22m
|
||||
2026-02-22T18:25:13.8845444Z [2mdist-react/[22m[2massets/[22m[32mman_lifting_weights_tone4-MLQqpJKZ.svg [39m[1m[2m 6.03 kB[22m[1m[22m[2m │ gzip: 2.07 kB[22m
|
||||
2026-02-22T18:25:13.8845494Z [2mdist-react/[22m[2massets/[22m[32mman_lifting_weights_tone2-CXUv2fBp.svg [39m[1m[2m 6.03 kB[22m[1m[22m[2m │ gzip: 2.07 kB[22m
|
||||
2026-02-22T18:25:13.8845541Z [2mdist-react/[22m[2massets/[22m[32mflag_fj-B2-D6gPQ.svg [39m[1m[2m 6.04 kB[22m[1m[22m[2m │ gzip: 2.64 kB[22m
|
||||
2026-02-22T18:25:13.8845589Z [2mdist-react/[22m[2massets/[22m[32mflag_pn-Bde7vecB.svg [39m[1m[2m 6.05 kB[22m[1m[22m[2m │ gzip: 2.68 kB[22m
|
||||
2026-02-22T18:25:13.8845637Z [2mdist-react/[22m[2massets/[22m[32mflag_bt-COHVTZ6I.svg [39m[1m[2m 6.06 kB[22m[1m[22m[2m │ gzip: 2.52 kB[22m
|
||||
2026-02-22T18:25:13.8845691Z [2mdist-react/[22m[2massets/[22m[32mperson_doing_cartwheel-B6e7BEW_.svg [39m[1m[2m 6.07 kB[22m[1m[22m[2m │ gzip: 2.65 kB[22m
|
||||
2026-02-22T18:25:13.8845742Z [2mdist-react/[22m[2massets/[22m[32mperson_doing_cartwheel_tone1-TBt_b-Oj.svg [39m[1m[2m 6.07 kB[22m[1m[22m[2m │ gzip: 2.65 kB[22m
|
||||
2026-02-22T18:25:13.8845798Z [2mdist-react/[22m[2massets/[22m[32mperson_doing_cartwheel_tone3-BzmNF0vv.svg [39m[1m[2m 6.07 kB[22m[1m[22m[2m │ gzip: 2.65 kB[22m
|
||||
2026-02-22T18:25:13.8845846Z [2mdist-react/[22m[2massets/[22m[32mperson_doing_cartwheel_tone2-BR4ztGzg.svg [39m[1m[2m 6.07 kB[22m[1m[22m[2m │ gzip: 2.65 kB[22m
|
||||
2026-02-22T18:25:13.8845900Z [2mdist-react/[22m[2massets/[22m[32mperson_doing_cartwheel_tone4-j074vq-9.svg [39m[1m[2m 6.07 kB[22m[1m[22m[2m │ gzip: 2.65 kB[22m
|
||||
2026-02-22T18:25:13.8845955Z [2mdist-react/[22m[2massets/[22m[32mperson_doing_cartwheel_tone5-BzNEt2oA.svg [39m[1m[2m 6.07 kB[22m[1m[22m[2m │ gzip: 2.65 kB[22m
|
||||
2026-02-22T18:25:13.8846003Z [2mdist-react/[22m[2massets/[22m[32maccordion-BPueGNgN.svg [39m[1m[2m 6.07 kB[22m[1m[22m[2m │ gzip: 1.20 kB[22m
|
||||
2026-02-22T18:25:13.8846058Z [2mdist-react/[22m[2massets/[22m[32mlobster-Cfls8jg_.svg [39m[1m[2m 6.07 kB[22m[1m[22m[2m │ gzip: 2.32 kB[22m
|
||||
2026-02-22T18:25:13.8846111Z [2mdist-react/[22m[2massets/[22m[32mvolcano-Bh_Lqk9r.svg [39m[1m[2m 6.14 kB[22m[1m[22m[2m │ gzip: 2.70 kB[22m
|
||||
2026-02-22T18:25:13.8846274Z [2mdist-react/[22m[2massets/[22m[32mman_cartwheeling-NFQt9ZB9.svg [39m[1m[2m 6.17 kB[22m[1m[22m[2m │ gzip: 2.69 kB[22m
|
||||
2026-02-22T18:25:13.8846330Z [2mdist-react/[22m[2massets/[22m[32mman_cartwheeling_tone1-B3S_eUE1.svg [39m[1m[2m 6.17 kB[22m[1m[22m[2m │ gzip: 2.69 kB[22m
|
||||
2026-02-22T18:25:13.8846395Z [2mdist-react/[22m[2massets/[22m[32mman_cartwheeling_tone2-CYBBI2iM.svg [39m[1m[2m 6.17 kB[22m[1m[22m[2m │ gzip: 2.69 kB[22m
|
||||
2026-02-22T18:25:13.8846444Z [2mdist-react/[22m[2massets/[22m[32mman_cartwheeling_tone4-B96D58fZ.svg [39m[1m[2m 6.17 kB[22m[1m[22m[2m │ gzip: 2.69 kB[22m
|
||||
2026-02-22T18:25:13.8846491Z [2mdist-react/[22m[2massets/[22m[32mman_cartwheeling_tone5-PFLWmq7Q.svg [39m[1m[2m 6.17 kB[22m[1m[22m[2m │ gzip: 2.69 kB[22m
|
||||
2026-02-22T18:25:13.8846542Z [2mdist-react/[22m[2massets/[22m[32mman_cartwheeling_tone3-D2kqEChS.svg [39m[1m[2m 6.17 kB[22m[1m[22m[2m │ gzip: 2.69 kB[22m
|
||||
2026-02-22T18:25:13.8846589Z [2mdist-react/[22m[2massets/[22m[32mflag_lb-DHr4ylgr.svg [39m[1m[2m 6.19 kB[22m[1m[22m[2m │ gzip: 2.75 kB[22m
|
||||
2026-02-22T18:25:13.8846638Z [2mdist-react/[22m[2massets/[22m[32mman_playing_handball-C_yN7fGQ.svg [39m[1m[2m 6.21 kB[22m[1m[22m[2m │ gzip: 2.62 kB[22m
|
||||
2026-02-22T18:25:13.8846687Z [2mdist-react/[22m[2massets/[22m[32mman_playing_handball_tone1-22QBgB92.svg [39m[1m[2m 6.21 kB[22m[1m[22m[2m │ gzip: 2.62 kB[22m
|
||||
2026-02-22T18:25:13.8846735Z [2mdist-react/[22m[2massets/[22m[32mman_playing_handball_tone2-Bs8PtV12.svg [39m[1m[2m 6.21 kB[22m[1m[22m[2m │ gzip: 2.62 kB[22m
|
||||
2026-02-22T18:25:13.8846797Z [2mdist-react/[22m[2massets/[22m[32mman_playing_handball_tone3-q-BDso_I.svg [39m[1m[2m 6.21 kB[22m[1m[22m[2m │ gzip: 2.62 kB[22m
|
||||
2026-02-22T18:25:13.8846855Z [2mdist-react/[22m[2massets/[22m[32mman_playing_handball_tone4-BUH96fLA.svg [39m[1m[2m 6.21 kB[22m[1m[22m[2m │ gzip: 2.62 kB[22m
|
||||
2026-02-22T18:25:13.8846912Z [2mdist-react/[22m[2massets/[22m[32mman_playing_handball_tone5-DK-UJ5SH.svg [39m[1m[2m 6.21 kB[22m[1m[22m[2m │ gzip: 2.62 kB[22m
|
||||
2026-02-22T18:25:13.8846959Z [2mdist-react/[22m[2massets/[22m[32mflag_fk-1KKBtSFw.svg [39m[1m[2m 6.22 kB[22m[1m[22m[2m │ gzip: 2.54 kB[22m
|
||||
2026-02-22T18:25:13.8847013Z [2mdist-react/[22m[2massets/[22m[32mflag_rs-CmpxaRIS.svg [39m[1m[2m 6.23 kB[22m[1m[22m[2m │ gzip: 2.35 kB[22m
|
||||
2026-02-22T18:25:13.8847062Z [2mdist-react/[22m[2massets/[22m[32mcucumber-oVkPYVB9.svg [39m[1m[2m 6.24 kB[22m[1m[22m[2m │ gzip: 2.01 kB[22m
|
||||
2026-02-22T18:25:13.8847118Z [2mdist-react/[22m[2massets/[22m[32mwoman_lifting_weights_tone5-BJQrRdVE.svg [39m[1m[2m 6.25 kB[22m[1m[22m[2m │ gzip: 2.15 kB[22m
|
||||
2026-02-22T18:25:13.8847174Z [2mdist-react/[22m[2massets/[22m[32mwoman_lifting_weights-CsixMYFL.svg [39m[1m[2m 6.28 kB[22m[1m[22m[2m │ gzip: 2.15 kB[22m
|
||||
2026-02-22T18:25:13.8847225Z [2mdist-react/[22m[2massets/[22m[32mwoman_lifting_weights_tone1-BpRsBk7z.svg [39m[1m[2m 6.28 kB[22m[1m[22m[2m │ gzip: 2.15 kB[22m
|
||||
2026-02-22T18:25:13.8847288Z [2mdist-react/[22m[2massets/[22m[32mwoman_lifting_weights_tone3-C0gnGp49.svg [39m[1m[2m 6.28 kB[22m[1m[22m[2m │ gzip: 2.15 kB[22m
|
||||
2026-02-22T18:25:13.8847335Z [2mdist-react/[22m[2massets/[22m[32mwoman_lifting_weights_tone4-CQZmiYUl.svg [39m[1m[2m 6.28 kB[22m[1m[22m[2m │ gzip: 2.16 kB[22m
|
||||
2026-02-22T18:25:13.8847390Z [2mdist-react/[22m[2massets/[22m[32mwoman_lifting_weights_tone2-P18Nfbuz.svg [39m[1m[2m 6.28 kB[22m[1m[22m[2m │ gzip: 2.15 kB[22m
|
||||
2026-02-22T18:25:13.8847438Z [2mdist-react/[22m[2massets/[22m[32mperson_lifting_weights_tone5-DEciUSJH.svg [39m[1m[2m 6.29 kB[22m[1m[22m[2m │ gzip: 2.20 kB[22m
|
||||
2026-02-22T18:25:13.8847494Z [2mdist-react/[22m[2massets/[22m[32mperson_lifting_weights_tone1-CXfKAA0L.svg [39m[1m[2m 6.30 kB[22m[1m[22m[2m │ gzip: 2.19 kB[22m
|
||||
2026-02-22T18:25:13.8847542Z [2mdist-react/[22m[2massets/[22m[32mperson_lifting_weights-Cn0dQ6qY.svg [39m[1m[2m 6.30 kB[22m[1m[22m[2m │ gzip: 2.20 kB[22m
|
||||
2026-02-22T18:25:13.8847592Z [2mdist-react/[22m[2massets/[22m[32mperson_lifting_weights_tone2-Dkw3-09P.svg [39m[1m[2m 6.30 kB[22m[1m[22m[2m │ gzip: 2.20 kB[22m
|
||||
2026-02-22T18:25:13.8847639Z [2mdist-react/[22m[2massets/[22m[32mperson_lifting_weights_tone3-3OqiHF7e.svg [39m[1m[2m 6.30 kB[22m[1m[22m[2m │ gzip: 2.20 kB[22m
|
||||
2026-02-22T18:25:13.8847715Z [2mdist-react/[22m[2massets/[22m[32mperson_lifting_weights_tone4-C62SuN24.svg [39m[1m[2m 6.30 kB[22m[1m[22m[2m │ gzip: 2.20 kB[22m
|
||||
2026-02-22T18:25:13.8847762Z [2mdist-react/[22m[2massets/[22m[32mwomen_with_bunny_ears_partying-CKr9TLic.svg [39m[1m[2m 6.30 kB[22m[1m[22m[2m │ gzip: 1.68 kB[22m
|
||||
2026-02-22T18:25:13.8847821Z [2mdist-react/[22m[2massets/[22m[32mflag_sm-BYO1ASeM.svg [39m[1m[2m 6.31 kB[22m[1m[22m[2m │ gzip: 2.30 kB[22m
|
||||
2026-02-22T18:25:13.8847870Z [2mdist-react/[22m[2massets/[22m[32mcoat-Cbu3wnI6.svg [39m[1m[2m 6.35 kB[22m[1m[22m[2m │ gzip: 2.52 kB[22m
|
||||
2026-02-22T18:25:13.8847927Z [2mdist-react/[22m[2massets/[22m[32mwoman_cartwheeling_tone1-fJFXi2hD.svg [39m[1m[2m 6.37 kB[22m[1m[22m[2m │ gzip: 2.78 kB[22m
|
||||
2026-02-22T18:25:13.8847975Z [2mdist-react/[22m[2massets/[22m[32mwoman_cartwheeling-tGvm940R.svg [39m[1m[2m 6.37 kB[22m[1m[22m[2m │ gzip: 2.78 kB[22m
|
||||
2026-02-22T18:25:13.8848032Z [2mdist-react/[22m[2massets/[22m[32mwoman_cartwheeling_tone2-C5lE2K9g.svg [39m[1m[2m 6.37 kB[22m[1m[22m[2m │ gzip: 2.77 kB[22m
|
||||
2026-02-22T18:25:13.8848082Z [2mdist-react/[22m[2massets/[22m[32mwoman_cartwheeling_tone4-CjyM2w54.svg [39m[1m[2m 6.37 kB[22m[1m[22m[2m │ gzip: 2.77 kB[22m
|
||||
2026-02-22T18:25:13.8848138Z [2mdist-react/[22m[2massets/[22m[32mwoman_cartwheeling_tone5-D-eW47Ua.svg [39m[1m[2m 6.37 kB[22m[1m[22m[2m │ gzip: 2.77 kB[22m
|
||||
2026-02-22T18:25:13.8848186Z [2mdist-react/[22m[2massets/[22m[32mwoman_cartwheeling_tone3-BourpL3A.svg [39m[1m[2m 6.37 kB[22m[1m[22m[2m │ gzip: 2.77 kB[22m
|
||||
2026-02-22T18:25:13.8848232Z [2mdist-react/[22m[2massets/[22m[32mman_running_tone1-BbRoQah0.svg [39m[1m[2m 6.37 kB[22m[1m[22m[2m │ gzip: 2.89 kB[22m
|
||||
2026-02-22T18:25:13.8848294Z [2mdist-react/[22m[2massets/[22m[32mman_running-Bp7fZpx0.svg [39m[1m[2m 6.37 kB[22m[1m[22m[2m │ gzip: 2.89 kB[22m
|
||||
2026-02-22T18:25:13.8848342Z [2mdist-react/[22m[2massets/[22m[32mman_running_tone2-gBe1A9EP.svg [39m[1m[2m 6.37 kB[22m[1m[22m[2m │ gzip: 2.89 kB[22m
|
||||
2026-02-22T18:25:13.8848397Z [2mdist-react/[22m[2massets/[22m[32mman_running_tone3-DfAx9qZO.svg [39m[1m[2m 6.37 kB[22m[1m[22m[2m │ gzip: 2.88 kB[22m
|
||||
2026-02-22T18:25:13.8848444Z [2mdist-react/[22m[2massets/[22m[32mman_running_tone5-Do-aIXEX.svg [39m[1m[2m 6.37 kB[22m[1m[22m[2m │ gzip: 2.88 kB[22m
|
||||
2026-02-22T18:25:13.8848499Z [2mdist-react/[22m[2massets/[22m[32mman_running_tone4-CeeXJkX_.svg [39m[1m[2m 6.37 kB[22m[1m[22m[2m │ gzip: 2.88 kB[22m
|
||||
2026-02-22T18:25:13.8848553Z [2mdist-react/[22m[2massets/[22m[32mwoman_playing_handball-fiyPmBDz.svg [39m[1m[2m 6.42 kB[22m[1m[22m[2m │ gzip: 2.73 kB[22m
|
||||
2026-02-22T18:25:13.8848614Z [2mdist-react/[22m[2massets/[22m[32mwoman_playing_handball_tone2-BtTxnxhZ.svg [39m[1m[2m 6.42 kB[22m[1m[22m[2m │ gzip: 2.73 kB[22m
|
||||
2026-02-22T18:25:13.8848668Z [2mdist-react/[22m[2massets/[22m[32mwoman_playing_handball_tone1-B_P42W0r.svg [39m[1m[2m 6.42 kB[22m[1m[22m[2m │ gzip: 2.73 kB[22m
|
||||
2026-02-22T18:25:13.8848723Z [2mdist-react/[22m[2massets/[22m[32mwoman_playing_handball_tone3-C7TXAAWV.svg [39m[1m[2m 6.42 kB[22m[1m[22m[2m │ gzip: 2.72 kB[22m
|
||||
2026-02-22T18:25:13.8848773Z [2mdist-react/[22m[2massets/[22m[32mwoman_playing_handball_tone4-CtCwRGCv.svg [39m[1m[2m 6.42 kB[22m[1m[22m[2m │ gzip: 2.73 kB[22m
|
||||
2026-02-22T18:25:13.8848830Z [2mdist-react/[22m[2massets/[22m[32mwoman_playing_handball_tone5-CmZlugee.svg [39m[1m[2m 6.42 kB[22m[1m[22m[2m │ gzip: 2.73 kB[22m
|
||||
2026-02-22T18:25:13.8848881Z [2mdist-react/[22m[2massets/[22m[32mmicrobe-DHWlm4x3.svg [39m[1m[2m 6.48 kB[22m[1m[22m[2m │ gzip: 2.82 kB[22m
|
||||
2026-02-22T18:25:13.8849001Z [2mdist-react/[22m[2massets/[22m[32mhorse_racing-Cd5KXigQ.svg [39m[1m[2m 6.50 kB[22m[1m[22m[2m │ gzip: 2.87 kB[22m
|
||||
2026-02-22T18:25:13.8849061Z [2mdist-react/[22m[2massets/[22m[32mhorse_racing_tone1-BPFu29EM.svg [39m[1m[2m 6.50 kB[22m[1m[22m[2m │ gzip: 2.87 kB[22m
|
||||
2026-02-22T18:25:13.8849112Z [2mdist-react/[22m[2massets/[22m[32mhorse_racing_tone2-kHM6lt0G.svg [39m[1m[2m 6.50 kB[22m[1m[22m[2m │ gzip: 2.88 kB[22m
|
||||
2026-02-22T18:25:13.8849172Z [2mdist-react/[22m[2massets/[22m[32mhorse_racing_tone5-DoKtvypB.svg [39m[1m[2m 6.50 kB[22m[1m[22m[2m │ gzip: 2.88 kB[22m
|
||||
2026-02-22T18:25:13.8849222Z [2mdist-react/[22m[2massets/[22m[32mhorse_racing_tone3-1prjoMK9.svg [39m[1m[2m 6.50 kB[22m[1m[22m[2m │ gzip: 2.88 kB[22m
|
||||
2026-02-22T18:25:13.8849277Z [2mdist-react/[22m[2massets/[22m[32mhorse_racing_tone4-DZVx5-VD.svg [39m[1m[2m 6.50 kB[22m[1m[22m[2m │ gzip: 2.87 kB[22m
|
||||
2026-02-22T18:25:13.8849323Z [2mdist-react/[22m[2massets/[22m[32mperson_in_manual_wheelchair-B2ofcHYu.svg [39m[1m[2m 6.50 kB[22m[1m[22m[2m │ gzip: 2.57 kB[22m
|
||||
2026-02-22T18:25:13.8849390Z [2mdist-react/[22m[2massets/[22m[32mperson_in_manual_wheelchair_tone1-BrR0l2XR.svg [39m[1m[2m 6.50 kB[22m[1m[22m[2m │ gzip: 2.57 kB[22m
|
||||
2026-02-22T18:25:13.8849441Z [2mdist-react/[22m[2massets/[22m[32mperson_in_manual_wheelchair_tone2-DmJ1Zffk.svg [39m[1m[2m 6.50 kB[22m[1m[22m[2m │ gzip: 2.57 kB[22m
|
||||
2026-02-22T18:25:13.8849623Z [2mdist-react/[22m[2massets/[22m[32mperson_in_manual_wheelchair_tone3-Bt_5AaRy.svg [39m[1m[2m 6.50 kB[22m[1m[22m[2m │ gzip: 2.57 kB[22m
|
||||
2026-02-22T18:25:13.8849673Z [2mdist-react/[22m[2massets/[22m[32mperson_in_manual_wheelchair_tone4-TZTDWyKD.svg [39m[1m[2m 6.50 kB[22m[1m[22m[2m │ gzip: 2.57 kB[22m
|
||||
2026-02-22T18:25:13.8849732Z [2mdist-react/[22m[2massets/[22m[32mperson_in_manual_wheelchair_tone5-DrOKlCDl.svg [39m[1m[2m 6.50 kB[22m[1m[22m[2m │ gzip: 2.57 kB[22m
|
||||
2026-02-22T18:25:13.8849788Z [2mdist-react/[22m[2massets/[22m[32mburrito-B4L0kbwK.svg [39m[1m[2m 6.52 kB[22m[1m[22m[2m │ gzip: 2.58 kB[22m
|
||||
2026-02-22T18:25:13.8849844Z [2mdist-react/[22m[2massets/[22m[32mperson_running-DNDUEkxU.svg [39m[1m[2m 6.52 kB[22m[1m[22m[2m │ gzip: 2.94 kB[22m
|
||||
2026-02-22T18:25:13.8849906Z [2mdist-react/[22m[2massets/[22m[32mperson_running_tone1-B8sLRwke.svg [39m[1m[2m 6.52 kB[22m[1m[22m[2m │ gzip: 2.94 kB[22m
|
||||
2026-02-22T18:25:13.8849954Z [2mdist-react/[22m[2massets/[22m[32mperson_running_tone2-DNzEDUb0.svg [39m[1m[2m 6.52 kB[22m[1m[22m[2m │ gzip: 2.94 kB[22m
|
||||
2026-02-22T18:25:13.8850011Z [2mdist-react/[22m[2massets/[22m[32mperson_running_tone3-Dist2leS.svg [39m[1m[2m 6.52 kB[22m[1m[22m[2m │ gzip: 2.94 kB[22m
|
||||
2026-02-22T18:25:13.8850058Z [2mdist-react/[22m[2massets/[22m[32mperson_running_tone4-DVBWC3-p.svg [39m[1m[2m 6.52 kB[22m[1m[22m[2m │ gzip: 2.94 kB[22m
|
||||
2026-02-22T18:25:13.8850113Z [2mdist-react/[22m[2massets/[22m[32mperson_running_tone5-DEOJVy8u.svg [39m[1m[2m 6.52 kB[22m[1m[22m[2m │ gzip: 2.93 kB[22m
|
||||
2026-02-22T18:25:13.8850159Z [2mdist-react/[22m[2massets/[22m[32mman_in_manual_wheelchair_tone1-Da2hybrT.svg [39m[1m[2m 6.57 kB[22m[1m[22m[2m │ gzip: 2.62 kB[22m
|
||||
2026-02-22T18:25:13.8850219Z [2mdist-react/[22m[2massets/[22m[32mman_in_manual_wheelchair-cGfKOLRc.svg [39m[1m[2m 6.60 kB[22m[1m[22m[2m │ gzip: 2.63 kB[22m
|
||||
2026-02-22T18:25:13.8850274Z [2mdist-react/[22m[2massets/[22m[32mman_in_manual_wheelchair_tone2-BPBmkRcs.svg [39m[1m[2m 6.60 kB[22m[1m[22m[2m │ gzip: 2.63 kB[22m
|
||||
2026-02-22T18:25:13.8850331Z [2mdist-react/[22m[2massets/[22m[32mman_in_manual_wheelchair_tone3-H5kpv3q_.svg [39m[1m[2m 6.60 kB[22m[1m[22m[2m │ gzip: 2.63 kB[22m
|
||||
2026-02-22T18:25:13.8850380Z [2mdist-react/[22m[2massets/[22m[32mman_in_manual_wheelchair_tone4-BvKWPBcq.svg [39m[1m[2m 6.60 kB[22m[1m[22m[2m │ gzip: 2.63 kB[22m
|
||||
2026-02-22T18:25:13.8850438Z [2mdist-react/[22m[2massets/[22m[32mman_in_manual_wheelchair_tone5-YZQTD5Nr.svg [39m[1m[2m 6.60 kB[22m[1m[22m[2m │ gzip: 2.63 kB[22m
|
||||
2026-02-22T18:25:13.8850493Z [2mdist-react/[22m[2massets/[22m[32mperson_playing_handball_tone1-CbOONp_g.svg [39m[1m[2m 6.62 kB[22m[1m[22m[2m │ gzip: 2.66 kB[22m
|
||||
2026-02-22T18:25:13.8850554Z [2mdist-react/[22m[2massets/[22m[32mperson_playing_handball_tone2-jeC51_-P.svg [39m[1m[2m 6.62 kB[22m[1m[22m[2m │ gzip: 2.66 kB[22m
|
||||
2026-02-22T18:25:13.8850616Z [2mdist-react/[22m[2massets/[22m[32mperson_playing_handball-CH3hWpQR.svg [39m[1m[2m 6.62 kB[22m[1m[22m[2m │ gzip: 2.66 kB[22m
|
||||
2026-02-22T18:25:13.8850671Z [2mdist-react/[22m[2massets/[22m[32mperson_playing_handball_tone5-D_rmeJiN.svg [39m[1m[2m 6.62 kB[22m[1m[22m[2m │ gzip: 2.66 kB[22m
|
||||
2026-02-22T18:25:13.8850727Z [2mdist-react/[22m[2massets/[22m[32mperson_playing_handball_tone4-BsA09Avm.svg [39m[1m[2m 6.62 kB[22m[1m[22m[2m │ gzip: 2.66 kB[22m
|
||||
2026-02-22T18:25:13.8850783Z [2mdist-react/[22m[2massets/[22m[32mperson_playing_handball_tone3-BGgWTsuS.svg [39m[1m[2m 6.62 kB[22m[1m[22m[2m │ gzip: 2.66 kB[22m
|
||||
2026-02-22T18:25:13.8850832Z [2mdist-react/[22m[2massets/[22m[32mmen_with_bunny_ears_partying-DabknRQ1.svg [39m[1m[2m 6.64 kB[22m[1m[22m[2m │ gzip: 1.78 kB[22m
|
||||
2026-02-22T18:25:13.8850889Z [2mdist-react/[22m[2massets/[22m[32mman_bouncing_ball_tone1-BrCW39oq.svg [39m[1m[2m 6.67 kB[22m[1m[22m[2m │ gzip: 3.05 kB[22m
|
||||
2026-02-22T18:25:13.8850946Z [2mdist-react/[22m[2massets/[22m[32mman_bouncing_ball-BCtAjpGP.svg [39m[1m[2m 6.67 kB[22m[1m[22m[2m │ gzip: 3.04 kB[22m
|
||||
2026-02-22T18:25:13.8851051Z [2mdist-react/[22m[2massets/[22m[32mman_bouncing_ball_tone2-pU3f7Oqo.svg [39m[1m[2m 6.67 kB[22m[1m[22m[2m │ gzip: 3.05 kB[22m
|
||||
2026-02-22T18:25:13.8851110Z [2mdist-react/[22m[2massets/[22m[32mman_bouncing_ball_tone4-BonEB_V5.svg [39m[1m[2m 6.67 kB[22m[1m[22m[2m │ gzip: 3.05 kB[22m
|
||||
2026-02-22T18:25:13.8851162Z [2mdist-react/[22m[2massets/[22m[32mman_bouncing_ball_tone3-CMYhYDFZ.svg [39m[1m[2m 6.67 kB[22m[1m[22m[2m │ gzip: 3.05 kB[22m
|
||||
2026-02-22T18:25:13.8851219Z [2mdist-react/[22m[2massets/[22m[32mman_bouncing_ball_tone5-mVU7qtFm.svg [39m[1m[2m 6.67 kB[22m[1m[22m[2m │ gzip: 3.05 kB[22m
|
||||
2026-02-22T18:25:13.8851266Z [2mdist-react/[22m[2massets/[22m[32mwoman_running-_mwbLWM0.svg [39m[1m[2m 6.74 kB[22m[1m[22m[2m │ gzip: 3.03 kB[22m
|
||||
2026-02-22T18:25:13.8851326Z [2mdist-react/[22m[2massets/[22m[32mwoman_running_tone1-Dfqdg043.svg [39m[1m[2m 6.74 kB[22m[1m[22m[2m │ gzip: 3.03 kB[22m
|
||||
2026-02-22T18:25:13.8851374Z [2mdist-react/[22m[2massets/[22m[32mwoman_running_tone2-rXRqTMa0.svg [39m[1m[2m 6.74 kB[22m[1m[22m[2m │ gzip: 3.03 kB[22m
|
||||
2026-02-22T18:25:13.8851430Z [2mdist-react/[22m[2massets/[22m[32mwoman_running_tone3-BmRDPwCM.svg [39m[1m[2m 6.74 kB[22m[1m[22m[2m │ gzip: 3.03 kB[22m
|
||||
2026-02-22T18:25:13.8851478Z [2mdist-react/[22m[2massets/[22m[32mwoman_running_tone4-DmFzAsxD.svg [39m[1m[2m 6.74 kB[22m[1m[22m[2m │ gzip: 3.03 kB[22m
|
||||
2026-02-22T18:25:13.8851533Z [2mdist-react/[22m[2massets/[22m[32mwoman_running_tone5-C66GYSAh.svg [39m[1m[2m 6.74 kB[22m[1m[22m[2m │ gzip: 3.03 kB[22m
|
||||
2026-02-22T18:25:13.8851584Z [2mdist-react/[22m[2massets/[22m[32mwoman_in_manual_wheelchair-Ba72kfnU.svg [39m[1m[2m 6.75 kB[22m[1m[22m[2m │ gzip: 2.66 kB[22m
|
||||
2026-02-22T18:25:13.8851640Z [2mdist-react/[22m[2massets/[22m[32mwoman_in_manual_wheelchair_tone1-Ce9x88Rf.svg [39m[1m[2m 6.75 kB[22m[1m[22m[2m │ gzip: 2.66 kB[22m
|
||||
2026-02-22T18:25:13.8851689Z [2mdist-react/[22m[2massets/[22m[32mwoman_in_manual_wheelchair_tone2-CAKIPnJE.svg [39m[1m[2m 6.75 kB[22m[1m[22m[2m │ gzip: 2.66 kB[22m
|
||||
2026-02-22T18:25:13.8851749Z [2mdist-react/[22m[2massets/[22m[32mwoman_in_manual_wheelchair_tone3-D4YsEoBp.svg [39m[1m[2m 6.75 kB[22m[1m[22m[2m │ gzip: 2.66 kB[22m
|
||||
2026-02-22T18:25:13.8851797Z [2mdist-react/[22m[2massets/[22m[32mwoman_in_manual_wheelchair_tone4-BD3k04p2.svg [39m[1m[2m 6.75 kB[22m[1m[22m[2m │ gzip: 2.66 kB[22m
|
||||
2026-02-22T18:25:13.8851857Z [2mdist-react/[22m[2massets/[22m[32mwoman_in_manual_wheelchair_tone5-BmBeJ4-f.svg [39m[1m[2m 6.75 kB[22m[1m[22m[2m │ gzip: 2.66 kB[22m
|
||||
2026-02-22T18:25:13.8851904Z [2mdist-react/[22m[2massets/[22m[32mperson_in_motorized_wheelchair-DxhhvjYe.svg [39m[1m[2m 6.80 kB[22m[1m[22m[2m │ gzip: 2.69 kB[22m
|
||||
2026-02-22T18:25:13.8851964Z [2mdist-react/[22m[2massets/[22m[32mperson_in_motorized_wheelchair_tone1-Dcta4qUb.svg [39m[1m[2m 6.80 kB[22m[1m[22m[2m │ gzip: 2.68 kB[22m
|
||||
2026-02-22T18:25:13.8852017Z [2mdist-react/[22m[2massets/[22m[32mperson_in_motorized_wheelchair_tone2-C8UQYonN.svg [39m[1m[2m 6.80 kB[22m[1m[22m[2m │ gzip: 2.69 kB[22m
|
||||
2026-02-22T18:25:13.8852077Z [2mdist-react/[22m[2massets/[22m[32mperson_in_motorized_wheelchair_tone3-BRD_Obbg.svg [39m[1m[2m 6.80 kB[22m[1m[22m[2m │ gzip: 2.69 kB[22m
|
||||
2026-02-22T18:25:13.8852134Z [2mdist-react/[22m[2massets/[22m[32mperson_in_motorized_wheelchair_tone4-DLSO0rlF.svg [39m[1m[2m 6.80 kB[22m[1m[22m[2m │ gzip: 2.69 kB[22m
|
||||
2026-02-22T18:25:13.8852190Z [2mdist-react/[22m[2massets/[22m[32mperson_in_motorized_wheelchair_tone5-SnULyxgF.svg [39m[1m[2m 6.80 kB[22m[1m[22m[2m │ gzip: 2.69 kB[22m
|
||||
2026-02-22T18:25:13.8852236Z [2mdist-react/[22m[2massets/[22m[32mperson_bouncing_ball_tone1-BIhBY2_P.svg [39m[1m[2m 6.82 kB[22m[1m[22m[2m │ gzip: 3.10 kB[22m
|
||||
2026-02-22T18:25:13.8852293Z [2mdist-react/[22m[2massets/[22m[32mperson_bouncing_ball_tone2-9V5mlEG0.svg [39m[1m[2m 6.82 kB[22m[1m[22m[2m │ gzip: 3.10 kB[22m
|
||||
2026-02-22T18:25:13.8852349Z [2mdist-react/[22m[2massets/[22m[32mperson_bouncing_ball-H1IsbPT2.svg [39m[1m[2m 6.82 kB[22m[1m[22m[2m │ gzip: 3.10 kB[22m
|
||||
2026-02-22T18:25:13.8852409Z [2mdist-react/[22m[2massets/[22m[32mperson_bouncing_ball_tone3-DSpJYpZ1.svg [39m[1m[2m 6.82 kB[22m[1m[22m[2m │ gzip: 3.10 kB[22m
|
||||
2026-02-22T18:25:13.8852458Z [2mdist-react/[22m[2massets/[22m[32mperson_bouncing_ball_tone4-BycyNnMy.svg [39m[1m[2m 6.82 kB[22m[1m[22m[2m │ gzip: 3.10 kB[22m
|
||||
2026-02-22T18:25:13.8852513Z [2mdist-react/[22m[2massets/[22m[32mperson_bouncing_ball_tone5-C9pS5gcg.svg [39m[1m[2m 6.82 kB[22m[1m[22m[2m │ gzip: 3.10 kB[22m
|
||||
2026-02-22T18:25:13.8852560Z [2mdist-react/[22m[2massets/[22m[32mman_in_motorized_wheelchair_tone1-B-J_H3TB.svg [39m[1m[2m 6.85 kB[22m[1m[22m[2m │ gzip: 2.71 kB[22m
|
||||
2026-02-22T18:25:13.8852618Z [2mdist-react/[22m[2massets/[22m[32mflag_ms-BKjfidu-.svg [39m[1m[2m 6.86 kB[22m[1m[22m[2m │ gzip: 3.00 kB[22m
|
||||
2026-02-22T18:25:13.8852673Z [2mdist-react/[22m[2massets/[22m[32mman_in_motorized_wheelchair-CiMQlH-Z.svg [39m[1m[2m 6.87 kB[22m[1m[22m[2m │ gzip: 2.72 kB[22m
|
||||
2026-02-22T18:25:13.8852729Z [2mdist-react/[22m[2massets/[22m[32mman_in_motorized_wheelchair_tone2-DQy0C3Cx.svg [39m[1m[2m 6.87 kB[22m[1m[22m[2m │ gzip: 2.72 kB[22m
|
||||
2026-02-22T18:25:13.8852784Z [2mdist-react/[22m[2massets/[22m[32mman_in_motorized_wheelchair_tone4-CoEn9n-F.svg [39m[1m[2m 6.87 kB[22m[1m[22m[2m │ gzip: 2.72 kB[22m
|
||||
2026-02-22T18:25:13.8852844Z [2mdist-react/[22m[2massets/[22m[32mman_in_motorized_wheelchair_tone3-DuduwQoe.svg [39m[1m[2m 6.87 kB[22m[1m[22m[2m │ gzip: 2.72 kB[22m
|
||||
2026-02-22T18:25:13.8852893Z [2mdist-react/[22m[2massets/[22m[32mman_in_motorized_wheelchair_tone5-CgvQDAuT.svg [39m[1m[2m 6.87 kB[22m[1m[22m[2m │ gzip: 2.72 kB[22m
|
||||
2026-02-22T18:25:13.8852950Z [2mdist-react/[22m[2massets/[22m[32mflag_ky-E8sT-Yzf.svg [39m[1m[2m 6.99 kB[22m[1m[22m[2m │ gzip: 2.92 kB[22m
|
||||
2026-02-22T18:25:13.8852998Z [2mdist-react/[22m[2massets/[22m[32manatomical_heart-DbQDqK_8.svg [39m[1m[2m 7.00 kB[22m[1m[22m[2m │ gzip: 3.14 kB[22m
|
||||
2026-02-22T18:25:13.8853057Z [2mdist-react/[22m[2massets/[22m[32mwales-ll0ySOk-.svg [39m[1m[2m 7.01 kB[22m[1m[22m[2m │ gzip: 2.89 kB[22m
|
||||
2026-02-22T18:25:13.8853107Z [2mdist-react/[22m[2massets/[22m[32mwoman_in_motorized_wheelchair-CIaEP3y5.svg [39m[1m[2m 7.03 kB[22m[1m[22m[2m │ gzip: 2.79 kB[22m
|
||||
2026-02-22T18:25:13.8853171Z [2mdist-react/[22m[2massets/[22m[32mwoman_in_motorized_wheelchair_tone2-uhLYilhF.svg [39m[1m[2m 7.03 kB[22m[1m[22m[2m │ gzip: 2.79 kB[22m
|
||||
2026-02-22T18:25:13.8853221Z [2mdist-react/[22m[2massets/[22m[32mwoman_in_motorized_wheelchair_tone1-1BibIgKr.svg [39m[1m[2m 7.03 kB[22m[1m[22m[2m │ gzip: 2.78 kB[22m
|
||||
2026-02-22T18:25:13.8853278Z [2mdist-react/[22m[2massets/[22m[32mwoman_in_motorized_wheelchair_tone3-B51r71l0.svg [39m[1m[2m 7.03 kB[22m[1m[22m[2m │ gzip: 2.79 kB[22m
|
||||
2026-02-22T18:25:13.8853345Z [2mdist-react/[22m[2massets/[22m[32mwoman_in_motorized_wheelchair_tone4-oIvpxZcp.svg [39m[1m[2m 7.03 kB[22m[1m[22m[2m │ gzip: 2.79 kB[22m
|
||||
2026-02-22T18:25:13.8853404Z [2mdist-react/[22m[2massets/[22m[32mwoman_in_motorized_wheelchair_tone5-_fFN26h0.svg [39m[1m[2m 7.03 kB[22m[1m[22m[2m │ gzip: 2.79 kB[22m
|
||||
2026-02-22T18:25:13.8853451Z [2mdist-react/[22m[2massets/[22m[32mwoman_bouncing_ball-B4V8jGG-.svg [39m[1m[2m 7.09 kB[22m[1m[22m[2m │ gzip: 3.20 kB[22m
|
||||
2026-02-22T18:25:13.8853513Z [2mdist-react/[22m[2massets/[22m[32mwoman_bouncing_ball_tone2-BPyPsinZ.svg [39m[1m[2m 7.09 kB[22m[1m[22m[2m │ gzip: 3.21 kB[22m
|
||||
2026-02-22T18:25:13.8853661Z [2mdist-react/[22m[2massets/[22m[32mwoman_bouncing_ball_tone3-UqVs8gxM.svg [39m[1m[2m 7.09 kB[22m[1m[22m[2m │ gzip: 3.21 kB[22m
|
||||
2026-02-22T18:25:13.8853719Z [2mdist-react/[22m[2massets/[22m[32mwoman_bouncing_ball_tone1-I7gUQpbX.svg [39m[1m[2m 7.09 kB[22m[1m[22m[2m │ gzip: 3.21 kB[22m
|
||||
2026-02-22T18:25:13.8853778Z [2mdist-react/[22m[2massets/[22m[32mwoman_bouncing_ball_tone4-CtQI59zT.svg [39m[1m[2m 7.09 kB[22m[1m[22m[2m │ gzip: 3.21 kB[22m
|
||||
2026-02-22T18:25:13.8853832Z [2mdist-react/[22m[2massets/[22m[32mwoman_bouncing_ball_tone5-BgHu12i2.svg [39m[1m[2m 7.09 kB[22m[1m[22m[2m │ gzip: 3.21 kB[22m
|
||||
2026-02-22T18:25:13.8853879Z [2mdist-react/[22m[2massets/[22m[32mflag_va-BB2uDrB0.svg [39m[1m[2m 7.21 kB[22m[1m[22m[2m │ gzip: 2.49 kB[22m
|
||||
2026-02-22T18:25:13.8853934Z [2mdist-react/[22m[2massets/[22m[32mmammoth-Diaisynz.svg [39m[1m[2m 7.29 kB[22m[1m[22m[2m │ gzip: 3.06 kB[22m
|
||||
2026-02-22T18:25:13.8853989Z [2mdist-react/[22m[2massets/[22m[32mnest_with_eggs-C5ulh3Rz.svg [39m[1m[2m 7.36 kB[22m[1m[22m[2m │ gzip: 3.39 kB[22m
|
||||
2026-02-22T18:25:13.8854036Z [2mdist-react/[22m[2massets/[22m[32mphoenix-QKXqSCuH.svg [39m[1m[2m 7.57 kB[22m[1m[22m[2m │ gzip: 2.91 kB[22m
|
||||
2026-02-22T18:25:13.8854092Z [2mdist-react/[22m[2massets/[22m[32mflag_cy-JKjUtxO9.svg [39m[1m[2m 7.60 kB[22m[1m[22m[2m │ gzip: 3.32 kB[22m
|
||||
2026-02-22T18:25:13.8854141Z [2mdist-react/[22m[2massets/[22m[32mpeople_with_bunny_ears_partying-BVR6SBwD.svg [39m[1m[2m 7.63 kB[22m[1m[22m[2m │ gzip: 1.93 kB[22m
|
||||
2026-02-22T18:25:13.8854198Z [2mdist-react/[22m[2massets/[22m[32mflag_gu-CyZZwWUz.svg [39m[1m[2m 7.68 kB[22m[1m[22m[2m │ gzip: 3.24 kB[22m
|
||||
2026-02-22T18:25:13.8854257Z [2mdist-react/[22m[2massets/[22m[32mt_rex-BYG-fgI4.svg [39m[1m[2m 8.00 kB[22m[1m[22m[2m │ gzip: 3.35 kB[22m
|
||||
2026-02-22T18:25:13.8854315Z [2mdist-react/[22m[2massets/[22m[32mflag_vg-DWuAWiyw.svg [39m[1m[2m 8.21 kB[22m[1m[22m[2m │ gzip: 1.91 kB[22m
|
||||
2026-02-22T18:25:13.8854377Z [2mdist-react/[22m[2massets/[22m[32mflag_yt-BfOxXbO5.svg [39m[1m[2m 8.42 kB[22m[1m[22m[2m │ gzip: 2.95 kB[22m
|
||||
2026-02-22T18:25:13.8854431Z [2mdist-react/[22m[2massets/[22m[32mpiñata-CQK6iMPe.svg [39m[1m[2m 8.47 kB[22m[1m[22m[2m │ gzip: 3.21 kB[22m
|
||||
2026-02-22T18:25:13.8854492Z [2mdist-react/[22m[2massets/[22m[32mmirror_ball-R_criUm_.svg [39m[1m[2m 8.55 kB[22m[1m[22m[2m │ gzip: 3.31 kB[22m
|
||||
2026-02-22T18:25:13.8854549Z [2mdist-react/[22m[2massets/[22m[32mping-LfakLpwb.mp3 [39m[1m[2m 8.58 kB[22m[1m[22m
|
||||
2026-02-22T18:25:13.8854610Z [2mdist-react/[22m[2massets/[22m[32mflag_gs-DhFNtBGF.svg [39m[1m[2m 8.86 kB[22m[1m[22m[2m │ gzip: 3.67 kB[22m
|
||||
2026-02-22T18:25:13.8854679Z [2mdist-react/[22m[2massets/[22m[32mknot-CpRGiIMe.svg [39m[1m[2m 8.92 kB[22m[1m[22m[2m │ gzip: 3.89 kB[22m
|
||||
2026-02-22T18:25:13.8854736Z [2mdist-react/[22m[2massets/[22m[32mflag_dg-DwJEN7pv.svg [39m[1m[2m 9.06 kB[22m[1m[22m[2m │ gzip: 2.87 kB[22m
|
||||
2026-02-22T18:25:13.8854787Z [2mdist-react/[22m[2massets/[22m[32mflag_gt-CietPgvg.svg [39m[1m[2m 9.11 kB[22m[1m[22m[2m │ gzip: 3.86 kB[22m
|
||||
2026-02-22T18:25:13.8854842Z [2mdist-react/[22m[2massets/[22m[32mflag_mx-g-aNhK9D.svg [39m[1m[2m 9.66 kB[22m[1m[22m[2m │ gzip: 3.72 kB[22m
|
||||
2026-02-22T18:25:13.8854891Z [2mdist-react/[22m[2massets/[22m[32mflag_ta-Q6DTxsoW.svg [39m[1m[2m 10.30 kB[22m[1m[22m[2m │ gzip: 4.05 kB[22m
|
||||
2026-02-22T18:25:13.8854945Z [2mdist-react/[22m[2massets/[22m[32mflag_je-CGBxZBdT.svg [39m[1m[2m 10.35 kB[22m[1m[22m[2m │ gzip: 4.17 kB[22m
|
||||
2026-02-22T18:25:13.8854993Z [2mdist-react/[22m[2massets/[22m[32mflag_do-sBcfT32z.svg [39m[1m[2m 11.37 kB[22m[1m[22m[2m │ gzip: 4.67 kB[22m
|
||||
2026-02-22T18:25:13.8855050Z [2mdist-react/[22m[2massets/[22m[32mflag_sa-B3EC8eCD.svg [39m[1m[2m 12.29 kB[22m[1m[22m[2m │ gzip: 5.12 kB[22m
|
||||
2026-02-22T18:25:13.8855097Z [2mdist-react/[22m[2massets/[22m[32mflag_al-D439po3l.svg [39m[1m[2m 12.43 kB[22m[1m[22m[2m │ gzip: 5.32 kB[22m
|
||||
2026-02-22T18:25:13.8855154Z [2mdist-react/[22m[2massets/[22m[32mflag_bz-B34xZjVJ.svg [39m[1m[2m 13.64 kB[22m[1m[22m[2m │ gzip: 5.44 kB[22m
|
||||
2026-02-22T18:25:13.8855209Z [2mdist-react/[22m[2massets/[22m[32mflag_pm-C-C2d-w4.svg [39m[1m[2m 13.65 kB[22m[1m[22m[2m │ gzip: 3.80 kB[22m
|
||||
2026-02-22T18:25:13.8855259Z [2mdist-react/[22m[2massets/[22m[32mflag_nf-BjOIhoMF.svg [39m[1m[2m 14.69 kB[22m[1m[22m[2m │ gzip: 5.98 kB[22m
|
||||
2026-02-22T18:25:13.8855314Z [2mdist-react/[22m[2massets/[22m[32mflag_ac-Dr8n8VBW.svg [39m[1m[2m 16.66 kB[22m[1m[22m[2m │ gzip: 5.62 kB[22m
|
||||
2026-02-22T18:25:13.8855371Z [2mdist-react/[22m[2massets/[22m[32mscreenshare_stop-DhppajDk.mp3 [39m[1m[2m 18.43 kB[22m[1m[22m
|
||||
2026-02-22T18:25:13.8855419Z [2mdist-react/[22m[2massets/[22m[32mpotted_plant-BHg6K0D8.svg [39m[1m[2m 21.00 kB[22m[1m[22m[2m │ gzip: 8.96 kB[22m
|
||||
2026-02-22T18:25:13.8855479Z [2mdist-react/[22m[2massets/[22m[32mmute-BoS1FmYK.mp3 [39m[1m[2m 22.10 kB[22m[1m[22m
|
||||
2026-02-22T18:25:13.8855526Z [2mdist-react/[22m[2massets/[22m[32munmute-BaZvvXS7.mp3 [39m[1m[2m 22.10 kB[22m[1m[22m
|
||||
2026-02-22T18:25:13.8855579Z [2mdist-react/[22m[2massets/[22m[32mflag_mp-Bs0Xr_ND.svg [39m[1m[2m 24.06 kB[22m[1m[22m[2m │ gzip: 9.57 kB[22m
|
||||
2026-02-22T18:25:13.8855630Z [2mdist-react/[22m[2massets/[22m[32mflag_af-CN78RMpg.svg [39m[1m[2m 24.13 kB[22m[1m[22m[2m │ gzip: 9.18 kB[22m
|
||||
2026-02-22T18:25:13.8855684Z [2mdist-react/[22m[2massets/[22m[32mflag_kz-D77IkgDL.svg [39m[1m[2m 26.58 kB[22m[1m[22m[2m │ gzip: 9.02 kB[22m
|
||||
2026-02-22T18:25:13.8855733Z [2mdist-react/[22m[2massets/[22m[32munited_nations-BC9awctQ.svg [39m[1m[2m 26.58 kB[22m[1m[22m[2m │ gzip: 10.32 kB[22m
|
||||
2026-02-22T18:25:13.8855793Z [2mdist-react/[22m[2massets/[22m[32ma_dcfe10bac4a782ffb5eefef7a8003115-BrkAovaO.png [39m[1m[2m 29.98 kB[22m[1m[22m
|
||||
2026-02-22T18:25:13.8855847Z [2mdist-react/[22m[2massets/[22m[32mdeafen-CRezb6LQ.mp3 [39m[1m[2m 32.55 kB[22m[1m[22m
|
||||
2026-02-22T18:25:13.8855913Z [2mdist-react/[22m[2massets/[22m[32mundeafen-DI8u8nRW.mp3 [39m[1m[2m 35.69 kB[22m[1m[22m
|
||||
2026-02-22T18:25:13.8855960Z [2mdist-react/[22m[2massets/[22m[32mgg sans Regular-Bd8GJPVd.woff [39m[1m[2m 39.09 kB[22m[1m[22m
|
||||
2026-02-22T18:25:13.8856019Z [2mdist-react/[22m[2massets/[22m[32memojies_greyscale-CtRIvx0g.png [39m[1m[2m 39.11 kB[22m[1m[22m
|
||||
2026-02-22T18:25:13.8856067Z [2mdist-react/[22m[2massets/[22m[32mgg sans Bold-BGlwbW8t.woff [39m[1m[2m 40.13 kB[22m[1m[22m
|
||||
2026-02-22T18:25:13.8856121Z [2mdist-react/[22m[2massets/[22m[32mgg sans Medium-BMWm4JFW.woff [39m[1m[2m 40.32 kB[22m[1m[22m
|
||||
2026-02-22T18:25:13.8856168Z [2mdist-react/[22m[2massets/[22m[32mgg sans Semibold-xAGa8zYH.woff [39m[1m[2m 40.57 kB[22m[1m[22m
|
||||
2026-02-22T18:25:13.8856222Z [2mdist-react/[22m[2massets/[22m[32mleave_call-BZHqChzH.mp3 [39m[1m[2m 41.95 kB[22m[1m[22m
|
||||
2026-02-22T18:25:13.8856276Z [2mdist-react/[22m[2massets/[22m[32memojies_colored-Cxo2u_zo.png [39m[1m[2m 45.89 kB[22m[1m[22m
|
||||
2026-02-22T18:25:13.8856332Z [2mdist-react/[22m[2massets/[22m[32mjoin_call-DlUYaXyD.mp3 [39m[1m[2m 47.18 kB[22m[1m[22m
|
||||
2026-02-22T18:25:13.8856377Z [2mdist-react/[22m[2massets/[22m[32mscreenshare_viewer_leave-BoDMhfvJ.mp3 [39m[1m[2m 67.54 kB[22m[1m[22m
|
||||
2026-02-22T18:25:13.8856433Z [2mdist-react/[22m[2massets/[22m[32mscreenshare_viewer_join-BOPrADSV.mp3 [39m[1m[2m 67.54 kB[22m[1m[22m
|
||||
2026-02-22T18:25:13.8856491Z [2mdist-react/[22m[2massets/[22m[32mscreenshare_start-Bpje2BJB.mp3 [39m[1m[2m 72.83 kB[22m[1m[22m
|
||||
2026-02-22T18:25:13.8856541Z [2mdist-react/[22m[2massets/[22m[32mdefault_call_sound-DTYq-Lur.mp3 [39m[1m[2m 90.63 kB[22m[1m[22m
|
||||
2026-02-22T18:25:13.8856595Z [2mdist-react/[22m[2massets/[22m[32msql-wasm-CbWyWKgW.wasm [39m[1m[2m 659.73 kB[22m[1m[22m[2m │ gzip: 323.01 kB[22m
|
||||
2026-02-22T18:25:13.8856645Z [2mdist-react/[22m[2massets/[22m[32ma_dcfe10bac4a782ffb5eefef7a8003115-DDM1tbIM.png [39m[1m[2m1,083.11 kB[22m[1m[22m
|
||||
2026-02-22T18:25:13.8856704Z [2mdist-react/[22m[2massets/[22m[35mindex-CAoTlF3l.css [39m[1m[2m 93.68 kB[22m[1m[22m[2m │ gzip: 16.45 kB[22m
|
||||
2026-02-22T18:25:13.8856767Z [2mdist-react/[22m[2massets/[22m[36mindex-C_rgzTeB.js [39m[1m[33m8,672.01 kB[39m[22m[2m │ gzip: 1,616.66 kB[22m
|
||||
2026-02-22T18:25:13.8856827Z [33m
|
||||
2026-02-22T18:25:13.8856869Z (!) Some chunks are larger than 1000 kB after minification. Consider:
|
||||
2026-02-22T18:25:13.8856917Z - Using dynamic import() to code-split the application
|
||||
2026-02-22T18:25:13.8856961Z - Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
|
||||
2026-02-22T18:25:13.8857016Z - Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.[39m
|
||||
2026-02-22T18:25:13.8857088Z [32m✓ built in 3.15s[39m
|
||||
2026-02-22T18:25:14.3025566Z [34m•[39m electron-builder [34mversion[39m=25.1.8 [34mos[39m=6.12.54-Unraid
|
||||
2026-02-22T18:25:14.3027203Z [34m•[39m artifacts will be published if draft release exists [34mreason[39m=CI detected
|
||||
2026-02-22T18:25:14.3044803Z [34m•[39m loaded configuration [34mfile[39m=package.json ("build" field)
|
||||
2026-02-22T18:25:14.3754964Z [31m⨯[39m [31mInvalid configuration object. electron-builder 25.1.8 has been initialized using a configuration object that does not match the API schema.[39m
|
||||
2026-02-22T18:25:14.3755344Z [31m - configuration.nsis has an unknown property 'compression'. These properties are valid:[39m
|
||||
2026-02-22T18:25:14.3755432Z [31m object { allowElevation?, allowToChangeInstallationDirectory?, artifactName?, createDesktopShortcut?, createStartMenuShortcut?, customNsisBinary?, deleteAppDataOnUninstall?, differentialPackage?, displayLanguageSelector?, guid?, include?, installerHeader?, installerHeaderIcon?, installerIcon?, installerLanguages?, installerSidebar?, language?, license?, menuCategory?, multiLanguageInstaller?, oneClick?, packElevateHelper?, perMachine?, preCompressedFileExtensions?, publish?, removeDefaultUninstallWelcomePage?, runAfterFinish?, script?, selectPerMachineByDefault?, shortcutName?, unicode?, uninstallDisplayName?, uninstallerIcon?, uninstallerSidebar?, useZip?, warningsAsErrors? }[39m
|
||||
2026-02-22T18:25:14.3755577Z [31m How to fix:[39m
|
||||
2026-02-22T18:25:14.3755627Z [31m 1. Open https://www.electron.build/nsis[39m
|
||||
2026-02-22T18:25:14.3755683Z [31m 2. Search the option name on the page (or type in into Search to find across the docs).[39m
|
||||
2026-02-22T18:25:14.3755740Z [31m * Not found? The option was deprecated or not exists (check spelling).[39m
|
||||
2026-02-22T18:25:14.3755793Z [31m * Found? Check that the option in the appropriate place. e.g. "title" only in the "dmg", not in the root.[39m
|
||||
2026-02-22T18:25:14.3755925Z [31m[39m [31mfailedTask[39m=build [31mstackTrace[39m=ValidationError: Invalid configuration object. electron-builder 25.1.8 has been initialized using a configuration object that does not match the API schema.
|
||||
2026-02-22T18:25:14.3755995Z - configuration.nsis has an unknown property 'compression'. These properties are valid:
|
||||
2026-02-22T18:25:14.3756044Z object { allowElevation?, allowToChangeInstallationDirectory?, artifactName?, createDesktopShortcut?, createStartMenuShortcut?, customNsisBinary?, deleteAppDataOnUninstall?, differentialPackage?, displayLanguageSelector?, guid?, include?, installerHeader?, installerHeaderIcon?, installerIcon?, installerLanguages?, installerSidebar?, language?, license?, menuCategory?, multiLanguageInstaller?, oneClick?, packElevateHelper?, perMachine?, preCompressedFileExtensions?, publish?, removeDefaultUninstallWelcomePage?, runAfterFinish?, script?, selectPerMachineByDefault?, shortcutName?, unicode?, uninstallDisplayName?, uninstallerIcon?, uninstallerSidebar?, useZip?, warningsAsErrors? }
|
||||
2026-02-22T18:25:14.3756156Z How to fix:
|
||||
2026-02-22T18:25:14.3756266Z 1. Open https://www.electron.build/nsis
|
||||
2026-02-22T18:25:14.3756310Z 2. Search the option name on the page (or type in into Search to find across the docs).
|
||||
2026-02-22T18:25:14.3756360Z * Not found? The option was deprecated or not exists (check spelling).
|
||||
2026-02-22T18:25:14.3756405Z * Found? Check that the option in the appropriate place. e.g. "title" only in the "dmg", not in the root.
|
||||
2026-02-22T18:25:14.3756503Z at validate (/workspace/Moyettes/DiscordClone/node_modules/@develar/schema-utils/dist/validate.js:86:11)
|
||||
2026-02-22T18:25:14.3756640Z at validateConfiguration (/workspace/Moyettes/DiscordClone/node_modules/app-builder-lib/src/util/config/config.ts:238:3)
|
||||
2026-02-22T18:25:14.3756702Z at Packager.validateConfig (/workspace/Moyettes/DiscordClone/node_modules/app-builder-lib/src/packager.ts:354:5)
|
||||
2026-02-22T18:25:14.3756751Z at Packager.build (/workspace/Moyettes/DiscordClone/node_modules/app-builder-lib/src/packager.ts:362:5)
|
||||
2026-02-22T18:25:14.3756798Z at executeFinally (/workspace/Moyettes/DiscordClone/node_modules/builder-util/src/promise.ts:12:14)
|
||||
2026-02-22T18:25:14.3952981Z ❌ Failure - Main Build Electron app
|
||||
2026-02-22T18:25:14.3976393Z exitcode '1': failure
|
||||
2026-02-22T18:25:14.4823938Z expression 'npm-electron-${{ hashFiles('package-lock.json') }}' rewritten to 'format('npm-electron-{0}', hashFiles('package-lock.json'))'
|
||||
2026-02-22T18:25:14.4824153Z evaluating expression 'format('npm-electron-{0}', hashFiles('package-lock.json'))'
|
||||
2026-02-22T18:25:14.4824385Z Writing entry to tarball workflow/hashfiles/index.js len:168437
|
||||
2026-02-22T18:25:14.4826023Z Extracting content to '/var/run/act'
|
||||
2026-02-22T18:25:14.4836053Z 🐳 docker exec cmd=[node /var/run/act/workflow/hashfiles/index.js] user= workdir=
|
||||
2026-02-22T18:25:14.4836201Z Exec command '[node /var/run/act/workflow/hashfiles/index.js]'
|
||||
2026-02-22T18:25:14.4836285Z Working directory '/workspace/Moyettes/DiscordClone'
|
||||
2026-02-22T18:25:14.5510887Z expression 'format('npm-electron-{0}', hashFiles('package-lock.json'))' evaluated to '%!t(string=npm-electron-2487a9d0dd01ab81f1c0a0ae11d988df70638852a8778c5593e6a6a43f537060)'
|
||||
2026-02-22T18:25:14.5547093Z evaluating expression 'success()'
|
||||
2026-02-22T18:25:14.5547372Z expression 'success()' evaluated to 'false'
|
||||
2026-02-22T18:25:14.5547454Z Skipping step 'Cache npm and Electron' due to 'success()'
|
||||
2026-02-22T18:25:14.5630579Z evaluating expression 'always()'
|
||||
2026-02-22T18:25:14.5630849Z expression 'always()' evaluated to 'true'
|
||||
2026-02-22T18:25:14.5630951Z ⭐ Run Post Checkout repository
|
||||
2026-02-22T18:25:14.5631090Z Writing entry to tarball workflow/outputcmd.txt len:0
|
||||
2026-02-22T18:25:14.5631231Z Writing entry to tarball workflow/statecmd.txt len:0
|
||||
2026-02-22T18:25:14.5631336Z Writing entry to tarball workflow/pathcmd.txt len:0
|
||||
2026-02-22T18:25:14.5631426Z Writing entry to tarball workflow/envs.txt len:0
|
||||
2026-02-22T18:25:14.5631501Z Writing entry to tarball workflow/SUMMARY.md len:0
|
||||
2026-02-22T18:25:14.5631586Z Extracting content to '/var/run/act'
|
||||
2026-02-22T18:25:14.5641701Z run post step for 'Checkout repository'
|
||||
2026-02-22T18:25:14.5642142Z executing remote job container: [node /var/run/act/actions/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/dist/index.js]
|
||||
2026-02-22T18:25:14.5642288Z 🐳 docker exec cmd=[node /var/run/act/actions/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/dist/index.js] user= workdir=
|
||||
2026-02-22T18:25:14.5642402Z Exec command '[node /var/run/act/actions/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/dist/index.js]'
|
||||
2026-02-22T18:25:14.5642637Z Working directory '/workspace/Moyettes/DiscordClone'
|
||||
2026-02-22T18:25:14.6451217Z [command]/usr/bin/git version
|
||||
2026-02-22T18:25:14.6473698Z git version 2.34.1
|
||||
2026-02-22T18:25:14.6492584Z ***
|
||||
2026-02-22T18:25:14.6499445Z Copying '/root/.gitconfig' to '/tmp/5f2eb5e2-7129-42c6-9748-38fd1038c4a4/.gitconfig'
|
||||
2026-02-22T18:25:14.6507937Z Temporarily overriding HOME='/tmp/5f2eb5e2-7129-42c6-9748-38fd1038c4a4' before making global git config changes
|
||||
2026-02-22T18:25:14.6509556Z Adding repository directory to the temporary git global config as a safe directory
|
||||
2026-02-22T18:25:14.6513122Z [command]/usr/bin/git config --global --add safe.directory /workspace/Moyettes/DiscordClone
|
||||
2026-02-22T18:25:14.6536297Z [command]/usr/bin/git config --local --name-only --get-regexp core\.sshCommand
|
||||
2026-02-22T18:25:14.6566221Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'core\.sshCommand' && git config --local --unset-all 'core.sshCommand' || :"
|
||||
2026-02-22T18:25:14.6731038Z [command]/usr/bin/git config --local --name-only --get-regexp http\.http\:\/\/192\.168\.125\.15\:4000\/\.extraheader
|
||||
2026-02-22T18:25:14.6743418Z http.http://192.168.125.15:4000/.extraheader
|
||||
2026-02-22T18:25:14.6746856Z [command]/usr/bin/git config --local --unset-all http.http://192.168.125.15:4000/.extraheader
|
||||
2026-02-22T18:25:14.6762942Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'http\.http\:\/\/192\.168\.125\.15\:4000\/\.extraheader' && git config --local --unset-all 'http.http://192.168.125.15:4000/.extraheader' || :"
|
||||
2026-02-22T18:25:14.6910862Z [command]/usr/bin/git config --local --name-only --get-regexp ^includeIf\.gitdir:
|
||||
2026-02-22T18:25:14.6926044Z [command]/usr/bin/git submodule foreach --recursive git config --local --show-origin --name-only --get-regexp remote.origin.url
|
||||
2026-02-22T18:25:14.7127748Z ✅ Success - Post Checkout repository
|
||||
2026-02-22T18:25:14.7150122Z Cleaning up container for job build-and-release
|
||||
2026-02-22T18:26:14.7155198Z failed to remove container: Delete "http://%2Fvar%2Frun%2Fdocker.sock/v1.44/containers/476c8d61de5006daf47cd044eca820c0f99b08acfc7b846a97d8d1560814378e?force=1&v=1": context deadline exceeded
|
||||
2026-02-22T18:26:14.7155530Z Removed container: 476c8d61de5006daf47cd044eca820c0f99b08acfc7b846a97d8d1560814378e
|
||||
2026-02-22T18:26:14.7155595Z Error while stop job container: context deadline exceeded
|
||||
2026-02-22T18:26:14.7155649Z 🏁 Job failed
|
||||
2026-02-22T18:26:14.7196884Z Error occurred running finally: Error occurred running finally: context deadline exceeded (original error: <nil>) (original error: <nil>)
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@discord-clone/shared",
|
||||
"private": true,
|
||||
"version": "1.0.37",
|
||||
"version": "1.0.38",
|
||||
"type": "module",
|
||||
"main": "src/App.jsx",
|
||||
"dependencies": {
|
||||
|
||||
126
packages/shared/src/components/MobileChannelDrawer.jsx
Normal file
126
packages/shared/src/components/MobileChannelDrawer.jsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import ColoredIcon from './ColoredIcon';
|
||||
import settingsIcon from '../assets/icons/settings.svg';
|
||||
|
||||
const ICON_COLOR_DEFAULT = 'hsl(240, 4.294%, 68.039%)';
|
||||
|
||||
const MobileChannelDrawer = ({ channel, isUnread, onMarkAsRead, onEditChannel, onClose }) => {
|
||||
const [closing, setClosing] = useState(false);
|
||||
const drawerRef = useRef(null);
|
||||
const dragStartY = useRef(null);
|
||||
const dragCurrentY = useRef(null);
|
||||
const dragStartTime = useRef(null);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setClosing(true);
|
||||
setTimeout(onClose, 200);
|
||||
}, [onClose]);
|
||||
|
||||
const handleAction = useCallback((cb) => {
|
||||
dismiss();
|
||||
setTimeout(cb, 220);
|
||||
}, [dismiss]);
|
||||
|
||||
// Swipe-to-dismiss
|
||||
const handleTouchStart = useCallback((e) => {
|
||||
dragStartY.current = e.touches[0].clientY;
|
||||
dragCurrentY.current = e.touches[0].clientY;
|
||||
dragStartTime.current = Date.now();
|
||||
if (drawerRef.current) {
|
||||
drawerRef.current.style.transition = 'none';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback((e) => {
|
||||
if (dragStartY.current === null) return;
|
||||
dragCurrentY.current = e.touches[0].clientY;
|
||||
const dy = dragCurrentY.current - dragStartY.current;
|
||||
if (dy > 0 && drawerRef.current) {
|
||||
drawerRef.current.style.transform = `translateY(${dy}px)`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
if (dragStartY.current === null || !drawerRef.current) return;
|
||||
const dy = dragCurrentY.current - dragStartY.current;
|
||||
const dt = (Date.now() - dragStartTime.current) / 1000;
|
||||
const velocity = dt > 0 ? dy / dt : 0;
|
||||
const drawerHeight = drawerRef.current.offsetHeight;
|
||||
const threshold = drawerHeight * 0.3;
|
||||
|
||||
if (dy > threshold || velocity > 500) {
|
||||
dismiss();
|
||||
} else {
|
||||
drawerRef.current.style.transition = 'transform 0.2s ease-out';
|
||||
drawerRef.current.style.transform = 'translateY(0)';
|
||||
}
|
||||
dragStartY.current = null;
|
||||
}, [dismiss]);
|
||||
|
||||
const isVoice = channel?.type === 'voice';
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<>
|
||||
<div className="mobile-drawer-overlay" onClick={dismiss} />
|
||||
<div
|
||||
ref={drawerRef}
|
||||
className={`mobile-drawer${closing ? ' mobile-drawer-closing' : ''}`}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<div className="mobile-drawer-handle">
|
||||
<div className="mobile-drawer-handle-bar" />
|
||||
</div>
|
||||
|
||||
{/* Channel header */}
|
||||
<div style={{
|
||||
padding: '4px 16px 12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}>
|
||||
{isVoice ? (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="var(--interactive-normal)">
|
||||
<path d="M11.383 3.07904C11.009 2.92504 10.579 3.01004 10.293 3.29604L6.586 7.00304H3C2.45 7.00304 2 7.45304 2 8.00304V16.003C2 16.553 2.45 17.003 3 17.003H6.586L10.293 20.71C10.579 20.996 11.009 21.082 11.383 20.927C11.757 20.772 12 20.407 12 20.003V4.00304C12 3.59904 11.757 3.23404 11.383 3.07904ZM14 5.00304V7.00304C16.757 7.00304 19 9.24604 19 12.003C19 14.76 16.757 17.003 14 17.003V19.003C17.86 19.003 21 15.863 21 12.003C21 8.14304 17.86 5.00304 14 5.00304ZM14 9.00304V15.003C15.654 15.003 17 13.657 17 12.003C17 10.349 15.654 9.00304 14 9.00304Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<span style={{ color: 'var(--interactive-normal)', fontSize: 20, fontWeight: 500 }}>#</span>
|
||||
)}
|
||||
<span style={{
|
||||
color: 'var(--text-normal)',
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{channel?.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mobile-drawer-card">
|
||||
<button
|
||||
className={`mobile-drawer-action${!isUnread ? ' mobile-drawer-action-disabled' : ''}`}
|
||||
onClick={isUnread ? () => handleAction(onMarkAsRead) : undefined}
|
||||
disabled={!isUnread}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill={isUnread ? ICON_COLOR_DEFAULT : 'var(--text-muted)'}>
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||
</svg>
|
||||
<span>Mark As Read</span>
|
||||
</button>
|
||||
<button className="mobile-drawer-action" onClick={() => handleAction(onEditChannel)}>
|
||||
<ColoredIcon src={settingsIcon} color={ICON_COLOR_DEFAULT} size="20px" />
|
||||
<span>Edit Channel</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileChannelDrawer;
|
||||
227
packages/shared/src/components/MobileChannelSettingsScreen.jsx
Normal file
227
packages/shared/src/components/MobileChannelSettingsScreen.jsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useConvex } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
|
||||
const MobileChannelSettingsScreen = ({ channel, categories, onClose, onDelete }) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [channelName, setChannelName] = useState(channel.name);
|
||||
const [channelTopic, setChannelTopic] = useState(channel.topic || '');
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState(channel.categoryId || null);
|
||||
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const convex = useConvex();
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => setVisible(true));
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
setTimeout(onClose, 250);
|
||||
};
|
||||
|
||||
const handleNameChange = (e) => {
|
||||
setChannelName(e.target.value.toLowerCase().replace(/\s+/g, '-'));
|
||||
};
|
||||
|
||||
const hasChanges =
|
||||
channelName.trim() !== channel.name ||
|
||||
channelTopic.trim() !== (channel.topic || '') ||
|
||||
selectedCategoryId !== (channel.categoryId || null);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!hasChanges || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const trimmedName = channelName.trim();
|
||||
if (trimmedName && trimmedName !== channel.name) {
|
||||
await convex.mutation(api.channels.rename, { id: channel._id, name: trimmedName });
|
||||
}
|
||||
const trimmedTopic = channelTopic.trim();
|
||||
if (trimmedTopic !== (channel.topic || '')) {
|
||||
await convex.mutation(api.channels.updateTopic, { id: channel._id, topic: trimmedTopic });
|
||||
}
|
||||
if (selectedCategoryId !== (channel.categoryId || null)) {
|
||||
await convex.mutation(api.channels.moveChannel, {
|
||||
id: channel._id,
|
||||
categoryId: selectedCategoryId || undefined,
|
||||
position: 0,
|
||||
});
|
||||
}
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to save channel settings:', err);
|
||||
alert('Failed to save: ' + err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await convex.mutation(api.channels.remove, { id: channel._id });
|
||||
if (onDelete) onDelete(channel._id);
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete channel:', err);
|
||||
alert('Failed to delete: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const currentCategoryName = selectedCategoryId
|
||||
? (categories || []).find(c => c._id === selectedCategoryId)?.name || 'Unknown'
|
||||
: 'None';
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div className={`mobile-create-screen${visible ? ' visible' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="mobile-create-header">
|
||||
<button className="mobile-create-close-btn" onClick={handleClose}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="mobile-create-title">Channel Settings</span>
|
||||
<button
|
||||
className={`mobile-create-submit-btn${!hasChanges || saving ? ' disabled' : ''}`}
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saving}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="mobile-create-body">
|
||||
{/* Channel Name */}
|
||||
<div className="mobile-create-section">
|
||||
<label className="mobile-create-section-label">Channel Name</label>
|
||||
<div className="mobile-create-input-wrapper">
|
||||
<span className="mobile-create-input-prefix">
|
||||
{channel.type === 'voice' ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 3a1 1 0 0 0-1-1h-.06a1 1 0 0 0-.74.32L5.92 7H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2.92l4.28 4.68a1 1 0 0 0 .74.32H11a1 1 0 0 0 1-1z" />
|
||||
</svg>
|
||||
) : '#'}
|
||||
</span>
|
||||
<input
|
||||
className="mobile-create-input"
|
||||
type="text"
|
||||
placeholder="channel-name"
|
||||
value={channelName}
|
||||
onChange={handleNameChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel Topic */}
|
||||
<div className="mobile-create-section">
|
||||
<label className="mobile-create-section-label">
|
||||
Channel Topic
|
||||
<span style={{ float: 'right', fontWeight: 400, textTransform: 'none' }}>
|
||||
{channelTopic.length}/1024
|
||||
</span>
|
||||
</label>
|
||||
<div className="mobile-create-input-wrapper" style={{ alignItems: 'flex-start' }}>
|
||||
<textarea
|
||||
className="mobile-create-input"
|
||||
placeholder="Set a topic for this channel"
|
||||
value={channelTopic}
|
||||
onChange={(e) => {
|
||||
if (e.target.value.length <= 1024) setChannelTopic(e.target.value);
|
||||
}}
|
||||
rows={3}
|
||||
style={{
|
||||
resize: 'none',
|
||||
fontFamily: 'inherit',
|
||||
lineHeight: '1.4',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div className="mobile-create-section">
|
||||
<label className="mobile-create-section-label">Category</label>
|
||||
<div
|
||||
className="mobile-create-input-wrapper"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setShowCategoryPicker(!showCategoryPicker)}
|
||||
>
|
||||
<span className="mobile-create-input" style={{ cursor: 'pointer', userSelect: 'none' }}>
|
||||
{currentCategoryName}
|
||||
</span>
|
||||
<svg
|
||||
width="20" height="20" viewBox="0 0 24 24"
|
||||
fill="var(--interactive-normal)"
|
||||
style={{
|
||||
marginRight: 8,
|
||||
flexShrink: 0,
|
||||
transform: showCategoryPicker ? 'rotate(180deg)' : 'none',
|
||||
transition: 'transform 0.15s',
|
||||
}}
|
||||
>
|
||||
<path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z" />
|
||||
</svg>
|
||||
</div>
|
||||
{showCategoryPicker && (
|
||||
<div className="mobile-channel-settings-category-list">
|
||||
<div
|
||||
className={`mobile-channel-settings-category-option${!selectedCategoryId ? ' selected' : ''}`}
|
||||
onClick={() => { setSelectedCategoryId(null); setShowCategoryPicker(false); }}
|
||||
>
|
||||
None
|
||||
</div>
|
||||
{(categories || []).map(cat => (
|
||||
<div
|
||||
key={cat._id}
|
||||
className={`mobile-channel-settings-category-option${selectedCategoryId === cat._id ? ' selected' : ''}`}
|
||||
onClick={() => { setSelectedCategoryId(cat._id); setShowCategoryPicker(false); }}
|
||||
>
|
||||
{cat.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Channel */}
|
||||
<div className="mobile-create-section" style={{ marginTop: 16 }}>
|
||||
{!confirmDelete ? (
|
||||
<button
|
||||
className="mobile-channel-settings-delete-btn"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
>
|
||||
Delete Channel
|
||||
</button>
|
||||
) : (
|
||||
<div className="mobile-channel-settings-delete-confirm">
|
||||
<p style={{ color: '#ed4245', fontSize: 14, margin: '0 0 12px' }}>
|
||||
Are you sure you want to delete <strong>#{channel.name}</strong>? This cannot be undone.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="mobile-channel-settings-cancel-btn"
|
||||
onClick={() => setConfirmDelete(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="mobile-channel-settings-delete-btn"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileChannelSettingsScreen;
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
const MobileCreateCategoryScreen = ({ onClose, onSubmit }) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [categoryName, setCategoryName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => setVisible(true));
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
setTimeout(onClose, 250);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!categoryName.trim()) return;
|
||||
onSubmit(categoryName.trim());
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div className={`mobile-create-screen${visible ? ' visible' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="mobile-create-header">
|
||||
<button className="mobile-create-close-btn" onClick={handleClose}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="mobile-create-title">Create Category</span>
|
||||
<button
|
||||
className={`mobile-create-submit-btn${!categoryName.trim() ? ' disabled' : ''}`}
|
||||
onClick={handleCreate}
|
||||
disabled={!categoryName.trim()}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="mobile-create-body">
|
||||
{/* Category Name */}
|
||||
<div className="mobile-create-section">
|
||||
<label className="mobile-create-section-label">Category Name</label>
|
||||
<div className="mobile-create-input-wrapper">
|
||||
<input
|
||||
className="mobile-create-input"
|
||||
type="text"
|
||||
placeholder="New Category"
|
||||
value={categoryName}
|
||||
onChange={(e) => setCategoryName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Private Category Toggle */}
|
||||
<div className="mobile-create-section">
|
||||
<p className="mobile-create-private-desc">
|
||||
By making a category private, only selected members and roles will be able to view this category.
|
||||
</p>
|
||||
<div className="mobile-create-toggle-row">
|
||||
<div className="mobile-create-toggle-left">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--interactive-normal)' }}>
|
||||
<path d="M17 11V7C17 4.243 14.757 2 12 2C9.243 2 7 4.243 7 7V11C5.897 11 5 11.896 5 13V20C5 21.103 5.897 22 7 22H17C18.103 22 19 21.103 19 20V13C19 11.896 18.103 11 17 11ZM12 18C11.172 18 10.5 17.328 10.5 16.5C10.5 15.672 11.172 15 12 15C12.828 15 13.5 15.672 13.5 16.5C13.5 17.328 12.828 18 12 18ZM15 11H9V7C9 5.346 10.346 4 12 4C13.654 4 15 5.346 15 7V11Z" />
|
||||
</svg>
|
||||
<span className="mobile-create-toggle-label">Private Category</span>
|
||||
</div>
|
||||
<div className="category-toggle-switch">
|
||||
<div className="category-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileCreateCategoryScreen;
|
||||
128
packages/shared/src/components/MobileCreateChannelScreen.jsx
Normal file
128
packages/shared/src/components/MobileCreateChannelScreen.jsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
const MobileCreateChannelScreen = ({ onClose, onSubmit, categoryId }) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [channelName, setChannelName] = useState('');
|
||||
const [channelType, setChannelType] = useState('text');
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => setVisible(true));
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
setTimeout(onClose, 250);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!channelName.trim()) return;
|
||||
onSubmit(channelName.trim(), channelType, categoryId);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleNameChange = (e) => {
|
||||
setChannelName(e.target.value.toLowerCase().replace(/\s+/g, '-'));
|
||||
};
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div className={`mobile-create-screen${visible ? ' visible' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="mobile-create-header">
|
||||
<button className="mobile-create-close-btn" onClick={handleClose}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="mobile-create-title">Create Channel</span>
|
||||
<button
|
||||
className={`mobile-create-submit-btn${!channelName.trim() ? ' disabled' : ''}`}
|
||||
onClick={handleCreate}
|
||||
disabled={!channelName.trim()}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="mobile-create-body">
|
||||
{/* Channel Name */}
|
||||
<div className="mobile-create-section">
|
||||
<label className="mobile-create-section-label">Channel Name</label>
|
||||
<div className="mobile-create-input-wrapper">
|
||||
<span className="mobile-create-input-prefix">
|
||||
{channelType === 'text' ? '#' : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 3a1 1 0 0 0-1-1h-.06a1 1 0 0 0-.74.32L5.92 7H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2.92l4.28 4.68a1 1 0 0 0 .74.32H11a1 1 0 0 0 1-1z" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<input
|
||||
className="mobile-create-input"
|
||||
type="text"
|
||||
placeholder="new-channel"
|
||||
value={channelName}
|
||||
onChange={handleNameChange}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel Type */}
|
||||
<div className="mobile-create-section">
|
||||
<label className="mobile-create-section-label">Channel Type</label>
|
||||
<div className="mobile-create-type-list">
|
||||
<div
|
||||
className={`mobile-create-type-option${channelType === 'text' ? ' selected' : ''}`}
|
||||
onClick={() => setChannelType('text')}
|
||||
>
|
||||
<span className="mobile-create-type-icon">#</span>
|
||||
<div className="mobile-create-type-info">
|
||||
<div className="mobile-create-type-name">Text</div>
|
||||
<div className="mobile-create-type-desc">Send messages, images, GIFs, emoji, opinions, and puns</div>
|
||||
</div>
|
||||
<div className={`mobile-create-radio${channelType === 'text' ? ' selected' : ''}`}>
|
||||
{channelType === 'text' && <div className="mobile-create-radio-dot" />}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`mobile-create-type-option${channelType === 'voice' ? ' selected' : ''}`}
|
||||
onClick={() => setChannelType('voice')}
|
||||
>
|
||||
<span className="mobile-create-type-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.383 3.07904C11.009 2.92504 10.579 3.01004 10.293 3.29604L6.586 7.00304H3C2.45 7.00304 2 7.45304 2 8.00304V16.003C2 16.553 2.45 17.003 3 17.003H6.586L10.293 20.71C10.579 20.996 11.009 21.082 11.383 20.927C11.757 20.772 12 20.407 12 20.003V4.00304C12 3.59904 11.757 3.23404 11.383 3.07904ZM14 5.00304V7.00304C16.757 7.00304 19 9.24604 19 12.003C19 14.76 16.757 17.003 14 17.003V19.003C17.86 19.003 21 15.863 21 12.003C21 8.14304 17.86 5.00304 14 5.00304ZM14 9.00304V15.003C15.654 15.003 17 13.657 17 12.003C17 10.349 15.654 9.00304 14 9.00304Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<div className="mobile-create-type-info">
|
||||
<div className="mobile-create-type-name">Voice</div>
|
||||
<div className="mobile-create-type-desc">Hang out together with voice, video, and screen share</div>
|
||||
</div>
|
||||
<div className={`mobile-create-radio${channelType === 'voice' ? ' selected' : ''}`}>
|
||||
{channelType === 'voice' && <div className="mobile-create-radio-dot" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Private Channel Toggle */}
|
||||
<div className="mobile-create-section">
|
||||
<div className="mobile-create-toggle-row">
|
||||
<div className="mobile-create-toggle-left">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--interactive-normal)' }}>
|
||||
<path d="M17 11V7C17 4.243 14.757 2 12 2C9.243 2 7 4.243 7 7V11C5.897 11 5 11.896 5 13V20C5 21.103 5.897 22 7 22H17C18.103 22 19 21.103 19 20V13C19 11.896 18.103 11 17 11ZM12 18C11.172 18 10.5 17.328 10.5 16.5C10.5 15.672 11.172 15 12 15C12.828 15 13.5 15.672 13.5 16.5C13.5 17.328 12.828 18 12 18ZM15 11H9V7C9 5.346 10.346 4 12 4C13.654 4 15 5.346 15 7V11Z" />
|
||||
</svg>
|
||||
<span className="mobile-create-toggle-label">Private Channel</span>
|
||||
</div>
|
||||
<div className="category-toggle-switch">
|
||||
<div className="category-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileCreateChannelScreen;
|
||||
464
packages/shared/src/components/MobileSearchScreen.jsx
Normal file
464
packages/shared/src/components/MobileSearchScreen.jsx
Normal file
@@ -0,0 +1,464 @@
|
||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import { useOnlineUsers } from '../contexts/PresenceContext';
|
||||
import { useSearch } from '../contexts/SearchContext';
|
||||
import { usePlatform } from '../platform';
|
||||
import { LinkPreview } from './ChatArea';
|
||||
import { extractUrls } from './MessageItem';
|
||||
import Avatar from './Avatar';
|
||||
import {
|
||||
formatTime, escapeHtml, linkifyHtml, formatEmojisHtml, getAvatarColor,
|
||||
SearchResultImage, SearchResultVideo, SearchResultFile
|
||||
} from '../utils/searchRendering';
|
||||
|
||||
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||
|
||||
function getUserColor(name) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
|
||||
}
|
||||
|
||||
const STATUS_COLORS = {
|
||||
online: '#3ba55c',
|
||||
idle: '#faa61a',
|
||||
dnd: '#ed4245',
|
||||
invisible: '#747f8d',
|
||||
offline: '#747f8d',
|
||||
};
|
||||
|
||||
const BROWSE_TABS = ['Recent', 'Members', 'Channels'];
|
||||
const SEARCH_TABS = ['Messages', 'Media', 'Links', 'Files'];
|
||||
|
||||
function formatTimeAgo(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp;
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return 'Active just now';
|
||||
if (minutes < 60) return `Active ${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `Active ${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days === 1) return 'Active 1d ago';
|
||||
return `Active ${days}d ago`;
|
||||
}
|
||||
|
||||
const MobileSearchScreen = ({ channels, allMembers, serverName, onClose, onSelectChannel, onJumpToMessage }) => {
|
||||
const [activeTab, setActiveTab] = useState('Recent');
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const { resolveStatus } = useOnlineUsers();
|
||||
const { search, isReady } = useSearch() || {};
|
||||
const { links } = usePlatform();
|
||||
const customEmojis = useQuery(api.customEmojis.list) || [];
|
||||
|
||||
// Search result state
|
||||
const [messageResults, setMessageResults] = useState([]);
|
||||
const [mediaResults, setMediaResults] = useState([]);
|
||||
const [linkResults, setLinkResults] = useState([]);
|
||||
const [fileResults, setFileResults] = useState([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const searchTimerRef = useRef(null);
|
||||
|
||||
const channelIds = useMemo(() => channels.map(c => c._id), [channels]);
|
||||
const latestTimestampsRaw = useQuery(
|
||||
api.readState.getLatestMessageTimestamps,
|
||||
channelIds.length > 0 ? { channelIds } : "skip"
|
||||
) || [];
|
||||
const latestTimestamps = useMemo(() => {
|
||||
const map = {};
|
||||
for (const item of latestTimestampsRaw) {
|
||||
map[item.channelId] = item.latestTimestamp;
|
||||
}
|
||||
return map;
|
||||
}, [latestTimestampsRaw]);
|
||||
|
||||
const serverChannelIds = useMemo(() => new Set(channels.map(c => c._id)), [channels]);
|
||||
|
||||
const channelMap = useMemo(() => {
|
||||
const map = {};
|
||||
for (const c of channels) map[c._id] = c.name;
|
||||
return map;
|
||||
}, [channels]);
|
||||
|
||||
// Determine mode based on search text
|
||||
const hasQuery = searchText.trim().length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => setVisible(true));
|
||||
}, []);
|
||||
|
||||
// Debounced search execution
|
||||
useEffect(() => {
|
||||
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
||||
|
||||
if (!hasQuery || !search || !isReady) {
|
||||
setMessageResults([]);
|
||||
setMediaResults([]);
|
||||
setLinkResults([]);
|
||||
setFileResults([]);
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearching(true);
|
||||
searchTimerRef.current = setTimeout(() => {
|
||||
const q = searchText.trim();
|
||||
const filterToServer = (results) => results.filter(r => serverChannelIds.has(r.channel_id));
|
||||
|
||||
// Messages search
|
||||
const msgs = filterToServer(search({ query: q, limit: 50 }));
|
||||
msgs.sort((a, b) => b.created_at - a.created_at);
|
||||
setMessageResults(msgs);
|
||||
|
||||
// Media search (images + videos, deduped)
|
||||
const images = filterToServer(search({ query: q, hasImage: true, limit: 50 }));
|
||||
const videos = filterToServer(search({ query: q, hasVideo: true, limit: 50 }));
|
||||
const mediaMap = new Map();
|
||||
for (const r of [...images, ...videos]) mediaMap.set(r.id, r);
|
||||
const media = Array.from(mediaMap.values());
|
||||
media.sort((a, b) => b.created_at - a.created_at);
|
||||
setMediaResults(media);
|
||||
|
||||
// Links search
|
||||
const lnks = filterToServer(search({ query: q, hasLink: true, limit: 50 }));
|
||||
lnks.sort((a, b) => b.created_at - a.created_at);
|
||||
setLinkResults(lnks);
|
||||
|
||||
// Files search
|
||||
const files = filterToServer(search({ query: q, hasFile: true, limit: 50 }));
|
||||
files.sort((a, b) => b.created_at - a.created_at);
|
||||
setFileResults(files);
|
||||
|
||||
setSearching(false);
|
||||
}, 300);
|
||||
|
||||
return () => { if (searchTimerRef.current) clearTimeout(searchTimerRef.current); };
|
||||
}, [searchText, hasQuery, search, isReady, serverChannelIds]);
|
||||
|
||||
// Reset to first search tab when entering search mode
|
||||
useEffect(() => {
|
||||
if (hasQuery) {
|
||||
setActiveTab('Messages');
|
||||
} else {
|
||||
setActiveTab('Recent');
|
||||
}
|
||||
}, [hasQuery]);
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
setTimeout(onClose, 250);
|
||||
};
|
||||
|
||||
const handleSelectChannel = (channelId) => {
|
||||
setVisible(false);
|
||||
setTimeout(() => {
|
||||
onSelectChannel(channelId);
|
||||
onClose();
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const handleResultClick = useCallback((result) => {
|
||||
if (onJumpToMessage) {
|
||||
setVisible(false);
|
||||
setTimeout(() => {
|
||||
onJumpToMessage(result.channel_id, result.id);
|
||||
}, 250);
|
||||
}
|
||||
}, [onJumpToMessage]);
|
||||
|
||||
const query = searchText.toLowerCase().trim();
|
||||
|
||||
// Browse mode data
|
||||
const recentChannels = useMemo(() => {
|
||||
const textChannels = channels.filter(c => c.type === 'text');
|
||||
return textChannels
|
||||
.map(c => ({ ...c, lastActivity: latestTimestamps[c._id] || 0 }))
|
||||
.sort((a, b) => b.lastActivity - a.lastActivity)
|
||||
.filter(c => !query || c.name.toLowerCase().includes(query));
|
||||
}, [channels, latestTimestamps, query]);
|
||||
|
||||
const filteredMembers = useMemo(() => {
|
||||
if (!query) return allMembers;
|
||||
return allMembers.filter(m =>
|
||||
m.username.toLowerCase().includes(query) ||
|
||||
(m.displayName && m.displayName.toLowerCase().includes(query))
|
||||
);
|
||||
}, [allMembers, query]);
|
||||
|
||||
const filteredChannels = useMemo(() => {
|
||||
if (!query) return channels;
|
||||
return channels.filter(c => c.name.toLowerCase().includes(query));
|
||||
}, [channels, query]);
|
||||
|
||||
// Group results by channel
|
||||
const groupByChannel = useCallback((results) => {
|
||||
const grouped = {};
|
||||
for (const r of results) {
|
||||
const chName = channelMap[r.channel_id] || 'Unknown';
|
||||
if (!grouped[chName]) grouped[chName] = [];
|
||||
grouped[chName].push(r);
|
||||
}
|
||||
return grouped;
|
||||
}, [channelMap]);
|
||||
|
||||
const renderSearchResult = useCallback((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="mobile-search-result-item"
|
||||
onClick={() => handleResultClick(r)}
|
||||
>
|
||||
<div
|
||||
className="mobile-search-result-avatar"
|
||||
style={{ backgroundColor: getAvatarColor(r.username) }}
|
||||
>
|
||||
{r.username?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
<div className="mobile-search-result-body">
|
||||
<div className="mobile-search-result-header">
|
||||
<span className="mobile-search-result-username" style={{ color: getAvatarColor(r.username) }}>
|
||||
{r.username}
|
||||
</span>
|
||||
<span className="mobile-search-result-time">{formatTime(r.created_at)}</span>
|
||||
</div>
|
||||
{!(r.has_attachment && r.attachment_meta) && (
|
||||
<div
|
||||
className="mobile-search-result-content"
|
||||
dangerouslySetInnerHTML={{ __html: formatEmojisHtml(linkifyHtml(r.snippet || escapeHtml(r.content)), customEmojis) }}
|
||||
onClick={(e) => {
|
||||
if (e.target.tagName === 'A' && e.target.href) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
links.openExternal(e.target.href);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{r.has_attachment && r.attachment_meta ? (() => {
|
||||
try {
|
||||
const meta = JSON.parse(r.attachment_meta);
|
||||
if (r.attachment_type?.startsWith('image/')) return <SearchResultImage metadata={meta} />;
|
||||
if (r.attachment_type?.startsWith('video/')) return <SearchResultVideo metadata={meta} />;
|
||||
return <SearchResultFile metadata={meta} />;
|
||||
} catch { return <span className="search-result-badge">File</span>; }
|
||||
})() : r.has_attachment ? <span className="search-result-badge">File</span> : null}
|
||||
{r.has_link && r.content && (() => {
|
||||
const urls = extractUrls(r.content);
|
||||
return urls.map((url, i) => <LinkPreview key={i} url={url} />);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
), [handleResultClick, customEmojis, links]);
|
||||
|
||||
const renderGroupedResults = useCallback((results) => {
|
||||
if (searching) {
|
||||
return <div className="mobile-search-empty">Searching...</div>;
|
||||
}
|
||||
if (!isReady) {
|
||||
return <div className="mobile-search-empty">Search database is loading...</div>;
|
||||
}
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<div className="mobile-search-empty">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor" style={{ opacity: 0.3, marginBottom: 8 }}>
|
||||
<path d="M21.71 20.29L18 16.61A9 9 0 1016.61 18l3.68 3.68a1 1 0 001.42 0 1 1 0 000-1.39zM11 18a7 7 0 110-14 7 7 0 010 14z"/>
|
||||
</svg>
|
||||
<div>No results found</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const grouped = groupByChannel(results);
|
||||
return (
|
||||
<div className="mobile-search-results">
|
||||
{Object.entries(grouped).map(([chName, msgs]) => (
|
||||
<div key={chName} className="mobile-search-channel-group">
|
||||
<div className="mobile-search-channel-group-header">#{chName}</div>
|
||||
{msgs.map(renderSearchResult)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}, [searching, isReady, groupByChannel, renderSearchResult]);
|
||||
|
||||
const renderContent = () => {
|
||||
// Search mode
|
||||
if (hasQuery) {
|
||||
switch (activeTab) {
|
||||
case 'Messages': return renderGroupedResults(messageResults);
|
||||
case 'Media': return renderGroupedResults(mediaResults);
|
||||
case 'Links': return renderGroupedResults(linkResults);
|
||||
case 'Files': return renderGroupedResults(fileResults);
|
||||
default: return renderGroupedResults(messageResults);
|
||||
}
|
||||
}
|
||||
|
||||
// Browse mode
|
||||
switch (activeTab) {
|
||||
case 'Recent':
|
||||
return (
|
||||
<div className="mobile-search-section">
|
||||
<div className="mobile-search-section-title">Suggested</div>
|
||||
{recentChannels.length === 0 ? (
|
||||
<div className="mobile-search-empty">No channels found</div>
|
||||
) : (
|
||||
recentChannels.map(channel => (
|
||||
<button
|
||||
key={channel._id}
|
||||
className="mobile-search-channel-item"
|
||||
onClick={() => handleSelectChannel(channel._id)}
|
||||
>
|
||||
<span className="mobile-search-channel-hash">#</span>
|
||||
<div className="mobile-search-channel-info">
|
||||
<span className="mobile-search-channel-name">{channel.name}</span>
|
||||
<span className="mobile-search-channel-activity">
|
||||
{formatTimeAgo(channel.lastActivity)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'Members':
|
||||
return (
|
||||
<div className="mobile-search-section">
|
||||
{filteredMembers.length === 0 ? (
|
||||
<div className="mobile-search-empty">No members found</div>
|
||||
) : (
|
||||
filteredMembers.map(member => {
|
||||
const effectiveStatus = resolveStatus(member.status, member.id);
|
||||
return (
|
||||
<div key={member.id} className="mobile-search-member-item">
|
||||
<div className="member-avatar-wrapper">
|
||||
{member.avatarUrl ? (
|
||||
<img
|
||||
className="member-avatar"
|
||||
src={member.avatarUrl}
|
||||
alt={member.username}
|
||||
style={{ objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="member-avatar"
|
||||
style={{ backgroundColor: getUserColor(member.username) }}
|
||||
>
|
||||
{(member.displayName || member.username).substring(0, 1).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="member-status-dot"
|
||||
style={{ backgroundColor: STATUS_COLORS[effectiveStatus] || STATUS_COLORS.offline }}
|
||||
/>
|
||||
</div>
|
||||
<span className="mobile-search-member-name">
|
||||
{member.displayName || member.username}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'Channels':
|
||||
return (
|
||||
<div className="mobile-search-section">
|
||||
{filteredChannels.length === 0 ? (
|
||||
<div className="mobile-search-empty">No channels found</div>
|
||||
) : (
|
||||
filteredChannels.map(channel => (
|
||||
<button
|
||||
key={channel._id}
|
||||
className="mobile-search-channel-item"
|
||||
onClick={() => handleSelectChannel(channel._id)}
|
||||
>
|
||||
<span className="mobile-search-channel-hash">
|
||||
{channel.type === 'voice' ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 3a1 1 0 0 0-1-1h-.06a1 1 0 0 0-.74.32L5.92 7H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2.92l4.28 4.68a1 1 0 0 0 .74.32H11a1 1 0 0 0 1-1z" />
|
||||
</svg>
|
||||
) : '#'}
|
||||
</span>
|
||||
<span className="mobile-search-channel-name">{channel.name}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="mobile-search-empty">
|
||||
{activeTab} coming soon
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const currentTabs = hasQuery ? SEARCH_TABS : BROWSE_TABS;
|
||||
const getTabLabel = (tab) => {
|
||||
if (!hasQuery) return tab;
|
||||
switch (tab) {
|
||||
case 'Messages': return `Messages${!searching ? ` (${messageResults.length})` : ''}`;
|
||||
case 'Media': return `Media${!searching ? ` (${mediaResults.length})` : ''}`;
|
||||
case 'Links': return `Links${!searching ? ` (${linkResults.length})` : ''}`;
|
||||
case 'Files': return `Files${!searching ? ` (${fileResults.length})` : ''}`;
|
||||
default: return tab;
|
||||
}
|
||||
};
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div className={`mobile-search-screen${visible ? ' visible' : ''}`}>
|
||||
<div className="mobile-search-header">
|
||||
<button className="mobile-search-back" onClick={handleClose}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="mobile-search-input-wrapper">
|
||||
<svg className="mobile-search-input-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
|
||||
</svg>
|
||||
<input
|
||||
className="mobile-search-input"
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{searchText && (
|
||||
<button className="mobile-search-clear" onClick={() => setSearchText('')}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mobile-search-tabs">
|
||||
{currentTabs.map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
className={`mobile-search-tab${activeTab === tab ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
>
|
||||
{getTabLabel(tab)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mobile-search-content">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileSearchScreen;
|
||||
124
packages/shared/src/components/MobileServerDrawer.jsx
Normal file
124
packages/shared/src/components/MobileServerDrawer.jsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import ColoredIcon from './ColoredIcon';
|
||||
import inviteUserIcon from '../assets/icons/invite_user.svg';
|
||||
import settingsIcon from '../assets/icons/settings.svg';
|
||||
import createIcon from '../assets/icons/create.svg';
|
||||
import createCategoryIcon from '../assets/icons/create_category.svg';
|
||||
|
||||
const ICON_COLOR_DEFAULT = 'hsl(240, 4.294%, 68.039%)';
|
||||
|
||||
const MobileServerDrawer = ({ serverName, serverIconUrl, memberCount, onInvite, onSettings, onCreateChannel, onCreateCategory, onClose }) => {
|
||||
const [closing, setClosing] = useState(false);
|
||||
const drawerRef = useRef(null);
|
||||
const dragStartY = useRef(null);
|
||||
const dragCurrentY = useRef(null);
|
||||
const dragStartTime = useRef(null);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setClosing(true);
|
||||
setTimeout(onClose, 200);
|
||||
}, [onClose]);
|
||||
|
||||
const handleAction = useCallback((cb) => {
|
||||
dismiss();
|
||||
setTimeout(cb, 220);
|
||||
}, [dismiss]);
|
||||
|
||||
// Swipe-to-dismiss
|
||||
const handleTouchStart = useCallback((e) => {
|
||||
dragStartY.current = e.touches[0].clientY;
|
||||
dragCurrentY.current = e.touches[0].clientY;
|
||||
dragStartTime.current = Date.now();
|
||||
if (drawerRef.current) {
|
||||
drawerRef.current.style.transition = 'none';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback((e) => {
|
||||
if (dragStartY.current === null) return;
|
||||
dragCurrentY.current = e.touches[0].clientY;
|
||||
const dy = dragCurrentY.current - dragStartY.current;
|
||||
if (dy > 0 && drawerRef.current) {
|
||||
drawerRef.current.style.transform = `translateY(${dy}px)`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
if (dragStartY.current === null || !drawerRef.current) return;
|
||||
const dy = dragCurrentY.current - dragStartY.current;
|
||||
const dt = (Date.now() - dragStartTime.current) / 1000;
|
||||
const velocity = dt > 0 ? dy / dt : 0;
|
||||
const drawerHeight = drawerRef.current.offsetHeight;
|
||||
const threshold = drawerHeight * 0.3;
|
||||
|
||||
if (dy > threshold || velocity > 500) {
|
||||
dismiss();
|
||||
} else {
|
||||
drawerRef.current.style.transition = 'transform 0.2s ease-out';
|
||||
drawerRef.current.style.transform = 'translateY(0)';
|
||||
}
|
||||
dragStartY.current = null;
|
||||
}, [dismiss]);
|
||||
|
||||
const initials = serverName ? serverName.split(/\s+/).map(w => w[0]).join('').slice(0, 2).toUpperCase() : '?';
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<>
|
||||
<div className="mobile-drawer-overlay" onClick={dismiss} />
|
||||
<div
|
||||
ref={drawerRef}
|
||||
className={`mobile-drawer${closing ? ' mobile-drawer-closing' : ''}`}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<div className="mobile-drawer-handle">
|
||||
<div className="mobile-drawer-handle-bar" />
|
||||
</div>
|
||||
|
||||
{/* Server header */}
|
||||
<div className="mobile-server-drawer-header">
|
||||
<div className="mobile-server-drawer-icon">
|
||||
{serverIconUrl ? (
|
||||
<img src={serverIconUrl} alt={serverName} />
|
||||
) : (
|
||||
<span>{initials}</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mobile-server-drawer-name">{serverName}</div>
|
||||
<div className="mobile-server-drawer-members">{memberCount} {memberCount === 1 ? 'member' : 'members'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions card 1 */}
|
||||
<div className="mobile-drawer-card">
|
||||
<button className="mobile-drawer-action" onClick={() => handleAction(onInvite)}>
|
||||
<ColoredIcon src={inviteUserIcon} color={ICON_COLOR_DEFAULT} size="20px" />
|
||||
<span>Invite People</span>
|
||||
</button>
|
||||
<button className="mobile-drawer-action" onClick={() => handleAction(onSettings)}>
|
||||
<ColoredIcon src={settingsIcon} color={ICON_COLOR_DEFAULT} size="20px" />
|
||||
<span>Server Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Actions card 2 */}
|
||||
<div className="mobile-drawer-card">
|
||||
<button className="mobile-drawer-action" onClick={() => handleAction(onCreateChannel)}>
|
||||
<ColoredIcon src={createIcon} color={ICON_COLOR_DEFAULT} size="20px" />
|
||||
<span>Create Channel</span>
|
||||
</button>
|
||||
<button className="mobile-drawer-action" onClick={() => handleAction(onCreateCategory)}>
|
||||
<ColoredIcon src={createCategoryIcon} color={ICON_COLOR_DEFAULT} size="20px" />
|
||||
<span>Create Category</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileServerDrawer;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import { useSearch } from '../contexts/SearchContext';
|
||||
@@ -6,216 +6,10 @@ import { parseFilters } from '../utils/searchUtils';
|
||||
import { usePlatform } from '../platform';
|
||||
import { LinkPreview } from './ChatArea';
|
||||
import { extractUrls } from './MessageItem';
|
||||
import { AllEmojis } from '../assets/emojis';
|
||||
|
||||
function formatTime(ts) {
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const isToday = d.toDateString() === now.toDateString();
|
||||
if (isToday) return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function linkifyHtml(html) {
|
||||
if (!html) return '';
|
||||
return html.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" class="search-result-link">$1</a>');
|
||||
}
|
||||
|
||||
function formatEmojisHtml(html, customEmojis = []) {
|
||||
if (!html) return '';
|
||||
return html.replace(/:([a-zA-Z0-9_]+):/g, (match, name) => {
|
||||
const custom = customEmojis.find(e => e.name === name);
|
||||
if (custom) return `<img src="${custom.src}" alt="${match}" style="width:48px;height:48px;vertical-align:bottom;margin:0 1px;display:inline" />`;
|
||||
const emoji = AllEmojis.find(e => e.name === name);
|
||||
if (emoji) return `<img src="${emoji.src}" alt="${match}" style="width:48px;height:48px;vertical-align:bottom;margin:0 1px;display:inline" />`;
|
||||
return match;
|
||||
});
|
||||
}
|
||||
|
||||
function getAvatarColor(name) {
|
||||
const colors = ['#5865F2', '#57F287', '#FEE75C', '#EB459E', '#ED4245', '#F47B67', '#E67E22', '#3498DB'];
|
||||
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];
|
||||
}
|
||||
|
||||
const CONVEX_PUBLIC_URL = 'https://api.brycord.com';
|
||||
const rewriteStorageUrl = (url) => {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const pub = new URL(CONVEX_PUBLIC_URL);
|
||||
u.hostname = pub.hostname;
|
||||
u.port = pub.port;
|
||||
u.protocol = pub.protocol;
|
||||
return u.toString();
|
||||
} catch { return url; }
|
||||
};
|
||||
|
||||
const toHexString = (bytes) =>
|
||||
bytes.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '');
|
||||
|
||||
const searchImageCache = new Map();
|
||||
|
||||
const SearchResultImage = ({ metadata }) => {
|
||||
const { crypto } = usePlatform();
|
||||
const fetchUrl = rewriteStorageUrl(metadata.url);
|
||||
const [url, setUrl] = useState(searchImageCache.get(fetchUrl) || null);
|
||||
const [loading, setLoading] = useState(!searchImageCache.has(fetchUrl));
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchImageCache.has(fetchUrl)) {
|
||||
setUrl(searchImageCache.get(fetchUrl));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let isMounted = true;
|
||||
const decrypt = async () => {
|
||||
try {
|
||||
const res = await fetch(fetchUrl);
|
||||
const blob = await res.blob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const hexInput = toHexString(new Uint8Array(arrayBuffer));
|
||||
if (hexInput.length < 32) throw new Error('Invalid file data');
|
||||
const TAG_HEX_LEN = 32;
|
||||
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
|
||||
const tagHex = hexInput.slice(-TAG_HEX_LEN);
|
||||
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
|
||||
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
|
||||
const objectUrl = URL.createObjectURL(decryptedBlob);
|
||||
if (isMounted) {
|
||||
searchImageCache.set(fetchUrl, objectUrl);
|
||||
setUrl(objectUrl);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Search image decrypt error:', err);
|
||||
if (isMounted) { setError('Failed to load'); setLoading(false); }
|
||||
}
|
||||
};
|
||||
decrypt();
|
||||
return () => { isMounted = false; };
|
||||
}, [fetchUrl, metadata, crypto]);
|
||||
|
||||
if (loading) return <div style={{ color: 'var(--header-secondary)', fontSize: '11px', marginTop: 4 }}>Loading image...</div>;
|
||||
if (error) return null;
|
||||
return <img src={url} alt={metadata.filename} style={{ width: '100%', height: 'auto', borderRadius: 4, marginTop: 4, display: 'block' }} />;
|
||||
};
|
||||
|
||||
const SearchResultVideo = ({ metadata }) => {
|
||||
const { crypto } = usePlatform();
|
||||
const fetchUrl = rewriteStorageUrl(metadata.url);
|
||||
const [url, setUrl] = useState(searchImageCache.get(fetchUrl) || null);
|
||||
const [loading, setLoading] = useState(!searchImageCache.has(fetchUrl));
|
||||
const [error, setError] = useState(null);
|
||||
const [showControls, setShowControls] = useState(false);
|
||||
const videoRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchImageCache.has(fetchUrl)) {
|
||||
setUrl(searchImageCache.get(fetchUrl));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let isMounted = true;
|
||||
const decrypt = async () => {
|
||||
try {
|
||||
const res = await fetch(fetchUrl);
|
||||
const blob = await res.blob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const hexInput = toHexString(new Uint8Array(arrayBuffer));
|
||||
if (hexInput.length < 32) throw new Error('Invalid file data');
|
||||
const TAG_HEX_LEN = 32;
|
||||
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
|
||||
const tagHex = hexInput.slice(-TAG_HEX_LEN);
|
||||
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
|
||||
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
|
||||
const objectUrl = URL.createObjectURL(decryptedBlob);
|
||||
if (isMounted) {
|
||||
searchImageCache.set(fetchUrl, objectUrl);
|
||||
setUrl(objectUrl);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Search video decrypt error:', err);
|
||||
if (isMounted) { setError('Failed to load'); setLoading(false); }
|
||||
}
|
||||
};
|
||||
decrypt();
|
||||
return () => { isMounted = false; };
|
||||
}, [fetchUrl, metadata, crypto]);
|
||||
|
||||
if (loading) return <div style={{ color: 'var(--header-secondary)', fontSize: '11px', marginTop: 4 }}>Loading video...</div>;
|
||||
if (error) return null;
|
||||
|
||||
const handlePlayClick = () => { setShowControls(true); if (videoRef.current) videoRef.current.play(); };
|
||||
return (
|
||||
<div style={{ marginTop: 4, position: 'relative', display: 'inline-block', maxWidth: '100%' }}>
|
||||
<video ref={videoRef} src={url} controls={showControls} style={{ width: '100%', maxHeight: 200, borderRadius: 4, display: 'block', backgroundColor: 'black' }} />
|
||||
{!showControls && <div className="play-icon" onClick={handlePlayClick} style={{ cursor: 'pointer' }}>▶</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SearchResultFile = ({ metadata }) => {
|
||||
const { crypto } = usePlatform();
|
||||
const fetchUrl = rewriteStorageUrl(metadata.url);
|
||||
const [url, setUrl] = useState(searchImageCache.get(fetchUrl) || null);
|
||||
const [loading, setLoading] = useState(!searchImageCache.has(fetchUrl));
|
||||
|
||||
useEffect(() => {
|
||||
if (searchImageCache.has(fetchUrl)) {
|
||||
setUrl(searchImageCache.get(fetchUrl));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let isMounted = true;
|
||||
const decrypt = async () => {
|
||||
try {
|
||||
const res = await fetch(fetchUrl);
|
||||
const blob = await res.blob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const hexInput = toHexString(new Uint8Array(arrayBuffer));
|
||||
if (hexInput.length < 32) return;
|
||||
const TAG_HEX_LEN = 32;
|
||||
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
|
||||
const tagHex = hexInput.slice(-TAG_HEX_LEN);
|
||||
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
|
||||
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
|
||||
const objectUrl = URL.createObjectURL(decryptedBlob);
|
||||
if (isMounted) {
|
||||
searchImageCache.set(fetchUrl, objectUrl);
|
||||
setUrl(objectUrl);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Search file decrypt error:', err);
|
||||
if (isMounted) setLoading(false);
|
||||
}
|
||||
};
|
||||
decrypt();
|
||||
return () => { isMounted = false; };
|
||||
}, [fetchUrl, metadata, crypto]);
|
||||
|
||||
const sizeStr = metadata.size ? `${(metadata.size / 1024).toFixed(1)} KB` : '';
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', backgroundColor: 'var(--embed-background)', padding: '8px 10px', borderRadius: 4, marginTop: 4, maxWidth: '100%' }}>
|
||||
<span style={{ marginRight: 8, fontSize: 20 }}>📄</span>
|
||||
<div style={{ overflow: 'hidden', flex: 1 }}>
|
||||
<div style={{ color: 'var(--text-link)', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontSize: 13 }}>{metadata.filename}</div>
|
||||
{sizeStr && <div style={{ color: 'var(--header-secondary)', fontSize: 11 }}>{sizeStr}</div>}
|
||||
{url && <a href={url} download={metadata.filename} onClick={e => e.stopPropagation()} style={{ color: 'var(--header-secondary)', fontSize: 11, textDecoration: 'underline' }}>Download</a>}
|
||||
{loading && <div style={{ color: 'var(--header-secondary)', fontSize: 11 }}>Decrypting...</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import {
|
||||
formatTime, escapeHtml, linkifyHtml, formatEmojisHtml, getAvatarColor,
|
||||
SearchResultImage, SearchResultVideo, SearchResultFile
|
||||
} from '../utils/searchRendering';
|
||||
|
||||
const SearchPanel = ({ visible, onClose, channels, isDM, dmChannelId, onJumpToMessage, query, sortOrder, onSortChange }) => {
|
||||
const { search, isReady } = useSearch() || {};
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useQuery, useConvex } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import { AllEmojis } from '../assets/emojis';
|
||||
import AvatarCropModal from './AvatarCropModal';
|
||||
import Cropper from 'react-easy-crop';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
|
||||
function getCroppedEmojiImg(imageSrc, pixelCrop, rotation, flipH, flipV) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -95,19 +97,44 @@ const ServerSettingsModal = ({ onClose }) => {
|
||||
const [savingIcon, setSavingIcon] = useState(false);
|
||||
const iconInputRef = useRef(null);
|
||||
|
||||
// Mobile state
|
||||
const isMobile = useIsMobile();
|
||||
const [mobileScreen, setMobileScreen] = useState('menu');
|
||||
|
||||
const mobileGoBack = () => {
|
||||
if (mobileScreen === 'role-edit') setMobileScreen('roles');
|
||||
else setMobileScreen('menu');
|
||||
};
|
||||
|
||||
const mobileSelectRole = (role) => {
|
||||
setSelectedRole(role);
|
||||
setMobileScreen('role-edit');
|
||||
};
|
||||
|
||||
// Auto-navigate to overview on icon crop (so user sees save button)
|
||||
useEffect(() => {
|
||||
if (isMobile && iconDirty && mobileScreen === 'menu') {
|
||||
setMobileScreen('overview');
|
||||
}
|
||||
}, [isMobile, iconDirty, mobileScreen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (showEmojiModal) {
|
||||
handleEmojiModalClose();
|
||||
} else if (!showIconCropModal) {
|
||||
if (isMobile && mobileScreen !== 'menu') {
|
||||
mobileGoBack();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [onClose, showEmojiModal, showIconCropModal]);
|
||||
}, [onClose, showEmojiModal, showIconCropModal, isMobile, mobileScreen]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (serverSettings) {
|
||||
@@ -745,6 +772,506 @@ const ServerSettingsModal = ({ onClose }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Mobile render functions ───
|
||||
|
||||
const roleMemberCounts = React.useMemo(() => {
|
||||
const counts = {};
|
||||
for (const m of members) {
|
||||
for (const r of (m.roles || [])) {
|
||||
counts[r._id] = (counts[r._id] || 0) + 1;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}, [members]);
|
||||
|
||||
const renderMobileHeader = (title, onBack, rightAction) => (
|
||||
<div className="msm-header">
|
||||
<button className="msm-back-btn" onClick={onBack}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
||||
</button>
|
||||
<div className="msm-header-title">{title}</div>
|
||||
{rightAction || <div style={{ width: 32 }} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderMobileMenu = () => (
|
||||
<div className="msm-screen">
|
||||
<div className="msm-header">
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="msm-back-btn" onClick={onClose}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="msm-content">
|
||||
{/* Server icon + name */}
|
||||
<div className="msm-icon-section">
|
||||
<div className="msm-icon-wrapper" onClick={() => myPermissions.manage_channels && iconInputRef.current?.click()}>
|
||||
{currentIconUrl ? (
|
||||
<img src={currentIconUrl} alt="Server Icon" className="msm-icon-img" />
|
||||
) : (
|
||||
<div className="msm-icon-placeholder">{serverName.substring(0, 2)}</div>
|
||||
)}
|
||||
{myPermissions.manage_channels && (
|
||||
<div className="msm-icon-badge">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="#fff"><path d="M19 7v2.99s-1.99.01-2 0V7h-3s.01-1.99 0-2h3V2h2v3h3v2h-3zm-3 4V8h-3V5H5c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-8h-3zM5 19l3-4 2 3 3-4 4 5H5z"/></svg>
|
||||
</div>
|
||||
)}
|
||||
<input ref={iconInputRef} type="file" accept="image/*" onChange={handleIconFileChange} style={{ display: 'none' }} />
|
||||
</div>
|
||||
<div className="msm-icon-name">{serverName}</div>
|
||||
</div>
|
||||
|
||||
{/* Settings menu */}
|
||||
<div className="msm-section-label">Settings</div>
|
||||
<div className="msm-card">
|
||||
{[
|
||||
{ key: 'overview', label: 'Overview', icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg> },
|
||||
{ key: 'emoji', label: 'Emoji', icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z"/></svg> },
|
||||
{ key: 'roles', label: 'Roles', icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/></svg> },
|
||||
{ key: 'members', label: 'Members', icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg> },
|
||||
].map(item => (
|
||||
<div key={item.key} className="msm-card-item" onClick={() => setMobileScreen(item.key)}>
|
||||
<span className="msm-card-item-icon">{item.icon}</span>
|
||||
<span style={{ flex: 1, fontSize: 15, color: 'var(--header-primary)' }}>{item.label}</span>
|
||||
<span className="msm-card-item-chevron">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderMobileOverview = () => (
|
||||
<div className="msm-screen">
|
||||
{renderMobileHeader('Overview', mobileGoBack)}
|
||||
<div className="msm-content">
|
||||
{/* Server icon (small) */}
|
||||
<div className="msm-section-label">Server Icon</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
||||
<div className="msm-icon-wrapper" style={{ width: 56, height: 56 }} onClick={() => myPermissions.manage_channels && iconInputRef.current?.click()}>
|
||||
{currentIconUrl ? (
|
||||
<img src={currentIconUrl} alt="Icon" style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 16 }} />
|
||||
) : (
|
||||
<div className="msm-icon-placeholder" style={{ width: 56, height: 56, fontSize: 18, borderRadius: 16 }}>{serverName.substring(0, 2)}</div>
|
||||
)}
|
||||
<input ref={iconInputRef} type="file" accept="image/*" onChange={handleIconFileChange} style={{ display: 'none' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{iconDirty && myPermissions.manage_channels && (
|
||||
<button className="msm-btn-primary" onClick={handleSaveIcon} disabled={savingIcon} style={{ fontSize: 13, padding: '6px 14px' }}>
|
||||
{savingIcon ? 'Saving...' : 'Save Icon'}
|
||||
</button>
|
||||
)}
|
||||
{currentIconUrl && !iconDirty && myPermissions.manage_channels && (
|
||||
<button className="msm-btn-danger-outline" onClick={handleRemoveIcon} disabled={savingIcon} style={{ fontSize: 13, padding: '5px 12px' }}>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server name */}
|
||||
<div className="msm-section-label">Server Name</div>
|
||||
<input
|
||||
className="msm-input"
|
||||
value={serverName}
|
||||
onChange={(e) => { setServerName(e.target.value); setServerNameDirty(true); }}
|
||||
disabled={!myPermissions.manage_channels}
|
||||
maxLength={100}
|
||||
style={{ opacity: myPermissions.manage_channels ? 1 : 0.5 }}
|
||||
/>
|
||||
{serverNameDirty && myPermissions.manage_channels && (
|
||||
<button className="msm-btn-primary msm-btn-full" onClick={handleSaveServerName} disabled={!serverName.trim()} style={{ marginTop: 8 }}>
|
||||
Save Changes
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* AFK settings */}
|
||||
<div className="msm-section-label">Inactive Channel</div>
|
||||
<select
|
||||
className="msm-select"
|
||||
value={afkChannelId}
|
||||
onChange={(e) => { setAfkChannelId(e.target.value); setAfkDirty(true); }}
|
||||
disabled={!myPermissions.manage_channels}
|
||||
style={{ opacity: myPermissions.manage_channels ? 1 : 0.5 }}
|
||||
>
|
||||
<option value="">No Inactive Channel</option>
|
||||
{voiceChannels.map(ch => (
|
||||
<option key={ch._id} value={ch._id}>{ch.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="msm-section-label">Inactive Timeout</div>
|
||||
<select
|
||||
className="msm-select"
|
||||
value={afkTimeout}
|
||||
onChange={(e) => { setAfkTimeout(Number(e.target.value)); setAfkDirty(true); }}
|
||||
disabled={!myPermissions.manage_channels}
|
||||
style={{ opacity: myPermissions.manage_channels ? 1 : 0.5 }}
|
||||
>
|
||||
{TIMEOUT_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{afkDirty && myPermissions.manage_channels && (
|
||||
<button className="msm-btn-primary msm-btn-full" onClick={handleSaveAfkSettings} style={{ marginTop: 8 }}>
|
||||
Save Changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderMobileEmoji = () => (
|
||||
<div className="msm-screen">
|
||||
{renderMobileHeader('Emoji', mobileGoBack)}
|
||||
<div className="msm-content">
|
||||
{myPermissions.manage_channels && (
|
||||
<>
|
||||
<button className="msm-btn-primary msm-btn-full" onClick={() => emojiFileInputRef.current?.click()} style={{ marginBottom: 12 }}>
|
||||
Upload Emoji
|
||||
</button>
|
||||
<input ref={emojiFileInputRef} type="file" accept="image/*,.gif" onChange={handleEmojiFileSelect} style={{ display: 'none' }} />
|
||||
</>
|
||||
)}
|
||||
<p className="msm-description">Add custom emoji that anyone can use in this server.</p>
|
||||
|
||||
{customEmojis.length === 0 ? (
|
||||
<div className="msm-empty">No custom emojis yet</div>
|
||||
) : (
|
||||
<div className="msm-card">
|
||||
{customEmojis.map(emoji => (
|
||||
<div key={emoji._id} className="msm-emoji-row">
|
||||
<img src={emoji.src} alt={emoji.name} className="msm-emoji-img" />
|
||||
<div className="msm-emoji-info">
|
||||
<span className="msm-emoji-name">:{emoji.name}:</span>
|
||||
<span className="msm-emoji-uploader">{emoji.uploadedByUsername}</span>
|
||||
</div>
|
||||
{myPermissions.manage_channels && (
|
||||
<button className="msm-emoji-delete" onClick={() => handleEmojiDelete(emoji._id)}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderMobileRoles = () => (
|
||||
<div className="msm-screen">
|
||||
{renderMobileHeader('Roles', mobileGoBack, canManageRoles ? (
|
||||
<button className="msm-header-action" onClick={handleCreateRole}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||
</button>
|
||||
) : null)}
|
||||
<div className="msm-content">
|
||||
<p className="msm-description">Roles let you organize members and customize permissions.</p>
|
||||
|
||||
{/* @everyone */}
|
||||
<div className="msm-card" style={{ marginBottom: 16 }}>
|
||||
<div className="msm-card-item" onClick={() => mobileSelectRole(editableRoles.find(r => r.name === '@everyone') || editableRoles[0])}>
|
||||
<span className="msm-role-dot" style={{ backgroundColor: '#99aab5' }} />
|
||||
<span style={{ flex: 1, color: 'var(--header-primary)', fontSize: 15 }}>@everyone</span>
|
||||
<span className="msm-role-count">{members.length}</span>
|
||||
<span className="msm-card-item-chevron">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Other roles */}
|
||||
{editableRoles.filter(r => r.name !== '@everyone').length > 0 && (
|
||||
<>
|
||||
<div className="msm-section-label">Roles - {editableRoles.filter(r => r.name !== '@everyone').length}</div>
|
||||
<div className="msm-card">
|
||||
{editableRoles.filter(r => r.name !== '@everyone').map(r => (
|
||||
<div key={r._id} className="msm-card-item" onClick={() => mobileSelectRole(r)}>
|
||||
<span className="msm-role-dot" style={{ backgroundColor: r.color || '#99aab5' }} />
|
||||
<span style={{ flex: 1, color: 'var(--header-primary)', fontSize: 15 }}>{r.name}</span>
|
||||
<span className="msm-role-count">{roleMemberCounts[r._id] || 0}</span>
|
||||
<span className="msm-card-item-chevron">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderMobileRoleEdit = () => {
|
||||
if (!selectedRole) return renderMobileRoles();
|
||||
const permList = ['manage_channels', 'manage_roles', 'manage_nicknames', 'create_invite', 'embed_links', 'attach_files', 'move_members', 'mute_members'];
|
||||
|
||||
return (
|
||||
<div className="msm-screen">
|
||||
{renderMobileHeader(`Edit Role`, () => setMobileScreen('roles'))}
|
||||
<div className="msm-content">
|
||||
{/* Role name */}
|
||||
<div className="msm-section-label">Role Name</div>
|
||||
<input
|
||||
className="msm-input"
|
||||
value={selectedRole.name}
|
||||
onChange={(e) => handleUpdateRole(selectedRole._id, { name: e.target.value })}
|
||||
disabled={!canManageRoles}
|
||||
style={{ opacity: canManageRoles ? 1 : 0.5 }}
|
||||
/>
|
||||
|
||||
{/* Role color */}
|
||||
<div className="msm-section-label">Role Color</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||
<input
|
||||
type="color"
|
||||
className="msm-color-input"
|
||||
value={selectedRole.color}
|
||||
onChange={(e) => handleUpdateRole(selectedRole._id, { color: e.target.value })}
|
||||
disabled={!canManageRoles}
|
||||
/>
|
||||
<span style={{ color: 'var(--header-secondary)', fontSize: 14 }}>{selectedRole.color}</span>
|
||||
</div>
|
||||
|
||||
{/* Display separately toggle (isHoist) */}
|
||||
<div className="msm-section-label">Display</div>
|
||||
<div className="msm-card">
|
||||
<div className="msm-card-item" onClick={() => canManageRoles && handleUpdateRole(selectedRole._id, { isHoist: !selectedRole.isHoist })}>
|
||||
<span style={{ flex: 1, color: 'var(--header-primary)', fontSize: 15 }}>Display separately</span>
|
||||
<div className={`msm-toggle ${selectedRole.isHoist ? 'msm-toggle-on' : ''}`}>
|
||||
<div className="msm-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
<div className="msm-section-label">Permissions</div>
|
||||
<div className="msm-card">
|
||||
{permList.map(perm => (
|
||||
<div key={perm} className="msm-card-item" onClick={() => canManageRoles && handleUpdateRole(selectedRole._id, { permissions: { ...selectedRole.permissions, [perm]: !selectedRole.permissions?.[perm] } })}>
|
||||
<span style={{ flex: 1, color: 'var(--header-primary)', fontSize: 15, textTransform: 'capitalize' }}>{perm.replace(/_/g, ' ')}</span>
|
||||
<div className={`msm-toggle ${selectedRole.permissions?.[perm] ? 'msm-toggle-on' : ''}`}>
|
||||
<div className="msm-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
{canManageRoles && selectedRole.name !== 'Owner' && selectedRole.name !== '@everyone' && (
|
||||
<div className="msm-card" style={{ marginTop: 24 }}>
|
||||
<div className="msm-card-item msm-card-item-danger" onClick={() => handleDeleteRole(selectedRole._id)}>
|
||||
Delete Role
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMobileMembers = () => (
|
||||
<div className="msm-screen">
|
||||
{renderMobileHeader('Members', mobileGoBack)}
|
||||
<div className="msm-content">
|
||||
{members.length === 0 ? (
|
||||
<div className="msm-empty">No members found</div>
|
||||
) : (
|
||||
<div className="msm-card">
|
||||
{members.map(m => (
|
||||
<div key={m.id} className="msm-member-row">
|
||||
<div className="msm-member-avatar">
|
||||
{m.avatarUrl ? (
|
||||
<img src={m.avatarUrl} alt="" style={{ width: 36, height: 36, borderRadius: '50%' }} />
|
||||
) : (
|
||||
m.username[0].toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<div className="msm-member-info">
|
||||
<div className="msm-member-name">{m.username}</div>
|
||||
<div className="msm-member-roles">
|
||||
{m.roles?.map(r => (
|
||||
<span key={r._id} className="msm-member-role-pill" style={{ backgroundColor: r.color + '33', color: r.color, borderColor: r.color + '66' }}>
|
||||
{r.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{canManageRoles && (
|
||||
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||||
{editableRoles.map(r => {
|
||||
const hasRole = m.roles?.some(ur => ur._id === r._id);
|
||||
return (
|
||||
<button
|
||||
key={r._id}
|
||||
className="msm-member-role-toggle"
|
||||
onClick={() => handleAssignRole(r._id, m.id, !hasRole)}
|
||||
style={{
|
||||
borderColor: r.color,
|
||||
backgroundColor: hasRole ? r.color : 'transparent',
|
||||
}}
|
||||
title={hasRole ? `Remove ${r.name}` : `Add ${r.name}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderMobileContent = () => {
|
||||
switch (mobileScreen) {
|
||||
case 'overview': return renderMobileOverview();
|
||||
case 'emoji': return renderMobileEmoji();
|
||||
case 'roles': return renderMobileRoles();
|
||||
case 'role-edit': return renderMobileRoleEdit();
|
||||
case 'members': return renderMobileMembers();
|
||||
default: return renderMobileMenu();
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Shared modals ───
|
||||
const sharedModals = (
|
||||
<>
|
||||
{showIconCropModal && rawIconUrl && (
|
||||
<AvatarCropModal
|
||||
imageUrl={rawIconUrl}
|
||||
onApply={handleIconCropApply}
|
||||
onCancel={handleIconCropCancel}
|
||||
cropShape="rect"
|
||||
/>
|
||||
)}
|
||||
{showEmojiModal && emojiPreviewUrl && (
|
||||
<div
|
||||
onClick={handleEmojiModalClose}
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)', zIndex: 2000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-secondary)', borderRadius: 8,
|
||||
width: 580, maxWidth: '90vw', overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '16px 16px 0',
|
||||
}}>
|
||||
<h2 style={{ color: 'var(--header-primary)', margin: 0, fontSize: 20, fontWeight: 600 }}>Add Emoji</h2>
|
||||
<button
|
||||
onClick={handleEmojiModalClose}
|
||||
style={{
|
||||
background: 'transparent', border: 'none', color: 'var(--header-secondary)',
|
||||
cursor: 'pointer', fontSize: 20, padding: '4px 8px',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', padding: '20px 16px 16px', gap: 24 }}>
|
||||
<div style={{ width: 240, minWidth: 240, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 240, height: 240, position: 'relative',
|
||||
backgroundColor: 'var(--bg-tertiary)', borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Cropper
|
||||
image={emojiPreviewUrl}
|
||||
crop={emojiCrop}
|
||||
zoom={emojiZoom}
|
||||
rotation={emojiRotation}
|
||||
aspect={1}
|
||||
cropShape="rect"
|
||||
showGrid={false}
|
||||
onCropChange={setEmojiCrop}
|
||||
onZoomChange={setEmojiZoom}
|
||||
onCropComplete={onEmojiCropComplete}
|
||||
style={{
|
||||
containerStyle: { width: 240, height: 240 },
|
||||
mediaStyle: {
|
||||
transform: `${emojiFlipH ? 'scaleX(-1)' : ''} ${emojiFlipV ? 'scaleY(-1)' : ''}`.trim() || undefined,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 4 }}>
|
||||
<button onClick={() => setEmojiRotation((r) => (r - 90 + 360) % 360)} title="Rotate left" style={{ width: 36, height: 36, borderRadius: 4, background: 'var(--bg-tertiary)', border: 'none', color: 'var(--header-secondary)', cursor: 'pointer', fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M7.11 8.53L5.7 7.11C4.8 8.27 4.24 9.61 4.07 11h2.02c.14-.87.49-1.72 1.02-2.47zM6.09 13H4.07c.17 1.39.72 2.73 1.62 3.89l1.41-1.42c-.52-.75-.87-1.59-1.01-2.47zm1.01 5.32c1.16.9 2.51 1.44 3.9 1.61V17.9c-.87-.15-1.71-.49-2.46-1.03L7.1 18.32zM13 4.07V1L8.45 5.55 13 10V6.09c2.84.48 5 2.94 5 5.91s-2.16 5.43-5 5.91v2.02c3.95-.49 7-3.85 7-7.93s-3.05-7.44-7-7.93z"/></svg>
|
||||
</button>
|
||||
<button onClick={() => setEmojiRotation((r) => (r + 90) % 360)} title="Rotate right" style={{ width: 36, height: 36, borderRadius: 4, background: 'var(--bg-tertiary)', border: 'none', color: 'var(--header-secondary)', cursor: 'pointer', fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ transform: 'scaleX(-1)' }}><path d="M7.11 8.53L5.7 7.11C4.8 8.27 4.24 9.61 4.07 11h2.02c.14-.87.49-1.72 1.02-2.47zM6.09 13H4.07c.17 1.39.72 2.73 1.62 3.89l1.41-1.42c-.52-.75-.87-1.59-1.01-2.47zm1.01 5.32c1.16.9 2.51 1.44 3.9 1.61V17.9c-.87-.15-1.71-.49-2.46-1.03L7.1 18.32zM13 4.07V1L8.45 5.55 13 10V6.09c2.84.48 5 2.94 5 5.91s-2.16 5.43-5 5.91v2.02c3.95-.49 7-3.85 7-7.93s-3.05-7.44-7-7.93z"/></svg>
|
||||
</button>
|
||||
<button onClick={() => setEmojiFlipH((f) => !f)} title="Flip horizontal" style={{ width: 36, height: 36, borderRadius: 4, background: emojiFlipH ? 'rgba(88, 101, 242, 0.3)' : 'var(--bg-tertiary)', border: emojiFlipH ? '1px solid #5865F2' : 'none', color: 'var(--header-secondary)', cursor: 'pointer', fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M15 21h2v-2h-2v2zm4-12h2V7h-2v2zM3 5v14c0 1.1.9 2 2 2h4v-2H5V5h4V3H5c-1.1 0-2 .9-2 2zm16-2v2h2c0-1.1-.9-2-2-2zm-8-2h2v24h-2V1zm8 8h2v2h-2v-2zm0 8c1.1 0 2-.9 2-2h-2v2zm0-4h2v-2h-2v2zm-4 8h2v-2h-2v2zm4-16h2v-2h-2v2z"/></svg>
|
||||
</button>
|
||||
<button onClick={() => setEmojiFlipV((f) => !f)} title="Flip vertical" style={{ width: 36, height: 36, borderRadius: 4, background: emojiFlipV ? 'rgba(88, 101, 242, 0.3)' : 'var(--bg-tertiary)', border: emojiFlipV ? '1px solid #5865F2' : 'none', color: 'var(--header-secondary)', cursor: 'pointer', fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ transform: 'rotate(90deg)' }}><path d="M15 21h2v-2h-2v2zm4-12h2V7h-2v2zM3 5v14c0 1.1.9 2 2 2h4v-2H5V5h4V3H5c-1.1 0-2 .9-2 2zm16-2v2h2c0-1.1-.9-2-2-2zm-8-2h2v24h-2V1zm8 8h2v2h-2v-2zm0 8c1.1 0 2-.9 2-2h-2v2zm0-4h2v-2h-2v2zm-4 8h2v-2h-2v2zm4-16h2v-2h-2v2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="avatar-crop-slider-row" style={{ padding: 0, margin: 0 }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="var(--header-secondary)"><path d="M15 3H9v2h6V3zm-4 18h2v-2h-2v2zm8-15.97l-1.41-1.41-1.76 1.76 1.41 1.41L19 5.03zM4.76 4.38L3.34 5.8 5.1 7.56 6.52 6.14 4.76 4.38zM21 11h-2v2h2v-2zM5 11H3v2h2v-2zm7-4a5 5 0 100 10 5 5 0 000-10z"/></svg>
|
||||
<input type="range" min={1} max={3} step={0.01} value={emojiZoom} onChange={(e) => setEmojiZoom(Number(e.target.value))} className="avatar-crop-slider" />
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="var(--header-secondary)"><path d="M15 3H9v2h6V3zm-4 18h2v-2h-2v2zm8-15.97l-1.41-1.41-1.76 1.76 1.41 1.41L19 5.03zM4.76 4.38L3.34 5.8 5.1 7.56 6.52 6.14 4.76 4.38zM21 11h-2v2h2v-2zM5 11H3v2h2v-2zm7-4a5 5 0 100 10 5 5 0 000-10z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span style={{ color: 'var(--header-secondary)', fontSize: 12, fontWeight: 700, textTransform: 'uppercase', display: 'block', marginBottom: 8 }}>Preview</span>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 4, backgroundColor: 'rgba(88, 101, 242, 0.15)', border: '1px solid var(--brand-experiment, #5865F2)', borderRadius: 8, padding: '2px 6px', cursor: 'default' }}>
|
||||
<img src={emojiPreviewUrl} alt="" style={{ width: 16, height: 16, objectFit: 'contain', transform: `${emojiFlipH ? 'scaleX(-1)' : ''} ${emojiFlipV ? 'scaleY(-1)' : ''}`.trim() || undefined }} />
|
||||
<span style={{ color: 'var(--text-normal)', fontSize: 14, marginLeft: 2 }}>1</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ color: 'var(--header-secondary)', fontSize: 12, fontWeight: 700, textTransform: 'uppercase', display: 'block', marginBottom: 8 }}>
|
||||
Emoji name <span style={{ color: '#ed4245' }}>*</span>
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input type="text" value={emojiName} onChange={(e) => { setEmojiName(e.target.value); setEmojiError(''); }} maxLength={32} style={{ width: '100%', padding: '10px 32px 10px 10px', background: 'var(--bg-tertiary)', border: 'none', borderRadius: 4, color: 'var(--header-primary)', fontSize: 14, boxSizing: 'border-box' }} />
|
||||
{emojiName && (
|
||||
<button onClick={() => setEmojiName('')} style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)', background: 'transparent', border: 'none', color: 'var(--header-secondary)', cursor: 'pointer', fontSize: 14, padding: '2px 4px' }}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
{emojiError && <div style={{ color: '#ed4245', fontSize: 13, marginTop: 6 }}>{emojiError}</div>}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button onClick={handleEmojiUpload} disabled={emojiUploading || !emojiName.trim()} style={{ backgroundColor: '#5865F2', color: '#fff', border: 'none', borderRadius: 3, padding: '10px 0', cursor: emojiUploading ? 'not-allowed' : 'pointer', fontWeight: 600, fontSize: 14, width: '100%', opacity: (emojiUploading || !emojiName.trim()) ? 0.5 : 1 }}>
|
||||
{emojiUploading ? 'Uploading...' : 'Finish'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// ─── Main return ───
|
||||
|
||||
if (isMobile) {
|
||||
return ReactDOM.createPortal(
|
||||
<div className="msm-root">
|
||||
{renderMobileContent()}
|
||||
{sharedModals}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'var(--bg-primary)', zIndex: 1000, display: 'flex', color: 'var(--text-normal)' }}>
|
||||
{renderSidebar()}
|
||||
@@ -772,231 +1299,7 @@ const ServerSettingsModal = ({ onClose }) => {
|
||||
</div>
|
||||
<div style={{ flex: '0.5' }} />
|
||||
</div>
|
||||
{showIconCropModal && rawIconUrl && (
|
||||
<AvatarCropModal
|
||||
imageUrl={rawIconUrl}
|
||||
onApply={handleIconCropApply}
|
||||
onCancel={handleIconCropCancel}
|
||||
cropShape="rect"
|
||||
/>
|
||||
)}
|
||||
{showEmojiModal && emojiPreviewUrl && (
|
||||
<div
|
||||
onClick={handleEmojiModalClose}
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)', zIndex: 2000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-secondary)', borderRadius: 8,
|
||||
width: 580, maxWidth: '90vw', overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Modal header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '16px 16px 0',
|
||||
}}>
|
||||
<h2 style={{ color: 'var(--header-primary)', margin: 0, fontSize: 20, fontWeight: 600 }}>Add Emoji</h2>
|
||||
<button
|
||||
onClick={handleEmojiModalClose}
|
||||
style={{
|
||||
background: 'transparent', border: 'none', color: 'var(--header-secondary)',
|
||||
cursor: 'pointer', fontSize: 20, padding: '4px 8px',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal body */}
|
||||
<div style={{ display: 'flex', padding: '20px 16px 16px', gap: 24 }}>
|
||||
{/* Left: Cropper + toolbar + zoom */}
|
||||
<div style={{ width: 240, minWidth: 240, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 240, height: 240, position: 'relative',
|
||||
backgroundColor: 'var(--bg-tertiary)', borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Cropper
|
||||
image={emojiPreviewUrl}
|
||||
crop={emojiCrop}
|
||||
zoom={emojiZoom}
|
||||
rotation={emojiRotation}
|
||||
aspect={1}
|
||||
cropShape="rect"
|
||||
showGrid={false}
|
||||
onCropChange={setEmojiCrop}
|
||||
onZoomChange={setEmojiZoom}
|
||||
onCropComplete={onEmojiCropComplete}
|
||||
style={{
|
||||
containerStyle: { width: 240, height: 240 },
|
||||
mediaStyle: {
|
||||
transform: `${emojiFlipH ? 'scaleX(-1)' : ''} ${emojiFlipV ? 'scaleY(-1)' : ''}`.trim() || undefined,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Toolbar: rotate + flip */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 4 }}>
|
||||
<button
|
||||
onClick={() => setEmojiRotation((r) => (r - 90 + 360) % 360)}
|
||||
title="Rotate left"
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: 4,
|
||||
background: 'var(--bg-tertiary)', border: 'none',
|
||||
color: 'var(--header-secondary)', cursor: 'pointer',
|
||||
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M7.11 8.53L5.7 7.11C4.8 8.27 4.24 9.61 4.07 11h2.02c.14-.87.49-1.72 1.02-2.47zM6.09 13H4.07c.17 1.39.72 2.73 1.62 3.89l1.41-1.42c-.52-.75-.87-1.59-1.01-2.47zm1.01 5.32c1.16.9 2.51 1.44 3.9 1.61V17.9c-.87-.15-1.71-.49-2.46-1.03L7.1 18.32zM13 4.07V1L8.45 5.55 13 10V6.09c2.84.48 5 2.94 5 5.91s-2.16 5.43-5 5.91v2.02c3.95-.49 7-3.85 7-7.93s-3.05-7.44-7-7.93z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEmojiRotation((r) => (r + 90) % 360)}
|
||||
title="Rotate right"
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: 4,
|
||||
background: 'var(--bg-tertiary)', border: 'none',
|
||||
color: 'var(--header-secondary)', cursor: 'pointer',
|
||||
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ transform: 'scaleX(-1)' }}><path d="M7.11 8.53L5.7 7.11C4.8 8.27 4.24 9.61 4.07 11h2.02c.14-.87.49-1.72 1.02-2.47zM6.09 13H4.07c.17 1.39.72 2.73 1.62 3.89l1.41-1.42c-.52-.75-.87-1.59-1.01-2.47zm1.01 5.32c1.16.9 2.51 1.44 3.9 1.61V17.9c-.87-.15-1.71-.49-2.46-1.03L7.1 18.32zM13 4.07V1L8.45 5.55 13 10V6.09c2.84.48 5 2.94 5 5.91s-2.16 5.43-5 5.91v2.02c3.95-.49 7-3.85 7-7.93s-3.05-7.44-7-7.93z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEmojiFlipH((f) => !f)}
|
||||
title="Flip horizontal"
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: 4,
|
||||
background: emojiFlipH ? 'rgba(88, 101, 242, 0.3)' : 'var(--bg-tertiary)',
|
||||
border: emojiFlipH ? '1px solid #5865F2' : 'none',
|
||||
color: 'var(--header-secondary)', cursor: 'pointer',
|
||||
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M15 21h2v-2h-2v2zm4-12h2V7h-2v2zM3 5v14c0 1.1.9 2 2 2h4v-2H5V5h4V3H5c-1.1 0-2 .9-2 2zm16-2v2h2c0-1.1-.9-2-2-2zm-8-2h2v24h-2V1zm8 8h2v2h-2v-2zm0 8c1.1 0 2-.9 2-2h-2v2zm0-4h2v-2h-2v2zm-4 8h2v-2h-2v2zm4-16h2v-2h-2v2z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEmojiFlipV((f) => !f)}
|
||||
title="Flip vertical"
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: 4,
|
||||
background: emojiFlipV ? 'rgba(88, 101, 242, 0.3)' : 'var(--bg-tertiary)',
|
||||
border: emojiFlipV ? '1px solid #5865F2' : 'none',
|
||||
color: 'var(--header-secondary)', cursor: 'pointer',
|
||||
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ transform: 'rotate(90deg)' }}><path d="M15 21h2v-2h-2v2zm4-12h2V7h-2v2zM3 5v14c0 1.1.9 2 2 2h4v-2H5V5h4V3H5c-1.1 0-2 .9-2 2zm16-2v2h2c0-1.1-.9-2-2-2zm-8-2h2v24h-2V1zm8 8h2v2h-2v-2zm0 8c1.1 0 2-.9 2-2h-2v2zm0-4h2v-2h-2v2zm-4 8h2v-2h-2v2zm4-16h2v-2h-2v2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Zoom slider */}
|
||||
<div className="avatar-crop-slider-row" style={{ padding: 0, margin: 0 }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="var(--header-secondary)">
|
||||
<path d="M15 3H9v2h6V3zm-4 18h2v-2h-2v2zm8-15.97l-1.41-1.41-1.76 1.76 1.41 1.41L19 5.03zM4.76 4.38L3.34 5.8 5.1 7.56 6.52 6.14 4.76 4.38zM21 11h-2v2h2v-2zM5 11H3v2h2v-2zm7-4a5 5 0 100 10 5 5 0 000-10z"/>
|
||||
</svg>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.01}
|
||||
value={emojiZoom}
|
||||
onChange={(e) => setEmojiZoom(Number(e.target.value))}
|
||||
className="avatar-crop-slider"
|
||||
/>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="var(--header-secondary)">
|
||||
<path d="M15 3H9v2h6V3zm-4 18h2v-2h-2v2zm8-15.97l-1.41-1.41-1.76 1.76 1.41 1.41L19 5.03zM4.76 4.38L3.34 5.8 5.1 7.56 6.52 6.14 4.76 4.38zM21 11h-2v2h2v-2zM5 11H3v2h2v-2zm7-4a5 5 0 100 10 5 5 0 000-10z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Reaction preview + Name + Finish */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Reaction pill preview */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span style={{ color: 'var(--header-secondary)', fontSize: 12, fontWeight: 700, textTransform: 'uppercase', display: 'block', marginBottom: 8 }}>
|
||||
Preview
|
||||
</span>
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
backgroundColor: 'rgba(88, 101, 242, 0.15)',
|
||||
border: '1px solid var(--brand-experiment, #5865F2)',
|
||||
borderRadius: 8, padding: '2px 6px', cursor: 'default',
|
||||
}}>
|
||||
<img
|
||||
src={emojiPreviewUrl}
|
||||
alt=""
|
||||
style={{
|
||||
width: 16, height: 16, objectFit: 'contain',
|
||||
transform: `${emojiFlipH ? 'scaleX(-1)' : ''} ${emojiFlipV ? 'scaleY(-1)' : ''}`.trim() || undefined,
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: 'var(--text-normal)', fontSize: 14, marginLeft: 2 }}>1</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Emoji name input */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ color: 'var(--header-secondary)', fontSize: 12, fontWeight: 700, textTransform: 'uppercase', display: 'block', marginBottom: 8 }}>
|
||||
Emoji name <span style={{ color: '#ed4245' }}>*</span>
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={emojiName}
|
||||
onChange={(e) => { setEmojiName(e.target.value); setEmojiError(''); }}
|
||||
maxLength={32}
|
||||
style={{
|
||||
width: '100%', padding: '10px 32px 10px 10px',
|
||||
background: 'var(--bg-tertiary)', border: 'none',
|
||||
borderRadius: 4, color: 'var(--header-primary)', fontSize: 14,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
{emojiName && (
|
||||
<button
|
||||
onClick={() => setEmojiName('')}
|
||||
style={{
|
||||
position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)',
|
||||
background: 'transparent', border: 'none', color: 'var(--header-secondary)',
|
||||
cursor: 'pointer', fontSize: 14, padding: '2px 4px',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{emojiError && (
|
||||
<div style={{ color: '#ed4245', fontSize: 13, marginTop: 6 }}>{emojiError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* Finish button */}
|
||||
<button
|
||||
onClick={handleEmojiUpload}
|
||||
disabled={emojiUploading || !emojiName.trim()}
|
||||
style={{
|
||||
backgroundColor: '#5865F2', color: '#fff', border: 'none',
|
||||
borderRadius: 3, padding: '10px 0', cursor: emojiUploading ? 'not-allowed' : 'pointer',
|
||||
fontWeight: 600, fontSize: 14, width: '100%',
|
||||
opacity: (emojiUploading || !emojiName.trim()) ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{emojiUploading ? 'Uploading...' : 'Finish'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sharedModals}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,11 @@ import { useVoice } from '../contexts/VoiceContext';
|
||||
import ChannelSettingsModal from './ChannelSettingsModal';
|
||||
import ServerSettingsModal from './ServerSettingsModal';
|
||||
import ScreenShareModal from './ScreenShareModal';
|
||||
import MobileServerDrawer from './MobileServerDrawer';
|
||||
import MobileCreateChannelScreen from './MobileCreateChannelScreen';
|
||||
import MobileCreateCategoryScreen from './MobileCreateCategoryScreen';
|
||||
import MobileChannelDrawer from './MobileChannelDrawer';
|
||||
import MobileChannelSettingsScreen from './MobileChannelSettingsScreen';
|
||||
import DMList from './DMList';
|
||||
import Avatar from './Avatar';
|
||||
import UserSettings from './UserSettings';
|
||||
@@ -808,7 +813,7 @@ const DraggableVoiceUser = ({ userId, channelId, disabled, children }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels, userId, serverName = 'Secure Chat', serverIconUrl, isMobile, onStartCallWithUser }) => {
|
||||
const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels, userId, serverName = 'Secure Chat', serverIconUrl, isMobile, onStartCallWithUser, onOpenMobileSearch }) => {
|
||||
const { crypto, settings } = usePlatform();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false);
|
||||
@@ -835,6 +840,11 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
const [activeDragItem, setActiveDragItem] = useState(null);
|
||||
const [dragOverChannelId, setDragOverChannelId] = useState(null);
|
||||
const [voiceNicknameModal, setVoiceNicknameModal] = useState(null);
|
||||
const [showMobileServerDrawer, setShowMobileServerDrawer] = useState(false);
|
||||
const [showMobileCreateChannel, setShowMobileCreateChannel] = useState(false);
|
||||
const [showMobileCreateCategory, setShowMobileCreateCategory] = useState(false);
|
||||
const [mobileChannelDrawer, setMobileChannelDrawer] = useState(null);
|
||||
const [showMobileChannelSettings, setShowMobileChannelSettings] = useState(null);
|
||||
|
||||
const convex = useConvex();
|
||||
|
||||
@@ -844,6 +854,9 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
userId ? { userId } : "skip"
|
||||
) || {};
|
||||
|
||||
// Member count for mobile server drawer
|
||||
const allUsersForDrawer = useQuery(api.auth.getPublicKeys) || [];
|
||||
|
||||
// DnD sensors
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: isMobile ? Infinity : 5 } })
|
||||
@@ -1099,6 +1112,52 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
}
|
||||
};
|
||||
|
||||
// Long-press handler factory for mobile channel items
|
||||
const createLongPressHandlers = (callback) => {
|
||||
let timer = null;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let triggered = false;
|
||||
return {
|
||||
onTouchStart: (e) => {
|
||||
triggered = false;
|
||||
startX = e.touches[0].clientX;
|
||||
startY = e.touches[0].clientY;
|
||||
timer = setTimeout(() => {
|
||||
triggered = true;
|
||||
if (navigator.vibrate) navigator.vibrate(50);
|
||||
callback();
|
||||
}, 500);
|
||||
},
|
||||
onTouchMove: (e) => {
|
||||
if (!timer) return;
|
||||
const dx = e.touches[0].clientX - startX;
|
||||
const dy = e.touches[0].clientY - startY;
|
||||
if (Math.abs(dx) > 10 || Math.abs(dy) > 10) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
},
|
||||
onTouchEnd: (e) => {
|
||||
if (timer) { clearTimeout(timer); timer = null; }
|
||||
if (triggered) { e.preventDefault(); triggered = false; }
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const handleMarkAsRead = async (channelId) => {
|
||||
if (!userId) return;
|
||||
try {
|
||||
await convex.mutation(api.readState.markRead, {
|
||||
userId,
|
||||
channelId,
|
||||
lastReadTimestamp: Date.now(),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to mark as read:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const renderDMView = () => (
|
||||
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', backgroundColor: 'var(--bg-tertiary)', borderLeft: '1px solid var(--app-frame-border)', borderTop: '1px solid var(--app-frame-border)', borderRadius: '8px 0 0 0' }}>
|
||||
<DMList
|
||||
@@ -1393,12 +1452,34 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
|
||||
const renderServerView = () => (
|
||||
<div className="channel-panel" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', backgroundColor: 'var(--bg-tertiary)', borderLeft: '1px solid var(--app-frame-border)', borderTop: '1px solid var(--app-frame-border)', borderRadius: '8px 0 0 0' }}>
|
||||
<div className="server-header" style={{ borderBottom: '1px solid var(--app-frame-border)' }}>
|
||||
<span className="server-header-name" onClick={() => setIsServerSettingsOpen(true)}>{serverName}</span>
|
||||
<div className="server-header" style={{ borderBottom: isMobile ? 'none' : '1px solid var(--app-frame-border)' }}>
|
||||
<span className="server-header-name" onClick={() => isMobile ? setShowMobileServerDrawer(true) : setIsServerSettingsOpen(true)}>
|
||||
{serverName}
|
||||
{isMobile && (
|
||||
<svg className="mobile-server-chevron" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9.29 6.71a1 1 0 0 0 0 1.41L13.17 12l-3.88 3.88a1 1 0 1 0 1.42 1.41l4.59-4.59a1 1 0 0 0 0-1.41L10.71 6.7a1 1 0 0 0-1.42 0Z"/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
{!isMobile && (
|
||||
<button className="server-header-invite" onClick={handleCreateInvite} title="Invite People">
|
||||
<img src={inviteUserIcon} alt="Invite" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isMobile && (
|
||||
<div className="mobile-search-invite-row">
|
||||
<button className="mobile-search-bar-btn" onClick={onOpenMobileSearch}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
|
||||
</svg>
|
||||
Search
|
||||
</button>
|
||||
<button className="mobile-search-invite-btn" onClick={handleCreateInvite} title="Invite People">
|
||||
<img src={inviteUserIcon} alt="Invite" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', backgroundColor: 'var(--bg-tertiary)' }} onContextMenu={isMobile ? undefined : (e) => {
|
||||
if (!e.target.closest('.channel-item') && !e.target.closest('.channel-category-header')) {
|
||||
@@ -1497,6 +1578,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''} ${dragOverChannelId === channel._id ? 'voice-drop-target' : ''}`}
|
||||
onClick={() => handleChannelClick(channel)}
|
||||
{...channelDragListeners}
|
||||
{...(isMobile ? createLongPressHandlers(() => setMobileChannelDrawer(channel)) : {})}
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
@@ -1523,6 +1605,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!isMobile && (
|
||||
<button
|
||||
className="channel-settings-icon"
|
||||
onClick={(e) => {
|
||||
@@ -1539,6 +1622,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
>
|
||||
<ColoredIcon src={settingsIcon} color="var(--interactive-normal)" size="14px" />
|
||||
</button>
|
||||
)}
|
||||
</div>}
|
||||
{isCollapsed
|
||||
? renderCollapsedVoiceUsers(channel)
|
||||
@@ -1712,7 +1796,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
|
||||
<UserControlPanel username={username} userId={userId} />
|
||||
|
||||
{editingChannel && (
|
||||
{editingChannel && !isMobile && (
|
||||
<ChannelSettingsModal
|
||||
channel={editingChannel}
|
||||
onClose={() => setEditingChannel(null)}
|
||||
@@ -1723,6 +1807,18 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
{isServerSettingsOpen && (
|
||||
<ServerSettingsModal onClose={() => setIsServerSettingsOpen(false)} />
|
||||
)}
|
||||
{showMobileServerDrawer && (
|
||||
<MobileServerDrawer
|
||||
serverName={serverName}
|
||||
serverIconUrl={serverIconUrl}
|
||||
memberCount={allUsersForDrawer.length}
|
||||
onInvite={handleCreateInvite}
|
||||
onSettings={() => setIsServerSettingsOpen(true)}
|
||||
onCreateChannel={() => { setCreateChannelCategoryId(null); setShowMobileCreateChannel(true); }}
|
||||
onCreateCategory={() => setShowMobileCreateCategory(true)}
|
||||
onClose={() => setShowMobileServerDrawer(false)}
|
||||
/>
|
||||
)}
|
||||
{isScreenShareModalOpen && (
|
||||
<ScreenShareModal
|
||||
onClose={() => setIsScreenShareModalOpen(false)}
|
||||
@@ -1828,6 +1924,54 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showMobileCreateChannel && (
|
||||
<MobileCreateChannelScreen
|
||||
categoryId={createChannelCategoryId}
|
||||
onClose={() => setShowMobileCreateChannel(false)}
|
||||
onSubmit={async (name, type, catId) => {
|
||||
const userId = localStorage.getItem('userId');
|
||||
if (!userId) { alert("Please login first."); return; }
|
||||
try {
|
||||
const createArgs = { name, type };
|
||||
if (catId) createArgs.categoryId = catId;
|
||||
const { id: channelId } = await convex.mutation(api.channels.create, createArgs);
|
||||
const keyHex = randomHex(32);
|
||||
try { await encryptKeyForUsers(convex, channelId, keyHex, crypto); }
|
||||
catch (keyErr) { console.error("Critical: Failed to distribute keys", keyErr); alert("Channel created but key distribution failed."); }
|
||||
} catch (err) { console.error(err); alert("Failed to create channel: " + err.message); }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showMobileCreateCategory && (
|
||||
<MobileCreateCategoryScreen
|
||||
onClose={() => setShowMobileCreateCategory(false)}
|
||||
onSubmit={async (name) => {
|
||||
try {
|
||||
await convex.mutation(api.categories.create, { name });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert("Failed to create category: " + err.message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{mobileChannelDrawer && (
|
||||
<MobileChannelDrawer
|
||||
channel={mobileChannelDrawer}
|
||||
isUnread={unreadChannels.has(mobileChannelDrawer._id)}
|
||||
onMarkAsRead={() => handleMarkAsRead(mobileChannelDrawer._id)}
|
||||
onEditChannel={() => setShowMobileChannelSettings(mobileChannelDrawer)}
|
||||
onClose={() => setMobileChannelDrawer(null)}
|
||||
/>
|
||||
)}
|
||||
{showMobileChannelSettings && (
|
||||
<MobileChannelSettingsScreen
|
||||
channel={showMobileChannelSettings}
|
||||
categories={categories}
|
||||
onClose={() => setShowMobileChannelSettings(null)}
|
||||
onDelete={onDeleteChannel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useQuery, useConvex } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import Avatar from './Avatar';
|
||||
@@ -7,6 +8,7 @@ import { useTheme, THEMES, THEME_LABELS } from '../contexts/ThemeContext';
|
||||
import { useVoice } from '../contexts/VoiceContext';
|
||||
import { useSearch } from '../contexts/SearchContext';
|
||||
import { usePlatform } from '../platform';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
|
||||
const THEME_PREVIEWS = {
|
||||
[THEMES.LIGHT]: { bg: '#ffffff', sidebar: '#f2f3f5', tertiary: '#e3e5e8', text: '#313338' },
|
||||
@@ -26,14 +28,22 @@ const TABS = [
|
||||
|
||||
const UserSettings = ({ onClose, userId, username, onLogout }) => {
|
||||
const [activeTab, setActiveTab] = useState('account');
|
||||
const isMobile = useIsMobile();
|
||||
const [mobileScreen, setMobileScreen] = useState('menu');
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Escape') {
|
||||
if (isMobile && mobileScreen !== 'menu') {
|
||||
setMobileScreen('menu');
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [onClose]);
|
||||
}, [onClose, isMobile, mobileScreen]);
|
||||
|
||||
const renderSidebar = () => {
|
||||
let lastSection = null;
|
||||
@@ -91,6 +101,174 @@ const UserSettings = ({ onClose, userId, username, onLogout }) => {
|
||||
return items;
|
||||
};
|
||||
|
||||
// ─── Mobile render functions ───
|
||||
|
||||
const renderMobileHeader = (title, onBack) => (
|
||||
<div className="msm-header">
|
||||
<button className="msm-back-btn" onClick={onBack}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
||||
</button>
|
||||
<div className="msm-header-title">{title}</div>
|
||||
<div style={{ width: 32 }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderMobileMenu = () => (
|
||||
<div className="msm-screen">
|
||||
<div className="msm-header">
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="msm-back-btn" onClick={onClose}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="msm-content">
|
||||
<div className="msm-section-label">User Settings</div>
|
||||
<div className="msm-card">
|
||||
<div className="msm-card-item" onClick={() => setMobileScreen('account')}>
|
||||
<span className="msm-card-item-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
|
||||
</span>
|
||||
<span style={{ flex: 1, fontSize: 15, color: 'var(--header-primary)' }}>Account</span>
|
||||
<span className="msm-card-item-chevron">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
<div className="msm-card-item" onClick={() => setMobileScreen('security')}>
|
||||
<span className="msm-card-item-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/></svg>
|
||||
</span>
|
||||
<span style={{ flex: 1, fontSize: 15, color: 'var(--header-primary)' }}>Security</span>
|
||||
<span className="msm-card-item-chevron">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="msm-section-label">App Settings</div>
|
||||
<div className="msm-card">
|
||||
<div className="msm-card-item" onClick={() => setMobileScreen('appearance')}>
|
||||
<span className="msm-card-item-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9c.83 0 1.5-.67 1.5-1.5 0-.39-.15-.74-.39-1.01-.23-.26-.38-.61-.38-1 0-.83.67-1.5 1.5-1.5H16c2.76 0 5-2.24 5-5 0-4.42-4.03-8-9-8zm-5.5 9c-.83 0-1.5-.67-1.5-1.5S5.67 9 6.5 9 8 9.67 8 10.5 7.33 12 6.5 12zm3-4C8.67 8 8 7.33 8 6.5S8.67 5 9.5 5s1.5.67 1.5 1.5S10.33 8 9.5 8zm5 0c-.83 0-1.5-.67-1.5-1.5S13.67 5 14.5 5s1.5.67 1.5 1.5S15.33 8 14.5 8zm3 4c-.83 0-1.5-.67-1.5-1.5S16.67 9 17.5 9s1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/></svg>
|
||||
</span>
|
||||
<span style={{ flex: 1, fontSize: 15, color: 'var(--header-primary)' }}>Appearance</span>
|
||||
<span className="msm-card-item-chevron">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
<div className="msm-card-item" onClick={() => setMobileScreen('voice')}>
|
||||
<span className="msm-card-item-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z"/></svg>
|
||||
</span>
|
||||
<span style={{ flex: 1, fontSize: 15, color: 'var(--header-primary)' }}>Voice & Video</span>
|
||||
<span className="msm-card-item-chevron">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
<div className="msm-card-item" onClick={() => setMobileScreen('keybinds')}>
|
||||
<span className="msm-card-item-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M20 5H4c-1.1 0-1.99.9-1.99 2L2 17c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm9 7H8v-2h8v2zm0-4h-2v-2h2v2zm0-3h-2V8h2v2zm3 3h-2v-2h2v2zm0-3h-2V8h2v2z"/></svg>
|
||||
</span>
|
||||
<span style={{ flex: 1, fontSize: 15, color: 'var(--header-primary)' }}>Keybinds</span>
|
||||
<span className="msm-card-item-chevron">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
<div className="msm-card-item" onClick={() => setMobileScreen('search')}>
|
||||
<span className="msm-card-item-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
||||
</span>
|
||||
<span style={{ flex: 1, fontSize: 15, color: 'var(--header-primary)' }}>Search</span>
|
||||
<span className="msm-card-item-chevron">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log Out */}
|
||||
<div className="msm-card" style={{ marginTop: 24 }}>
|
||||
<div className="msm-card-item msm-card-item-danger" onClick={onLogout}>
|
||||
<span className="msm-card-item-icon" style={{ color: '#ed4245' }}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M16 17L21 12L16 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M21 12H9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M9 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
Log Out
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderMobileContent = () => {
|
||||
switch (mobileScreen) {
|
||||
case 'account':
|
||||
return (
|
||||
<div className="msm-screen">
|
||||
{renderMobileHeader('Account', () => setMobileScreen('menu'))}
|
||||
<div className="msm-content">
|
||||
<MyAccountTab userId={userId} username={username} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'security':
|
||||
return (
|
||||
<div className="msm-screen">
|
||||
{renderMobileHeader('Security', () => setMobileScreen('menu'))}
|
||||
<div className="msm-content">
|
||||
<SecurityTab />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'appearance':
|
||||
return (
|
||||
<div className="msm-screen">
|
||||
{renderMobileHeader('Appearance', () => setMobileScreen('menu'))}
|
||||
<div className="msm-content">
|
||||
<AppearanceTab />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'voice':
|
||||
return (
|
||||
<div className="msm-screen">
|
||||
{renderMobileHeader('Voice & Video', () => setMobileScreen('menu'))}
|
||||
<div className="msm-content">
|
||||
<VoiceVideoTab />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'keybinds':
|
||||
return (
|
||||
<div className="msm-screen">
|
||||
{renderMobileHeader('Keybinds', () => setMobileScreen('menu'))}
|
||||
<div className="msm-content">
|
||||
<KeybindsTab />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'search':
|
||||
return (
|
||||
<div className="msm-screen">
|
||||
{renderMobileHeader('Search', () => setMobileScreen('menu'))}
|
||||
<div className="msm-content">
|
||||
<SearchTab userId={userId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return renderMobileMenu();
|
||||
}
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
return ReactDOM.createPortal(
|
||||
<div className="msm-root">{renderMobileContent()}</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import { useVoice } from '../contexts/VoiceContext';
|
||||
import FriendsView from '../components/FriendsView';
|
||||
import MembersList from '../components/MembersList';
|
||||
import MobileMembersScreen from '../components/MobileMembersScreen';
|
||||
import MobileSearchScreen from '../components/MobileSearchScreen';
|
||||
import ChatHeader from '../components/ChatHeader';
|
||||
import SearchPanel from '../components/SearchPanel';
|
||||
import SearchDropdown from '../components/SearchDropdown';
|
||||
@@ -40,6 +41,7 @@ const Chat = () => {
|
||||
const [showMembers, setShowMembers] = useState(true);
|
||||
const [showPinned, setShowPinned] = useState(false);
|
||||
const [showMobileMembersScreen, setShowMobileMembersScreen] = useState(false);
|
||||
const [showMobileSearchScreen, setShowMobileSearchScreen] = useState(false);
|
||||
|
||||
const { activeView: mobileView, goToChat, goToSidebar, trayRef, trayStyle, isSwiping, swipeBindProps } =
|
||||
useSwipeNavigation({
|
||||
@@ -497,7 +499,8 @@ const Chat = () => {
|
||||
setShowSearchResults(false);
|
||||
setSearchQuery('');
|
||||
setJumpToMessageId(messageId);
|
||||
}, [dmChannels]);
|
||||
if (isMobile) goToChat();
|
||||
}, [dmChannels, isMobile, goToChat]);
|
||||
|
||||
// Shared search props for ChatHeader
|
||||
const searchProps = {
|
||||
@@ -617,7 +620,7 @@ const Chat = () => {
|
||||
return (
|
||||
<div className="chat-container">
|
||||
{isMobile && (
|
||||
<div className="chat-header">
|
||||
<div className="chat-header voice-header">
|
||||
<div className="chat-header-left">
|
||||
<button className="mobile-back-btn" onClick={handleMobileBack}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
||||
@@ -736,6 +739,7 @@ const Chat = () => {
|
||||
serverIconUrl={serverIconUrl}
|
||||
isMobile={isMobile}
|
||||
onStartCallWithUser={handleStartCallWithUser}
|
||||
onOpenMobileSearch={() => setShowMobileSearchScreen(true)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -786,6 +790,22 @@ const Chat = () => {
|
||||
onClose={() => setShowMobileMembersScreen(false)}
|
||||
/>
|
||||
)}
|
||||
{showMobileSearchScreen && isMobile && (
|
||||
<MobileSearchScreen
|
||||
channels={channels}
|
||||
allMembers={allMembers}
|
||||
serverName={serverName}
|
||||
onClose={() => setShowMobileSearchScreen(false)}
|
||||
onSelectChannel={(channelId) => {
|
||||
handleSelectChannel(channelId);
|
||||
setShowMobileSearchScreen(false);
|
||||
}}
|
||||
onJumpToMessage={(channelId, messageId) => {
|
||||
handleJumpToMessage(channelId, messageId);
|
||||
setShowMobileSearchScreen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PresenceProvider>
|
||||
);
|
||||
|
||||
@@ -398,16 +398,21 @@
|
||||
/* Chat area/header use var(--bg-primary), not --bg-tertiary, so override explicitly */
|
||||
.theme-dark .is-mobile .chat-container,
|
||||
.theme-dark .is-mobile .chat-area,
|
||||
.theme-dark .is-mobile .chat-header,
|
||||
.theme-dark .is-mobile .chat-input-form,
|
||||
.theme-dark .mobile-members-screen {
|
||||
.theme-dark .mobile-members-screen,
|
||||
.theme-dark .mobile-search-screen {
|
||||
background-color: #1C1D22;
|
||||
}
|
||||
|
||||
.theme-dark .is-mobile .chat-header {
|
||||
background-color: #1C1D22;
|
||||
border-bottom: 1px solid var(--app-frame-border);
|
||||
}
|
||||
|
||||
.theme-dark .is-mobile .chat-header.voice-header {
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.theme-dark .is-mobile .chat-input-wrapper {
|
||||
background-color: #26262E;
|
||||
}
|
||||
|
||||
212
packages/shared/src/utils/searchRendering.jsx
Normal file
212
packages/shared/src/utils/searchRendering.jsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { usePlatform } from '../platform';
|
||||
import { AllEmojis } from '../assets/emojis';
|
||||
|
||||
export function formatTime(ts) {
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const isToday = d.toDateString() === now.toDateString();
|
||||
if (isToday) return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
export function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
export function linkifyHtml(html) {
|
||||
if (!html) return '';
|
||||
return html.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" class="search-result-link">$1</a>');
|
||||
}
|
||||
|
||||
export function formatEmojisHtml(html, customEmojis = []) {
|
||||
if (!html) return '';
|
||||
return html.replace(/:([a-zA-Z0-9_]+):/g, (match, name) => {
|
||||
const custom = customEmojis.find(e => e.name === name);
|
||||
if (custom) return `<img src="${custom.src}" alt="${match}" style="width:48px;height:48px;vertical-align:bottom;margin:0 1px;display:inline" />`;
|
||||
const emoji = AllEmojis.find(e => e.name === name);
|
||||
if (emoji) return `<img src="${emoji.src}" alt="${match}" style="width:48px;height:48px;vertical-align:bottom;margin:0 1px;display:inline" />`;
|
||||
return match;
|
||||
});
|
||||
}
|
||||
|
||||
export function getAvatarColor(name) {
|
||||
const colors = ['#5865F2', '#57F287', '#FEE75C', '#EB459E', '#ED4245', '#F47B67', '#E67E22', '#3498DB'];
|
||||
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];
|
||||
}
|
||||
|
||||
export const CONVEX_PUBLIC_URL = 'https://api.brycord.com';
|
||||
export const rewriteStorageUrl = (url) => {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const pub = new URL(CONVEX_PUBLIC_URL);
|
||||
u.hostname = pub.hostname;
|
||||
u.port = pub.port;
|
||||
u.protocol = pub.protocol;
|
||||
return u.toString();
|
||||
} catch { return url; }
|
||||
};
|
||||
|
||||
export const toHexString = (bytes) =>
|
||||
bytes.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '');
|
||||
|
||||
export const searchImageCache = new Map();
|
||||
|
||||
export const SearchResultImage = ({ metadata }) => {
|
||||
const { crypto } = usePlatform();
|
||||
const fetchUrl = rewriteStorageUrl(metadata.url);
|
||||
const [url, setUrl] = useState(searchImageCache.get(fetchUrl) || null);
|
||||
const [loading, setLoading] = useState(!searchImageCache.has(fetchUrl));
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchImageCache.has(fetchUrl)) {
|
||||
setUrl(searchImageCache.get(fetchUrl));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let isMounted = true;
|
||||
const decrypt = async () => {
|
||||
try {
|
||||
const res = await fetch(fetchUrl);
|
||||
const blob = await res.blob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const hexInput = toHexString(new Uint8Array(arrayBuffer));
|
||||
if (hexInput.length < 32) throw new Error('Invalid file data');
|
||||
const TAG_HEX_LEN = 32;
|
||||
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
|
||||
const tagHex = hexInput.slice(-TAG_HEX_LEN);
|
||||
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
|
||||
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
|
||||
const objectUrl = URL.createObjectURL(decryptedBlob);
|
||||
if (isMounted) {
|
||||
searchImageCache.set(fetchUrl, objectUrl);
|
||||
setUrl(objectUrl);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Search image decrypt error:', err);
|
||||
if (isMounted) { setError('Failed to load'); setLoading(false); }
|
||||
}
|
||||
};
|
||||
decrypt();
|
||||
return () => { isMounted = false; };
|
||||
}, [fetchUrl, metadata, crypto]);
|
||||
|
||||
if (loading) return <div style={{ color: 'var(--header-secondary)', fontSize: '11px', marginTop: 4 }}>Loading image...</div>;
|
||||
if (error) return null;
|
||||
return <img src={url} alt={metadata.filename} style={{ width: '100%', height: 'auto', borderRadius: 4, marginTop: 4, display: 'block' }} />;
|
||||
};
|
||||
|
||||
export const SearchResultVideo = ({ metadata }) => {
|
||||
const { crypto } = usePlatform();
|
||||
const fetchUrl = rewriteStorageUrl(metadata.url);
|
||||
const [url, setUrl] = useState(searchImageCache.get(fetchUrl) || null);
|
||||
const [loading, setLoading] = useState(!searchImageCache.has(fetchUrl));
|
||||
const [error, setError] = useState(null);
|
||||
const [showControls, setShowControls] = useState(false);
|
||||
const videoRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchImageCache.has(fetchUrl)) {
|
||||
setUrl(searchImageCache.get(fetchUrl));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let isMounted = true;
|
||||
const decrypt = async () => {
|
||||
try {
|
||||
const res = await fetch(fetchUrl);
|
||||
const blob = await res.blob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const hexInput = toHexString(new Uint8Array(arrayBuffer));
|
||||
if (hexInput.length < 32) throw new Error('Invalid file data');
|
||||
const TAG_HEX_LEN = 32;
|
||||
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
|
||||
const tagHex = hexInput.slice(-TAG_HEX_LEN);
|
||||
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
|
||||
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
|
||||
const objectUrl = URL.createObjectURL(decryptedBlob);
|
||||
if (isMounted) {
|
||||
searchImageCache.set(fetchUrl, objectUrl);
|
||||
setUrl(objectUrl);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Search video decrypt error:', err);
|
||||
if (isMounted) { setError('Failed to load'); setLoading(false); }
|
||||
}
|
||||
};
|
||||
decrypt();
|
||||
return () => { isMounted = false; };
|
||||
}, [fetchUrl, metadata, crypto]);
|
||||
|
||||
if (loading) return <div style={{ color: 'var(--header-secondary)', fontSize: '11px', marginTop: 4 }}>Loading video...</div>;
|
||||
if (error) return null;
|
||||
|
||||
const handlePlayClick = () => { setShowControls(true); if (videoRef.current) videoRef.current.play(); };
|
||||
return (
|
||||
<div style={{ marginTop: 4, position: 'relative', display: 'inline-block', maxWidth: '100%' }}>
|
||||
<video ref={videoRef} src={url} controls={showControls} style={{ width: '100%', maxHeight: 200, borderRadius: 4, display: 'block', backgroundColor: 'black' }} />
|
||||
{!showControls && <div className="play-icon" onClick={handlePlayClick} style={{ cursor: 'pointer' }}>▶</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SearchResultFile = ({ metadata }) => {
|
||||
const { crypto } = usePlatform();
|
||||
const fetchUrl = rewriteStorageUrl(metadata.url);
|
||||
const [url, setUrl] = useState(searchImageCache.get(fetchUrl) || null);
|
||||
const [loading, setLoading] = useState(!searchImageCache.has(fetchUrl));
|
||||
|
||||
useEffect(() => {
|
||||
if (searchImageCache.has(fetchUrl)) {
|
||||
setUrl(searchImageCache.get(fetchUrl));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let isMounted = true;
|
||||
const decrypt = async () => {
|
||||
try {
|
||||
const res = await fetch(fetchUrl);
|
||||
const blob = await res.blob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const hexInput = toHexString(new Uint8Array(arrayBuffer));
|
||||
if (hexInput.length < 32) return;
|
||||
const TAG_HEX_LEN = 32;
|
||||
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
|
||||
const tagHex = hexInput.slice(-TAG_HEX_LEN);
|
||||
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
|
||||
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
|
||||
const objectUrl = URL.createObjectURL(decryptedBlob);
|
||||
if (isMounted) {
|
||||
searchImageCache.set(fetchUrl, objectUrl);
|
||||
setUrl(objectUrl);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Search file decrypt error:', err);
|
||||
if (isMounted) setLoading(false);
|
||||
}
|
||||
};
|
||||
decrypt();
|
||||
return () => { isMounted = false; };
|
||||
}, [fetchUrl, metadata, crypto]);
|
||||
|
||||
const sizeStr = metadata.size ? `${(metadata.size / 1024).toFixed(1)} KB` : '';
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', backgroundColor: 'var(--embed-background)', padding: '8px 10px', borderRadius: 4, marginTop: 4, maxWidth: '100%' }}>
|
||||
<span style={{ marginRight: 8, fontSize: 20 }}>📄</span>
|
||||
<div style={{ overflow: 'hidden', flex: 1 }}>
|
||||
<div style={{ color: 'var(--text-link)', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontSize: 13 }}>{metadata.filename}</div>
|
||||
{sizeStr && <div style={{ color: 'var(--header-secondary)', fontSize: 11 }}>{sizeStr}</div>}
|
||||
{url && <a href={url} download={metadata.filename} onClick={e => e.stopPropagation()} style={{ color: 'var(--header-secondary)', fontSize: 11, textDecoration: 'underline' }}>Download</a>}
|
||||
{loading && <div style={{ color: 'var(--header-secondary)', fontSize: 11 }}>Decrypting...</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user