chore: squash repository history

This commit is contained in:
skyseek committed 2026-02-26 02:45:58 +08:00
commit f7cc81599e
76 files changed
+9908

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
+38
View File
@@ -0,0 +1,38 @@
# Logs
logs
log/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.golutra/local.json
# Rust
target/
Cargo.lock
src-tauri/vendor/
src-tauri/binaries/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Optional npm cache directory
.npm
# Release packages
发布包/
+8
View File
@@ -0,0 +1,8 @@
{
"printWidth": 100,
"singleQuote": true,
"semi": true,
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "always"
}
+36
View File
@@ -0,0 +1,36 @@
Business Source License 1.1
License text copyright © 2024 MariaDB plc, All Rights Reserved. “Business Source License” is a trademark of MariaDB plc.
Licensor: seekskyworld
Licensed Work: golutra
Additional Use Grant: None
Change Date: 2030-02-25
Change License: GPL-2.0-or-later
Terms
The Licensor hereby grants you the right to copy, modify, create derivative works, redistribute, and make non-production use of the Licensed Work. The Licensor may make an Additional Use Grant, above, permitting limited production use.
Effective on the Change Date, or the fourth anniversary of the first publicly available distribution of a specific version of the Licensed Work under this License, whichever comes first, the Licensor hereby grants you rights under the terms of the Change License, and the rights granted in the paragraph above terminate.
If your use of the Licensed Work does not comply with the requirements currently in effect as described in this License, you must purchase a commercial license from the Licensor, its affiliated entities, or authorized resellers, or you must refrain from using the Licensed Work.
All copies of the original and modified Licensed Work, and derivative works of the Licensed Work, are subject to this License. This License applies separately for each version of the Licensed Work and the Change Date may vary for each version of the Licensed Work released by Licensor.
You must conspicuously display this License on each original or modified copy of the Licensed Work. If you receive the Licensed Work in original or modified form from a third party, the terms and conditions set forth in this License apply to your use of that work.
Any use of the Licensed Work in violation of this License will automatically terminate your rights under this License for the current and all other versions of the Licensed Work.
This License does not grant you any right in any trademark or logo of Licensor or its affiliates (provided that you may use a trademark or logo of Licensor as expressly required by this License). TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE. MariaDB hereby grants you permission to use this License’s text to license your works, and to refer to it using the trademark “Business Source License”, as long as you comply with the Covenants of Licensor below.
Covenants of Licensor
In consideration of the right to use this License’s text and the “Business Source License” name and trademark, Licensor covenants to MariaDB, and to all other recipients of the licensed work to be provided by Licensor:
To specify as the Change License the GPL Version 2.0 or any later version, or a license that is compatible with GPL Version 2.0 or a later version, where “compatible” means that software provided under the Change License can be included in a program with software provided under GPL Version 2.0 or a later version. Licensor may specify additional Change Licenses without limitation.
To either: (a) specify an additional grant of rights to use that does not impose any additional restriction on the right granted in this License, as the Additional Use Grant; or (b) insert the text “None” to specify a Change Date. Not to modify this License in any other way.
Notice
The Business Source License (this document, or the “License”) is not an Open Source license. However, the Licensed Work will eventually be made available under an Open Source License, as stated in this License.
For more information on the use of the Business Source License for MariaDB products, please visit the MariaDB Business Source License FAQ. For more information on the use of the Business Source License generally, please visit the Adopting and Developing Business Source License FAQ.
Projects using BSL
+243
View File
@@ -0,0 +1,243 @@
# golutra
## English
### Tagline
One person. One AI squad.
### What is golutra
`golutra` is a next-generation multi-agent collaboration workspace.
It goes beyond traditional IDE extensions and single-chat AI tools by orchestrating multiple agents to execute complex tasks in parallel.
Built with Vue 3 + Rust as a Tauri desktop application, `golutra` currently supports Windows and macOS.
### Core Value
- Unlimited multi-agent collaboration: automatically break down complex tasks and execute in parallel.
- Silent background execution: agents keep running even when you leave the main view.
- Terminal show/hide on demand: switch between high-level control and terminal details instantly.
- Avatar-based inspection: click each agent avatar to view terminal status, logs, and outputs.
- Automatic orchestration: from analysis and generation to strategy and deployment.
- Personal AI team: move from "one person + one editor" to "one person + an AI squad."
### CLI Compatibility Layer
Keep your existing CLI and instantly upgrade it into a collaboration hub.
No terminal switch, no workflow rewrite, no re-training. Keep using the CLI you already trust while adding multi-agent collaboration, orchestration, and result handoff to your current engineering pipeline.
Supported CLI tools:
- Claude Code
- Gemini CLI
- Codex CLI
- OpenCode
- Qwen Code
What you keep:
- No project migration
- No command relearning
- No single-tool lock-in
With your existing CLI, you can now:
- Run parallel multi-agent execution with automatic result handoff
- Track status and scheduling across CLIs in one orchestration layer
- Reuse session-level context and prompts without repeating requirements
- Aggregate test, build, and regression output into one delivery path
Keep your familiar commands. `golutra` wires them into a complete engineering loop.
### Stealth Terminal
Command line power, visual interface simplicity.
Seamlessly integrate code execution with a background terminal that adapts to your workflow. Experience the raw power of command-line control without leaving the visual interface.
- Direct Injection: inject prompts directly into the terminal stream for instant agent feedback loops.
- Context Awareness: the terminal understands your project context, offering intelligent autocompletion for complex tasks.
### Why Beyond Traditional IDEs
Traditional IDE workflows are usually "single-threaded + manual context switching."
`golutra` is "parallel multi-agent execution + automated orchestration."
### Repository Scope
This repository is for source code storage and releases.
Business Email: [golutra@hotmail.com](mailto:golutra%40hotmail.com)
Official Website: [https://www.golutra.com/](https://www.golutra.com/)
Discord: [https://discord.gg/QyNVu56mpY](https://discord.gg/QyNVu56mpY)
Security Policy: See [SECURITY.md](SECURITY.md)
### Author
This software is independently developed and maintained by [seekskyworld](https://github.com/seekskyworld).
### Open Source Status
This project is now open source, and related code will be organized and opened in phases.
To protect security and privacy, any parts involving server keys, account configuration, or other sensitive information will be sanitized and refactored before being gradually published to the repository.
### Usage License
- Using `golutra` as a tool to build commercial software is allowed.
- Code and deliverables produced by users through `golutra` belong to the users.
- This project follows the [Business Source License 1.1 (BSL 1.1)](https://mariadb.com/bsl11/) open-source license.
### Downloads
- Releases: <https://github.com/golutra/golutra/releases>
### macOS Security Notice
When first installing from `golutra_macos_aarch64.dmg`, macOS may show:
- "App is damaged" or "Developer cannot be verified"
This is a macOS security mechanism and does not necessarily indicate an issue with the app.
How to open:
1. Open Terminal.
2. Run the command below (replace the path with your actual app location).
3. If installed to Applications, for example:
```bash
xattr -rd com.apple.quarantine /Applications/Golutra.app
```
Then open the app again.
Why this happens:
- macOS applies quarantine and signature checks to apps downloaded from the internet.
- Development or non-notarized builds may trigger this warning.
- This command only removes the quarantine flag and does not modify app contents.
If the app still cannot be opened, please contact the publisher for support.
## 中文
### 标语
一个人,拥有一个 AI 军团。
### 什么是 golutra
`golutra` 是新一代多 Agent 协同工作软件。
它不是传统 IDE 的插件增强,也不是单一 AI 对话工具,而是把复杂任务交给多个智能 Agent 并行执行的系统级工作台。
`golutra` 基于 Vue 3 + Rust 开发,采用 Tauri 桌面应用架构,当前支持 Windows 和 macOS。
### 核心价值
- 无限 Agent 协作:自动拆解复杂任务,多个 Agent 并行推进。
- 后台静默运行:离开当前界面时,Agent 仍可持续执行任务。
- 终端随时显隐:主界面与终端状态快速切换,专注与透明兼得。
- 头像即入口:点击 Agent 头像即可查看各自终端状态、日志与结果输出。
- 自动编排流程:从分析、生成、策略到执行部署,形成完整闭环。
- 个人 AI 团队:从“一个人 + 一个编辑器”升级为“一个人 + 一支 AI 军团”。
### CLI 兼容生态
保留你原本的 CLI,也能一键升级成协作中枢。
不换终端、不改习惯、不重学流程。直接沿用你正在使用的 CLI,把多智能体协作、任务编排与结果回传能力接到现有工程链路里。
支持的 CLI 工具:
- Claude Code
- Gemini CLI
- Codex CLI
- OpenCode
- Qwen Code
你将保留:
- 无需迁移项目
- 无需重学命令
- 无需绑定单一工具
只用原本 CLI,也能做到:
- 多智能体并行执行,结果自动回传到同一工作流
- 跨 CLI 的统一调度与状态追踪,减少手动切换成本
- 会话级上下文记忆与指令复用,避免重复解释需求
- 测试、构建、回归信息集中汇总,交付路径更短
你继续使用熟悉的命令,`golutra` 负责把能力串联成完整工程闭环。
### 隐形终端
命令行级能力,可视化界面般易用。
将代码执行与后台终端无缝融合,让工作流实时响应。无需离开可视界面,也能获得命令行的原生掌控力。
- 直连注入:将提示词直接注入终端流,构建即时智能体反馈闭环。
- 上下文感知:终端理解项目上下文,可为复杂任务提供更智能的自动补全。
### 为什么超越传统 IDE
传统 IDE 工作流通常是“单线程 + 人工切换上下文”。
`golutra` 是“多 Agent 并行 + 自动化编排协作”。
### 仓库说明
这个仓库用于源代码存放和版本发布。
商务邮箱: [golutra&#64;hotmail.com](mailto:golutra%40hotmail.com)
官网: [https://www.golutra.com/](https://www.golutra.com/)
问题反馈企鹅群: 252090140
安全策略: 详见 [SECURITY.md](SECURITY.md)
### 作者
本软件由 [seekskyworld](https://github.com/seekskyworld) 独立开发与维护。
### 开源状态
项目现已开源,并将持续分阶段整理与开放相关代码。
为保障安全与隐私,涉及服务器密钥、账号配置及其他敏感信息的部分会先完成脱敏与重构,再逐步同步到仓库。
### 使用许可
- 允许将 `golutra` 作为工具用于商业软件开发。
- 用户通过 `golutra` 产出的代码与交付成果归用户所有。
- 项目遵守 [Business Source License 1.1 (BSL 1.1)](https://mariadb.com/bsl11/) 开源协议。
### 下载
- Releases: <https://github.com/golutra/golutra/releases>
### macOS 安全提示说明
首次通过 `golutra_macos_aarch64.dmg` 安装应用,打开时,macOS 可能会提示:
- “App 已损坏”或“无法验证开发者”
这是 macOS 的安全机制提示,并不一定代表应用本身存在问题。
打开方式:
1. 打开终端。
2. 输入以下命令(将路径替换为你的 App 实际位置)。
3. 例如安装后的位置:
```bash
xattr -rd com.apple.quarantine /Applications/Golutra.app
```
然后再次打开应用即可。
为什么会出现这个提示:
- macOS 会对来自互联网的应用进行隔离与签名校验。
- 开发版或未公证版本可能触发该提示。
- 该命令仅移除系统隔离标记,不会修改应用内容。
如果仍无法打开应用,请联系发布者获取帮助。
+16
View File
@@ -0,0 +1,16 @@
# Security Policy
If you discover a security vulnerability, please report it privately and do not open a public issue.
## Reporting
- Email: golutra@hotmail.com
- Subject: [Security] Vulnerability Report
## What to include
- A clear description of the issue
- Steps to reproduce (PoC if possible)
- Impact assessment
- Affected version(s)
- Your contact info for follow-up
We will acknowledge receipt and provide a remediation plan once verified.
+70
View File
@@ -0,0 +1,70 @@
import js from '@eslint/js';
import vue from 'eslint-plugin-vue';
import tsParser from '@typescript-eslint/parser';
import tsPlugin from '@typescript-eslint/eslint-plugin';
import vueParser from 'vue-eslint-parser';
import prettier from 'eslint-config-prettier';
import globals from 'globals';
export default [
{
ignores: ['node_modules', 'dist', 'coverage']
},
js.configs.recommended,
...vue.configs['flat/recommended'],
{
files: ['**/*.ts'],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module'
},
globals: {
...globals.browser
}
},
plugins: {
'@typescript-eslint': tsPlugin
},
rules: {
...tsPlugin.configs.recommended.rules,
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/no-explicit-any': 'error'
}
},
{
files: ['**/*.vue'],
languageOptions: {
parser: vueParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
parser: tsParser
},
globals: {
...globals.browser
}
},
plugins: {
'@typescript-eslint': tsPlugin
},
rules: {
...tsPlugin.configs.recommended.rules,
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/no-explicit-any': 'error',
'vue/component-name-in-template-casing': ['error', 'PascalCase', { registeredComponentsOnly: false }],
'vue/multi-word-component-names': ['error', { ignores: ['App', 'Settings'] }],
'vue/attributes-order': 'off'
}
},
{
files: ['vite.config.ts', 'eslint.config.js'],
languageOptions: {
globals: {
...globals.node
}
}
},
prettier
];
+109
View File
@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="en" class="dark" data-theme="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nexus Dashboard</title>
<script>
(() => {
const storageKey = 'nexus-theme';
const stored = window.localStorage.getItem(storageKey);
const theme = stored === 'light' || stored === 'dark' || stored === 'system' ? stored : 'dark';
const root = document.documentElement;
root.dataset.theme = theme;
const resolvedTheme = theme === 'system'
? (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme;
root.dataset.resolvedTheme = resolvedTheme;
root.classList.toggle('dark', resolvedTheme === 'dark');
})();
</script>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" rel="stylesheet">
<!-- Material Symbols -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['"Be Vietnam Pro"', 'sans-serif'],
},
colors: {
background: 'rgb(var(--color-background) / <alpha-value>)',
surface: 'rgb(var(--color-surface) / <alpha-value>)',
panel: 'rgb(var(--color-panel) / <alpha-value>)',
'panel-strong': 'rgb(var(--color-panel-strong) / <alpha-value>)',
'panel-soft': 'rgb(var(--color-panel-soft) / <alpha-value>)',
primary: 'rgb(var(--color-primary) / <alpha-value>)',
'primary-hover': 'rgb(var(--color-primary-hover) / <alpha-value>)',
secondary: 'rgb(var(--color-secondary) / <alpha-value>)',
text: 'rgb(var(--color-text) / <alpha-value>)',
muted: 'rgb(var(--color-text-muted) / <alpha-value>)',
border: 'rgb(var(--color-border) / <alpha-value>)',
},
boxShadow: {
'glow': '0 0 20px -5px rgb(var(--color-primary) / 0.45)',
'glass': '0 8px 32px 0 rgb(0 0 0 / 0.32)',
}
}
}
}
</script>
<style>
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgb(var(--scrollbar-thumb));
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgb(var(--scrollbar-thumb-hover));
}
.glass-panel {
background: rgb(var(--color-panel) / 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgb(var(--color-border) / 0.08);
}
.glass-modal {
background: rgb(var(--color-panel-strong) / 0.95);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgb(var(--color-border) / 0.1);
box-shadow: var(--shadow-modal);
}
.bg-glass-header {
background: rgb(var(--color-panel) / 0.6);
}
.bg-glass-sidebar {
background: rgb(var(--color-panel) / 0.7);
}
.text-shadow {
text-shadow: 0 1px 2px rgba(0,0,0,0.5);
}
</style>
</head>
<body class="bg-background text-white h-screen overflow-hidden selection:bg-primary/30 selection:text-white">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
{
"name": "Nexus Dashboard Suite",
"description": "A high-fidelity suite of dashboard screens featuring a Skill Store, Plugin Marketplace, Settings, Workspace Selection, and advanced Chat Modals with glassmorphism aesthetics.",
"requestFramePermissions": []
}
+37
View File
@@ -0,0 +1,37 @@
{
"name": "nexus-dashboard-suite",
"private": true,
"version": "0.0.0",
"packageManager": "pnpm@10.28.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"lint": "eslint \"src/**/*.{ts,vue}\" \"vite.config.ts\"",
"lint:fix": "eslint \"src/**/*.{ts,vue}\" \"vite.config.ts\" --fix",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"vue": "^3.5.12",
"vue-i18n": "^11.2.8"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^8.53.0",
"@typescript-eslint/parser": "^8.53.0",
"@eslint/js": "^9.39.2",
"@types/node": "^22.14.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-vue": "^10.7.0",
"globals": "^17.0.0",
"prettier": "^3.3.3",
"vue-eslint-parser": "^10.2.0",
"@vitejs/plugin-vue": "^5.2.4",
"typescript": "~5.8.2",
"vite": "^6.2.0",
"vitest": "^2.1.9"
}
}
+2430
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "app"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.5.3", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.9.5", features = [] }
tauri-plugin-log = "2"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"permissions": [
"core:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

+16
View File
@@ -0,0 +1,16 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
}
+37
View File
@@ -0,0 +1,37 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "nexus-dashboard-suite",
"version": "0.1.0",
"identifier": "com.tauri.dev",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:3000",
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build"
},
"app": {
"windows": [
{
"title": "Nexus Dashboard Suite",
"width": 800,
"height": 600,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+31
View File
@@ -0,0 +1,31 @@
<template>
<div v-if="activeTab === 'workspaces'" class="flex h-screen w-full bg-background app-shell relative overflow-hidden">
<WorkspaceSelection @select-workspace="activeTab = 'chat'" />
</div>
<div
v-else
class="flex h-screen w-full bg-background app-shell font-sans relative overflow-hidden"
>
<SidebarNav :active-tab="activeTab" @change="activeTab = $event" />
<main class="flex-1 h-full overflow-hidden relative flex flex-col pb-16 md:pb-0">
<SkillStore v-if="activeTab === 'store'" />
<PluginMarketplace v-else-if="activeTab === 'plugins'" />
<Settings v-else-if="activeTab === 'settings'" @logout="activeTab = 'workspaces'" />
<ChatInterface v-else />
</main>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import SidebarNav from '@/shared/components/SidebarNav.vue';
import SkillStore from '@/features/SkillStore.vue';
import PluginMarketplace from '@/features/PluginMarketplace.vue';
import Settings from '@/features/Settings.vue';
import WorkspaceSelection from '@/features/WorkspaceSelection.vue';
import ChatInterface from '@/features/chat/ChatInterface.vue';
type TabId = 'workspaces' | 'chat' | 'store' | 'plugins' | 'settings';
const activeTab = ref<TabId>('workspaces');
</script>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
export default component;
}
+150
View File
@@ -0,0 +1,150 @@
<template>
<div class="flex flex-col h-full w-full">
<header class="px-10 py-10 flex flex-col items-center justify-center shrink-0 z-10">
<h1 class="text-3xl font-bold text-white mb-8 tracking-tight drop-shadow-lg">{{ t('marketplace.title') }}</h1>
<div class="relative w-full max-w-xl group">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<span class="material-symbols-outlined text-white/40 group-focus-within:text-primary transition-colors">search</span>
</div>
<input
type="text"
class="w-full bg-white/5 border border-white/10 rounded-2xl py-3.5 pl-12 pr-16 text-white placeholder-white/30 focus:bg-white/10 focus:border-primary/50 focus:ring-0 transition-all shadow-glass backdrop-blur-md text-[15px]"
:placeholder="t('marketplace.searchPlaceholder')"
/>
<div class="absolute inset-y-0 right-0 pr-3 flex items-center">
<button class="bg-white/10 hover:bg-white/20 text-[11px] px-2 py-1 rounded-md text-white/60 font-medium transition-colors border border-white/5">CMD+K</button>
</div>
</div>
<div class="flex items-center p-1 bg-panel/60 border border-white/10 rounded-full w-full max-w-[320px] mt-8 backdrop-blur-xl relative z-10 shadow-lg">
<span
class="absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-full bg-gradient-to-br from-primary/25 via-white/[0.08] to-white/[0.03] border border-primary/20 shadow-[0_8px_20px_rgba(0,0,0,0.25)] backdrop-blur-md transition-transform duration-300 ease-out pointer-events-none"
:class="activeTab === 'installed' ? 'translate-x-full' : 'translate-x-0'"
></span>
<button
type="button"
@click="activeTab = 'store'"
:class="[
'flex-1 py-1.5 rounded-full font-bold text-[13px] transition-all flex items-center justify-center tracking-wide relative z-10',
activeTab === 'store'
? 'text-white text-shadow'
: 'text-white/60 hover:text-white'
]"
>
{{ t('marketplace.browseStore') }}
</button>
<button
type="button"
@click="activeTab = 'installed'"
:class="[
'flex-1 py-1.5 rounded-full font-bold text-[13px] transition-all flex items-center justify-center tracking-wide relative z-10',
activeTab === 'installed'
? 'text-white text-shadow'
: 'text-white/60 hover:text-white'
]"
>
{{ t('marketplace.myPlugins') }}
</button>
</div>
<div class="flex items-center gap-2 mt-8 overflow-x-auto max-w-full pb-2 no-scrollbar">
<button class="px-4 py-1.5 rounded-full bg-primary/20 text-primary border border-primary/20 text-xs font-bold whitespace-nowrap">{{ t('marketplace.categories.all') }}</button>
<button
v-for="cat in categories"
:key="cat"
class="px-4 py-1.5 rounded-full bg-white/5 hover:bg-white/10 text-white/70 hover:text-white border border-white/5 text-xs font-bold transition-colors whitespace-nowrap"
>
{{ t(cat) }}
</button>
</div>
</header>
<div class="flex-1 overflow-y-auto px-10 pb-12">
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 max-w-[1600px] mx-auto">
<div
v-for="plugin in visiblePlugins"
:key="plugin.id"
class="glass-panel bg-panel-strong/60 rounded-3xl p-6 border border-white/5 hover:border-primary/30 transition-all duration-300 hover:shadow-glow hover:-translate-y-1 group flex flex-col h-full relative overflow-hidden"
>
<div class="absolute top-0 right-0 p-5 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<span class="material-symbols-outlined text-white/20 hover:text-white cursor-pointer">more_horiz</span>
</div>
<div class="flex items-start justify-between mb-4">
<div :class="['w-14 h-14 rounded-2xl bg-gradient-to-br border border-white/10 flex items-center justify-center shadow-lg text-white', plugin.bg]">
<span class="material-symbols-outlined text-[28px]">{{ plugin.icon }}</span>
</div>
<div class="flex items-center gap-1 bg-white/5 px-2 py-1 rounded-lg border border-white/5 backdrop-blur-sm">
<span class="material-symbols-outlined text-yellow-400 text-[14px] fill-current">star</span>
<span class="text-[11px] font-bold text-white">{{ plugin.rating }}</span>
</div>
</div>
<h3 class="text-white font-bold text-lg mb-1.5">{{ t(plugin.titleKey) }}</h3>
<p class="text-white/50 text-[13px] mb-6 flex-1 leading-relaxed">{{ t(plugin.descKey) }}</p>
<div
v-if="plugin.installed && activeTab === 'installed'"
class="flex items-center gap-2"
>
<div class="flex-1 py-2.5 rounded-xl bg-white/5 border border-white/10 text-white/70 font-bold text-[13px] flex items-center justify-center gap-2">
{{ t('marketplace.installed') }}
</div>
<button
type="button"
class="flex-1 py-2.5 rounded-xl bg-red-500/10 hover:bg-red-500/20 border border-red-500/20 text-red-200 font-bold text-[13px] transition-all active:scale-95 flex items-center justify-center gap-2"
>
<span class="material-symbols-outlined text-[18px]">delete</span>
{{ t('common.remove') }}
</button>
</div>
<button
v-else-if="plugin.installed"
class="w-full py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-white font-bold text-[13px] transition-all active:scale-95 flex items-center justify-center gap-2"
>
{{ t('marketplace.installed') }}
</button>
<button
v-else
class="w-full py-2.5 rounded-xl bg-primary hover:bg-primary-hover text-on-primary font-bold text-[13px] shadow-glow transition-all active:scale-95 flex items-center justify-center gap-2"
>
<span class="material-symbols-outlined text-[18px]">download</span>
{{ t('marketplace.install') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
const plugins = [
{ id: 1, titleKey: 'marketplace.plugins.github.title', descKey: 'marketplace.plugins.github.desc', icon: 'terminal', bg: 'from-gray-800 to-black', rating: '4.9' },
{ id: 2, titleKey: 'marketplace.plugins.spotify.title', descKey: 'marketplace.plugins.spotify.desc', icon: 'music_note', bg: 'from-green-600 to-green-900', rating: '4.7' },
{ id: 3, titleKey: 'marketplace.plugins.taskManager.title', descKey: 'marketplace.plugins.taskManager.desc', icon: 'check_circle', bg: 'from-blue-500 to-indigo-600', rating: '4.8' },
{ id: 4, titleKey: 'marketplace.plugins.calendar.title', descKey: 'marketplace.plugins.calendar.desc', icon: 'calendar_month', bg: 'from-orange-400 to-red-500', rating: '4.5', installed: true },
{ id: 5, titleKey: 'marketplace.plugins.aiAssistant.title', descKey: 'marketplace.plugins.aiAssistant.desc', icon: 'smart_toy', bg: 'from-purple-500 to-pink-500', rating: '4.2' },
{ id: 6, titleKey: 'marketplace.plugins.terminal.title', descKey: 'marketplace.plugins.terminal.desc', icon: 'code', bg: 'from-emerald-400 to-teal-600', rating: '5.0' },
{ id: 7, titleKey: 'marketplace.plugins.figma.title', descKey: 'marketplace.plugins.figma.desc', icon: 'design_services', bg: 'from-[#F24E1E] to-[#A259FF]', rating: '4.6' },
{ id: 8, titleKey: 'marketplace.plugins.quickNotes.title', descKey: 'marketplace.plugins.quickNotes.desc', icon: 'sticky_note_2', bg: 'from-yellow-400 to-orange-500', rating: '4.3' }
];
const activeTab = ref<'store' | 'installed'>('store');
const visiblePlugins = computed(() =>
activeTab.value === 'installed' ? plugins.filter((plugin) => plugin.installed) : plugins
);
const categories = [
'marketplace.categories.productivity',
'marketplace.categories.development',
'marketplace.categories.design',
'marketplace.categories.communication',
'marketplace.categories.music'
];
const { t } = useI18n();
</script>
+921
View File
@@ -0,0 +1,921 @@
<template>
<div class="flex h-full w-full overflow-hidden">
<aside class="w-16 md:w-[280px] bg-glass-sidebar glass-panel border-r border-white/5 shrink-0 py-4 md:py-8 px-2 md:px-3 flex flex-col">
<div class="px-4 mb-6 hidden md:block">
<h2 class="text-white font-bold text-xl tracking-tight">{{ t('settings.title') }}</h2>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-1 md:px-2 space-y-4 md:space-y-6">
<div>
<h3 class="px-3 mb-2 text-white/40 text-[11px] font-bold uppercase tracking-widest hidden md:block">{{ t('settings.userSettings') }}</h3>
<div class="space-y-0.5">
<button type="button" :title="t('settings.myAccount')" @click="scrollToSection('account')" :class="sectionButtonClass('account')">
<span class="material-symbols-outlined text-[20px]" :class="activeSection === 'account' ? 'text-primary' : ''">person</span>
<span class="text-[14px] font-medium hidden md:inline">{{ t('settings.myAccount') }}</span>
</button>
</div>
</div>
<div>
<h3 class="px-3 mb-2 text-white/40 text-[11px] font-bold uppercase tracking-widest hidden md:block">{{ t('settings.appSettings') }}</h3>
<div class="space-y-0.5">
<button type="button" :title="t('settings.appearance')" @click="scrollToSection('appearance')" :class="sectionButtonClass('appearance')">
<span class="material-symbols-outlined text-[20px]" :class="activeSection === 'appearance' ? 'text-primary' : ''">palette</span>
<span class="text-[14px] font-medium hidden md:inline">{{ t('settings.appearance') }}</span>
</button>
<button type="button" :title="t('settings.language')" @click="scrollToSection('language')" :class="sectionButtonClass('language')">
<span class="material-symbols-outlined text-[20px]" :class="activeSection === 'language' ? 'text-primary' : ''">translate</span>
<span class="text-[14px] font-medium hidden md:inline">{{ t('settings.language') }}</span>
</button>
<button type="button" :title="t('settings.members')" @click="scrollToSection('members')" :class="sectionButtonClass('members')">
<span class="material-symbols-outlined text-[20px]" :class="activeSection === 'members' ? 'text-primary' : ''">groups</span>
<span class="text-[14px] font-medium hidden md:inline">{{ t('settings.members') }}</span>
</button>
<button type="button" :title="t('settings.notifications')" @click="scrollToSection('notifications')" :class="sectionButtonClass('notifications')">
<span class="material-symbols-outlined text-[20px]" :class="activeSection === 'notifications' ? 'text-primary' : ''">notifications</span>
<span class="text-[14px] font-medium hidden md:inline">{{ t('settings.notifications') }}</span>
</button>
<button type="button" :title="t('settings.keybinds')" @click="scrollToSection('keybinds')" :class="sectionButtonClass('keybinds')">
<span class="material-symbols-outlined text-[20px]" :class="activeSection === 'keybinds' ? 'text-primary' : ''">keyboard_command_key</span>
<span class="text-[14px] font-medium hidden md:inline">{{ t('settings.keybinds') }}</span>
</button>
</div>
</div>
<div class="pt-4 mt-4 border-t border-white/5">
<button
type="button"
@click="emit('logout')"
:title="t('settings.leaveTeam')"
class="w-full text-left flex items-center gap-3 px-3 py-2.5 rounded-lg text-red-400/70 hover:text-red-400 hover:bg-red-500/10 transition-all group justify-center md:justify-start"
>
<span class="material-symbols-outlined text-[20px]">logout</span>
<span class="text-[14px] font-medium hidden md:inline">{{ t('settings.leaveTeam') }}</span>
</button>
</div>
</div>
</aside>
<main ref="contentRef" class="flex-1 flex flex-col bg-transparent relative min-w-0 overflow-y-auto custom-scrollbar p-12">
<div class="max-w-3xl w-full mx-auto pb-20">
<header class="mb-10">
<h1 class="text-3xl font-bold text-white mb-2">{{ t('settings.preferences') }}</h1>
<p class="text-white/40 text-sm">{{ t('settings.preferencesSubtitle') }}</p>
</header>
<section ref="accountRef" id="account" class="mb-12 scroll-mt-8">
<h2 class="text-white/90 text-lg font-semibold mb-2 flex items-center gap-2">
<span class="material-symbols-outlined text-primary">person</span>
{{ t('settings.myAccount') }}
</h2>
<p class="text-white/40 text-sm">{{ t('settings.accountSubtitle') }}</p>
<div class="bg-white/[0.03] border border-white/5 rounded-2xl p-6 mt-6">
<div class="flex flex-col md:flex-row gap-6">
<div class="w-16 h-16 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center text-white shadow-lg">
<span class="text-lg font-semibold">{{ accountInitials }}</span>
</div>
<div class="flex-1 grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<label class="text-[11px] font-bold text-white/40 uppercase tracking-wider block">{{ t('settings.displayName') }}</label>
<input
v-model="draftSettings.account.displayName"
:class="inputClass"
:placeholder="t('settings.displayNamePlaceholder')"
type="text"
@blur="trimAccountField('displayName')"
/>
</div>
<div class="space-y-2">
<label class="text-[11px] font-bold text-white/40 uppercase tracking-wider block">{{ t('settings.emailAddress') }}</label>
<input
v-model="draftSettings.account.email"
:class="inputClass"
:placeholder="t('settings.emailPlaceholder')"
type="email"
@blur="trimAccountField('email')"
/>
</div>
<div class="space-y-2">
<label class="text-[11px] font-bold text-white/40 uppercase tracking-wider block">{{ t('settings.jobTitle') }}</label>
<input
v-model="draftSettings.account.title"
:class="inputClass"
:placeholder="t('settings.jobTitlePlaceholder')"
type="text"
@blur="trimAccountField('title')"
/>
</div>
<div class="space-y-2">
<label class="text-[11px] font-bold text-white/40 uppercase tracking-wider block">{{ t('settings.timeZone') }}</label>
<select v-model="draftSettings.account.timezone" :class="selectClass">
<option v-for="zone in timeZones" :key="zone" :value="zone">
{{ zone }}
</option>
</select>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-5">
<div class="space-y-2">
<label class="text-[11px] font-bold text-white/40 uppercase tracking-wider block">{{ t('settings.status') }}</label>
<select v-model="draftSettings.account.status" :class="selectClass">
<option v-for="status in statusOptions" :key="status.id" :value="status.id">
{{ t(status.labelKey) }}
</option>
</select>
</div>
<div class="space-y-2">
<label class="text-[11px] font-bold text-white/40 uppercase tracking-wider block">{{ t('settings.statusMessage') }}</label>
<input
v-model="draftSettings.account.statusMessage"
:class="inputClass"
:placeholder="t('settings.statusMessagePlaceholder')"
type="text"
@blur="trimAccountField('statusMessage')"
/>
</div>
</div>
</div>
<div class="mt-6 flex justify-end">
<button
type="button"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-red-400/80 hover:text-red-400 hover:bg-red-500/10 transition-all"
>
<span class="material-symbols-outlined text-[18px]">logout</span>
<span class="text-[14px] font-semibold">{{ t('settings.logOut') }}</span>
</button>
</div>
</section>
<div class="w-full h-[1px] bg-gradient-to-r from-transparent via-white/10 to-transparent mb-12"></div>
<section ref="appearanceRef" id="appearance" class="mb-12 scroll-mt-8">
<h2 class="text-white/90 text-lg font-semibold mb-2 flex items-center gap-2">
<span class="material-symbols-outlined text-primary">palette</span>
{{ t('settings.appearance') }}
</h2>
<p class="text-white/40 text-sm">{{ t('settings.appearanceSubtitle') }}</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
<button
v-for="option in themeOptions"
:key="option.id"
type="button"
@click="setTheme(option.id)"
:class="[
'text-left rounded-2xl border p-4 transition-all group relative',
option.id === theme
? 'border-primary/40 bg-primary/[0.08] shadow-[0_0_20px_rgb(var(--color-primary)_/_0.18)]'
: 'border-white/5 bg-white/[0.02] hover:bg-white/[0.05] hover:border-white/10'
]"
>
<div class="flex items-center justify-between">
<span class="text-[13px] font-semibold text-white">{{ t(option.labelKey) }}</span>
<span
v-if="option.id === theme"
class="w-6 h-6 rounded-full bg-primary flex items-center justify-center shadow-lg shadow-primary/20"
>
<span class="material-symbols-outlined text-on-primary text-[14px] font-bold">check</span>
</span>
</div>
<div class="flex items-center gap-2 mt-3">
<div class="h-8 w-8 rounded-lg border border-white/10" :style="{ background: themePreview[option.id].base }"></div>
<div class="h-8 flex-1 rounded-lg border border-white/10" :style="{ background: themePreview[option.id].panel }"></div>
<div class="h-8 w-10 rounded-lg border border-white/10" :style="{ background: themePreview[option.id].accent }"></div>
</div>
<p class="text-[12px] text-white/40 mt-3 leading-relaxed">{{ t(option.descriptionKey) }}</p>
</button>
</div>
</section>
<div class="w-full h-[1px] bg-gradient-to-r from-transparent via-white/10 to-transparent mb-12"></div>
<section ref="languageRef" id="language" class="mb-12 scroll-mt-8">
<h2 class="text-white/90 text-lg font-semibold mb-5 flex items-center gap-2">
<span class="material-symbols-outlined text-primary">translate</span>
{{ t('settings.language') }}
</h2>
<div class="bg-white/[0.03] border border-white/5 rounded-2xl overflow-hidden">
<template v-for="(option, index) in localeOptions" :key="option.id">
<button
class="w-full flex items-center justify-between p-4 transition-colors text-left group"
:class="option.id === locale ? 'bg-primary/[0.08] border-l-[3px] border-primary cursor-default' : 'hover:bg-white/[0.05]'"
@click="setLocale(option.id)"
type="button"
>
<div class="flex items-center gap-4">
<span class="text-2xl">{{ option.flag }}</span>
<div class="flex flex-col">
<span class="text-[15px] font-medium transition-colors" :class="option.id === locale ? 'text-white' : 'text-white/70 group-hover:text-white'">
{{ t(option.labelKey) }}
</span>
<span v-if="option.id === locale" class="text-white/40 text-xs">{{ t('settings.languageDefault') }}</span>
</div>
</div>
<div v-if="option.id === locale" class="w-8 h-8 rounded-full bg-primary flex items-center justify-center shadow-lg shadow-primary/20">
<span class="material-symbols-outlined text-on-primary text-sm font-bold">check</span>
</div>
</button>
<div v-if="index < localeOptions.length - 1" class="w-full h-[1px] bg-white/5"></div>
</template>
</div>
<div class="mt-4 flex items-center justify-between px-2">
<span class="text-[13px] text-white/30">{{ t('settings.changesApply') }}</span>
<div class="flex items-center gap-3">
<span class="text-[14px] font-medium text-white/70">{{ t('settings.spellCheck') }}</span>
<div class="relative inline-block w-10 align-middle select-none">
<input
id="spellcheck"
v-model="draftSettings.language.spellCheck"
:class="toggleInputClass"
type="checkbox"
/>
<label class="block overflow-hidden h-6 rounded-full bg-panel-strong/80 peer-checked:bg-primary cursor-pointer transition-colors" for="spellcheck"></label>
</div>
</div>
</div>
</section>
<div class="w-full h-[1px] bg-gradient-to-r from-transparent via-white/10 to-transparent mb-12"></div>
<section ref="membersRef" id="members" class="mb-12 scroll-mt-8">
<h2 class="text-white/90 text-lg font-semibold mb-5 flex items-center gap-2">
<span class="material-symbols-outlined text-primary">groups</span>
{{ t('settings.defaultMember') }}
</h2>
<div class="bg-white/[0.03] border border-white/5 rounded-2xl p-6">
<div class="flex justify-between items-center mb-4">
<p class="text-[13px] font-medium text-white/60 uppercase tracking-wider">{{ t('settings.selectMember') }}</p>
<button type="button" class="text-primary hover:text-primary-hover text-[13px] font-medium transition-colors" @click="resetMemberDraft">
{{ t('settings.refreshList') }}
</button>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6" @click="openMemberMenuId = null">
<div v-for="member in memberOptions" :key="member.id" class="relative group cursor-pointer" @click="selectMemberCard(member.id)">
<div
:class="[
'cursor-pointer block h-full rounded-xl border p-4 flex flex-col items-center gap-2 transition-all',
selectedMember === member.id
? 'bg-primary/[0.08] border-primary/50 shadow-[0_0_20px_rgb(var(--color-primary)_/_0.12)]'
: 'border-white/10 bg-white/[0.02] hover:bg-white/5 hover:border-white/20'
]"
>
<div
:class="[
'w-12 h-12 rounded-xl border border-white/10 flex items-center justify-center text-white shadow-lg',
member.isCustom ? 'bg-white/5 group-hover:bg-white/10' : `bg-gradient-to-tr ${member.gradient}`
]"
>
<span class="material-symbols-outlined text-[28px]">{{ member.icon }}</span>
</div>
<div class="text-center">
<div class="text-white font-semibold text-sm">{{ member.label }}</div>
<div v-if="member.command" class="text-[11px] text-white/40 mt-1 truncate max-w-[120px]">{{ member.command }}</div>
<div class="text-[10px] uppercase tracking-widest text-white/35 mt-1">{{ member.kindLabel }}</div>
</div>
<div v-if="selectedMember === member.id" class="absolute top-2 right-9 animate-in fade-in zoom-in duration-200">
<div class="w-5 h-5 rounded-full bg-primary flex items-center justify-center shadow-md">
<span class="material-symbols-outlined text-on-primary text-[14px] font-bold">check</span>
</div>
</div>
</div>
<div class="absolute top-2 right-2">
<button
type="button"
class="w-6 h-6 rounded-full bg-white/10 text-white/70 hover:text-white hover:bg-white/20 flex items-center justify-center transition-colors"
:aria-label="t('settings.memberActions.menuLabel')"
@click.stop="toggleMemberMenu(member.id)"
>
<span class="material-symbols-outlined text-[16px]">more_vert</span>
</button>
<div
v-if="openMemberMenuId === member.id"
class="absolute right-0 mt-2 w-36 rounded-xl border border-white/10 bg-panel/95 backdrop-blur-xl shadow-xl overflow-hidden z-10"
@click.stop
>
<button
type="button"
class="w-full text-left px-3 py-2 text-[12px] text-white/80 hover:text-white hover:bg-white/10 transition-colors"
@click="handleMemberTest(member.id)"
>
{{ t('settings.memberActions.test') }}
</button>
<button
v-if="member.isDeletable"
type="button"
class="w-full text-left px-3 py-2 text-[12px] text-white/80 hover:text-white hover:bg-white/10 transition-colors"
@click="startEditMember(member.id)"
>
{{ t('settings.memberActions.edit') }}
</button>
<button
v-if="member.isDeletable"
type="button"
class="w-full text-left px-3 py-2 text-[12px] text-red-300/80 hover:text-red-300 hover:bg-red-500/10 transition-colors"
@click="removeCustomMember(member.id)"
>
{{ t('settings.memberActions.remove') }}
</button>
</div>
</div>
</div>
<div class="relative group cursor-pointer" @click="openCustomMemberForm">
<div class="cursor-pointer block h-full rounded-xl border border-dashed border-white/20 p-4 flex flex-col items-center gap-2 transition-all bg-white/[0.02] hover:bg-white/5 hover:border-white/30">
<div class="w-12 h-12 rounded-xl border border-white/10 flex items-center justify-center text-white/60 shadow-lg bg-white/5 group-hover:bg-white/10">
<span class="material-symbols-outlined text-[28px]">add</span>
</div>
<div class="text-center">
<div class="text-white/70 font-semibold text-sm">{{ t('settings.memberOptions.custom') }}</div>
</div>
</div>
</div>
</div>
<div v-if="isAddingCustom" class="animate-in fade-in slide-in-from-top-2 duration-300">
<div class="bg-white/[0.02] border border-white/10 rounded-xl p-5 relative">
<div class="grid grid-cols-1 gap-4">
<div class="space-y-2">
<label class="text-[11px] font-bold text-white/40 uppercase tracking-wider block">{{ t('settings.memberName') }}</label>
<input
v-model="customName"
:class="inputClass"
:placeholder="t('settings.memberNamePlaceholder')"
type="text"
/>
</div>
<div class="space-y-2">
<label class="text-[11px] font-bold text-white/40 uppercase tracking-wider block">{{ t('settings.commandInput') }}</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-white/30 font-mono text-sm">$</span>
<input
v-model="customCommand"
class="block w-full pl-7 pr-4 py-2.5 bg-surface/80 border border-white/10 rounded-lg text-white placeholder-white/20 focus:outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/50 transition-all font-mono text-sm"
:placeholder="t('settings.commandPlaceholder')"
type="text"
/>
</div>
</div>
</div>
<div class="flex justify-end gap-2 mt-4">
<button
type="button"
class="w-8 h-8 rounded-lg bg-red-500/10 hover:bg-red-500/20 text-red-500 flex items-center justify-center transition-colors"
:title="t('settings.cancel')"
@click="resetCustomMemberForm"
>
<span class="material-symbols-outlined text-sm font-bold">close</span>
</button>
<button
type="button"
class="w-8 h-8 rounded-lg bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-500 flex items-center justify-center transition-colors"
:title="t('settings.confirm')"
@click="applyCustomMember"
>
<span class="material-symbols-outlined text-sm font-bold">check</span>
</button>
</div>
</div>
</div>
</div>
</section>
<div class="w-full h-[1px] bg-gradient-to-r from-transparent via-white/10 to-transparent mb-12"></div>
<section ref="notificationsRef" id="notifications" class="mb-12 scroll-mt-8">
<h2 class="text-white/90 text-lg font-semibold mb-2 flex items-center gap-2">
<span class="material-symbols-outlined text-primary">notifications</span>
{{ t('settings.notifications') }}
</h2>
<p class="text-white/40 text-sm">{{ t('settings.notificationsSubtitle') }}</p>
<div class="bg-white/[0.03] border border-white/5 rounded-2xl mt-6 overflow-hidden">
<div v-for="(option, index) in notificationOptions" :key="option.key">
<div class="flex items-center justify-between gap-6 px-6 py-4">
<div>
<div class="text-sm font-semibold text-white">{{ t(option.labelKey) }}</div>
<div class="text-xs text-white/40 mt-1">{{ t(option.descriptionKey) }}</div>
</div>
<div class="relative inline-block w-10 align-middle select-none">
<input
:id="`notification-${option.key}`"
v-model="draftSettings.notifications[option.key]"
:class="toggleInputClass"
type="checkbox"
/>
<label
class="block overflow-hidden h-6 rounded-full bg-panel-strong/80 peer-checked:bg-primary cursor-pointer transition-colors"
:for="`notification-${option.key}`"
></label>
</div>
</div>
<div v-if="index < notificationOptions.length - 1" class="w-full h-[1px] bg-white/5"></div>
</div>
<div v-if="draftSettings.notifications.quietHoursEnabled" class="px-6 pb-5">
<div class="flex flex-col md:flex-row gap-4 mt-1">
<div class="flex-1 space-y-2">
<label class="text-[11px] font-bold text-white/40 uppercase tracking-wider block">{{ t('settings.quietHoursFrom') }}</label>
<input v-model="draftSettings.notifications.quietHoursStart" :class="inputClass" type="time" />
</div>
<div class="flex-1 space-y-2">
<label class="text-[11px] font-bold text-white/40 uppercase tracking-wider block">{{ t('settings.quietHoursTo') }}</label>
<input v-model="draftSettings.notifications.quietHoursEnd" :class="inputClass" type="time" />
</div>
</div>
</div>
</div>
</section>
<div class="w-full h-[1px] bg-gradient-to-r from-transparent via-white/10 to-transparent mb-12"></div>
<section ref="keybindsRef" id="keybinds" class="scroll-mt-8">
<h2 class="text-white/90 text-lg font-semibold mb-2 flex items-center gap-2">
<span class="material-symbols-outlined text-primary">keyboard_command_key</span>
{{ t('settings.keybinds') }}
</h2>
<p class="text-white/40 text-sm">{{ t('settings.keybindsSubtitle') }}</p>
<div class="bg-white/[0.03] border border-white/5 rounded-2xl mt-6 p-6 space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<label class="text-[11px] font-bold text-white/40 uppercase tracking-wider block">{{ t('settings.keybindsProfile') }}</label>
<select v-model="draftSettings.keybinds.profile" :class="selectClass">
<option v-for="profile in keybindProfiles" :key="profile.id" :value="profile.id">
{{ t(profile.labelKey) }}
</option>
</select>
</div>
<div class="flex items-end justify-between gap-4">
<div class="flex items-center gap-3">
<span class="text-[14px] font-medium text-white/70">{{ t('settings.keybindsEnable') }}</span>
<div class="relative inline-block w-10 align-middle select-none">
<input id="keybinds-enabled" v-model="draftSettings.keybinds.enabled" :class="toggleInputClass" type="checkbox" />
<label class="block overflow-hidden h-6 rounded-full bg-panel-strong/80 peer-checked:bg-primary cursor-pointer transition-colors" for="keybinds-enabled"></label>
</div>
</div>
<div class="flex items-center gap-3">
<span class="text-[14px] font-medium text-white/70">{{ t('settings.keybindsHints') }}</span>
<div class="relative inline-block w-10 align-middle select-none">
<input id="keybinds-hints" v-model="draftSettings.keybinds.showHints" :class="toggleInputClass" type="checkbox" />
<label class="block overflow-hidden h-6 rounded-full bg-panel-strong/80 peer-checked:bg-primary cursor-pointer transition-colors" for="keybinds-hints"></label>
</div>
</div>
</div>
</div>
<div>
<div class="flex items-center justify-between mb-3">
<span class="text-[12px] font-bold text-white/40 uppercase tracking-wider">{{ t('settings.keybindsListTitle') }}</span>
<button type="button" class="text-primary hover:text-primary-hover text-[13px] font-medium transition-colors" @click="resetKeybinds">
{{ t('settings.keybindsReset') }}
</button>
</div>
<div class="space-y-2">
<div
v-for="binding in activeKeybindProfile.bindings"
:key="binding.actionKey"
class="flex items-center justify-between px-4 py-2 rounded-lg bg-white/[0.02] border border-white/5"
>
<span class="text-sm text-white/70">{{ t(binding.actionKey) }}</span>
<span class="text-xs font-mono text-white/70 bg-white/5 px-2 py-1 rounded-md border border-white/10">{{ binding.keys }}</span>
</div>
</div>
</div>
</div>
</section>
<div class="mt-8 flex justify-end gap-3 pt-6 border-t border-white/5">
<button
type="button"
@click="handleCancel"
:disabled="!isDirty"
:class="[
'px-5 py-2.5 rounded-xl border border-white/10 text-white/70 hover:text-white hover:bg-white/5 hover:border-white/20 transition-all text-sm font-medium',
!isDirty ? 'opacity-40 cursor-not-allowed hover:bg-transparent hover:text-white/70' : ''
]"
>
{{ t('settings.cancel') }}
</button>
<button
type="button"
@click="handleSave"
:disabled="!isDirty"
:class="[
'px-6 py-2.5 rounded-xl bg-primary hover:bg-primary-hover text-on-primary font-bold shadow-glow transition-all active:scale-95 text-sm',
!isDirty ? 'opacity-50 cursor-not-allowed hover:bg-primary' : ''
]"
>
{{ t('settings.saveChanges') }}
</button>
</div>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useLocale } from '@/shared/composables/useLocale';
import { useTheme, type AppTheme } from '@/shared/composables/useTheme';
import {
cloneSettings,
useSettings,
type AccountStatus,
type KeybindProfile,
type SettingsState
} from '@/shared/composables/useSettings';
type MemberOption = {
id: string;
nameKey: string;
icon: string;
gradient: string;
};
type MemberDisplayOption = {
id: string;
label: string;
command: string;
kindLabel: string;
icon: string;
gradient?: string;
isCustom: boolean;
isDeletable: boolean;
};
type NotificationToggleKey = 'desktop' | 'sound' | 'mentionsOnly' | 'previews' | 'weeklyDigest' | 'quietHoursEnabled';
const emit = defineEmits<{ (e: 'logout'): void }>();
type SectionId = 'account' | 'appearance' | 'language' | 'members' | 'notifications' | 'keybinds';
const { t } = useI18n();
const { locale, setLocale, localeOptions } = useLocale();
const { theme, setTheme, themeOptions } = useTheme();
const { settings, saveSettings } = useSettings();
const savedTheme = ref(theme.value);
const savedLocale = ref(locale.value);
const draftSettings = ref<SettingsState>(cloneSettings(settings.value));
const customName = ref('');
const customCommand = ref('');
const isAddingCustom = ref(false);
const openMemberMenuId = ref<string | null>(null);
const editingMemberId = ref<string | null>(null);
const inputClass =
'block w-full px-4 py-2.5 bg-surface/80 border border-white/10 rounded-lg text-white placeholder-white/20 focus:outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/50 transition-all text-sm';
const selectClass = `${inputClass} appearance-none`;
const toggleInputClass =
'peer absolute block w-5 h-5 rounded-full bg-white border-4 border-white/20 appearance-none cursor-pointer transition-all duration-300 top-[2px] checked:right-0 checked:border-primary';
const contentRef = ref<HTMLElement | null>(null);
const accountRef = ref<HTMLElement | null>(null);
const appearanceRef = ref<HTMLElement | null>(null);
const languageRef = ref<HTMLElement | null>(null);
const membersRef = ref<HTMLElement | null>(null);
const notificationsRef = ref<HTMLElement | null>(null);
const keybindsRef = ref<HTMLElement | null>(null);
const activeSection = ref<SectionId>('account');
const serializeSettings = (value: SettingsState) => JSON.stringify(value);
const isDirty = computed(
() =>
serializeSettings(draftSettings.value) !== serializeSettings(settings.value) ||
theme.value !== savedTheme.value ||
locale.value !== savedLocale.value
);
const applySettingsToDraft = (next: SettingsState) => {
draftSettings.value = cloneSettings(next);
customName.value = '';
customCommand.value = '';
isAddingCustom.value = false;
openMemberMenuId.value = null;
editingMemberId.value = null;
};
const handleSave = () => {
const next = saveSettings(draftSettings.value);
applySettingsToDraft(next);
savedTheme.value = theme.value;
savedLocale.value = locale.value;
};
const handleCancel = () => {
applySettingsToDraft(settings.value);
if (theme.value !== savedTheme.value) {
setTheme(savedTheme.value);
}
if (locale.value !== savedLocale.value) {
setLocale(savedLocale.value);
}
};
watch(
settings,
(next) => {
if (!isDirty.value) {
applySettingsToDraft(next);
}
},
{ deep: true }
);
const selectedMember = computed({
get: () => draftSettings.value.members.defaultMemberId,
set: (value: string) => {
draftSettings.value.members.defaultMemberId = value;
}
});
const resetMemberDraft = () => {
const next = cloneSettings(settings.value);
draftSettings.value.members = next.members;
customName.value = '';
customCommand.value = '';
isAddingCustom.value = false;
openMemberMenuId.value = null;
editingMemberId.value = null;
};
const buildCustomMemberId = () => `custom-cli-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const openCustomMemberForm = () => {
editingMemberId.value = null;
isAddingCustom.value = true;
customName.value = '';
customCommand.value = '';
};
const applyCustomMember = () => {
const name = customName.value.trim();
const command = customCommand.value.trim();
if (!name && !command) {
return;
}
if (editingMemberId.value) {
draftSettings.value.members.customMembers = draftSettings.value.members.customMembers.map((member) =>
member.id === editingMemberId.value ? { ...member, name, command } : member
);
} else {
const existingIds = new Set(draftSettings.value.members.customMembers.map((member) => member.id));
let id = buildCustomMemberId();
while (existingIds.has(id)) {
id = buildCustomMemberId();
}
draftSettings.value.members.customMembers = [...draftSettings.value.members.customMembers, { id, name, command }];
selectedMember.value = id;
}
resetCustomMemberForm();
};
const resetCustomMemberForm = () => {
customName.value = '';
customCommand.value = '';
isAddingCustom.value = false;
editingMemberId.value = null;
};
const removeCustomMember = (id: string) => {
draftSettings.value.members.customMembers = draftSettings.value.members.customMembers.filter((member) => member.id !== id);
if (draftSettings.value.members.defaultMemberId === id) {
draftSettings.value.members.defaultMemberId = baseMembers[0]?.id ?? 'gemini-cli';
}
if (editingMemberId.value === id) {
resetCustomMemberForm();
}
if (openMemberMenuId.value === id) {
openMemberMenuId.value = null;
}
};
const selectMemberCard = (id: string) => {
selectedMember.value = id;
openMemberMenuId.value = null;
};
const toggleMemberMenu = (id: string) => {
openMemberMenuId.value = openMemberMenuId.value === id ? null : id;
};
const handleMemberTest = (_id: string) => {
openMemberMenuId.value = null;
};
const startEditMember = (id: string) => {
const target = draftSettings.value.members.customMembers.find((member) => member.id === id);
if (!target) {
openMemberMenuId.value = null;
return;
}
editingMemberId.value = id;
isAddingCustom.value = true;
customName.value = target.name;
customCommand.value = target.command;
openMemberMenuId.value = null;
};
const accountInitials = computed(() => {
const name = draftSettings.value.account.displayName.trim();
if (!name) return 'NA';
const parts = name.split(' ').filter(Boolean);
const initials = parts
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('');
return initials || 'NA';
});
const trimAccountField = (field: 'displayName' | 'email' | 'title' | 'statusMessage') => {
const value = draftSettings.value.account[field].trim();
draftSettings.value.account[field] = field === 'email' ? value.toLowerCase() : value;
};
const timeZones = ['UTC', 'America/Los_Angeles', 'America/New_York', 'Europe/London', 'Europe/Berlin', 'Asia/Shanghai', 'Asia/Tokyo', 'Australia/Sydney'];
const statusOptions: Array<{ id: AccountStatus; labelKey: string }> = [
{ id: 'online', labelKey: 'settings.statusOptions.online' },
{ id: 'away', labelKey: 'settings.statusOptions.away' },
{ id: 'dnd', labelKey: 'settings.statusOptions.dnd' }
];
const notificationOptions: Array<{ key: NotificationToggleKey; labelKey: string; descriptionKey: string }> = [
{ key: 'desktop', labelKey: 'settings.notificationOptions.desktop', descriptionKey: 'settings.notificationOptions.desktopDesc' },
{ key: 'sound', labelKey: 'settings.notificationOptions.sound', descriptionKey: 'settings.notificationOptions.soundDesc' },
{ key: 'mentionsOnly', labelKey: 'settings.notificationOptions.mentionsOnly', descriptionKey: 'settings.notificationOptions.mentionsOnlyDesc' },
{ key: 'previews', labelKey: 'settings.notificationOptions.previews', descriptionKey: 'settings.notificationOptions.previewsDesc' },
{ key: 'weeklyDigest', labelKey: 'settings.notificationOptions.weeklyDigest', descriptionKey: 'settings.notificationOptions.weeklyDigestDesc' },
{ key: 'quietHoursEnabled', labelKey: 'settings.notificationOptions.quietHours', descriptionKey: 'settings.notificationOptions.quietHoursDesc' }
];
const keybindProfiles: Array<{
id: KeybindProfile;
labelKey: string;
bindings: Array<{ actionKey: string; keys: string }>;
}> = [
{
id: 'default',
labelKey: 'settings.keybindProfiles.default',
bindings: [
{ actionKey: 'settings.keybindActions.focusSearch', keys: 'Ctrl + K' },
{ actionKey: 'settings.keybindActions.newMessage', keys: 'Ctrl + Enter' },
{ actionKey: 'settings.keybindActions.toggleSidebar', keys: 'Ctrl + B' },
{ actionKey: 'settings.keybindActions.toggleMute', keys: 'Ctrl + Shift + M' },
{ actionKey: 'settings.keybindActions.jumpToLatest', keys: 'Alt + J' },
{ actionKey: 'settings.keybindActions.openSettings', keys: 'Ctrl + ,' }
]
},
{
id: 'vscode',
labelKey: 'settings.keybindProfiles.vscode',
bindings: [
{ actionKey: 'settings.keybindActions.focusSearch', keys: 'Ctrl + P' },
{ actionKey: 'settings.keybindActions.newMessage', keys: 'Ctrl + Enter' },
{ actionKey: 'settings.keybindActions.toggleSidebar', keys: 'Ctrl + B' },
{ actionKey: 'settings.keybindActions.toggleMute', keys: 'Ctrl + Shift + M' },
{ actionKey: 'settings.keybindActions.jumpToLatest', keys: 'Alt + End' },
{ actionKey: 'settings.keybindActions.openSettings', keys: 'Ctrl + ,' }
]
},
{
id: 'slack',
labelKey: 'settings.keybindProfiles.slack',
bindings: [
{ actionKey: 'settings.keybindActions.focusSearch', keys: 'Ctrl + K' },
{ actionKey: 'settings.keybindActions.newMessage', keys: 'Ctrl + N' },
{ actionKey: 'settings.keybindActions.toggleSidebar', keys: 'Ctrl + Shift + S' },
{ actionKey: 'settings.keybindActions.toggleMute', keys: 'Ctrl + Shift + M' },
{ actionKey: 'settings.keybindActions.jumpToLatest', keys: 'Alt + J' },
{ actionKey: 'settings.keybindActions.openSettings', keys: 'Ctrl + ,' }
]
}
];
const activeKeybindProfile = computed(
() => keybindProfiles.find((profile) => profile.id === draftSettings.value.keybinds.profile) ?? keybindProfiles[0]
);
const resetKeybinds = () => {
draftSettings.value.keybinds = {
enabled: true,
showHints: true,
profile: 'default'
};
};
const baseMembers: MemberOption[] = [
{ id: 'gemini-cli', nameKey: 'settings.memberOptions.gemini', icon: 'token', gradient: 'from-blue-600 to-cyan-400' },
{ id: 'codex', nameKey: 'settings.memberOptions.codex', icon: 'code', gradient: 'from-emerald-600 to-green-400' },
{ id: 'claude-code', nameKey: 'settings.memberOptions.claude', icon: 'terminal', gradient: 'from-orange-600 to-amber-400' }
];
const memberOptions = computed<MemberDisplayOption[]>(() => {
const baseOptions = baseMembers.map((member) => ({
id: member.id,
label: t(member.nameKey),
command: '',
kindLabel: t('settings.memberKind.default'),
icon: member.icon,
gradient: member.gradient,
isCustom: false,
isDeletable: false
}));
const customOptions = draftSettings.value.members.customMembers.map((member) => ({
id: member.id,
label: member.name || t('settings.memberOptions.custom'),
command: member.command,
kindLabel: t('settings.memberKind.custom'),
icon: 'terminal',
isCustom: true,
isDeletable: true
}));
return [...baseOptions, ...customOptions];
});
const sectionButtonClass = (section: SectionId) => [
'w-full text-left flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all group justify-center md:justify-start md:gap-3 gap-0',
activeSection.value === section
? 'bg-primary/10 text-white border border-primary/20 shadow-[0_1px_2px_rgba(0,0,0,0.05)]'
: 'text-white/60 hover:text-white hover:bg-white/5'
];
const themePreview: Record<AppTheme, { base: string; panel: string; accent: string }> = {
dark: {
base: '#0f0f12',
panel: '#1c1d24',
accent: '#38bdf8'
},
light: {
base: '#f8fafd',
panel: '#f0f4f9',
accent: '#0b57d0'
},
system: {
base: 'linear-gradient(135deg, #0f0f12 0%, #0f0f12 50%, #f8fafd 50%, #f8fafd 100%)',
panel: 'linear-gradient(135deg, #1c1d24 0%, #1c1d24 50%, #f0f4f9 50%, #f0f4f9 100%)',
accent: 'linear-gradient(135deg, #38bdf8 0%, #0b57d0 100%)'
}
};
const scrollToSection = (section: SectionId) => {
const sectionMap: Record<SectionId, HTMLElement | null> = {
account: accountRef.value,
appearance: appearanceRef.value,
language: languageRef.value,
members: membersRef.value,
notifications: notificationsRef.value,
keybinds: keybindsRef.value
};
activeSection.value = section;
sectionMap[section]?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
let observer: IntersectionObserver | null = null;
onMounted(() => {
if (!contentRef.value) return;
const sections: Array<{ id: SectionId; ref: typeof accountRef }> = [
{ id: 'account', ref: accountRef },
{ id: 'appearance', ref: appearanceRef },
{ id: 'language', ref: languageRef },
{ id: 'members', ref: membersRef },
{ id: 'notifications', ref: notificationsRef },
{ id: 'keybinds', ref: keybindsRef }
];
observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
const nextSection = visible[0]?.target.getAttribute('data-section-id') as SectionId | null;
if (nextSection) {
activeSection.value = nextSection;
}
},
{
root: contentRef.value,
threshold: [0.25, 0.6, 0.9],
rootMargin: '0px 0px -55% 0px'
}
);
sections.forEach(({ id, ref }) => {
if (ref.value) {
ref.value.dataset.sectionId = id;
observer?.observe(ref.value);
}
});
});
onBeforeUnmount(() => {
observer?.disconnect();
});
</script>
+185
View File
@@ -0,0 +1,185 @@
<template>
<div class="flex flex-col h-full w-full">
<header class="px-10 py-10 flex flex-col items-center justify-center shrink-0 z-10">
<h1 class="text-3xl font-bold text-white mb-8 tracking-tight drop-shadow-lg">{{ t('skillStore.title') }}</h1>
<div class="relative w-full max-w-xl group">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<span class="material-symbols-outlined text-white/40 group-focus-within:text-primary transition-colors">search</span>
</div>
<input
type="text"
class="w-full bg-white/5 border border-white/10 rounded-2xl py-3.5 pl-12 pr-16 text-white placeholder-white/30 focus:bg-white/10 focus:border-primary/50 focus:ring-0 transition-all shadow-glass backdrop-blur-md text-[15px]"
:placeholder="t('skillStore.searchPlaceholder')"
/>
<div class="absolute inset-y-0 right-0 pr-3 flex items-center">
<button class="bg-white/10 hover:bg-white/20 text-[11px] px-2 py-1 rounded-md text-white/60 font-medium transition-colors border border-white/5">CMD+K</button>
</div>
</div>
<div class="mt-8 bg-panel/60 backdrop-blur-md p-1 rounded-xl border border-white/5 inline-flex relative z-10 w-72 shadow-lg">
<span
class="absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-lg bg-gradient-to-br from-primary/25 via-white/[0.08] to-white/[0.03] border border-primary/20 shadow-[0_8px_20px_rgba(0,0,0,0.25)] backdrop-blur-md transition-transform duration-300 ease-out pointer-events-none"
:class="activeTab === 'installed' ? 'translate-x-full' : 'translate-x-0'"
></span>
<button
type="button"
@click="activeTab = 'store'"
:class="[
'flex-1 py-1.5 rounded-lg text-xs font-bold transition-all relative z-10',
activeTab === 'store'
? 'text-white text-shadow'
: 'text-white/60 hover:text-white'
]"
>
{{ t('skillStore.tabs.store') }}
</button>
<button
type="button"
@click="activeTab = 'installed'"
:class="[
'flex-1 py-1.5 rounded-lg text-xs font-bold transition-all relative z-10',
activeTab === 'installed'
? 'text-white text-shadow'
: 'text-white/60 hover:text-white'
]"
>
{{ t('skillStore.tabs.installed') }}
</button>
</div>
<div class="flex items-center gap-2 mt-6 overflow-x-auto max-w-full pb-2 no-scrollbar">
<button class="px-4 py-1.5 rounded-full bg-primary/20 text-primary border border-primary/20 text-xs font-bold whitespace-nowrap">{{ t('skillStore.filters.all') }}</button>
<button
v-for="filter in filters"
:key="filter"
class="px-4 py-1.5 rounded-full bg-white/5 hover:bg-white/10 text-white/60 hover:text-white border border-white/5 text-xs font-bold transition-colors whitespace-nowrap"
>
{{ t(filter) }}
</button>
</div>
</header>
<div class="flex-1 overflow-y-auto px-10 pb-12">
<div v-if="activeTab === 'store'" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 max-w-7xl mx-auto">
<div
v-for="skill in skills"
:key="skill.id"
class="glass-panel bg-panel-strong/60 rounded-3xl p-6 border border-white/5 hover:border-primary/30 transition-all duration-300 hover:shadow-glow hover:-translate-y-1 group flex flex-col h-full relative overflow-hidden"
>
<div class="flex items-start justify-between mb-4">
<div :class="['w-14 h-14 rounded-2xl border border-white/5 flex items-center justify-center shadow-lg', skill.bg, skill.color]">
<span class="material-symbols-outlined text-[32px]">{{ skill.icon }}</span>
</div>
<div class="flex items-center gap-1 bg-panel/60 px-2 py-1 rounded-lg border border-white/5 backdrop-blur-sm">
<span class="material-symbols-outlined text-yellow-400 text-[14px] fill-current">star</span>
<span class="text-[11px] font-bold text-white">{{ skill.rating }}</span>
</div>
</div>
<h3 class="text-white font-bold text-lg mb-1.5">{{ t(skill.titleKey) }}</h3>
<p class="text-white/50 text-[13px] mb-6 flex-1 leading-relaxed">{{ t(skill.descKey) }}</p>
<div class="mb-4">
<div class="flex items-center gap-2 bg-panel/60 rounded-xl p-1.5 border border-white/5 focus-within:border-primary/50 transition-colors group/input">
<span class="material-symbols-outlined text-white/30 text-[18px] ml-2 group-focus-within/input:text-primary transition-colors">link</span>
<input
type="text"
class="bg-transparent border-none text-[11px] text-white placeholder-white/20 w-full focus:ring-0 p-0 h-8"
:placeholder="t('skillStore.syncPlaceholder')"
/>
<button class="w-8 h-8 rounded-lg bg-white/5 hover:bg-primary/20 hover:text-primary text-white/40 flex items-center justify-center transition-all" :title="t('skillStore.syncNow')">
<span class="material-symbols-outlined text-[16px]">sync</span>
</button>
</div>
</div>
<button
v-if="skill.installed"
class="w-full py-3 rounded-xl bg-white/5 border border-white/10 text-white/60 font-bold text-[13px] cursor-default flex items-center justify-center gap-2"
>
{{ t('skillStore.installed') }}
</button>
<button
v-else
class="w-full py-3 rounded-xl bg-primary hover:bg-primary-hover text-on-primary font-bold text-[13px] shadow-glow transition-all active:scale-95 flex items-center justify-center gap-2"
>
<span class="material-symbols-outlined text-[18px]">add_to_drive</span>
{{ t('skillStore.installFolder') }}
</button>
</div>
</div>
<div v-else class="max-w-7xl mx-auto">
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
<div
v-for="skill in installedSkills"
:key="skill.id"
class="glass-panel bg-panel-strong/60 rounded-3xl p-6 border border-white/5 hover:border-primary/30 transition-all duration-300 hover:shadow-glow hover:-translate-y-1 group flex flex-col h-full relative overflow-hidden"
>
<div :class="['absolute top-0 right-0 w-16 h-16 bg-gradient-to-br to-transparent rounded-bl-3xl -mr-4 -mt-4', skill.gradient]"></div>
<div class="flex justify-between items-start mb-4 relative z-10">
<div :class="['w-14 h-14 rounded-2xl border border-white/5 flex items-center justify-center shadow-lg', skill.bg, skill.color]">
<span class="material-symbols-outlined text-[32px]">{{ skill.icon }}</span>
</div>
</div>
<h3 class="text-white font-bold text-lg mb-1.5">{{ t(skill.nameKey) }}</h3>
<p class="text-white/50 text-[13px] mb-6 flex-1 leading-relaxed">{{ t(skill.descKey) }}</p>
<div class="flex items-center justify-between gap-3 mt-auto">
<div class="flex items-center gap-2">
<span class="text-[10px] font-mono text-white/30 bg-white/5 px-1.5 py-0.5 rounded border border-white/5">{{ skill.ver }}</span>
<span :class="['text-[10px] font-medium', skill.color]">{{ t(skill.assetsKey) }}</span>
</div>
<button
type="button"
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border border-red-500/20 bg-red-500/5 text-red-300 text-[11px] font-semibold hover:bg-red-500/10 hover:border-red-500/40 transition-colors"
>
<span class="material-symbols-outlined text-[14px]">delete</span>
{{ t('common.remove') }}
</button>
</div>
</div>
<button
type="button"
class="glass-panel bg-panel-strong/40 rounded-3xl p-6 border border-dashed border-white/10 hover:border-primary/40 transition-all duration-300 hover:shadow-glow hover:-translate-y-1 group flex flex-col items-center justify-center h-full min-h-[220px] text-center"
>
<div class="w-12 h-12 rounded-full bg-white/5 group-hover:bg-primary/10 flex items-center justify-center text-white/30 group-hover:text-primary transition-colors mb-3">
<span class="material-symbols-outlined text-2xl">add</span>
</div>
<span class="text-white/60 font-medium text-sm group-hover:text-white transition-colors">{{ t('skills.library.importTitle') }}</span>
<span class="text-white/30 text-xs mt-1">{{ t('skills.library.importSubtitle') }}</span>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { createLibrarySkills } from '@/features/skills/skillLibrary';
const skills = [
{ id: 1, titleKey: 'skillStore.skills.automation.title', descKey: 'skillStore.skills.automation.desc', icon: 'folder_special', color: 'text-indigo-400', bg: 'bg-indigo-500/10', rating: '4.9', type: 'Install Folder' },
{ id: 2, titleKey: 'skillStore.skills.uiToolkit.title', descKey: 'skillStore.skills.uiToolkit.desc', icon: 'folder_shared', color: 'text-pink-400', bg: 'bg-pink-500/10', rating: '4.8', type: 'Install Folder' },
{ id: 3, titleKey: 'skillStore.skills.projectTracking.title', descKey: 'skillStore.skills.projectTracking.desc', icon: 'topic', color: 'text-emerald-400', bg: 'bg-emerald-500/10', rating: '4.7', type: 'Install Folder' },
{ id: 4, titleKey: 'skillStore.skills.marketingAssets.title', descKey: 'skillStore.skills.marketingAssets.desc', icon: 'snippet_folder', color: 'text-orange-400', bg: 'bg-orange-500/10', rating: '4.5', type: 'Installed', installed: true },
{ id: 5, titleKey: 'skillStore.skills.devOpsConfig.title', descKey: 'skillStore.skills.devOpsConfig.desc', icon: 'folder_zip', color: 'text-cyan-400', bg: 'bg-cyan-500/10', rating: '4.6', type: 'Install Folder' },
{ id: 6, titleKey: 'skillStore.skills.researchLibrary.title', descKey: 'skillStore.skills.researchLibrary.desc', icon: 'folder_copy', color: 'text-red-400', bg: 'bg-red-500/10', rating: '5.0', type: 'Install Folder' }
];
const activeTab = ref<'store' | 'installed'>('store');
const installedSkills = createLibrarySkills();
const filters = [
'skillStore.filters.engineering',
'skillStore.filters.design',
'skillStore.filters.management',
'skillStore.filters.marketing',
'skillStore.filters.finance'
];
const { t } = useI18n();
</script>
+227
View File
@@ -0,0 +1,227 @@
<template>
<div ref="containerRef" class="flex-grow flex flex-col items-center justify-center p-6 w-full max-w-7xl mx-auto z-10 relative">
<div class="fixed inset-0 overflow-hidden pointer-events-none z-0">
<div class="absolute -top-[20%] left-1/2 -translate-x-1/2 w-[60%] h-[60%] bg-primary/5 rounded-full blur-[120px]"></div>
<div class="absolute bottom-0 right-0 w-[40%] h-[40%] bg-blue-900/10 rounded-full blur-[100px]"></div>
</div>
<div class="w-full max-w-3xl mb-16 mt-12 md:mt-0 z-10">
<button
type="button"
@click="emit('select-workspace')"
class="group w-full glass-panel bg-panel/40 rounded-3xl p-16 flex flex-col items-center justify-center text-center relative overflow-hidden transition-all duration-300 hover:bg-panel/60 hover:shadow-[0_0_40px_rgb(var(--color-primary)_/_0.15)] hover:border-primary/20 border border-white/5"
>
<div class="absolute inset-0 bg-gradient-to-b from-white/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none"></div>
<div class="relative w-20 h-20 rounded-full bg-white/5 flex items-center justify-center mb-6 group-hover:scale-110 group-hover:bg-primary/20 transition-all duration-300 border border-white/10 group-hover:border-primary/40 shadow-lg group-hover:shadow-primary/20">
<span class="material-symbols-outlined text-4xl text-gray-400 group-hover:text-primary transition-colors duration-300">add</span>
</div>
<h1 class="relative text-3xl font-bold text-white mb-3 tracking-tight">{{ t('workspace.createTitle') }}</h1>
<p class="relative text-gray-400 font-medium text-lg group-hover:text-gray-300 transition-colors">{{ t('workspace.createSubtitle') }}</p>
</button>
</div>
<div class="w-full max-w-6xl z-10">
<div class="flex items-center space-x-4 mb-8 px-2">
<h2 class="text-xs font-bold text-gray-500 tracking-[0.25em] uppercase">{{ t('workspace.recentTitle') }}</h2>
<div class="h-px bg-gradient-to-r from-white/10 to-transparent flex-grow"></div>
<div class="relative group">
<button class="flex items-center space-x-2 text-xs font-bold text-gray-400 hover:text-white transition-colors py-1.5 px-3 rounded-lg hover:bg-white/5">
<span class="tracking-wider uppercase">{{ t('workspace.more') }}</span>
<span class="material-symbols-outlined text-base">expand_more</span>
</button>
<div class="absolute right-0 top-full mt-2 w-72 glass-modal bg-panel-strong/95 rounded-xl shadow-2xl flex flex-col overflow-hidden invisible group-focus-within:visible group-hover:visible opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 transition-all duration-200 transform origin-top-right z-50">
<div class="p-3 border-b border-white/5">
<div class="relative">
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 text-[18px]">search</span>
<input
type="text"
class="w-full bg-surface/80 text-xs text-gray-300 placeholder-gray-500 rounded-lg py-2.5 pl-10 pr-3 border border-white/10 focus:border-primary/50 focus:outline-none"
:placeholder="t('workspace.searchPlaceholder')"
/>
</div>
</div>
<div class="py-2">
<button
v-for="item in recentTeams"
:key="item"
class="w-full text-left px-4 py-2.5 hover:bg-white/5 flex items-center gap-3 transition-colors"
>
<div class="w-8 h-8 rounded-lg bg-white/10 flex items-center justify-center text-white/50">
<span class="material-symbols-outlined text-sm">group</span>
</div>
<span class="text-sm font-medium text-gray-300">{{ t(item) }}</span>
</button>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 pb-20">
<div
v-for="ws in workspaces"
:key="ws.id"
@click="handleSelectWorkspace"
:class="[
'glass-panel bg-panel/40 rounded-3xl p-8 flex flex-col justify-between h-72 cursor-pointer group relative transition-all hover:bg-panel/60 hover:shadow-[0_0_30px_rgb(var(--color-primary)_/_0.12)]',
activeMenu === ws.id ? 'z-50 ring-1 ring-white/10' : 'z-0 hover:z-40'
]"
>
<div v-if="ws.active" class="absolute top-6 right-16 z-20">
<span class="bg-primary/10 text-primary text-[10px] font-bold px-3 py-1.5 rounded-full tracking-wider border border-primary/20 shadow-[0_0_15px_rgb(var(--color-primary)_/_0.18)] backdrop-blur-md">{{ t('workspace.activeLabel') }}</span>
</div>
<div class="absolute top-4 right-4 z-30">
<button
type="button"
@click.stop="toggleMenu(ws.id)"
:class="[
'p-2 rounded-lg transition-colors ring-1 ring-transparent',
activeMenu === ws.id ? 'bg-white/10 text-white ring-white/10 shadow-lg' : 'hover:bg-white/10 text-gray-400 hover:text-white'
]"
>
<span class="material-symbols-outlined text-xl">more_horiz</span>
</button>
<div
v-if="activeMenu === ws.id"
class="absolute right-0 top-12 w-56 rounded-xl glass-modal bg-panel-strong/95 flex flex-col py-1.5 shadow-2xl overflow-hidden z-50 animate-in fade-in zoom-in-95 duration-200 ring-1 ring-white/10"
@click.stop
>
<button class="relative w-full text-left px-4 py-2.5 text-xs font-bold text-gray-300 hover:bg-primary/10 hover:text-primary transition-colors flex items-center gap-3">
<span class="material-symbols-outlined text-lg opacity-70">settings</span> {{ t('workspace.menu.settings') }}
</button>
<button class="relative w-full text-left px-4 py-2.5 text-xs font-bold text-gray-300 hover:bg-primary/10 hover:text-primary transition-colors flex items-center gap-3">
<span class="material-symbols-outlined text-lg opacity-70">person_add</span> {{ t('workspace.menu.invite') }}
</button>
<button class="relative w-full text-left px-4 py-2.5 text-xs font-bold text-gray-300 hover:bg-primary/10 hover:text-primary transition-colors flex items-center gap-3">
<span class="material-symbols-outlined text-lg opacity-70">content_copy</span> {{ t('workspace.menu.copyId') }}
</button>
<div class="h-px bg-white/10 my-1 mx-2"></div>
<button class="relative w-full text-left px-4 py-2.5 text-xs font-bold text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-colors flex items-center gap-3">
<span class="material-symbols-outlined text-lg opacity-70">logout</span> {{ t('workspace.menu.leave') }}
</button>
</div>
</div>
<div>
<div :class="['w-14 h-14 rounded-2xl flex items-center justify-center mb-6 border transition-colors duration-300', ws.iconBg, ws.iconHoverBg, ws.borderColor, ws.groupHoverBorder]">
<span :class="['material-symbols-outlined text-3xl transition-colors', ws.iconColor]">{{ ws.icon }}</span>
</div>
<h3 class="text-2xl font-bold text-white mb-3 group-hover:text-white/90 transition-colors tracking-tight">{{ t(ws.titleKey) }}</h3>
<p class="text-sm text-gray-400 leading-relaxed font-medium">{{ t(ws.descKey) }}</p>
</div>
<div class="flex items-center justify-between mt-6 border-t border-white/5 pt-5">
<div class="flex -space-x-3 overflow-hidden pl-1">
<img
v-for="(memberId, idx) in ws.members"
:key="idx"
:src="`https://picsum.photos/id/${memberId}/100/100`"
class="inline-block h-8 w-8 rounded-full ring-2 ring-white/10"
alt=""
/>
</div>
<span class="text-xs text-gray-500 font-bold">{{ t(ws.timeKey) }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
type Workspace = {
id: string;
titleKey: string;
descKey: string;
icon: string;
iconColor: string;
iconBg: string;
borderColor: string;
groupHoverBorder: string;
iconHoverBg: string;
active: boolean;
members: number[];
timeKey: string;
};
const emit = defineEmits<{ (e: 'select-workspace'): void }>();
const workspaces: Workspace[] = [
{
id: 'frontend',
titleKey: 'workspace.list.frontend.title',
descKey: 'workspace.list.frontend.desc',
icon: 'layers',
iconColor: 'text-primary',
iconBg: 'bg-primary/10',
borderColor: 'border-primary/20',
groupHoverBorder: '',
iconHoverBg: 'group-hover:bg-primary/20',
active: true,
members: [10, 12],
timeKey: 'workspace.times.twoMinutes'
},
{
id: 'mobile',
titleKey: 'workspace.list.mobile.title',
descKey: 'workspace.list.mobile.desc',
icon: 'hub',
iconColor: 'text-orange-400',
iconBg: 'bg-orange-500/10',
borderColor: 'border-orange-500/20',
groupHoverBorder: 'group-hover:border-orange-500/40',
iconHoverBg: '',
active: false,
members: [22, 32],
timeKey: 'workspace.times.fourHours'
},
{
id: 'infra',
titleKey: 'workspace.list.infrastructure.title',
descKey: 'workspace.list.infrastructure.desc',
icon: 'dns',
iconColor: 'text-purple-400',
iconBg: 'bg-purple-500/10',
borderColor: 'border-purple-500/20',
groupHoverBorder: 'group-hover:border-purple-500/40',
iconHoverBg: '',
active: false,
members: [45],
timeKey: 'workspace.times.oneDay'
}
];
const recentTeams = ['workspace.recent.designSystems', 'workspace.recent.marketingTeam', 'workspace.recent.backendCore'];
const activeMenu = ref<string | null>(null);
const containerRef = ref<HTMLElement | null>(null);
const { t } = useI18n();
const handleSelectWorkspace = () => {
activeMenu.value = null;
emit('select-workspace');
};
const toggleMenu = (id: string) => {
activeMenu.value = activeMenu.value === id ? null : id;
};
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
activeMenu.value = null;
}
};
onMounted(() => {
document.addEventListener('mousedown', handleClickOutside);
});
onBeforeUnmount(() => {
document.removeEventListener('mousedown', handleClickOutside);
});
</script>
+395
View File
@@ -0,0 +1,395 @@
<template>
<div class="flex h-full w-full relative">
<ChatSidebar
:conversations="conversations"
:members="members"
:active-conversation-id="activeConversationId"
@select-conversation="handleSelectConversation"
@conversation-action="handleConversationAction"
/>
<div class="flex-1 flex flex-col min-w-0 bg-transparent relative z-0">
<ChatHeader
:title="headerTitle"
:description="headerDescription"
:member-count="members.length"
@open-roadmap="activeModal = 'roadmap'"
@open-skills="activeModal = 'skills'"
@open-members="showMembersDrawer = true"
/>
<MessagesList
:messages="activeMessages"
:current-user-id="currentUserId"
:current-user-name="currentUserName"
:is-typing="isAssistantTyping"
:typing-name="assistantName"
:typing-avatar="assistantAvatar"
@open-roadmap="activeModal = 'roadmap'"
/>
<ChatInput
ref="chatInputRef"
v-model="inputValue"
:max-length="maxMessageLength"
:is-generating="isAssistantTyping"
:quick-prompts="quickPrompts"
:placeholder="inputPlaceholder"
:members="members"
@send="handleSendMessage"
@stop="cancelAssistantReply"
/>
</div>
<MembersSidebar
:members="members"
:show-invite-menu="showInviteMenu"
:current-user-id="currentUserId"
variant="sidebar"
@toggle-invite="toggleInviteMenu"
@member-action="handleMemberAction"
/>
<template v-if="showMembersDrawer">
<div class="fixed inset-0 bg-black/40 backdrop-blur-[1px] z-40 xl:hidden" @click="showMembersDrawer = false"></div>
<div class="fixed right-0 top-0 bottom-0 z-50 xl:hidden">
<MembersSidebar
:members="members"
:show-invite-menu="showInviteMenu"
:current-user-id="currentUserId"
variant="drawer"
@toggle-invite="toggleInviteMenu"
@member-action="handleMemberAction"
/>
</div>
</template>
<template v-if="showInviteMenu">
<div class="fixed inset-0 bg-background/60 backdrop-blur-[2px] z-40 transition-opacity duration-300" @click="showInviteMenu = false"></div>
<InviteMenu @select="handleInviteSelect" />
</template>
<RoadmapModal v-if="activeModal === 'roadmap'" @close="activeModal = null" />
<SkillManagementModal
v-if="activeModal === 'skills'"
@close="activeModal = null"
@configure="activeModal = 'skillDetail'"
/>
<SkillDetailModal v-if="activeModal === 'skillDetail'" @close="activeModal = null" @back="activeModal = 'skills'" />
<InviteAdminModal v-if="activeModalType === 'admin'" @close="activeModalType = null" />
<InviteAssistantModal
v-if="activeModalType === 'assistant'"
:title="t('invite.assistant.title')"
@close="activeModalType = null"
@invite="handleInvite($event, 'assistant')"
/>
<InviteAssistantModal
v-if="activeModalType === 'member'"
:title="t('invite.member.title')"
@close="activeModalType = null"
@invite="handleInvite($event, 'member')"
/>
<ManageMemberModal
v-if="managingMember"
:member="managingMember"
:show-remove="false"
@close="managingMember = null"
@save="handleUpdateMember"
@remove="handleRemoveMember"
/>
<RenameConversationModal
v-if="renamingConversation"
:name="renamingConversationName"
@close="renamingConversation = null"
@save="handleRenameConversation"
/>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import ChatSidebar from './components/ChatSidebar.vue';
import ChatHeader from './components/ChatHeader.vue';
import MessagesList from './components/MessagesList.vue';
import ChatInput from './components/ChatInput.vue';
import MembersSidebar from './components/MembersSidebar.vue';
import InviteMenu from './components/InviteMenu.vue';
import RoadmapModal from './modals/RoadmapModal.vue';
import SkillManagementModal from './modals/SkillManagementModal.vue';
import SkillDetailModal from './modals/SkillDetailModal.vue';
import InviteAdminModal from './modals/InviteAdminModal.vue';
import InviteAssistantModal from './modals/InviteAssistantModal.vue';
import ManageMemberModal from './modals/ManageMemberModal.vue';
import RenameConversationModal from './modals/RenameConversationModal.vue';
import type { Conversation, ConversationAction, Member, MemberAction } from './types';
import { useChatSession } from './composables/useChatSession';
import { DEFAULT_CHANNEL_ID } from './data';
type ActiveModal = 'roadmap' | 'skills' | 'skillDetail' | null;
type InviteModal = 'admin' | 'assistant' | 'member' | null;
type InviteRole = 'assistant' | 'member';
const activeModal = ref<ActiveModal>(null);
const activeModalType = ref<InviteModal>(null);
const showInviteMenu = ref(false);
const showMembersDrawer = ref(false);
const managingMember = ref<Member | null>(null);
const renamingConversation = ref<Conversation | null>(null);
const chatInputRef = ref<{ focus: () => void } | null>(null);
const buildConversationId = (type: 'channel' | 'dm', targetId: string) => `${type}:${targetId}`;
const activeConversationId = ref<string>(buildConversationId('channel', DEFAULT_CHANNEL_ID));
const inputValue = ref('');
const { t } = useI18n();
const {
members,
conversations,
currentUser,
assistantMember,
assistantTypingConversationId,
cancelAssistantReply,
maxMessageLength,
sendMessage,
addMember,
updateMember,
removeMember,
createInviteMember,
ensureDirectMessage,
toggleConversationPin,
toggleConversationMute,
renameConversation,
clearConversationMessages,
deleteConversation
} = useChatSession();
const currentUserId = computed(() => currentUser.value?.id ?? 'me');
const currentUserName = computed(() => currentUser.value?.name ?? t('members.roles.member'));
const assistantName = computed(() => assistantMember.value?.name ?? t('members.roles.aiAssistant'));
const assistantAvatar = computed(() => assistantMember.value?.avatar ?? '');
const quickPrompts = computed(() => [
t('chat.input.quickPrompts.summarize'),
t('chat.input.quickPrompts.draftReply'),
t('chat.input.quickPrompts.extractTasks')
]);
const buildMentionLabel = (name: string) => `@${name},`;
const activeConversation = computed(() => conversations.value.find((conversation) => conversation.id === activeConversationId.value) ?? null);
const activeMessages = computed(() => activeConversation.value?.messages ?? []);
const memberById = computed(() => new Map(members.value.map((member) => [member.id, member])));
const activeDirectMember = computed(() => {
if (activeConversation.value?.type !== 'dm') return null;
return memberById.value.get(activeConversation.value.targetId) ?? null;
});
const getConversationTitle = (conversation: Conversation) => {
if (conversation.type === 'dm') {
return memberById.value.get(conversation.targetId)?.name ?? t('members.roles.member');
}
return conversation.customName ?? (conversation.nameKey ? t(conversation.nameKey) : conversation.targetId);
};
const headerTitle = computed(() => {
if (!activeConversation.value) {
return t('chat.channelName');
}
if (activeConversation.value.type === 'dm') {
return activeDirectMember.value?.name ?? t('chat.channelName');
}
return getConversationTitle(activeConversation.value);
});
const headerDescription = computed(() => {
if (!activeConversation.value) {
return '';
}
if (activeConversation.value.type === 'dm') {
return activeDirectMember.value ? t('chat.directMessageDescription', { name: activeDirectMember.value.name }) : '';
}
if (activeConversation.value.descriptionKey) {
return t(activeConversation.value.descriptionKey);
}
return '';
});
const inputPlaceholder = computed(() => {
if (activeConversation.value?.type === 'dm' && activeDirectMember.value) {
return t('chat.input.directPlaceholder', { name: activeDirectMember.value.name });
}
if (activeConversation.value?.type === 'channel') {
const label = getConversationTitle(activeConversation.value);
const display = label.startsWith('#') ? label : `#${label}`;
return t('chat.input.placeholder', { channel: display });
}
return t('chat.input.placeholder', { channel: t('chat.channelDisplay') });
});
const isAssistantTyping = computed(() => assistantTypingConversationId.value === activeConversationId.value);
const renamingConversationName = computed(() => {
if (!renamingConversation.value) return '';
if (renamingConversation.value.type === 'dm') {
return memberById.value.get(renamingConversation.value.targetId)?.name ?? '';
}
return getConversationTitle(renamingConversation.value);
});
const focusChatInput = async () => {
await nextTick();
chatInputRef.value?.focus();
};
const setMessageTarget = () => {
inputValue.value = '';
void focusChatInput();
};
const appendMention = (member: Member) => {
const mention = buildMentionLabel(member.name);
const trimmed = inputValue.value.replace(/[ \t]+$/, '');
const separator = trimmed ? (trimmed.endsWith('\n') ? '' : ' ') : '';
inputValue.value = `${trimmed}${separator}${mention} `;
void focusChatInput();
};
const toggleInviteMenu = () => {
showInviteMenu.value = !showInviteMenu.value;
};
const handleSelectConversation = (conversationId: string) => {
activeConversationId.value = conversationId;
};
const handleInviteSelect = (type: 'admin' | 'assistant' | 'member') => {
showInviteMenu.value = false;
activeModalType.value = type;
};
const handleInvite = (name: string, type: InviteRole) => {
const isAssistant = type === 'assistant';
const newMember = createInviteMember(
name,
isAssistant ? 'assistant' : 'member',
isAssistant ? 'members.roles.aiAssistant' : 'members.roles.member',
isAssistant ? 'online' : 'offline'
);
addMember(newMember);
activeModalType.value = null;
};
const handleUpdateMember = (id: string, newName: string) => {
updateMember(id, { name: newName });
managingMember.value = null;
};
const handleRemoveMember = (id: string) => {
if (id === currentUserId.value) return;
removeMember(id);
managingMember.value = null;
};
const handleMemberAction = ({ action, member }: { action: MemberAction; member: Member }) => {
if (action === 'send-message') {
const conversationId = ensureDirectMessage(member.id);
if (conversationId) {
activeConversationId.value = conversationId;
}
setMessageTarget();
return;
}
if (action === 'mention') {
appendMention(member);
return;
}
if (action === 'rename') {
managingMember.value = member;
return;
}
if (action === 'remove') {
if (member.id === currentUserId.value) {
return;
}
if (managingMember.value?.id === member.id) {
managingMember.value = null;
}
removeMember(member.id);
}
};
const handleSendMessage = () => {
if (!activeConversation.value) return;
const result = sendMessage(inputValue.value, activeConversation.value.id);
if (result) {
inputValue.value = '';
}
};
const handleConversationAction = ({ conversationId, action }: { conversationId: string; action: ConversationAction }) => {
if (action === 'rename') {
const conversation = conversations.value.find((item) => item.id === conversationId);
if (conversation?.type === 'channel') {
renamingConversation.value = conversation;
}
return;
}
if (action === 'pin' || action === 'unpin') {
toggleConversationPin(conversationId);
return;
}
if (action === 'mute' || action === 'unmute') {
toggleConversationMute(conversationId);
return;
}
if (action === 'clear') {
if (assistantTypingConversationId.value === conversationId) {
cancelAssistantReply();
}
clearConversationMessages(conversationId);
return;
}
if (action === 'delete') {
if (conversationId === buildConversationId('channel', DEFAULT_CHANNEL_ID)) {
return;
}
if (assistantTypingConversationId.value === conversationId) {
cancelAssistantReply();
}
deleteConversation(conversationId);
}
};
const handleRenameConversation = (name: string) => {
if (!renamingConversation.value) return;
renameConversation(renamingConversation.value.id, name);
renamingConversation.value = null;
};
watch(conversations, (nextConversations) => {
if (renamingConversation.value && !nextConversations.some((conversation) => conversation.id === renamingConversation.value?.id)) {
renamingConversation.value = null;
}
if (!nextConversations.length) {
activeConversationId.value = '';
return;
}
if (!nextConversations.some((conversation) => conversation.id === activeConversationId.value)) {
activeConversationId.value = nextConversations[0].id;
}
});
</script>
@@ -0,0 +1,49 @@
<template>
<header class="h-[72px] px-8 flex items-center justify-between border-b border-white/5 bg-glass-header glass-panel sticky top-0 z-10 backdrop-blur-md">
<button
type="button"
@click="emit('open-roadmap')"
class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary/60 to-primary-hover/50 backdrop-blur-md border border-white/20 shadow-lg text-on-primary flex items-center justify-center transition-all group active:scale-95 hover:shadow-glow"
:title="t('chat.header.todo')"
>
<span class="material-symbols-outlined text-[22px] drop-shadow-sm group-hover:rotate-12 transition-transform">checklist</span>
</button>
<div class="flex flex-col items-center text-center">
<h1 class="text-white font-semibold text-[17px] tracking-tight leading-tight mb-0.5">{{ headerTitle }}</h1>
<p class="text-white/40 text-[11px] font-medium tracking-wide truncate max-w-xs">{{ headerDescription }}</p>
</div>
<div class="flex items-center gap-2">
<button
v-if="memberCount !== undefined"
type="button"
class="xl:hidden inline-flex items-center gap-2 px-3 py-2 rounded-full bg-white/10 text-white/70 border border-white/10 hover:bg-white/20 hover:text-white transition-colors"
@click="emit('open-members')"
>
<span class="material-symbols-outlined text-[18px]">group</span>
<span class="text-[11px] font-semibold">Members</span>
<span class="text-[10px] font-bold bg-white/10 px-1.5 py-0.5 rounded-full">{{ memberCount }}</span>
</button>
<button
type="button"
@click="emit('open-skills')"
class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary/60 to-primary-hover/50 backdrop-blur-md border border-white/20 shadow-lg text-on-primary flex items-center justify-center transition-all group active:scale-95 hover:shadow-glow"
:title="t('chat.header.inventory')"
>
<span class="material-symbols-outlined text-[22px] drop-shadow-sm group-hover:rotate-12 transition-transform">backpack</span>
</button>
</div>
</header>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps<{ title?: string; description?: string; memberCount?: number }>();
const emit = defineEmits<{ (e: 'open-roadmap'): void; (e: 'open-skills'): void; (e: 'open-members'): void }>();
const { t } = useI18n();
const headerTitle = computed(() => props.title ?? t('chat.channelName'));
const headerDescription = computed(() => props.description ?? t('chat.channelDescription'));
</script>
+326
View File
@@ -0,0 +1,326 @@
<template>
<div class="p-6 pb-8">
<div v-if="quickPrompts.length" class="mb-3 flex items-center gap-2 overflow-x-auto no-scrollbar">
<button
v-for="prompt in quickPrompts"
:key="prompt"
type="button"
class="px-3 py-1.5 rounded-full bg-white/5 border border-white/10 text-[12px] text-white/70 hover:text-white hover:bg-white/10 transition-colors whitespace-nowrap"
@click="applyPrompt(prompt)"
>
{{ prompt }}
</button>
</div>
<form
ref="formRef"
class="relative bg-white/5 backdrop-blur-md rounded-2xl shadow-lg flex items-end p-2 gap-3 border border-white/5 focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/25 focus-within:bg-white/[0.07] transition-all duration-300 group"
@submit.prevent="emit('send')"
>
<button type="button" class="w-10 h-10 rounded-xl hover:bg-white/10 text-white/70 flex items-center justify-center shrink-0 transition-colors mb-0.5">
<span class="material-symbols-outlined text-[22px]">add_circle</span>
</button>
<div ref="scrollRef" class="flex-1 min-h-[44px] max-h-40 overflow-y-auto py-2.5" @scroll="scheduleMentionAnchorUpdate">
<textarea
ref="inputRef"
:value="modelValue"
class="w-full bg-transparent border-none p-0 text-white placeholder-white/30 focus:ring-0 outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 text-[15px] font-light resize-none min-h-[24px]"
:placeholder="placeholder"
:maxlength="maxLength"
:spellcheck="spellCheck"
rows="1"
@input="handleInput"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
@click="updateCursor"
@keydown="handleKeydown"
@keyup="updateCursor"
></textarea>
</div>
<div
v-if="showMentionSuggestions"
ref="mentionDropdownRef"
class="absolute w-64 max-h-64 rounded-xl glass-modal bg-panel-strong/95 border border-white/10 shadow-2xl overflow-y-auto custom-scrollbar z-20 animate-in fade-in zoom-in-95 duration-200 ring-1 ring-white/10"
:style="mentionDropdownStyle"
>
<button
v-for="(member, index) in mentionSuggestions"
:key="member.id"
type="button"
:class="[
'w-full px-3 py-2 rounded-lg flex items-center gap-3 text-left transition-colors',
index === activeMentionIndex
? 'bg-white/[0.12] ring-1 ring-white/10 hover:bg-white/[0.16]'
: 'hover:bg-white/10 hover:ring-1 hover:ring-white/10'
]"
@click="applyMention(member)"
>
<div class="w-8 h-8 rounded-full bg-cover bg-center shadow-md" :style="{ backgroundImage: `url('${member.avatar}')` }"></div>
<div class="min-w-0">
<div class="text-xs font-semibold text-white truncate">@{{ member.name }}</div>
<div class="text-[10px] text-white/40 truncate">{{ getMemberRole(member) }}</div>
</div>
</button>
</div>
<div class="flex items-center gap-1 shrink-0 mb-0.5">
<button type="button" class="w-10 h-10 rounded-xl hover:bg-white/10 text-white/70 hover:text-white transition-colors flex items-center justify-center">
<span class="material-symbols-outlined text-[22px]">sentiment_satisfied</span>
</button>
<div class="w-[1px] h-6 bg-white/10 mx-1"></div>
<button
v-if="isGenerating"
type="button"
class="h-10 px-4 bg-red-500/10 text-red-300 border border-red-500/30 text-sm font-semibold rounded-xl flex items-center gap-2 transition-all hover:bg-red-500/20 active:scale-95"
@click="emit('stop')"
>
<span class="material-symbols-outlined text-[18px]">stop_circle</span>
{{ t('chat.input.stop') }}
</button>
<button
v-else
type="submit"
:disabled="!modelValue.trim()"
:class="[
'h-10 px-5 bg-primary hover:bg-primary-hover text-on-primary text-sm font-bold rounded-xl shadow-glow flex items-center gap-2 transition-all active:scale-95 transform',
!modelValue.trim() ? 'opacity-50 cursor-not-allowed' : ''
]"
>
{{ t('chat.input.send') }}
</button>
</div>
</form>
<div class="mt-2 flex items-center justify-between px-2 text-[11px] text-white/30 font-medium tracking-wide">
<span>{{ t('chat.input.hint') }}</span>
<span>{{ modelValue.length }}/{{ maxLength }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, toRef, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useSettings } from '@/shared/composables/useSettings';
import type { Member } from '../types';
const props = defineProps<{
modelValue: string;
maxLength?: number;
isGenerating?: boolean;
quickPrompts?: string[];
placeholder?: string;
members?: Member[];
}>();
const emit = defineEmits<{ (e: 'update:modelValue', value: string): void; (e: 'send'): void; (e: 'stop'): void }>();
const { t } = useI18n();
const { settings } = useSettings();
const formRef = ref<HTMLFormElement | null>(null);
const scrollRef = ref<HTMLDivElement | null>(null);
const inputRef = ref<HTMLTextAreaElement | null>(null);
const mentionDropdownRef = ref<HTMLDivElement | null>(null);
const maxLength = computed(() => props.maxLength ?? 1200);
const isGenerating = computed(() => props.isGenerating ?? false);
const quickPrompts = computed(() => props.quickPrompts ?? []);
const placeholder = computed(() => props.placeholder ?? t('chat.input.placeholder', { channel: t('chat.channelDisplay') }));
const isComposing = ref(false);
const spellCheck = computed(() => settings.value.language.spellCheck);
const members = computed(() => props.members ?? []);
const cursorIndex = ref(0);
const activeMentionIndex = ref(0);
const mentionAnchor = ref({ left: 0, top: 0 });
const handleKeydown = (event: KeyboardEvent) => {
if (showMentionSuggestions.value) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
if (!mentionSuggestions.value.length) return;
const direction = event.key === 'ArrowDown' ? 1 : -1;
activeMentionIndex.value =
(activeMentionIndex.value + direction + mentionSuggestions.value.length) % mentionSuggestions.value.length;
return;
}
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
const selected = mentionSuggestions.value[activeMentionIndex.value];
if (selected) {
applyMention(selected);
}
return;
}
}
if (event.key === 'Enter' && !event.shiftKey && !isComposing.value) {
event.preventDefault();
if (isGenerating.value) return;
emit('send');
}
};
const modelValue = toRef(props, 'modelValue');
const resizeInput = () => {
if (!inputRef.value) return;
inputRef.value.style.height = 'auto';
inputRef.value.style.height = `${Math.min(inputRef.value.scrollHeight, 160)}px`;
};
const focusInput = () => {
if (!inputRef.value) return;
inputRef.value.focus();
const length = inputRef.value.value.length;
inputRef.value.setSelectionRange(length, length);
};
const applyPrompt = (prompt: string) => {
const nextValue = modelValue.value.trim()
? `${modelValue.value.trim()}\n${prompt}`
: prompt;
emit('update:modelValue', nextValue);
nextTick(() => {
inputRef.value?.focus();
});
};
const updateCursor = () => {
if (!inputRef.value) return;
cursorIndex.value = inputRef.value.selectionStart ?? inputRef.value.value.length;
scheduleMentionAnchorUpdate();
};
const handleInput = (event: Event) => {
const target = event.target as HTMLTextAreaElement;
emit('update:modelValue', target.value);
cursorIndex.value = target.selectionStart ?? target.value.length;
scheduleMentionAnchorUpdate();
};
const mentionState = computed(() => {
if (isComposing.value) return null;
const text = modelValue.value;
const cursor = cursorIndex.value;
const prefix = text.slice(0, cursor);
const match = prefix.match(/(^|\s)@([^\s@]*)$/);
if (!match) return null;
const query = match[2] ?? '';
const startIndex = prefix.length - query.length - 1;
return { query, startIndex };
});
const mentionSuggestions = computed(() => {
if (!mentionState.value) return [];
const query = mentionState.value.query.trim().toLowerCase();
const options = members.value.filter((member) => member.name.toLowerCase().startsWith(query));
return options.slice(0, 6);
});
const showMentionSuggestions = computed(() => mentionState.value !== null && mentionSuggestions.value.length > 0);
const mentionDropdownStyle = computed(() => ({
left: `${mentionAnchor.value.left}px`,
top: `${mentionAnchor.value.top}px`
}));
const getMemberRole = (member: Member) => {
if (member.roleKey) return t(member.roleKey);
if (member.role) return member.role;
return t(`members.roles.${member.roleType}`);
};
const applyMention = (member: Member) => {
if (!mentionState.value) return;
const mention = `@${member.name}`;
const before = modelValue.value.slice(0, mentionState.value.startIndex);
const after = modelValue.value.slice(cursorIndex.value);
const needsSpace = after.length > 0 && !/^[\s\n]/.test(after);
const nextValue = `${before}${mention}${needsSpace ? ' ' : ''}${after}`;
emit('update:modelValue', nextValue);
nextTick(() => {
if (!inputRef.value) return;
const nextCursor = before.length + mention.length + (needsSpace ? 1 : 0);
inputRef.value.focus();
inputRef.value.setSelectionRange(nextCursor, nextCursor);
cursorIndex.value = nextCursor;
updateMentionAnchor();
});
};
const scheduleMentionAnchorUpdate = () => {
if (!showMentionSuggestions.value) return;
nextTick(() => {
updateMentionAnchor();
});
};
const updateMentionAnchor = () => {
if (!inputRef.value || !formRef.value || !mentionState.value) return;
const anchorIndex = Math.min(Math.max(mentionState.value.startIndex, 0), inputRef.value.value.length);
const caret = getCaretCoordinates(inputRef.value, anchorIndex);
if (!caret) return;
const style = window.getComputedStyle(inputRef.value);
const fontSize = Number.parseFloat(style.fontSize) || caret.height;
const lineHeight = Number.parseFloat(style.lineHeight);
const resolvedLineHeight = Number.isFinite(lineHeight) ? lineHeight : caret.height;
const inputRect = inputRef.value.getBoundingClientRect();
const formRect = formRef.value.getBoundingClientRect();
const scrollOffsetY = scrollRef.value?.scrollTop ?? 0;
const scrollOffsetX = inputRef.value.scrollLeft ?? 0;
const absLeft = inputRect.left + caret.left - scrollOffsetX;
const absTop = inputRect.top + caret.top - scrollOffsetY;
const dropdownWidth = mentionDropdownRef.value?.offsetWidth ?? 256;
const dropdownHeight = mentionDropdownRef.value?.offsetHeight ?? 256;
const viewportPadding = 8;
const clampedLeft = Math.max(viewportPadding, Math.min(absLeft, window.innerWidth - dropdownWidth - viewportPadding));
const clampedTop = Math.max(viewportPadding, Math.min(absTop - dropdownHeight, window.innerHeight - dropdownHeight - viewportPadding));
mentionAnchor.value = {
left: clampedLeft - formRect.left,
top: clampedTop - formRect.top
};
};
const getCaretCoordinates = (input: HTMLTextAreaElement, position: number) => {
const style = window.getComputedStyle(input);
const mirror = document.createElement('div');
mirror.style.position = 'absolute';
mirror.style.visibility = 'hidden';
mirror.style.whiteSpace = 'pre-wrap';
mirror.style.wordWrap = 'break-word';
mirror.style.boxSizing = 'border-box';
mirror.style.padding = style.padding;
mirror.style.border = style.border;
mirror.style.fontFamily = style.fontFamily;
mirror.style.fontSize = style.fontSize;
mirror.style.lineHeight = style.lineHeight;
mirror.style.letterSpacing = style.letterSpacing;
mirror.style.width = `${input.clientWidth}px`;
mirror.textContent = input.value.slice(0, position);
const marker = document.createElement('span');
marker.textContent = '\u200b';
mirror.appendChild(marker);
document.body.appendChild(mirror);
const coords = { left: marker.offsetLeft, top: marker.offsetTop, height: marker.offsetHeight };
document.body.removeChild(mirror);
return coords;
};
watch(modelValue, async () => {
await nextTick();
resizeInput();
scheduleMentionAnchorUpdate();
});
watch(mentionSuggestions, () => {
activeMentionIndex.value = 0;
scheduleMentionAnchorUpdate();
});
watch(showMentionSuggestions, (visible) => {
if (visible) {
scheduleMentionAnchorUpdate();
}
});
onMounted(() => {
resizeInput();
});
defineExpose({ focus: focusInput });
</script>
@@ -0,0 +1,307 @@
<template>
<div ref="containerRef" class="w-16 md:w-64 bg-panel/50 border-r border-white/5 flex flex-col shrink-0">
<div class="h-16 flex items-center px-2 md:px-4 border-b border-white/5 justify-center md:justify-start">
<h2 class="text-white font-bold text-sm tracking-wide flex items-center gap-2">
<span class="material-symbols-outlined text-primary text-[18px]">layers</span>
<span class="hidden md:inline">{{ t('chat.sidebar.workspaceName') }}</span>
</h2>
</div>
<div class="flex-1 overflow-y-auto py-4 px-1 md:px-2 space-y-6 custom-scrollbar">
<div>
<div class="px-2 mb-2 items-center justify-between group hidden md:flex">
<h3 class="text-[11px] font-bold text-white/40 uppercase tracking-wider">{{ t('chat.sidebar.channels') }}</h3>
<button class="text-white/20 hover:text-white transition-colors opacity-0 group-hover:opacity-100" type="button">
<span class="material-symbols-outlined text-[16px]">add</span>
</button>
</div>
<div class="space-y-1">
<div
v-for="item in channelItems"
:key="item.conversation.id"
:class="[
'w-full px-2 md:px-3 py-2 rounded-xl flex items-center md:items-start gap-3 transition-all group cursor-pointer justify-center md:justify-start',
item.conversation.id === activeConversationId ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'
]"
@click="selectConversation(item.conversation.id)"
>
<span :class="['text-[18px] font-semibold leading-none mt-0.5', item.conversation.id === activeConversationId ? 'text-primary' : 'text-white/30']">#</span>
<div class="hidden md:flex items-start gap-3 min-w-0 flex-1">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 min-w-0">
<span class="text-[13px] font-semibold truncate">{{ item.title }}</span>
<span v-if="item.conversation.pinned" class="material-symbols-outlined text-[12px] text-white/40">push_pin</span>
<span v-if="item.conversation.muted" class="material-symbols-outlined text-[12px] text-white/40">notifications_off</span>
</div>
<div class="text-[11px] text-white/40 truncate">{{ item.preview }}</div>
</div>
</div>
<div class="relative shrink-0 hidden md:block">
<button
type="button"
@click.stop="toggleMenu(item.conversation.id)"
:class="[
'w-7 h-7 rounded-lg flex items-center justify-center transition-colors',
openMenuId === item.conversation.id ? 'bg-white/10 text-white' : 'text-white/30 hover:text-white hover:bg-white/10'
]"
>
<span class="material-symbols-outlined text-[16px]">more_vert</span>
</button>
<div
v-if="openMenuId === item.conversation.id"
class="absolute right-0 top-full mt-2 w-56 max-h-64 rounded-xl glass-modal bg-panel-strong/95 flex flex-col py-1.5 shadow-2xl overflow-y-auto custom-scrollbar z-50 animate-in fade-in zoom-in-95 duration-200 ring-1 ring-white/10"
@click.stop
>
<button
type="button"
class="relative w-full text-left px-4 py-2.5 text-xs font-bold text-white hover:bg-white/5 hover:text-white transition-colors flex items-center gap-3"
@click="handleAction(item.conversation, item.conversation.pinned ? 'unpin' : 'pin')"
>
<span class="material-symbols-outlined text-lg opacity-70">push_pin</span>
{{ item.conversation.pinned ? t('chat.conversation.actions.unpin') : t('chat.conversation.actions.pin') }}
</button>
<button
type="button"
class="relative w-full text-left px-4 py-2.5 text-xs font-bold text-white hover:bg-white/5 hover:text-white transition-colors flex items-center gap-3"
@click="handleAction(item.conversation, 'rename')"
>
<span class="material-symbols-outlined text-lg opacity-70">edit</span>
{{ t('chat.conversation.actions.rename') }}
</button>
<button
type="button"
class="relative w-full text-left px-4 py-2.5 text-xs font-bold text-white hover:bg-white/5 hover:text-white transition-colors flex items-center gap-3"
@click="handleAction(item.conversation, item.conversation.muted ? 'unmute' : 'mute')"
>
<span class="material-symbols-outlined text-lg opacity-70">notifications_off</span>
{{ item.conversation.muted ? t('chat.conversation.actions.unmute') : t('chat.conversation.actions.mute') }}
</button>
<div class="h-px bg-white/10 my-1 mx-2"></div>
<button
type="button"
class="relative w-full text-left px-4 py-2.5 text-xs font-bold text-white hover:bg-white/5 hover:text-white transition-colors flex items-center gap-3"
@click="handleAction(item.conversation, 'clear')"
>
<span class="material-symbols-outlined text-lg opacity-70">delete_sweep</span>
{{ t('chat.conversation.actions.clear') }}
</button>
<button
type="button"
class="relative w-full text-left px-4 py-2.5 text-xs font-bold text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-colors flex items-center gap-3"
v-if="!isDefaultChannel(item.conversation)"
@click="handleAction(item.conversation, 'delete')"
>
<span class="material-symbols-outlined text-lg opacity-70">delete</span>
{{ t('chat.conversation.actions.deleteChannel') }}
</button>
</div>
</div>
</div>
</div>
</div>
<div>
<div class="px-2 mb-2 items-center justify-between group hidden md:flex">
<h3 class="text-[11px] font-bold text-white/40 uppercase tracking-wider">{{ t('chat.sidebar.directMessages') }}</h3>
<button class="text-white/20 hover:text-white transition-colors opacity-0 group-hover:opacity-100" type="button">
<span class="material-symbols-outlined text-[16px]">add</span>
</button>
</div>
<div class="space-y-1">
<div
v-for="item in directMessageItems"
:key="item.conversation.id"
:class="[
'w-full px-2 md:px-3 py-2 rounded-xl flex items-center md:items-start gap-3 transition-all group cursor-pointer justify-center md:justify-start',
item.conversation.id === activeConversationId ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'
]"
@click="selectConversation(item.conversation.id)"
>
<div class="relative">
<div class="w-9 h-9 rounded-full bg-cover bg-center shadow-md" :style="{ backgroundImage: `url('${item.member.avatar}')` }"></div>
<div :class="['absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-panel', item.member.status === 'online' ? 'bg-green-500' : item.member.status === 'dnd' ? 'bg-red-500' : 'bg-white/20']"></div>
</div>
<div class="hidden md:flex items-start gap-3 min-w-0 flex-1">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 min-w-0">
<span class="text-[13px] font-semibold truncate">{{ item.title }}</span>
<span v-if="item.conversation.pinned" class="material-symbols-outlined text-[12px] text-white/40">push_pin</span>
<span v-if="item.conversation.muted" class="material-symbols-outlined text-[12px] text-white/40">notifications_off</span>
</div>
<div class="text-[11px] text-white/40 truncate">{{ item.preview }}</div>
</div>
</div>
<div class="relative shrink-0 hidden md:block">
<button
type="button"
@click.stop="toggleMenu(item.conversation.id)"
:class="[
'w-7 h-7 rounded-lg flex items-center justify-center transition-colors',
openMenuId === item.conversation.id ? 'bg-white/10 text-white' : 'text-white/30 hover:text-white hover:bg-white/10'
]"
>
<span class="material-symbols-outlined text-[16px]">more_vert</span>
</button>
<div
v-if="openMenuId === item.conversation.id"
class="absolute right-0 top-full mt-2 w-56 max-h-64 rounded-xl glass-modal bg-panel-strong/95 flex flex-col py-1.5 shadow-2xl overflow-y-auto custom-scrollbar z-50 animate-in fade-in zoom-in-95 duration-200 ring-1 ring-white/10"
@click.stop
>
<button
type="button"
class="relative w-full text-left px-4 py-2.5 text-xs font-bold text-white hover:bg-white/5 hover:text-white transition-colors flex items-center gap-3"
@click="handleAction(item.conversation, item.conversation.pinned ? 'unpin' : 'pin')"
>
<span class="material-symbols-outlined text-lg opacity-70">push_pin</span>
{{ item.conversation.pinned ? t('chat.conversation.actions.unpin') : t('chat.conversation.actions.pin') }}
</button>
<button
type="button"
class="relative w-full text-left px-4 py-2.5 text-xs font-bold text-white hover:bg-white/5 hover:text-white transition-colors flex items-center gap-3"
@click="handleAction(item.conversation, item.conversation.muted ? 'unmute' : 'mute')"
>
<span class="material-symbols-outlined text-lg opacity-70">notifications_off</span>
{{ item.conversation.muted ? t('chat.conversation.actions.unmute') : t('chat.conversation.actions.mute') }}
</button>
<div class="h-px bg-white/10 my-1 mx-2"></div>
<button
type="button"
class="relative w-full text-left px-4 py-2.5 text-xs font-bold text-white hover:bg-white/5 hover:text-white transition-colors flex items-center gap-3"
@click="handleAction(item.conversation, 'clear')"
>
<span class="material-symbols-outlined text-lg opacity-70">delete_sweep</span>
{{ t('chat.conversation.actions.clear') }}
</button>
<button
type="button"
class="relative w-full text-left px-4 py-2.5 text-xs font-bold text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-colors flex items-center gap-3"
@click="handleAction(item.conversation, 'delete')"
>
<span class="material-symbols-outlined text-lg opacity-70">delete</span>
{{ t('chat.conversation.actions.deleteDirect') }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Conversation, ConversationAction, Member } from '../types';
import { DEFAULT_CHANNEL_ID } from '../data';
const props = defineProps<{
conversations: Conversation[];
members: Member[];
activeConversationId: string;
}>();
const emit = defineEmits<{
(e: 'select-conversation', conversationId: string): void;
(e: 'conversation-action', payload: { conversationId: string; action: ConversationAction }): void;
}>();
const { t } = useI18n();
const openMenuId = ref<string | null>(null);
const containerRef = ref<HTMLElement | null>(null);
const memberById = computed(() => new Map(props.members.map((member) => [member.id, member])));
const normalizePreview = (text: string) => text.replace(/\s+/g, ' ').trim();
const getConversationTitle = (conversation: Conversation) => {
if (conversation.type === 'dm') {
return memberById.value.get(conversation.targetId)?.name ?? t('members.roles.member');
}
return conversation.customName ?? (conversation.nameKey ? t(conversation.nameKey) : conversation.targetId);
};
const isDefaultChannel = (conversation: Conversation) =>
conversation.type === 'channel' && conversation.targetId === DEFAULT_CHANNEL_ID;
const getLatestMessage = (conversation: Conversation) => {
let latest: Conversation['messages'][number] | null = null;
for (const message of conversation.messages) {
if (!latest || message.createdAt > latest.createdAt) {
latest = message;
}
}
return latest;
};
const getLastMessageTime = (conversation: Conversation) => getLatestMessage(conversation)?.createdAt ?? 0;
const getLastMessagePreview = (conversation: Conversation) => {
const latest = getLatestMessage(conversation);
return latest ? normalizePreview(latest.text) : '';
};
const sortConversations = (items: Conversation[]) =>
[...items].sort((a, b) => {
if (a.pinned !== b.pinned) {
return a.pinned ? -1 : 1;
}
const timeA = getLastMessageTime(a);
const timeB = getLastMessageTime(b);
if (timeA !== timeB) {
return timeB - timeA;
}
return getConversationTitle(a).localeCompare(getConversationTitle(b));
});
const channelItems = computed(() =>
sortConversations(props.conversations.filter((conversation) => conversation.type === 'channel')).map((conversation) => ({
conversation,
title: getConversationTitle(conversation),
preview: getLastMessagePreview(conversation)
}))
);
const directMessageItems = computed(() =>
sortConversations(props.conversations.filter((conversation) => conversation.type === 'dm'))
.map((conversation) => {
const member = memberById.value.get(conversation.targetId);
if (!member) return null;
return {
conversation,
member,
title: getConversationTitle(conversation),
preview: getLastMessagePreview(conversation)
};
})
.filter((item): item is { conversation: Conversation; member: Member; title: string; preview: string } => Boolean(item))
);
const toggleMenu = (conversationId: string) => {
openMenuId.value = openMenuId.value === conversationId ? null : conversationId;
};
const selectConversation = (conversationId: string) => {
openMenuId.value = null;
emit('select-conversation', conversationId);
};
const handleAction = (conversation: Conversation, action: ConversationAction) => {
openMenuId.value = null;
emit('conversation-action', { conversationId: conversation.id, action });
};
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
openMenuId.value = null;
}
};
onMounted(() => {
document.addEventListener('mousedown', handleClickOutside);
});
onBeforeUnmount(() => {
document.removeEventListener('mousedown', handleClickOutside);
});
</script>
@@ -0,0 +1,56 @@
<template>
<div class="fixed top-[74px] right-6 z-50 w-[260px] bg-panel/80 backdrop-blur-2xl border border-white/10 rounded-2xl shadow-2xl flex flex-col p-1.5 animate-in fade-in zoom-in-95 duration-200 origin-top-right ring-1 ring-white/5">
<div class="px-3 py-2 mb-1">
<h3 class="text-white font-semibold text-[13px] tracking-tight">{{ t('invite.menu.title') }}</h3>
<p class="text-white/40 text-[11px] font-medium mt-0.5">{{ t('invite.menu.subtitle') }}</p>
</div>
<div class="h-[1px] bg-white/5 mx-2 mb-1.5"></div>
<button
type="button"
@click="emit('select', 'admin')"
class="flex items-center gap-3 px-3 py-2 rounded-xl hover:bg-white/10 group transition-all duration-200 text-left w-full"
>
<div class="w-8 h-8 rounded-[10px] bg-red-500/10 text-red-400 flex items-center justify-center group-hover:bg-red-500 group-hover:text-on-primary group-hover:shadow-lg group-hover:shadow-red-500/20 transition-all duration-200">
<span class="material-symbols-outlined text-[18px]">admin_panel_settings</span>
</div>
<div class="flex flex-col">
<span class="text-white/90 text-[13px] font-medium group-hover:text-white tracking-tight">{{ t('invite.menu.admin') }}</span>
<span class="text-white/40 text-[10px] group-hover:text-white/60">{{ t('invite.menu.adminDesc') }}</span>
</div>
</button>
<button
type="button"
@click="emit('select', 'assistant')"
class="flex items-center gap-3 px-3 py-2 rounded-xl hover:bg-white/10 group transition-all duration-200 text-left w-full"
>
<div class="w-8 h-8 rounded-[10px] bg-yellow-500/10 text-yellow-400 flex items-center justify-center group-hover:bg-yellow-500 group-hover:text-on-primary group-hover:shadow-lg group-hover:shadow-yellow-500/20 transition-all duration-200">
<span class="material-symbols-outlined text-[18px]">manage_accounts</span>
</div>
<div class="flex flex-col">
<span class="text-white/90 text-[13px] font-medium group-hover:text-white tracking-tight">{{ t('invite.menu.assistant') }}</span>
<span class="text-white/40 text-[10px] group-hover:text-white/60">{{ t('invite.menu.assistantDesc') }}</span>
</div>
</button>
<button
type="button"
@click="emit('select', 'member')"
class="flex items-center gap-3 px-3 py-2 rounded-xl hover:bg-white/10 group transition-all duration-200 text-left w-full"
>
<div class="w-8 h-8 rounded-[10px] bg-primary/10 text-primary flex items-center justify-center group-hover:bg-primary group-hover:text-on-primary group-hover:shadow-lg group-hover:shadow-primary/20 transition-all duration-200">
<span class="material-symbols-outlined text-[18px]">person</span>
</div>
<div class="flex flex-col">
<span class="text-white/90 text-[13px] font-medium group-hover:text-white tracking-tight">{{ t('invite.menu.member') }}</span>
<span class="text-white/40 text-[10px] group-hover:text-white/60">{{ t('invite.menu.memberDesc') }}</span>
</div>
</button>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
const emit = defineEmits<{ (e: 'select', type: 'admin' | 'assistant' | 'member'): void }>();
const { t } = useI18n();
</script>
@@ -0,0 +1,98 @@
<template>
<div class="flex items-center gap-3 p-2 rounded-xl hover:bg-white/5 group transition-all duration-200">
<div :class="['relative transition-all duration-300', member.status === 'offline' ? 'grayscale opacity-60 group-hover:grayscale-0 group-hover:opacity-100' : '']">
<div class="w-10 h-10 rounded-full bg-cover bg-center shadow-md" :style="{ backgroundImage: `url('${member.avatar}')` }"></div>
<div v-if="member.roleType === 'owner'" class="absolute -bottom-0.5 -right-0.5 w-4 h-4 bg-green-500 border-2 border-panel rounded-full flex items-center justify-center text-panel"></div>
<div v-else-if="member.roleType === 'admin'" class="absolute bottom-0 right-0 w-3 h-3 bg-[#2ecc71] border-2 border-panel rounded-full"></div>
<div v-else-if="member.roleType === 'assistant'" class="absolute bottom-0 right-0 w-3 h-3 bg-[#e74c3c] border-2 border-panel rounded-full"></div>
<div v-else class="absolute bottom-0 right-0 w-3 h-3 bg-gray-500 border-2 border-panel rounded-full"></div>
</div>
<div class="flex items-center justify-between gap-2 min-w-0 flex-1">
<div class="flex flex-col min-w-0">
<div class="flex items-center gap-2">
<span :class="[member.roleType === 'owner' ? 'text-primary font-bold' : 'text-white font-medium group-hover:text-primary', 'text-[13px] leading-none transition-colors']">{{ member.name }}</span>
<span v-if="member.roleType === 'owner'" class="px-1.5 py-0.5 bg-yellow-500/20 text-yellow-500 text-[9px] rounded border border-yellow-500/20 font-bold uppercase tracking-wide">{{ t('members.roles.owner') }}</span>
<span v-if="member.roleType === 'admin'" class="px-1.5 py-0.5 bg-primary/20 text-primary text-[9px] rounded border border-primary/20 font-bold uppercase tracking-wide">{{ t('members.roles.admin') }}</span>
</div>
<span v-if="displayRole" class="text-white/30 text-[10px] mt-1.5 font-medium truncate">{{ displayRole }}</span>
</div>
<div class="relative shrink-0">
<button
type="button"
@click.stop="emit('toggle-menu', member)"
:class="[
'w-8 h-8 rounded-lg flex items-center justify-center transition-colors',
menuOpen ? 'bg-white/10 text-white' : 'text-white/30 hover:text-white hover:bg-white/10'
]"
>
<span class="material-symbols-outlined text-[18px]">more_vert</span>
</button>
<div
v-if="menuOpen"
class="absolute right-0 top-full mt-2 w-52 rounded-xl glass-modal bg-panel-strong/95 flex flex-col py-1.5 shadow-2xl overflow-hidden z-50 animate-in fade-in zoom-in-95 duration-200 ring-1 ring-white/10"
@click.stop
>
<button
type="button"
class="relative w-full text-left px-4 py-2.5 text-xs font-bold text-white hover:bg-white/5 hover:text-white transition-colors flex items-center gap-3"
@click="emit('action', { action: 'send-message', member })"
>
<span class="material-symbols-outlined text-lg opacity-70">chat_bubble</span>
{{ t('members.actions.sendMessage') }}
</button>
<button
type="button"
class="relative w-full text-left px-4 py-2.5 text-xs font-bold text-white hover:bg-white/5 hover:text-white transition-colors flex items-center gap-3"
@click="emit('action', { action: 'mention', member })"
>
<span class="material-symbols-outlined text-lg opacity-70">alternate_email</span>
{{ t('members.actions.mention') }}
</button>
<button
type="button"
class="relative w-full text-left px-4 py-2.5 text-xs font-bold text-white hover:bg-white/5 hover:text-white transition-colors flex items-center gap-3"
@click="emit('action', { action: 'rename', member })"
>
<span class="material-symbols-outlined text-lg opacity-70">edit</span>
{{ t('members.actions.rename') }}
</button>
<template v-if="canRemove">
<div class="h-px bg-white/10 my-1 mx-2"></div>
<button
type="button"
class="relative w-full text-left px-4 py-2.5 text-xs font-bold text-red-400 hover:bg-red-500/10 hover:text-red-500 transition-colors flex items-center gap-3"
@click="emit('action', { action: 'remove', member })"
>
<span class="material-symbols-outlined text-lg opacity-70">person_remove</span>
{{ t('members.manage.remove') }}
</button>
</template>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, toRef } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Member, MemberAction } from '../types';
const props = defineProps<{ member: Member; menuOpen?: boolean; currentUserId?: string }>();
const emit = defineEmits<{
(e: 'toggle-menu', member: Member): void;
(e: 'action', payload: { action: MemberAction; member: Member }): void;
}>();
const member = toRef(props, 'member');
const menuOpen = computed(() => props.menuOpen ?? false);
const canRemove = computed(() => (props.currentUserId ? member.value.id !== props.currentUserId : true));
const { t } = useI18n();
const displayRole = computed(() => {
if (member.value.roleKey) {
return t(member.value.roleKey);
}
return member.value.role;
});
</script>
@@ -0,0 +1,131 @@
<template>
<aside
ref="containerRef"
:class="[
'bg-panel/50 glass-panel border-l border-white/5 shrink-0 flex-col py-6 px-4 h-full',
variant === 'drawer' ? 'flex w-72' : 'hidden xl:flex w-[260px]'
]"
>
<div class="mb-6 flex items-center justify-between px-2">
<h2 class="text-white font-bold text-[15px]">{{ t('members.title') }}</h2>
<button
type="button"
@click="emit('toggle-invite')"
:class="[
'w-8 h-8 rounded-full flex items-center justify-center transition-colors shadow-glow ring-1 ring-white/20 relative z-50',
showInviteMenu ? 'bg-primary text-on-primary shadow-glow' : 'bg-panel/60 text-white/70 hover:bg-panel/80 hover:text-white'
]"
>
<span class="material-symbols-outlined text-[20px]">person_add</span>
</button>
</div>
<div class="space-y-2 overflow-y-auto custom-scrollbar flex-1">
<div v-if="owners.length" class="mb-6">
<h3 class="text-white/30 text-[10px] font-bold uppercase tracking-widest mb-3 px-2">{{ t('members.sections.owner', { count: owners.length }) }}</h3>
<div class="space-y-1">
<MemberRow
v-for="member in owners"
:key="member.id"
:member="member"
:current-user-id="currentUserId"
:menu-open="openMenuId === member.id"
@toggle-menu="toggleMenu"
@action="handleAction"
/>
</div>
</div>
<div v-if="admins.length" class="mb-6">
<h3 class="text-white/30 text-[10px] font-bold uppercase tracking-widest mb-3 px-2">{{ t('members.sections.admin', { count: admins.length }) }}</h3>
<div class="space-y-1">
<MemberRow
v-for="member in admins"
:key="member.id"
:member="member"
:current-user-id="currentUserId"
:menu-open="openMenuId === member.id"
@toggle-menu="toggleMenu"
@action="handleAction"
/>
</div>
</div>
<div v-if="assistants.length" class="mb-6">
<h3 class="text-white/30 text-[10px] font-bold uppercase tracking-widest mb-3 px-2">{{ t('members.sections.assistant', { count: assistants.length }) }}</h3>
<div class="space-y-1">
<MemberRow
v-for="member in assistants"
:key="member.id"
:member="member"
:current-user-id="currentUserId"
:menu-open="openMenuId === member.id"
@toggle-menu="toggleMenu"
@action="handleAction"
/>
</div>
</div>
<div v-if="membersGroup.length" class="mb-6">
<h3 class="text-white/30 text-[10px] font-bold uppercase tracking-widest mb-3 px-2">{{ t('members.sections.member', { count: membersGroup.length }) }}</h3>
<div class="space-y-1">
<MemberRow
v-for="member in membersGroup"
:key="member.id"
:member="member"
:current-user-id="currentUserId"
:menu-open="openMenuId === member.id"
@toggle-menu="toggleMenu"
@action="handleAction"
/>
</div>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, toRef } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Member, MemberAction } from '../types';
import MemberRow from './MemberRow.vue';
const props = defineProps<{ members: Member[]; showInviteMenu: boolean; currentUserId?: string; variant?: 'sidebar' | 'drawer' }>();
const emit = defineEmits<{ (e: 'toggle-invite'): void; (e: 'member-action', payload: { action: MemberAction; member: Member }): void }>();
const owners = computed(() => props.members.filter((member) => member.roleType === 'owner'));
const admins = computed(() => props.members.filter((member) => member.roleType === 'admin'));
const assistants = computed(() => props.members.filter((member) => member.roleType === 'assistant'));
const membersGroup = computed(() => props.members.filter((member) => member.roleType === 'member'));
const showInviteMenu = toRef(props, 'showInviteMenu');
const currentUserId = toRef(props, 'currentUserId');
const variant = computed(() => props.variant ?? 'sidebar');
const openMenuId = ref<string | null>(null);
const containerRef = ref<HTMLElement | null>(null);
const { t } = useI18n();
const toggleMenu = (member: Member) => {
openMenuId.value = openMenuId.value === member.id ? null : member.id;
};
const handleAction = (payload: { action: MemberAction; member: Member }) => {
openMenuId.value = null;
emit('member-action', payload);
};
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
openMenuId.value = null;
}
};
onMounted(() => {
document.addEventListener('mousedown', handleClickOutside);
});
onBeforeUnmount(() => {
document.removeEventListener('mousedown', handleClickOutside);
});
</script>
@@ -0,0 +1,166 @@
<template>
<div ref="listRef" class="flex-1 overflow-y-auto px-8 py-6 space-y-8 custom-scrollbar flex flex-col">
<template v-for="item in groupedItems" :key="item.id">
<div v-if="item.type === 'separator'" class="relative flex py-2 items-center justify-center">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-white/5"></div>
</div>
<span class="relative px-4 bg-panel-strong/30 rounded-full text-white/30 text-[11px] font-semibold backdrop-blur-md border border-white/5">{{ item.label }}</span>
</div>
<div
v-else
:class="[
'flex gap-5 group hover:bg-white/[0.02] -mx-6 px-6 py-2 rounded-2xl transition-colors',
isMe(item.message) ? 'flex-row-reverse' : ''
]"
>
<div class="mt-1 shrink-0 cursor-pointer">
<div class="w-11 h-11 rounded-[14px] bg-cover bg-center shadow-lg" :style="{ backgroundImage: `url('${item.message.avatar}')` }"></div>
</div>
<div :class="['flex flex-col flex-1 min-w-0', isMe(item.message) ? 'items-end' : '']">
<div :class="['flex items-baseline gap-2.5', isMe(item.message) ? 'flex-row-reverse' : '']">
<span class="text-white font-semibold text-[15px] cursor-pointer hover:underline tracking-tight">{{ item.message.user }}</span>
<span class="text-white/30 text-[11px] font-medium">{{ item.message.time }}</span>
</div>
<div v-if="isMe(item.message)" class="mt-1 bg-white text-slate-900 message-bubble--me px-5 py-3 rounded-2xl rounded-tr-sm shadow-lg max-w-[80%] text-[15px] leading-relaxed font-medium">
{{ item.message.text }}
</div>
<div v-else class="text-white/90 text-[15px] leading-relaxed mt-1 font-light tracking-wide">
<template v-for="(part, index) in splitMentions(item.message.text)" :key="index">
<span v-if="part.startsWith('@')" class="text-primary bg-primary/10 px-1.5 py-0.5 rounded cursor-pointer hover:bg-primary/20 transition-colors font-medium">{{ part }}</span>
<span v-else>{{ part }}</span>
</template>
</div>
<div v-if="isMe(item.message) && item.message.status" class="mt-1 text-[11px] text-white/30 font-medium">
<span v-if="item.message.status === 'sending'">{{ t('chat.messages.status.sending') }}</span>
<span v-else-if="item.message.status === 'failed'">{{ t('chat.messages.status.failed') }}</span>
</div>
<div v-if="item.message.attachment && item.message.attachment.type === 'image'" class="mt-3 bg-white/5 rounded-xl max-w-sm overflow-hidden border border-white/5 group/image relative cursor-pointer hover:border-primary/30 transition-all hover:shadow-lg">
<div class="h-44 w-full bg-cover bg-center" :style="{ backgroundImage: `url('${item.message.attachment.url}')` }"></div>
<div class="p-3 bg-white/[0.02] backdrop-blur-sm">
<div class="text-[13px] font-medium text-white truncate">{{ item.message.attachment.name }}</div>
<div class="text-[11px] text-white/40 mt-0.5">{{ item.message.attachment.size }}</div>
</div>
<div class="absolute inset-0 bg-primary/20 opacity-0 group-hover/image:opacity-100 flex items-center justify-center transition-opacity backdrop-blur-[2px]">
<span class="material-symbols-outlined text-white text-3xl drop-shadow-lg">download</span>
</div>
</div>
<div
v-if="item.message.attachment && item.message.attachment.type === 'roadmap'"
class="mt-3 inline-flex items-center gap-3 p-3 rounded-xl bg-panel-soft border border-white/10 hover:border-primary/30 transition-colors cursor-pointer group/attachment"
@click="emit('open-roadmap')"
>
<div class="w-10 h-10 rounded-lg bg-emerald-500/10 flex items-center justify-center text-emerald-400">
<span class="material-symbols-outlined">map</span>
</div>
<div>
<div class="text-sm font-bold text-white group-hover/attachment:text-primary transition-colors">{{ item.message.attachment.title }}</div>
<div class="text-xs text-white/40">{{ t('chat.messages.roadmapHint') }}</div>
</div>
</div>
</div>
</div>
</template>
<div v-if="isTyping" class="flex items-center gap-4 py-2 px-6 -mx-6 rounded-2xl">
<div class="w-11 h-11 rounded-[14px] bg-cover bg-center shadow-lg flex items-center justify-center" :style="{ backgroundImage: typingAvatar ? `url('${typingAvatar}')` : 'none' }">
<span v-if="!typingAvatar" class="material-symbols-outlined text-white/40 text-[22px]">smart_toy</span>
</div>
<div class="flex flex-col">
<div class="flex items-center gap-1 text-white/40 text-[12px]">
<span class="animate-pulse">•</span>
<span class="animate-pulse">•</span>
<span class="animate-pulse">•</span>
<span class="ml-2">{{ t('chat.messages.typing', { name: typingName }) }}</span>
</div>
</div>
</div>
<button
v-if="showJumpButton"
type="button"
class="sticky bottom-6 self-end mr-2 px-4 py-2 rounded-full bg-panel/80 border border-white/10 text-white/70 hover:text-white hover:bg-panel-strong/80 transition-all shadow-lg backdrop-blur"
@click="handleJumpToLatest"
>
<span class="material-symbols-outlined text-[16px] mr-1 align-middle">south</span>
<span class="text-[12px] font-medium">{{ t('chat.messages.jumpToLatest') }}</span>
</button>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, toRef, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Message } from '../types';
import { groupMessagesByDay, splitMentions } from '../utils';
const props = defineProps<{
messages: Message[];
currentUserId: string;
currentUserName: string;
isTyping?: boolean;
typingName?: string;
typingAvatar?: string;
}>();
const emit = defineEmits<{ (e: 'open-roadmap'): void }>();
const messages = toRef(props, 'messages');
const listRef = ref<HTMLDivElement | null>(null);
const isPinnedToBottom = ref(true);
const { t, locale } = useI18n();
const groupedItems = computed(() => groupMessagesByDay(messages.value, locale.value));
const isTyping = computed(() => props.isTyping ?? false);
const typingName = computed(() => props.typingName ?? t('members.roles.aiAssistant'));
const typingAvatar = computed(() => props.typingAvatar ?? '');
const showJumpButton = computed(() => !isPinnedToBottom.value);
const isMe = (msg: Message) => {
if (msg.senderId) {
return msg.senderId === props.currentUserId;
}
return msg.user === props.currentUserName;
};
const updatePinnedState = () => {
if (!listRef.value) return;
const threshold = 120;
const distanceFromBottom = listRef.value.scrollHeight - listRef.value.scrollTop - listRef.value.clientHeight;
isPinnedToBottom.value = distanceFromBottom < threshold;
};
const scrollToBottom = () => {
if (!listRef.value) return;
listRef.value.scrollTop = listRef.value.scrollHeight;
};
const handleJumpToLatest = () => {
scrollToBottom();
isPinnedToBottom.value = true;
};
watch(
[() => messages.value.length, isTyping],
async () => {
await nextTick();
if (isPinnedToBottom.value) {
scrollToBottom();
}
}
);
onMounted(() => {
updatePinnedState();
listRef.value?.addEventListener('scroll', updatePinnedState, { passive: true });
scrollToBottom();
});
onBeforeUnmount(() => {
listRef.value?.removeEventListener('scroll', updatePinnedState);
});
</script>
@@ -0,0 +1,428 @@
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import {
AI_ASSISTANT_ID,
CURRENT_USER_ID,
DEFAULT_CHANNEL_ID,
DEFAULT_CHANNEL_NAME_KEY,
initialMembers,
initialMessages
} from '../data';
import { PRIMARY_USER_AVATAR_URL } from '../../../shared/constants/avatars';
import type { Conversation, ConversationType, Member, Message, MessageStatus } from '../types';
import { ensureUniqueName, formatMessageTime } from '../utils';
const STORAGE_KEY = 'nexus-chat-session-v1';
const SEND_SIMULATION_DELAY = 200;
const ASSISTANT_REPLY_DELAY = 900;
const MAX_MESSAGE_LENGTH = 1200;
type StoredSession = {
members: Member[];
conversations: Conversation[];
nextMessageId: number;
messages?: Message[];
directMessageIds?: string[];
};
const buildConversationId = (type: ConversationType, targetId: string) => `${type}:${targetId}`;
const getMemberAvatar = (members: Member[], id?: string) => {
if (!id) return undefined;
return members.find((member) => member.id === id)?.avatar;
};
const getCurrentUserName = (members: Member[]) => members.find((member) => member.id === CURRENT_USER_ID)?.name ?? 'You';
const buildUiAvatar = (name: string) =>
`https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=0b57d0&color=fff`;
const resolveMessageAvatar = (message: Message, members: Member[]) => {
const memberAvatar = getMemberAvatar(members, message.senderId);
if (memberAvatar) return memberAvatar;
if (message.avatar && message.avatar !== PRIMARY_USER_AVATAR_URL) {
return message.avatar;
}
const currentUserName = getCurrentUserName(members);
if (message.user && message.user !== currentUserName) {
return buildUiAvatar(message.user);
}
return message.avatar || PRIMARY_USER_AVATAR_URL;
};
const getNextMessageIdFromConversations = (conversations: Conversation[]) =>
conversations.reduce((max, conversation) => {
const conversationMax = conversation.messages.reduce((messageMax, message) => Math.max(messageMax, message.id), 0);
return Math.max(max, conversationMax);
}, 0) + 1;
const normalizeMessage = (message: Message, members: Member[]): Message => {
const createdAt = typeof message.createdAt === 'number' ? message.createdAt : Date.now();
const status: MessageStatus = message.status === 'failed' ? 'failed' : 'sent';
const avatar = resolveMessageAvatar(message, members);
return {
...message,
createdAt,
time: message.time || formatMessageTime(createdAt),
status,
avatar
};
};
const createChannelConversation = (messages: Message[], members: Member[]): Conversation => ({
id: buildConversationId('channel', DEFAULT_CHANNEL_ID),
type: 'channel',
targetId: DEFAULT_CHANNEL_ID,
nameKey: DEFAULT_CHANNEL_NAME_KEY,
pinned: false,
muted: false,
messages: messages.map((message) => normalizeMessage(message, members))
});
const createDirectConversation = (memberId: string): Conversation => ({
id: buildConversationId('dm', memberId),
type: 'dm',
targetId: memberId,
pinned: false,
muted: false,
messages: []
});
const normalizeConversation = (conversation: Partial<Conversation>, members: Member[]): Conversation | null => {
if (conversation.type !== 'channel' && conversation.type !== 'dm') return null;
if (typeof conversation.id !== 'string' || !conversation.id) return null;
if (typeof conversation.targetId !== 'string' || !conversation.targetId) return null;
return {
id: conversation.id,
type: conversation.type,
targetId: conversation.targetId,
nameKey: typeof conversation.nameKey === 'string' ? conversation.nameKey : undefined,
customName: typeof conversation.customName === 'string' ? conversation.customName : undefined,
descriptionKey: typeof conversation.descriptionKey === 'string' ? conversation.descriptionKey : undefined,
pinned: Boolean(conversation.pinned),
muted: Boolean(conversation.muted),
messages: Array.isArray(conversation.messages)
? conversation.messages.map((message) => normalizeMessage(message as Message, members))
: []
};
};
const buildDefaultSession = (): StoredSession => {
const members = [...initialMembers];
const defaultConversation = createChannelConversation(initialMessages, members);
return {
members,
conversations: [defaultConversation],
nextMessageId: getNextMessageIdFromConversations([defaultConversation])
};
};
const loadStoredSession = (): StoredSession => {
if (typeof window === 'undefined') {
return buildDefaultSession();
}
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
return buildDefaultSession();
}
try {
const parsed = JSON.parse(raw) as Partial<StoredSession>;
const members = Array.isArray(parsed.members) ? parsed.members : initialMembers;
let conversations: Conversation[] = [];
if (Array.isArray(parsed.conversations)) {
conversations = parsed.conversations
.map((conversation) => normalizeConversation(conversation, members))
.filter((conversation): conversation is Conversation => Boolean(conversation));
} else {
const messages = Array.isArray(parsed.messages)
? parsed.messages.map((message) => normalizeMessage(message as Message, members))
: initialMessages.map((message) => normalizeMessage(message, members));
conversations = [createChannelConversation(messages, members)];
if (Array.isArray(parsed.directMessageIds)) {
parsed.directMessageIds
.filter((id) => typeof id === 'string')
.forEach((memberId) => {
if (members.some((member) => member.id === memberId)) {
conversations.push(createDirectConversation(memberId));
}
});
}
}
conversations = conversations.filter((conversation) => {
if (conversation.type === 'dm') {
return members.some((member) => member.id === conversation.targetId);
}
return true;
});
const computedNextId = getNextMessageIdFromConversations(conversations);
const nextMessageId =
typeof parsed.nextMessageId === 'number' ? Math.max(parsed.nextMessageId, computedNextId) : computedNextId;
return {
members,
conversations,
nextMessageId
};
} catch {
return buildDefaultSession();
}
};
const persistSession = (session: StoredSession) => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
};
export const useChatSession = () => {
const { t, locale } = useI18n();
const stored = loadStoredSession();
const members = ref<Member[]>(stored.members);
const conversations = ref<Conversation[]>(stored.conversations);
const nextMessageId = ref(stored.nextMessageId);
const assistantReplyTimeoutId = ref<number | null>(null);
const assistantTypingConversationId = ref<string | null>(null);
const currentUser = computed(() => members.value.find((member) => member.id === CURRENT_USER_ID) ?? members.value[0]);
const assistantMember = computed(
() =>
members.value.find((member) => member.id === AI_ASSISTANT_ID) ??
members.value.find((member) => member.roleType === 'assistant') ??
null
);
const updateConversation = (conversationId: string, updater: (conversation: Conversation) => Conversation) => {
conversations.value = conversations.value.map((conversation) =>
conversation.id === conversationId ? updater(conversation) : conversation
);
};
const updateMessageStatus = (conversationId: string, id: number, status: MessageStatus) => {
updateConversation(conversationId, (conversation) => ({
...conversation,
messages: conversation.messages.map((message) => (message.id === id ? { ...message, status } : message))
}));
};
const addMessage = (conversationId: string, message: Message) => {
updateConversation(conversationId, (conversation) => ({
...conversation,
messages: [...conversation.messages, message]
}));
};
const sendMessage = (rawText: string, conversationId: string) => {
const trimmed = rawText.trim();
if (!trimmed) return null;
const conversation = conversations.value.find((item) => item.id === conversationId);
if (!conversation) return null;
const text = trimmed.slice(0, MAX_MESSAGE_LENGTH);
const createdAt = Date.now();
const message: Message = {
id: nextMessageId.value,
senderId: CURRENT_USER_ID,
user: currentUser.value?.name ?? 'You',
avatar: currentUser.value?.avatar ?? PRIMARY_USER_AVATAR_URL,
text,
time: formatMessageTime(createdAt, locale.value),
createdAt,
isAi: false,
status: 'sending'
};
nextMessageId.value += 1;
addMessage(conversation.id, message);
window.setTimeout(() => {
updateMessageStatus(conversation.id, message.id, 'sent');
}, SEND_SIMULATION_DELAY);
if (assistantMember.value) {
queueAssistantReply(text, conversation.id);
}
return message;
};
const queueAssistantReply = (prompt: string, conversationId: string) => {
if (assistantReplyTimeoutId.value !== null) {
window.clearTimeout(assistantReplyTimeoutId.value);
}
assistantTypingConversationId.value = conversationId;
const replyText = prompt.includes('?') ? t('chat.messages.autoReplyQuestion') : t('chat.messages.autoReply');
assistantReplyTimeoutId.value = window.setTimeout(() => {
const createdAt = Date.now();
const reply: Message = {
id: nextMessageId.value,
senderId: assistantMember.value?.id,
user: assistantMember.value?.name ?? t('members.roles.aiAssistant'),
avatar: assistantMember.value?.avatar ?? PRIMARY_USER_AVATAR_URL,
text: replyText,
time: formatMessageTime(createdAt, locale.value),
createdAt,
isAi: true,
status: 'sent'
};
nextMessageId.value += 1;
addMessage(conversationId, reply);
assistantTypingConversationId.value = null;
assistantReplyTimeoutId.value = null;
}, ASSISTANT_REPLY_DELAY);
};
const cancelAssistantReply = () => {
if (assistantReplyTimeoutId.value !== null) {
window.clearTimeout(assistantReplyTimeoutId.value);
assistantReplyTimeoutId.value = null;
}
assistantTypingConversationId.value = null;
};
const addMember = (member: Omit<Member, 'id'> & { id?: string }) => {
const id = member.id ?? Date.now().toString();
members.value = [...members.value, { ...member, id }];
return id;
};
const updateMember = (id: string, updates: Partial<Member>) => {
const updatedMember = members.value.find((member) => member.id === id);
if (!updatedMember) return;
const nextMember = { ...updatedMember, ...updates };
members.value = members.value.map((member) => (member.id === id ? nextMember : member));
conversations.value = conversations.value.map((conversation) => ({
...conversation,
messages: conversation.messages.map((message) => {
if (message.senderId !== id) return message;
return {
...message,
user: updates.name ?? message.user,
avatar: updates.avatar ?? message.avatar
};
})
}));
};
const removeMember = (id: string) => {
if (id === CURRENT_USER_ID) return;
members.value = members.value.filter((member) => member.id !== id);
conversations.value = conversations.value.filter((conversation) =>
conversation.type === 'dm' ? conversation.targetId !== id : true
);
};
const ensureDirectMessage = (memberId: string) => {
if (!memberId || memberId === CURRENT_USER_ID) return null;
if (!members.value.some((member) => member.id === memberId)) return null;
const existing = conversations.value.find((conversation) => conversation.type === 'dm' && conversation.targetId === memberId);
if (existing) {
return existing.id;
}
const conversation = createDirectConversation(memberId);
conversations.value = [...conversations.value, conversation];
return conversation.id;
};
const toggleConversationPin = (conversationId: string) => {
updateConversation(conversationId, (conversation) => ({
...conversation,
pinned: !conversation.pinned
}));
};
const toggleConversationMute = (conversationId: string) => {
updateConversation(conversationId, (conversation) => ({
...conversation,
muted: !conversation.muted
}));
};
const renameConversation = (conversationId: string, name: string) => {
const trimmed = name.trim();
updateConversation(conversationId, (conversation) => ({
...conversation,
customName: trimmed ? trimmed : undefined
}));
};
const clearConversationMessages = (conversationId: string) => {
updateConversation(conversationId, (conversation) => ({
...conversation,
messages: []
}));
};
const deleteConversation = (conversationId: string) => {
if (conversationId === buildConversationId('channel', DEFAULT_CHANNEL_ID)) {
return;
}
conversations.value = conversations.value.filter((conversation) => conversation.id !== conversationId);
};
const createInviteMember = (name: string, roleType: 'assistant' | 'member', roleKey: string, status: Member['status']) => {
const finalName = ensureUniqueName(name, members.value);
const avatar = roleType === 'assistant'
? `https://ui-avatars.com/api/?name=${encodeURIComponent(finalName)}&background=0b57d0&color=fff`
: `https://picsum.photos/seed/${encodeURIComponent(finalName)}/200/200`;
return {
id: Date.now().toString(),
name: finalName,
role: '',
roleKey,
roleType,
avatar,
status
};
};
watch(
[conversations, members, nextMessageId],
() => {
persistSession({
conversations: conversations.value,
members: members.value,
nextMessageId: nextMessageId.value
});
},
{ deep: true }
);
return {
members,
conversations,
currentUser,
assistantMember,
assistantTypingConversationId,
cancelAssistantReply,
maxMessageLength: MAX_MESSAGE_LENGTH,
sendMessage,
addMember,
updateMember,
removeMember,
createInviteMember,
ensureDirectMessage,
toggleConversationPin,
toggleConversationMute,
renameConversation,
clearConversationMessages,
deleteConversation
};
};
+118
View File
@@ -0,0 +1,118 @@
import type { Member, Message } from './types';
import { PRIMARY_USER_AVATAR_URL } from '../../shared/constants/avatars';
import { formatMessageTime } from './utils';
export const CURRENT_USER_ID = 'me';
export const AI_ASSISTANT_ID = 'ai-assistant';
export const DEFAULT_CHANNEL_ID = 'design-critique';
export const DEFAULT_CHANNEL_NAME_KEY = 'chat.sidebar.channelList.designCritique';
const seedBaseTime = Date.now() - 1000 * 60 * 45;
const getMemberAvatar = (id: string) => initialMembers.find((member) => member.id === id)?.avatar ?? PRIMARY_USER_AVATAR_URL;
const SARAH_AVATAR_URL = 'https://ui-avatars.com/api/?name=Sarah+Jenkins&background=f97316&color=fff';
export const initialMembers: Member[] = [
{
id: 'me',
name: 'You (Owner)',
role: '',
roleKey: 'members.activity.reviewingPRs',
roleType: 'owner',
avatar: PRIMARY_USER_AVATAR_URL,
status: 'online'
},
{
id: AI_ASSISTANT_ID,
name: 'AI Assistant',
role: '',
roleKey: 'members.roles.aiAssistant',
roleType: 'assistant',
avatar: 'https://ui-avatars.com/api/?name=AI+Assistant&background=0b57d0&color=fff',
status: 'online'
},
{
id: 'alex',
name: 'Alex Designer',
role: '',
roleKey: 'members.activity.listeningSpotify',
roleType: 'admin',
avatar: 'https://lh3.googleusercontent.com/aida-public/AB6AXuBaSWKB6mJCxTBrtvopWrUkwsow24JW16yM25iBaJ-i9zei2LLH2S2RuQAjClLKuBNbaKzFR9KdlrlnzjOONBvHumzYm6gwPCtacfn8Q1j2EM62pNme3MdwszqcLe-WnRiGFfDWnjvZna6DoiK0mpMNiTQyR5BfbSkirNIuJkIsXfO-uNqs0bwysJtFjeHP22BouFsb7HsObMaenKoOCxLgfgmYhMwTA_0ycwngEFMPuRnSXwfrAJThiBYH1y7nP7_pdwvn5SLJEVQ',
status: 'online'
},
{
id: 'marcus',
name: 'Marcus Chen',
role: '',
roleKey: 'members.activity.fixingBugs',
roleType: 'admin',
avatar: 'https://lh3.googleusercontent.com/aida-public/AB6AXuA6Ru1OP242ZM7Pqmcc0E4azuCTf6vQIbkDx_iqStSVeKqyw3_us_PT6uR2ieVDWo2PvDla6v8RvelP6L8mpxUNyJEzg5ch_a6A74BqRQ5ET3yvc0Iffy76hUZm2OG1xulIanIzIgTq6Roi7YG89KWLFjdKvww42xqUfnZE-BZMBVOm3n5EPuj-uUKP38BsWLfg7sPsv6-RquG7EMzizpe131L1FOy338mP5E_lX8bj_DGkZB_LtAa4onKR6wNSUm0x523rU124FFU',
status: 'offline'
},
{
id: 'elena',
name: 'Elena Rodriguez',
role: '',
roleKey: 'members.activity.doNotDisturb',
roleType: 'assistant',
avatar: 'https://lh3.googleusercontent.com/aida-public/AB6AXuANDYn5G_lzwb69JjXV0UV20RCfWcczalPe0pR-bPyraoHXJHGDyu6TWus4ohJqw3XZHMdjNjeg6nPslzIBQsDvxzpZhZ1N-4Qrz9kJGhzxY29NPp5Om9mssWsQnYbUZX6yxX44uj8TVVPb25Wft6Xsf39EMCb0Phw7liJjW4G1jbw0_nkffIq8h6UGHjoYAszOmHSKsfeIPKB78f5MICNpst_Cg6gYCc_PrX4DfXeAFupP9Ur1SlE1gH-hw2OPj9oFc46WNApWrEA',
status: 'dnd'
},
{
id: 'david',
name: 'David Kim',
role: '',
roleType: 'member',
avatar: 'https://lh3.googleusercontent.com/aida-public/AB6AXuBFQzuk64FLzX_ajt_3zGChUGLWu1uKdEIOD2Dhf-vzZ_qsa4W9PaFKPoBZbHJWwOzqMuqSzlcI-8qJsp1S7zzFOiNOrVtRhwe3pyPbWdd6KXY_EiNKRTk7T7odtGYfgww3ZB98j9bs0kY0wXgMLPGvKUHECZtbXbPX3OUW67dhx5Yj_IxM-cQnmYCMSAs7S_eYfBi7aVonF2Ab8Yb8lhxA5mC8S9Vdp2i3hmSihMheDqZY97OTqwh4vX-yzFai4f_K1WK-nRWyB7Q',
status: 'offline'
},
{
id: 'priya',
name: 'Priya Patel',
role: '',
roleType: 'member',
avatar: 'https://lh3.googleusercontent.com/aida-public/AB6AXuBS4LtfNbFLYhN3jlSqNIjwYajAlCLQehKY1GrzGpz7HuLfg_5Hp2_Nant5AasdB-kQg-3K9XgPfV2yHnT3k0OYMi4hAb7ZgtgG21tEINu95frS1lG58-HH_NYb4eH-2BLSvnxpxkUKjWxueAkDNBcJ4VM3FZMYZ94039lGD17Z7ha6oGb9XgD73hrhR6PaiAam5uaJxxUOTEpvlBgRbeYoSVIgQfh3B_fVZmSgppRfZliShDFtm4uWn16K4WIopmqqiNe68biESLE',
status: 'offline'
}
];
export const initialMessages: Message[] = [
{
id: 1,
user: 'Sarah Jenkins',
avatar: SARAH_AVATAR_URL,
text: 'Hey everyone! 👋 I just pushed the new mockups for the dashboard. Would love some eyes on the spacing for the sidebar.',
time: formatMessageTime(seedBaseTime),
createdAt: seedBaseTime,
isAi: false,
status: 'sent',
attachment: {
type: 'image',
name: 'dashboard_v2_final.png',
size: '2.4 MB',
url: 'https://lh3.googleusercontent.com/aida-public/AB6AXuAXwrGif3OTugu7R6Ry57VrWDqm-ccgrPxj2i2TLmIiwGt1GBqG5ekNNmrF-8FN0auYgBF_kd72SzQayNrryi57HXUT9eQXLeEqdnFwo4T4uwg8vzfF_l9qg_fRwZcqL6DmPlnYBxfErcbpj4ehD1UqZP5QdlZTFcpIfdcYP01LJ77YSTXSwjiEL3oK2eFAyD6lJlYoKzuOQgyvC6o4hmfwW7QG_mhp_6IlG207xJQ0YJJuvpE7CP3MSJAP0iiFESKx7OiNguxi-IE'
}
},
{
id: 2,
senderId: 'alex',
user: 'Alex Designer',
avatar: getMemberAvatar('alex'),
text: 'Looks great! I love the coral accent color you chose. It really pops against the dark background. 🔥',
time: formatMessageTime(seedBaseTime + 1000 * 60 * 3),
createdAt: seedBaseTime + 1000 * 60 * 3,
isAi: false,
status: 'sent'
},
{
id: 3,
senderId: 'marcus',
user: 'Marcus Chen',
avatar: getMemberAvatar('marcus'),
text: 'Agreed. @Sarah Jenkins when do we plan to ship this? I need to prep the backend.',
time: formatMessageTime(seedBaseTime + 1000 * 60 * 10),
createdAt: seedBaseTime + 1000 * 60 * 10,
isAi: false,
status: 'sent'
}
];
@@ -0,0 +1,98 @@
<template>
<div class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-in fade-in duration-200">
<div class="relative w-full max-w-[480px] bg-panel/90 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl flex flex-col overflow-hidden ring-1 ring-white/5">
<div class="px-6 py-5 border-b border-white/5 flex items-center gap-4 bg-gradient-to-r from-white/[0.03] to-transparent">
<div class="w-10 h-10 rounded-[12px] bg-red-500/10 text-red-400 flex items-center justify-center shadow-lg shadow-red-500/10 shrink-0 border border-red-500/10">
<span class="material-symbols-outlined text-[20px]">admin_panel_settings</span>
</div>
<div class="flex-1">
<h3 class="text-white font-semibold text-[17px] tracking-tight leading-tight">{{ t('invite.admin.title') }}</h3>
<p class="text-white/40 text-[12px] font-medium mt-0.5">{{ t('invite.admin.subtitle') }}</p>
</div>
<button type="button" @click="emit('close')" class="text-white/20 hover:text-white transition-colors rounded-lg p-1 hover:bg-white/5">
<span class="material-symbols-outlined text-[20px]">close</span>
</button>
</div>
<div class="p-6 space-y-6 max-h-[70vh] overflow-y-auto custom-scrollbar">
<div class="space-y-2.5">
<div class="flex items-center justify-between">
<label class="text-[11px] font-bold text-white/40 uppercase tracking-widest">{{ t('invite.admin.uniqueLink') }}</label>
<span class="text-[11px] text-primary cursor-pointer hover:underline">{{ t('invite.admin.regenerate') }}</span>
</div>
<div class="flex gap-2 group focus-within:ring-2 ring-primary/20 rounded-xl transition-all">
<div class="flex-1 bg-surface border border-white/10 rounded-xl px-3.5 py-2.5 flex items-center gap-2 transition-colors group-focus-within:border-primary/50 group-focus-within:bg-panel/70">
<span class="material-symbols-outlined text-white/20 text-[18px]">link</span>
<input class="bg-transparent border-none p-0 text-white/70 text-[13px] font-mono w-full focus:ring-0 truncate select-all" readonly value="https://sky.chat/invite/adm_9x82m..." />
</div>
<button type="button" class="px-4 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl text-white/70 hover:text-white transition-all font-medium text-[13px] flex items-center gap-2 active:scale-95">
<span class="material-symbols-outlined text-[18px]">content_copy</span>
</button>
</div>
</div>
<div class="space-y-2.5">
<label class="text-[11px] font-bold text-white/40 uppercase tracking-widest">{{ t('invite.admin.userIdentifier') }}</label>
<div class="relative group">
<div class="absolute left-3.5 top-2.5 text-white/30 group-focus-within:text-primary transition-colors">
<span class="material-symbols-outlined text-[20px]">alternate_email</span>
</div>
<input class="w-full bg-surface border border-white/10 rounded-xl py-2.5 pl-11 pr-4 text-white placeholder-white/20 focus:outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/50 transition-all text-[14px]" :placeholder="t('invite.admin.userPlaceholder')" type="text" />
</div>
</div>
<div class="space-y-2.5">
<label class="text-[11px] font-bold text-white/40 uppercase tracking-widest">{{ t('invite.admin.permissions') }}</label>
<div class="bg-surface/50 border border-white/10 rounded-xl overflow-hidden">
<label v-for="perm in permissions" :key="perm.title" class="flex items-start gap-3 p-3.5 hover:bg-white/5 cursor-pointer transition-colors group border-b border-white/5 last:border-0 relative">
<div class="relative flex items-center mt-0.5">
<input type="checkbox" :checked="perm.checked" class="peer sr-only" />
<div class="w-4 h-4 border-2 border-white/20 rounded-[4px] peer-checked:bg-primary peer-checked:border-primary transition-all flex items-center justify-center">
<span class="material-symbols-outlined text-[12px] text-on-primary font-bold opacity-0 peer-checked:opacity-100">check</span>
</div>
</div>
<div class="flex flex-col">
<span class="text-[13px] font-medium text-white/90 group-hover:text-white transition-colors">{{ perm.title }}</span>
<span class="text-[11px] text-white/30 mt-0.5 font-light">{{ perm.desc }}</span>
</div>
</label>
</div>
</div>
</div>
<div class="p-6 pt-2 bg-gradient-to-t from-panel-strong/80 to-transparent">
<button type="button" class="w-full py-3 rounded-xl bg-gradient-to-r from-primary to-primary-hover hover:brightness-110 text-on-primary font-bold text-[14px] shadow-glow flex items-center justify-center gap-2 transition-all active:scale-[0.98] group">
<span>{{ t('invite.admin.send') }}</span>
<span class="material-symbols-outlined text-[18px] group-hover:translate-x-0.5 transition-transform">send</span>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const emit = defineEmits<{ (e: 'close'): void }>();
const { t } = useI18n();
const permissions = computed(() => [
{
title: t('invite.admin.permissionsList.fullAccess.title'),
desc: t('invite.admin.permissionsList.fullAccess.desc'),
checked: true
},
{
title: t('invite.admin.permissionsList.billing.title'),
desc: t('invite.admin.permissionsList.billing.desc'),
checked: false
},
{
title: t('invite.admin.permissionsList.memberManagement.title'),
desc: t('invite.admin.permissionsList.memberManagement.desc'),
checked: true
}
]);
</script>
@@ -0,0 +1,186 @@
<template>
<div class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-in fade-in duration-200">
<div class="w-full max-w-[340px] bg-panel/80 backdrop-blur-2xl border border-white/10 rounded-3xl shadow-2xl flex flex-col relative ring-1 ring-white/5 overflow-hidden">
<button type="button" @click="emit('close')" class="absolute top-4 right-4 w-7 h-7 rounded-full bg-white/5 text-white/40 hover:text-white flex items-center justify-center transition-colors z-10 hover:bg-white/10">
<span class="material-symbols-outlined text-[16px]">close</span>
</button>
<div class="px-6 pt-8 pb-3 text-center">
<h2 class="text-white font-bold text-[17px] tracking-tight">{{ title }}</h2>
<p class="text-white/40 text-[12px] font-medium mt-1">{{ t('invite.assistant.subtitle') }}</p>
</div>
<div class="px-3 py-2 space-y-1">
<div
v-for="model in models"
:key="model.id"
@click="selectedModel = model.id"
:class="[
'flex items-center gap-3 p-2.5 rounded-xl border cursor-pointer group transition-all',
selectedModel === model.id ? 'bg-primary/10 border-primary/20 relative overflow-hidden' : 'border-transparent hover:bg-white/5'
]"
>
<div v-if="selectedModel === model.id" class="absolute left-0 top-0 bottom-0 w-0.5 bg-primary"></div>
<div
:class="[
'w-9 h-9 rounded-lg flex items-center justify-center',
selectedModel === model.id
? 'bg-primary text-on-primary shadow-lg shadow-primary/20'
: 'bg-white/5 text-white/70 border border-white/5 group-hover:bg-white/10'
]"
>
<span class="material-symbols-outlined text-[20px]">{{ model.icon }}</span>
</div>
<div class="flex flex-col z-10">
<span :class="['text-[13px] font-medium', selectedModel === model.id ? 'text-white font-semibold' : 'text-white/80 group-hover:text-white']">{{ t(model.labelKey) }}</span>
</div>
<div v-if="selectedModel === model.id" class="ml-auto text-primary">
<span class="material-symbols-outlined text-[18px]">check_circle</span>
</div>
</div>
</div>
<div class="px-5 py-5 space-y-4 mt-1 border-t border-white/5 bg-white/[0.02]">
<div class="flex items-center justify-between">
<span class="text-[13px] text-white/90 font-medium tracking-tight">{{ t('invite.assistant.instances') }}</span>
<div class="flex items-center bg-panel/60 rounded-lg p-0.5 border border-white/10 shadow-inner">
<button
type="button"
:disabled="!canDecreaseInstances"
:class="[
'w-7 h-6 rounded-md flex items-center justify-center transition-colors',
canDecreaseInstances ? 'text-white/60 hover:text-white hover:bg-white/10' : 'text-white/20 cursor-not-allowed'
]"
@click="decreaseInstances"
>
<span class="material-symbols-outlined text-[16px]">remove</span>
</button>
<input
:value="instanceText"
type="text"
inputmode="numeric"
class="w-10 bg-transparent text-center text-[13px] font-semibold text-white tabular-nums outline-none border-none focus:ring-0"
aria-label="Instance count"
@input="handleInstanceInput"
@blur="commitInstanceInput"
@keydown.enter.prevent="commitInstanceInput"
/>
<button
type="button"
:disabled="!canIncreaseInstances"
:class="[
'w-7 h-6 rounded-md flex items-center justify-center transition-colors',
canIncreaseInstances
? 'text-primary hover:bg-primary/10 shadow-[0_0_8px_rgb(var(--color-primary)_/_0.2)]'
: 'text-white/20 cursor-not-allowed'
]"
@click="increaseInstances"
>
<span class="material-symbols-outlined text-[16px]">add</span>
</button>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex flex-col">
<span class="text-[13px] text-white/90 font-medium tracking-tight">{{ t('invite.assistant.unlimitedAccess') }}</span>
<span class="text-[11px] text-white/30 font-medium">{{ t('invite.assistant.unlimitedAccessDesc') }}</span>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input v-model="unlimitedAccess" type="checkbox" class="sr-only peer" />
<div class="w-11 h-6 bg-panel-strong/80 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-border/40 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary shadow-inner"></div>
</label>
</div>
<div class="flex items-center justify-between">
<div class="flex flex-col">
<span class="text-[13px] text-white/90 font-medium tracking-tight">{{ t('invite.assistant.sandboxed') }}</span>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input v-model="sandboxed" type="checkbox" class="sr-only peer" />
<div class="w-11 h-6 bg-panel-strong/80 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-border/40 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary shadow-inner"></div>
</label>
</div>
</div>
<div class="p-4 pt-2">
<button
type="button"
@click="emitInvite"
class="w-full py-3 bg-gradient-to-r from-primary to-primary-hover hover:brightness-110 text-on-primary text-[13px] font-bold rounded-xl shadow-[0_0_20px_rgb(var(--color-primary)_/_0.3)] transition-all active:scale-[0.98] border-t border-white/20"
>
{{ t('invite.assistant.send') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps<{ title?: string }>();
const emit = defineEmits<{ (e: 'close'): void; (e: 'invite', model: string): void }>();
const { t } = useI18n();
const models = [
{ id: 'gemini', labelKey: 'invite.assistant.models.gemini', icon: 'terminal' },
{ id: 'codex', labelKey: 'invite.assistant.models.codex', icon: 'code' },
{ id: 'claude', labelKey: 'invite.assistant.models.claude', icon: 'psychology' },
{ id: 'custom', labelKey: 'invite.assistant.models.custom', icon: 'settings_suggest' }
];
const selectedModel = ref('claude');
const minInstances = 1;
const maxInstances = 10;
const instances = ref(minInstances);
const instanceText = ref(String(minInstances));
const unlimitedAccess = ref(true);
const sandboxed = ref(false);
const title = computed(() => props.title ?? t('invite.assistant.title'));
const canDecreaseInstances = computed(() => instances.value > minInstances);
const canIncreaseInstances = computed(() => instances.value < maxInstances);
const clampInstances = (value: number) => Math.min(maxInstances, Math.max(minInstances, value));
const handleInstanceInput = (event: Event) => {
const nextValue = (event.target as HTMLInputElement).value;
instanceText.value = nextValue;
if (!nextValue.trim()) return;
const parsed = Number(nextValue);
if (!Number.isFinite(parsed)) return;
instances.value = clampInstances(Math.round(parsed));
};
const commitInstanceInput = () => {
const parsed = Number(instanceText.value);
if (!Number.isFinite(parsed)) {
instanceText.value = String(instances.value);
return;
}
instances.value = clampInstances(Math.round(parsed));
instanceText.value = String(instances.value);
};
const decreaseInstances = () => {
if (instances.value > minInstances) {
instances.value -= 1;
instanceText.value = String(instances.value);
}
};
const increaseInstances = () => {
if (instances.value < maxInstances) {
instances.value += 1;
instanceText.value = String(instances.value);
}
};
const emitInvite = () => {
const model = models.find((item) => item.id === selectedModel.value);
if (model) {
emit('invite', t(model.labelKey));
}
};
</script>
@@ -0,0 +1,69 @@
<template>
<div class="fixed inset-0 z-[150] flex items-center justify-center bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
<div class="w-full max-w-sm bg-panel-strong border border-white/10 rounded-2xl shadow-2xl p-6 ring-1 ring-white/5">
<div class="flex justify-between items-center mb-6">
<h3 class="text-white font-bold text-lg">{{ t('members.manage.title') }}</h3>
<button type="button" @click="emit('close')" class="text-white/40 hover:text-white transition-colors">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div class="mb-6 text-center">
<img :src="member.avatar" class="w-20 h-20 rounded-full mx-auto mb-3 object-cover border-2 border-white/10" />
<p class="text-white/40 text-xs uppercase font-bold tracking-widest">{{ roleLabel }}</p>
</div>
<div class="space-y-4">
<div>
<label class="text-xs font-bold text-white/50 uppercase tracking-wider mb-1.5 block">{{ t('members.manage.displayName') }}</label>
<input
v-model="name"
class="w-full bg-surface/80 border border-white/10 rounded-xl px-4 py-2.5 text-white focus:border-primary/50 focus:ring-1 focus:ring-primary/50 outline-none transition-all"
/>
</div>
<button type="button" @click="emit('save', member.id, name)" class="w-full py-2.5 bg-primary text-on-primary font-bold rounded-xl hover:bg-primary-hover transition-colors">
{{ t('settings.saveChanges') }}
</button>
<template v-if="showRemove">
<div class="h-px bg-white/5 my-2"></div>
<button type="button" @click="emit('remove', member.id)" class="w-full py-2.5 text-red-400 hover:bg-red-500/10 font-medium rounded-xl transition-colors flex items-center justify-center gap-2">
<span class="material-symbols-outlined text-[18px]">person_remove</span>
{{ t('members.manage.remove') }}
</button>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, toRef } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Member } from '../types';
const props = defineProps<{ member: Member; showRemove?: boolean }>();
const emit = defineEmits<{ (e: 'close'): void; (e: 'save', id: string, name: string): void; (e: 'remove', id: string): void }>();
const member = toRef(props, 'member');
const name = ref(member.value.name);
const showRemove = computed(() => props.showRemove !== false);
const { t } = useI18n();
const roleLabel = computed(() => {
if (member.value.roleKey) {
return t(member.value.roleKey);
}
if (member.value.role) {
return member.value.role;
}
return t('members.roles.member');
});
watch(member, (nextMember) => {
name.value = nextMember.name;
});
</script>
@@ -0,0 +1,56 @@
<template>
<div class="fixed inset-0 z-[150] flex items-center justify-center bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
<div class="w-full max-w-sm bg-panel-strong border border-white/10 rounded-2xl shadow-2xl p-6 ring-1 ring-white/5">
<div class="flex justify-between items-center mb-6">
<h3 class="text-white font-bold text-lg">{{ t('chat.conversation.renameTitle') }}</h3>
<button type="button" @click="emit('close')" class="text-white/40 hover:text-white transition-colors">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div class="space-y-4">
<div>
<label class="text-xs font-bold text-white/50 uppercase tracking-wider mb-1.5 block">{{ t('chat.conversation.renameLabel') }}</label>
<input
v-model="draftName"
class="w-full bg-surface/80 border border-white/10 rounded-xl px-4 py-2.5 text-white focus:border-primary/50 focus:ring-1 focus:ring-primary/50 outline-none transition-all"
:placeholder="t('chat.conversation.renamePlaceholder')"
/>
</div>
<div class="flex gap-3">
<button
type="button"
@click="emit('close')"
class="flex-1 py-2.5 border border-white/10 text-white/70 font-semibold rounded-xl hover:bg-white/5 transition-colors"
>
{{ t('settings.cancel') }}
</button>
<button
type="button"
@click="emit('save', draftName)"
class="flex-1 py-2.5 bg-primary text-on-primary font-bold rounded-xl hover:bg-primary-hover transition-colors"
>
{{ t('settings.saveChanges') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, toRef, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps<{ name: string }>();
const emit = defineEmits<{ (e: 'close'): void; (e: 'save', name: string): void }>();
const { t } = useI18n();
const name = toRef(props, 'name');
const draftName = ref(name.value);
watch(name, (nextName) => {
draftName.value = nextName;
});
</script>
+239
View File
@@ -0,0 +1,239 @@
<template>
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm animate-in fade-in duration-200">
<div class="w-full max-w-2xl bg-panel-strong/90 backdrop-blur-2xl border border-white/10 rounded-2xl shadow-2xl flex flex-col max-h-[85vh] ring-1 ring-white/5">
<div class="px-8 py-6 border-b border-white/5 flex justify-between items-start bg-white/[0.02]">
<div class="space-y-1.5 w-full mr-8">
<h2 class="text-xl font-bold text-white tracking-tight flex items-center gap-3 mb-3">
<div class="w-8 h-8 rounded-lg bg-primary/20 flex items-center justify-center text-primary border border-primary/20 shadow-[0_0_15px_rgb(var(--color-primary)_/_0.2)]">
<span class="material-symbols-outlined text-[20px]">checklist</span>
</div>
{{ t('roadmap.title') }}
</h2>
<div class="pl-[44px] flex items-center w-full">
<div class="flex-1 relative group">
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<span class="text-white/30 text-[13px] font-bold">{{ t('roadmap.objectiveLabel') }}</span>
</div>
<input
class="w-full bg-white/[0.03] border border-white/5 rounded-lg py-2 pl-[84px] pr-8 text-white/80 text-[13px] font-medium placeholder-white/20 focus:outline-none focus:bg-white/[0.06] focus:border-primary/40 focus:ring-1 focus:ring-primary/40 transition-all duration-200"
type="text"
:value="objectiveValue"
/>
<div class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span class="material-symbols-outlined text-white/20 text-[14px]">edit</span>
</div>
</div>
</div>
</div>
<button type="button" @click="emit('close')" class="w-8 h-8 rounded-full hover:bg-white/10 text-white/40 hover:text-white flex items-center justify-center transition-colors shrink-0">
<span class="material-symbols-outlined text-xl">close</span>
</button>
</div>
<div ref="listRef" class="p-6 overflow-y-auto custom-scrollbar space-y-3">
<div
v-for="task in tasks"
:key="task.id"
:class="[
'relative group flex items-center gap-4 p-3 pr-4 rounded-xl border transition-all duration-200',
openMenuId === task.id
? 'bg-white/[0.04] border-white/10 z-20'
: 'bg-white/[0.02] border-white/5 hover:border-white/10 hover:bg-white/[0.04]'
]"
>
<div :class="['w-8 flex justify-center font-mono text-xs font-medium', task.status === 'in-progress' ? 'text-primary font-bold' : 'text-white/20']">
{{ task.number }}
</div>
<div class="flex-1 min-w-0">
<input
v-if="editingId === task.id"
class="w-full bg-surface/80 border border-primary/50 rounded px-2 py-1 text-white text-[14px] focus:outline-none"
v-model="editValue"
@blur="saveTask"
@keydown.enter.prevent="saveTask"
autofocus
:placeholder="t('roadmap.taskPlaceholder')"
/>
<h3
v-else
:class="['font-medium text-[14px]', task.status === 'done' ? 'text-white/60 line-through decoration-white/20' : 'text-white']"
>
{{ task.title }}
</h3>
</div>
<div :class="['flex items-center gap-1.5 px-2.5 py-1 rounded-full shadow-sm border', statusConfig(task.status).bg, statusConfig(task.status).border]">
<div v-if="statusConfig(task.status).indicator" class="w-2 h-2 rounded-full bg-amber-400 animate-pulse"></div>
<span v-if="statusConfig(task.status).icon" :class="['material-symbols-outlined text-[16px]', statusConfig(task.status).text]">
{{ statusConfig(task.status).icon }}
</span>
<span :class="['text-[11px] font-bold tracking-wide uppercase', statusConfig(task.status).text]">
{{ statusConfig(task.status).label }}
</span>
</div>
<button
type="button"
@click.stop="openMenuId = openMenuId === task.id ? null : task.id"
:class="['w-8 h-8 flex items-center justify-center rounded-lg transition-colors', openMenuId === task.id ? 'bg-white/10 text-white' : 'text-white/20 hover:text-white hover:bg-white/10']"
>
<span class="material-symbols-outlined">more_horiz</span>
</button>
<div
v-if="openMenuId === task.id"
class="absolute right-10 top-8 w-48 bg-panel-soft border border-white/10 rounded-xl shadow-2xl z-30 overflow-hidden ring-1 ring-black/50 origin-top-right animate-in fade-in zoom-in-95 duration-100 backdrop-blur-3xl"
>
<div class="p-1.5 space-y-0.5">
<button
v-for="action in taskActions"
:key="action.key"
type="button"
@click="handleTaskAction(action.id, task)"
class="w-full text-left px-3 py-2 text-[13px] font-medium text-white rounded-lg hover:bg-primary/20 hover:text-primary transition-colors flex items-center gap-2.5 group/item"
>
<span class="material-symbols-outlined text-[18px] text-white/50 group-hover/item:text-primary">
{{ action.icon }}
</span>
{{ t(action.key) }}
</button>
</div>
<div class="h-px bg-white/5 mx-2"></div>
<div class="p-1.5">
<button
type="button"
@click="handleDelete(task.id)"
class="w-full text-left px-3 py-2 text-[13px] font-medium text-red-400 rounded-lg hover:bg-red-500/10 transition-colors flex items-center gap-2.5"
>
<span class="material-symbols-outlined text-[18px]">delete</span> {{ t('roadmap.actions.delete') }}
</button>
</div>
</div>
<div v-if="openMenuId === task.id" class="fixed inset-0 z-10" @click="openMenuId = null"></div>
</div>
</div>
<div class="p-5 border-t border-white/5 bg-white/[0.02] flex justify-between items-center rounded-b-2xl">
<div class="text-[12px] text-white/30 font-medium">
{{ t('roadmap.footer', { count: tasks.length, percent: completion }) }}
</div>
<button
type="button"
@click="handleAddTask"
class="px-5 py-2.5 bg-primary hover:bg-primary-hover text-on-primary text-sm font-bold rounded-xl shadow-glow transition-all active:scale-95 flex items-center gap-2"
>
<span class="material-symbols-outlined text-[18px]">add</span> {{ t('roadmap.addTask') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { RoadmapTask, RoadmapTaskStatus } from '../types';
const emit = defineEmits<{ (e: 'close'): void }>();
const openMenuId = ref<number | null>(null);
const editingId = ref<number | null>(null);
const editValue = ref('');
const listRef = ref<HTMLDivElement | null>(null);
const { t } = useI18n();
const objectiveValue = 'Q4 界面重构与功能实现';
const taskActions = [
{ id: 0, key: 'roadmap.actions.edit', icon: 'edit' },
{ id: 1, key: 'roadmap.actions.changeOrder', icon: 'swap_vert' },
{ id: 2, key: 'roadmap.actions.markPriority', icon: 'flag' }
];
const tasks = ref<RoadmapTask[]>([
{ id: 1, number: '01', title: 'Initial Research Phase', status: 'done' },
{ id: 2, number: '02', title: 'UI Wireframes & Prototyping', status: 'in-progress' },
{ id: 3, number: '03', title: 'Legacy Database Migration', status: 'pending' }
]);
watch([editingId, () => tasks.value.length], async () => {
if (editingId.value && listRef.value) {
await nextTick();
listRef.value.scrollTop = listRef.value.scrollHeight;
}
});
const statusConfig = (status: RoadmapTaskStatus) => {
switch (status) {
case 'done':
return {
bg: 'bg-emerald-500/10',
border: 'border-emerald-500/20',
text: 'text-emerald-400',
icon: 'check_circle',
label: t('roadmap.status.done')
};
case 'in-progress':
return {
bg: 'bg-amber-400/10',
border: 'border-amber-400/20',
text: 'text-amber-400',
icon: null,
label: t('roadmap.status.inProgress'),
indicator: true
};
default:
return {
bg: 'bg-white/5',
border: 'border-white/10',
text: 'text-white/40',
icon: 'radio_button_unchecked',
label: t('roadmap.status.pending')
};
}
};
const handleAddTask = () => {
const newId = Math.max(0, ...tasks.value.map((task) => task.id)) + 1;
tasks.value = [
...tasks.value,
{
id: newId,
number: newId.toString().padStart(2, '0'),
title: '',
status: 'pending'
}
];
editingId.value = newId;
editValue.value = '';
};
const saveTask = () => {
if (editingId.value === null) return;
tasks.value = tasks.value.map((task) =>
task.id === editingId.value ? { ...task, title: editValue.value.trim() || t('roadmap.newTask') } : task
);
editingId.value = null;
};
const handleDelete = (id: number) => {
tasks.value = tasks.value.filter((task) => task.id !== id);
if (openMenuId.value === id) {
openMenuId.value = null;
}
};
const handleTaskAction = (index: number, task: RoadmapTask) => {
if (index === 0) {
editingId.value = task.id;
editValue.value = task.title;
openMenuId.value = null;
}
};
const completion = computed(() => {
const doneCount = tasks.value.filter((task) => task.status === 'done').length;
return Math.round((doneCount / Math.max(tasks.value.length, 1)) * 100);
});
</script>
@@ -0,0 +1,133 @@
<template>
<div class="fixed inset-0 z-[100] flex items-center justify-center p-6 bg-black/70 backdrop-blur-sm animate-in fade-in duration-200">
<div class="w-full max-w-2xl h-auto bg-panel-strong/90 backdrop-blur-2xl border border-white/10 rounded-2xl flex flex-col overflow-hidden relative shadow-2xl ring-1 ring-white/10">
<button type="button" @click="emit('close')" class="absolute top-4 right-4 w-8 h-8 rounded-full bg-white/5 hover:bg-white/10 text-white/50 hover:text-white flex items-center justify-center transition-colors z-20">
<span class="material-symbols-outlined text-lg">close</span>
</button>
<div class="px-8 pt-8 pb-4 shrink-0 border-b border-white/5 bg-white/[0.02]">
<div class="flex items-center gap-3">
<button type="button" @click="emit('back')" class="w-8 h-8 rounded-lg bg-white/5 hover:bg-white/10 text-white/50 hover:text-white flex items-center justify-center transition-colors">
<span class="material-symbols-outlined text-lg">arrow_back</span>
</button>
<div class="h-6 w-[1px] bg-white/10"></div>
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-blue-500/10 flex items-center justify-center text-blue-400 ring-1 ring-blue-500/20">
<span class="material-symbols-outlined text-lg">palette</span>
</div>
<h2 class="text-xl font-bold text-white tracking-tight">{{ t('skills.items.designSystemCore.name') }}</h2>
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar p-8">
<div class="space-y-8">
<div>
<h3 class="text-sm font-bold text-white/60 uppercase tracking-wider mb-4">{{ t('skills.detail.sourceConfig') }}</h3>
<div class="p-6 rounded-2xl bg-white/5 border border-white/5">
<div class="flex mb-6 bg-panel/60 p-1 rounded-xl border border-white/5">
<button type="button" class="flex-1 py-2 px-3 rounded-lg bg-white/10 text-white text-sm font-medium shadow-sm ring-1 ring-white/5 transition-all flex items-center justify-center gap-2">
<span class="material-symbols-outlined text-base">code</span>
{{ t('skills.detail.source.github') }}
</button>
<button type="button" class="flex-1 py-2 px-3 rounded-lg text-white/40 hover:text-white text-sm font-medium transition-colors flex items-center justify-center gap-2">
<span class="material-symbols-outlined text-base">terminal</span>
{{ t('skills.detail.source.command') }}
</button>
<button type="button" class="flex-1 py-2 px-3 rounded-lg text-white/40 hover:text-white text-sm font-medium transition-colors flex items-center justify-center gap-2">
<span class="material-symbols-outlined text-base">folder_open</span>
{{ t('skills.detail.source.local') }}
</button>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-white/70">{{ t('skills.detail.repoLabel') }}</label>
<div class="relative group">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-white/30">
<span class="material-symbols-outlined text-lg">link</span>
</span>
<input
class="w-full bg-surface border border-white/10 rounded-xl py-2.5 pl-10 pr-10 text-white placeholder-white/20 focus:outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/50 transition-all text-sm"
:placeholder="t('skills.detail.repoPlaceholder')"
type="text"
value="https://github.com/acme-inc/design-system-core.git"
/>
<div class="absolute right-2 top-1/2 -translate-y-1/2">
<button type="button" class="p-1 rounded-md hover:bg-white/10 text-white/30 hover:text-white transition-colors">
<span class="material-symbols-outlined text-sm">content_paste</span>
</button>
</div>
</div>
<p class="text-[11px] text-white/30 mt-1.5 pl-1">{{ t('skills.detail.repoHint') }}</p>
</div>
</div>
</div>
<div>
<h3 class="text-sm font-bold text-white/60 uppercase tracking-wider mb-4">{{ t('skills.detail.syncPreferences') }}</h3>
<div class="p-6 rounded-2xl bg-white/5 border border-white/5 space-y-6">
<div class="flex items-center justify-between pb-6 border-b border-white/5">
<div class="flex flex-col">
<span class="text-white font-medium text-sm">{{ t('skills.detail.autoSync') }}</span>
<span class="text-white/40 text-xs mt-0.5">{{ t('skills.detail.autoSyncDesc') }}</span>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input checked class="sr-only peer" type="checkbox" />
<div class="w-11 h-6 bg-panel-strong/80 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-border/40 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary shadow-inner transition-colors"></div>
</label>
</div>
<div class="grid grid-cols-2 gap-6 items-center">
<div class="flex flex-col">
<label class="text-white font-medium text-sm mb-1">{{ t('skills.detail.updateFrequency') }}</label>
<span class="text-white/40 text-xs">{{ t('skills.detail.updateFrequencyDesc') }}</span>
</div>
<div class="relative">
<select class="w-full appearance-none bg-surface border border-white/10 rounded-xl py-2.5 px-4 text-white text-sm cursor-pointer hover:bg-white/5 transition-colors focus:outline-none focus:border-primary/50">
<option>{{ t('skills.detail.frequency.every15') }}</option>
<option selected>{{ t('skills.detail.frequency.hour') }}</option>
<option>{{ t('skills.detail.frequency.daily') }}</option>
<option>{{ t('skills.detail.frequency.manual') }}</option>
</select>
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-white/50">
<span class="material-symbols-outlined text-lg">expand_more</span>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-6 items-center">
<div class="flex flex-col">
<label class="text-white font-medium text-sm mb-1">{{ t('skills.detail.targetBranch') }}</label>
<span class="text-white/40 text-xs">{{ t('skills.detail.targetBranchDesc') }}</span>
</div>
<div class="relative">
<select class="w-full appearance-none bg-surface border border-white/10 rounded-xl py-2.5 px-4 text-white text-sm cursor-pointer hover:bg-white/5 transition-colors focus:outline-none focus:border-primary/50">
<option selected>main</option>
<option>develop</option>
<option>release/v2.4</option>
</select>
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-white/50">
<span class="material-symbols-outlined text-lg">call_split</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-end gap-3 pt-6 border-t border-white/5 mt-2">
<button class="px-4 py-2 rounded-lg text-sm font-medium text-red-400 hover:text-red-300 hover:bg-red-500/10 transition-colors mr-auto" type="button">
{{ t('skills.detail.deleteSkill') }}
</button>
<button type="button" @click="emit('back')" class="px-5 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-white text-sm font-medium transition-colors border border-white/10">
{{ t('skills.detail.cancel') }}
</button>
<button type="button" @click="emit('back')" class="px-5 py-2.5 rounded-xl bg-primary hover:bg-primary-hover text-on-primary font-bold text-sm shadow-glow transition-all active:scale-95">
{{ t('skills.detail.saveChanges') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
const emit = defineEmits<{ (e: 'close'): void; (e: 'back'): void }>();
const { t } = useI18n();
</script>
@@ -0,0 +1,205 @@
<template>
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm animate-in fade-in duration-200">
<div class="w-full max-w-4xl max-h-[80vh] bg-panel-strong/95 backdrop-blur-2xl border border-white/10 rounded-2xl shadow-2xl flex flex-col overflow-hidden relative ring-1 ring-white/10">
<button type="button" @click="emit('close')" class="absolute top-4 right-4 w-8 h-8 rounded-full bg-white/5 hover:bg-white/10 text-white/50 hover:text-white flex items-center justify-center transition-colors z-20">
<span class="material-symbols-outlined text-lg">close</span>
</button>
<div class="px-8 pt-8 pb-4 shrink-0 border-b border-white/5 bg-white/[0.02]">
<div class="flex items-center gap-3 mb-6">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary/20 to-primary-hover/10 flex items-center justify-center text-primary ring-1 ring-primary/20 shadow-glow">
<span class="material-symbols-outlined text-2xl">backpack</span>
</div>
<div>
<h2 class="text-xl font-bold text-white tracking-tight">{{ t('skills.management.title') }}</h2>
<p class="text-white/40 text-xs font-medium">{{ t('skills.management.subtitle', { channel: t('chat.channelDisplay') }) }}</p>
</div>
</div>
<div class="flex bg-panel/60 p-1 rounded-lg w-full max-w-md border border-white/5">
<button
type="button"
@click="activeTab = 'current'"
:class="[
'flex-1 py-1.5 px-3 rounded-md text-sm font-medium transition-all',
activeTab === 'current' ? 'bg-white/10 text-white shadow-sm ring-1 ring-white/5' : 'text-white/40 hover:text-white'
]"
>
{{ t('skills.management.tabs.current') }}
<span :class="['ml-1.5 px-1.5 py-0.5 rounded text-[10px] font-bold', activeTab === 'current' ? 'bg-primary/20 text-primary' : 'bg-white/5 text-white/30']">3</span>
</button>
<button
type="button"
@click="activeTab = 'library'"
:class="[
'flex-1 py-1.5 px-3 rounded-md text-sm font-medium transition-all',
activeTab === 'library' ? 'bg-white/10 text-white shadow-sm ring-1 ring-white/5' : 'text-white/40 hover:text-white'
]"
>
{{ t('skills.management.tabs.library') }}
<span :class="['ml-1.5 px-1.5 py-0.5 rounded text-[10px] font-bold', activeTab === 'library' ? 'bg-primary/20 text-primary' : 'bg-white/5 text-white/30']">12</span>
</button>
</div>
</div>
<div class="flex-1 min-h-0 overflow-y-auto custom-scrollbar p-8">
<div v-if="activeTab === 'current'" class="space-y-6 animate-in fade-in duration-200">
<div class="flex items-center justify-between">
<h3 class="text-sm font-bold text-white/60 uppercase tracking-wider">{{ t('skills.current.activeFolders') }}</h3>
<button type="button" class="text-primary text-xs font-medium hover:text-primary-hover flex items-center gap-1 transition-colors">
<span class="material-symbols-outlined text-sm">sync</span> {{ t('skills.current.syncAll') }}
</button>
</div>
<div
v-for="skill in currentSkills"
:key="skill.name"
class="p-4 rounded-xl bg-white/5 border border-white/5 hover:border-white/10 transition-all group hover:bg-white/[0.07]"
>
<div class="flex items-start justify-between">
<div class="flex gap-4">
<div :class="['w-12 h-12 rounded-lg flex items-center justify-center ring-1', skill.bg, skill.color, skill.ring]">
<span class="material-symbols-outlined text-2xl">{{ skill.icon }}</span>
</div>
<div>
<h4 class="text-white font-semibold text-base mb-1">{{ t(skill.nameKey) }}</h4>
<div class="flex items-center gap-2 text-xs text-white/40 mb-2">
<span class="bg-white/5 px-2 py-0.5 rounded text-white/60 font-medium">{{ skill.ver }}</span>
<span>•</span>
<span>{{ t('skills.current.updated') }}</span>
</div>
<div class="flex gap-2">
<span class="px-2 py-1 rounded bg-green-500/10 text-green-400 text-[10px] font-bold uppercase tracking-wide border border-green-500/10 flex items-center gap-1">
<span class="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse"></span> {{ t('skills.current.active') }}
</span>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button type="button" @click="emit('configure')" class="w-8 h-8 rounded-lg hover:bg-white/10 text-white/40 hover:text-white flex items-center justify-center transition-colors">
<span class="material-symbols-outlined text-lg">settings</span>
</button>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" checked class="sr-only peer" readonly />
<div class="w-11 h-6 bg-panel-strong/80 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-border/40 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary shadow-inner"></div>
</label>
</div>
</div>
<div v-if="skill.tags" class="mt-4 pt-4 border-t border-white/5 flex gap-2 overflow-x-auto pb-1 no-scrollbar">
<span v-for="tag in skill.tags" :key="tag" class="text-xs px-2 py-1 rounded-md bg-panel/60 text-white/50 border border-white/5 whitespace-nowrap">{{ t(tag) }}</span>
</div>
</div>
</div>
<div v-else class="animate-in fade-in duration-200">
<div class="relative mb-8">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<span class="material-symbols-outlined text-white/30 text-lg">search</span>
</div>
<input class="block w-full pl-10 pr-3 py-2.5 border border-white/10 rounded-xl leading-5 bg-white/5 text-white placeholder-white/30 focus:outline-none focus:bg-white/10 focus:ring-1 focus:ring-primary/50 focus:border-primary/50 sm:text-sm transition-all shadow-inner" :placeholder="t('skills.library.searchPlaceholder')" type="text" />
<div class="absolute inset-y-0 right-0 pr-2 flex items-center">
<kbd class="inline-flex items-center border border-white/10 rounded px-2 text-sm font-sans font-medium text-white/30">⌘K</kbd>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
<div
v-for="skill in librarySkills"
:key="skill.id"
class="p-5 rounded-2xl bg-white/5 border border-white/5 hover:border-white/10 transition-all group hover:bg-white/[0.07] hover:shadow-lg hover:shadow-black/20 flex flex-col h-full relative overflow-hidden"
>
<div :class="['absolute top-0 right-0 w-16 h-16 bg-gradient-to-br to-transparent rounded-bl-3xl -mr-4 -mt-4', skill.gradient]"></div>
<div class="flex justify-between items-start mb-4 relative z-10">
<div :class="['w-12 h-12 rounded-xl flex items-center justify-center ring-1 shadow-glow', skill.bg, skill.color, skill.ring]">
<span class="material-symbols-outlined text-2xl">{{ skill.icon }}</span>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input v-model="skill.checked" class="sr-only peer" type="checkbox" />
<div class="w-9 h-5 bg-panel-strong/80 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-border/40 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary shadow-inner"></div>
</label>
</div>
<h3 class="text-white font-semibold text-base mb-1 tracking-tight">{{ t(skill.nameKey) }}</h3>
<p class="text-white/40 text-xs leading-relaxed mb-4 flex-grow">{{ t(skill.descKey) }}</p>
<div class="flex items-center gap-2 mt-auto">
<span class="text-[10px] font-mono text-white/30 bg-white/5 px-1.5 py-0.5 rounded border border-white/5">{{ skill.ver }}</span>
<span :class="['text-[10px] font-medium', skill.color]">{{ t(skill.assetsKey) }}</span>
</div>
</div>
<button class="p-5 rounded-2xl bg-white/[0.02] border border-dashed border-white/10 hover:border-primary/50 hover:bg-white/5 transition-all group flex flex-col items-center justify-center h-full min-h-[180px] text-center" type="button">
<div class="w-12 h-12 rounded-full bg-white/5 group-hover:bg-primary/10 flex items-center justify-center text-white/30 group-hover:text-primary transition-colors mb-3">
<span class="material-symbols-outlined text-2xl">add</span>
</div>
<span class="text-white/60 font-medium text-sm group-hover:text-white transition-colors">{{ t('skills.library.importTitle') }}</span>
<span class="text-white/30 text-xs mt-1">{{ t('skills.library.importSubtitle') }}</span>
</button>
</div>
<div class="mt-10 flex justify-center pb-2">
<button class="flex items-center gap-2 px-6 py-3 rounded-xl bg-gradient-to-r from-primary/10 to-blue-500/10 border border-primary/20 hover:border-primary/40 text-white font-semibold text-sm shadow-lg hover:scale-[1.02] active:scale-[0.98] transition-all" type="button">
<span class="material-symbols-outlined text-lg">storefront</span>
{{ t('skills.library.browseShop') }}
</button>
</div>
</div>
</div>
<div class="p-4 bg-panel/60 border-t border-white/5 flex justify-between items-center text-xs text-white/30 px-8 rounded-b-2xl">
<div class="flex gap-4">
<span class="hover:text-white/60 cursor-pointer transition-colors">{{ t('skills.footer.documentation') }}</span>
<span class="hover:text-white/60 cursor-pointer transition-colors">{{ t('skills.footer.privacy') }}</span>
</div>
<button
v-if="activeTab === 'current'"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-white transition-colors border border-white/5"
type="button"
>
<span class="material-symbols-outlined text-sm">add</span> {{ t('skills.footer.newFolder') }}
</button>
<div v-else class="flex gap-2">
<span class="text-white/20">{{ t('skills.footer.lastSynced') }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { createLibrarySkills } from '@/features/skills/skillLibrary';
const emit = defineEmits<{ (e: 'close'): void; (e: 'configure'): void }>();
const activeTab = ref<'current' | 'library'>('current');
const { t } = useI18n();
const currentSkills = [
{
nameKey: 'skills.items.designSystemCore.name',
icon: 'palette',
color: 'text-blue-400',
bg: 'bg-blue-500/10',
ring: 'ring-blue-500/20',
ver: 'v2.4.0',
tags: ['skills.tags.typography', 'skills.tags.colorPalette', 'skills.tags.components']
},
{
nameKey: 'skills.items.uxResearchPatterns.name',
icon: 'psychology',
color: 'text-purple-400',
bg: 'bg-purple-500/10',
ring: 'ring-purple-500/20',
ver: 'v1.1.2'
},
{
nameKey: 'skills.items.a11yGuidelines.name',
icon: 'accessibility_new',
color: 'text-orange-400',
bg: 'bg-orange-500/10',
ring: 'ring-orange-500/20',
ver: 'v3.0.0'
}
];
const librarySkills = ref(createLibrarySkills());
</script>
+66
View File
@@ -0,0 +1,66 @@
export type MemberRole = 'owner' | 'admin' | 'assistant' | 'member';
export type MemberStatus = 'online' | 'offline' | 'dnd';
export type Member = {
id: string;
name: string;
role: string;
roleKey?: string;
roleType: MemberRole;
avatar: string;
status: MemberStatus;
};
export type MemberAction = 'send-message' | 'mention' | 'rename' | 'remove';
export type ConversationType = 'channel' | 'dm';
export type Conversation = {
id: string;
type: ConversationType;
targetId: string;
nameKey?: string;
customName?: string;
descriptionKey?: string;
pinned: boolean;
muted: boolean;
messages: Message[];
};
export type ConversationAction = 'pin' | 'unpin' | 'rename' | 'mute' | 'unmute' | 'clear' | 'delete';
export type MessageAttachment =
| {
type: 'image';
name: string;
size: string;
url: string;
}
| {
type: 'roadmap';
title: string;
};
export type MessageStatus = 'sending' | 'sent' | 'failed';
export type Message = {
id: number;
senderId?: string;
user: string;
avatar: string;
text: string;
time: string;
createdAt: number;
isAi: boolean;
attachment?: MessageAttachment;
status?: MessageStatus;
};
export type RoadmapTaskStatus = 'done' | 'in-progress' | 'pending';
export type RoadmapTask = {
id: number;
number: string;
title: string;
status: RoadmapTaskStatus;
};
+77
View File
@@ -0,0 +1,77 @@
import type { Member, Message } from './types';
export const splitMentions = (text: string) => text.split(/(@[\w\s]+)/g).filter(Boolean);
export const ensureUniqueName = (name: string, members: Member[]) => {
// Enforce case-insensitive uniqueness to match the UI's duplicate prevention.
const lowerNames = new Set(members.map((member) => member.name.toLowerCase()));
if (!lowerNames.has(name.toLowerCase())) {
return name;
}
let counter = 1;
let candidate = `${name}-${counter}`;
while (lowerNames.has(candidate.toLowerCase())) {
counter += 1;
candidate = `${name}-${counter}`;
}
return candidate;
};
export const formatMessageTime = (timestamp: number, locale?: string) => {
const formatter = new Intl.DateTimeFormat(locale, {
hour: '2-digit',
minute: '2-digit'
});
return formatter.format(new Date(timestamp));
};
export const getMessageDayKey = (timestamp: number) => {
const date = new Date(timestamp);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
export const formatDayLabel = (timestamp: number, locale?: string) => {
const formatter = new Intl.DateTimeFormat(locale, {
month: 'long',
day: 'numeric',
year: 'numeric'
});
return formatter.format(new Date(timestamp));
};
export type MessageDisplayItem =
| { type: 'separator'; id: string; label: string }
| { type: 'message'; id: string; message: Message };
export const groupMessagesByDay = (messages: Message[], locale?: string): MessageDisplayItem[] => {
const items: MessageDisplayItem[] = [];
let lastDayKey = '';
[...messages]
.sort((a, b) => a.createdAt - b.createdAt)
.forEach((message) => {
const dayKey = getMessageDayKey(message.createdAt);
if (dayKey !== lastDayKey) {
items.push({
type: 'separator',
id: `separator-${dayKey}`,
label: formatDayLabel(message.createdAt, locale)
});
lastDayKey = dayKey;
}
items.push({
type: 'message',
id: `message-${message.id}`,
message
});
});
return items;
};
+84
View File
@@ -0,0 +1,84 @@
export type SkillLibraryItem = {
id: number;
nameKey: string;
descKey: string;
icon: string;
color: string;
bg: string;
ring: string;
gradient: string;
ver: string;
assetsKey: string;
checked: boolean;
};
const librarySkillSeed: SkillLibraryItem[] = [
{
id: 1,
nameKey: 'skills.items.frontendToolkit.name',
descKey: 'skills.items.frontendToolkit.desc',
icon: 'code',
color: 'text-blue-400',
bg: 'bg-blue-500/10',
ring: 'ring-blue-500/20',
gradient: 'from-blue-500/10',
ver: 'v2.4.0',
assetsKey: 'skills.assets.frontendToolkit',
checked: true
},
{
id: 2,
nameKey: 'skills.items.iconAssetPack.name',
descKey: 'skills.items.iconAssetPack.desc',
icon: 'category',
color: 'text-purple-400',
bg: 'bg-purple-500/10',
ring: 'ring-purple-500/20',
gradient: 'from-purple-500/10',
ver: 'v1.1.0',
assetsKey: 'skills.assets.iconAssetPack',
checked: false
},
{
id: 3,
nameKey: 'skills.items.motionPresets.name',
descKey: 'skills.items.motionPresets.desc',
icon: 'motion_photos_on',
color: 'text-orange-400',
bg: 'bg-orange-500/10',
ring: 'ring-orange-500/20',
gradient: 'from-orange-500/10',
ver: 'v3.0.0',
assetsKey: 'skills.assets.motionPresets',
checked: true
},
{
id: 4,
nameKey: 'skills.items.shellCommands.name',
descKey: 'skills.items.shellCommands.desc',
icon: 'terminal',
color: 'text-emerald-400',
bg: 'bg-emerald-500/10',
ring: 'ring-emerald-500/20',
gradient: 'from-emerald-500/10',
ver: 'v0.9.1',
assetsKey: 'skills.assets.shellCommands',
checked: false
},
{
id: 5,
nameKey: 'skills.items.brandColors.name',
descKey: 'skills.items.brandColors.desc',
icon: 'palette',
color: 'text-pink-400',
bg: 'bg-pink-500/10',
ring: 'ring-pink-500/20',
gradient: 'from-pink-500/10',
ver: 'v4.2',
assetsKey: 'skills.assets.brandColors',
checked: false
}
];
export const createLibrarySkills = (): SkillLibraryItem[] =>
librarySkillSeed.map((skill) => ({ ...skill }));
+42
View File
@@ -0,0 +1,42 @@
import { createI18n } from 'vue-i18n';
import enUS from './locales/en-US';
import zhCN from './locales/zh-CN';
export const LOCALE_STORAGE_KEY = 'nexus-locale';
export const messages = {
'en-US': enUS,
'zh-CN': zhCN
} as const;
export type AppLocale = keyof typeof messages;
const detectLocale = (): AppLocale => {
if (typeof window === 'undefined') {
return 'en-US';
}
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored && stored in messages) {
return stored as AppLocale;
}
return 'en-US';
};
const initialLocale = detectLocale();
export const i18n = createI18n({
legacy: false,
locale: initialLocale,
fallbackLocale: 'en-US',
messages
});
export const setDocumentLang = (locale: AppLocale) => {
if (typeof document !== 'undefined') {
document.documentElement.lang = locale;
}
};
setDocumentLang(initialLocale);
+535
View File
@@ -0,0 +1,535 @@
export default {
app: {
name: 'Nexus Dashboard'
},
nav: {
chat: 'Chat & Modals',
workspaces: 'Workspaces',
store: 'Skill Store',
plugins: 'Plugins',
settings: 'Settings'
},
common: {
userAvatarAlt: 'User',
remove: 'Remove'
},
chat: {
channelName: 'design-critique',
channelDisplay: '#design-critique',
channelDescription: '',
directMessageDescription: 'Direct message with {name}',
header: {
todo: 'Todo',
inventory: 'Inventory'
},
input: {
placeholder: 'Message {channel}',
directPlaceholder: 'Message @{name}',
send: 'Send',
stop: 'Stop',
hint: 'Enter to send • Shift+Enter for newline',
quickPrompts: {
summarize: 'Summarize the latest discussion',
draftReply: 'Draft a polite reply',
extractTasks: 'Extract action items'
}
},
sidebar: {
workspaceName: 'Frontend Squad',
channels: 'Channels',
directMessages: 'Direct Messages',
channelList: {
announcements: 'announcements',
generalChat: 'general-chat',
designCritique: 'design-critique',
resources: 'resources'
}
},
messages: {
dateSeparator: 'October 24, 2023',
roadmapHint: 'Click to view roadmap',
userJoined: '{name} joined the server',
joinedUser: 'James',
sampleMessage: {
user: 'Sarah Jenkins',
time: '11:05 AM',
text: 'Hopefully by Friday! Just need final sign-off from product.'
},
autoReply: 'I have processed your request. Is there anything else you need?',
autoReplyQuestion: 'Let me take a closer look and get back to you shortly.',
jumpToLatest: 'Jump to latest',
typing: '{name} is typing...',
status: {
sending: 'Sending...',
failed: 'Failed to send'
}
},
conversation: {
actions: {
pin: 'Pin',
unpin: 'Unpin',
rename: 'Rename Group',
mute: 'Mute Notifications',
unmute: 'Unmute Notifications',
clear: 'Clear Chat History',
deleteChannel: 'Delete Group Chat',
deleteDirect: 'Delete Conversation'
},
renameTitle: 'Rename Group Chat',
renameLabel: 'Group Name',
renamePlaceholder: 'Enter group name'
}
},
members: {
title: 'Members',
sections: {
owner: 'Group Owner — {count}',
admin: 'Admins — {count}',
assistant: 'Assistants — {count}',
member: 'General Members — {count}'
},
roles: {
owner: 'Owner',
admin: 'Admin',
assistant: 'Assistant',
member: 'Member',
aiAssistant: 'AI Assistant'
},
actions: {
sendMessage: 'Send Message',
mention: '@ Mention',
rename: 'Rename'
},
manage: {
title: 'Manage Member',
displayName: 'Display Name',
remove: 'Remove from Group'
},
activity: {
reviewingPRs: 'Reviewing PRs',
listeningSpotify: 'Listening to Spotify',
fixingBugs: 'Fixing bugs',
doNotDisturb: 'Do Not Disturb'
}
},
invite: {
menu: {
title: 'Invite to Server',
subtitle: 'Generate a unique invite link',
admin: 'Invite as Admin',
adminDesc: 'Full server access',
assistant: 'Invite as Assistant',
assistantDesc: 'Moderation permissions',
member: 'General Member',
memberDesc: 'Standard access'
},
admin: {
title: 'Invite as Admin',
subtitle: 'Configure access level and duration',
uniqueLink: 'Unique Invite Link',
regenerate: 'Regenerate',
userIdentifier: 'User Identifier',
userPlaceholder: 'Username or email address',
permissions: 'Permissions Level',
send: 'Send Invitation',
permissionsList: {
fullAccess: {
title: 'Full Server Access',
desc: 'Can modify settings, channels & roles'
},
billing: {
title: 'Billing Access',
desc: 'Manage subscription and payments'
},
memberManagement: {
title: 'Member Management',
desc: 'Kick, ban, and assign lower roles'
}
}
},
assistant: {
title: 'Invite as Assistant',
subtitle: 'Select an AI model to join the workspace',
instances: 'Number of Instances',
unlimitedAccess: 'Unlimited Access',
unlimitedAccessDesc: 'Bypass usage limits',
sandboxed: 'Sandboxed environment',
send: 'Send Invitation',
models: {
gemini: 'Gemini CLI',
codex: 'Codex',
claude: 'Claude Code',
custom: 'Custom CLI'
}
},
member: {
title: 'Invite as Member'
}
},
roadmap: {
title: 'Project Roadmap',
objectiveLabel: 'Objective:',
taskPlaceholder: 'Enter task title...',
newTask: 'New Task',
status: {
done: 'DONE',
inProgress: 'IN PROGRESS',
pending: 'PENDING'
},
actions: {
edit: 'Edit Task',
changeOrder: 'Change Order',
markPriority: 'Mark as Priority',
delete: 'Delete'
},
footer: '{count} Tasks • {percent}% Complete',
addTask: 'Add Task'
},
skills: {
management: {
title: 'Skill Management',
subtitle: 'Configure active skills for {channel}',
tabs: {
current: 'Current Skills',
library: 'My Skills Library'
}
},
current: {
activeFolders: 'Active Folders',
syncAll: 'Sync All',
updated: 'Updated 2h ago',
active: 'Active'
},
library: {
searchPlaceholder: 'Search your library...',
importTitle: 'Import Skill',
importSubtitle: 'From URL or Local File',
browseShop: 'Browse Skill Shop'
},
footer: {
documentation: 'Documentation',
privacy: 'Privacy',
newFolder: 'New Folder',
lastSynced: 'Last synced: 2 mins ago'
},
tags: {
typography: 'Typography',
colorPalette: 'Color Palette',
components: 'Components'
},
items: {
designSystemCore: {
name: 'Design System Core'
},
uxResearchPatterns: {
name: 'UX Research Patterns'
},
a11yGuidelines: {
name: 'A11y Guidelines'
},
frontendToolkit: {
name: 'Frontend Toolkit',
desc: 'Essential snippets for React, Vue, and Tailwind CSS development.'
},
iconAssetPack: {
name: 'Icon Asset Pack',
desc: 'Premium outline and solid icons for modern interface design.'
},
motionPresets: {
name: 'Motion Presets',
desc: 'Standardized animation curves and transition timings.'
},
shellCommands: {
name: 'Shell Commands',
desc: 'Quick access to common CLI scripts and deployment hooks.'
},
brandColors: {
name: 'Brand Colors',
desc: 'Company color palettes and accessible contrast ratios.'
}
},
assets: {
frontendToolkit: '45 assets',
iconAssetPack: '1.2k icons',
motionPresets: '12 presets',
shellCommands: '24 cmds',
brandColors: '8 swatches'
},
detail: {
sourceConfig: 'Source Configuration',
source: {
github: 'GitHub Repo',
command: 'Command Source',
local: 'Local Path'
},
repoLabel: 'Repository URL',
repoPlaceholder: 'https://github.com/username/repository.git',
repoHint: 'Supports HTTPS and SSH URLs from GitHub, GitLab, and Bitbucket.',
syncPreferences: 'Sync Preferences',
autoSync: 'Auto-sync Updates',
autoSyncDesc: 'Automatically pull latest changes from source',
updateFrequency: 'Update Frequency',
updateFrequencyDesc: 'How often to check for new versions',
frequency: {
every15: 'Every 15 minutes',
hour: 'Every hour',
daily: 'Daily',
manual: 'Manual only'
},
targetBranch: 'Target Branch',
targetBranchDesc: 'Branch to track for updates',
deleteSkill: 'Delete Skill',
cancel: 'Cancel',
saveChanges: 'Save Changes'
}
},
marketplace: {
title: 'Plugin Marketplace',
searchPlaceholder: 'Search plugins, integrations, and themes...',
browseStore: 'Browse Store',
myPlugins: 'My Plugins',
categories: {
all: 'All Plugins',
productivity: 'Productivity',
development: 'Development',
design: 'Design',
communication: 'Communication',
music: 'Music'
},
install: 'Install',
installed: 'Installed',
plugins: {
github: {
title: 'GitHub Integration',
desc: 'Connect your repositories, track issues, and manage pull requests directly.'
},
spotify: {
title: 'Spotify Player',
desc: 'Listen together. Control playback and share your favorite tracks.'
},
taskManager: {
title: 'Task Manager',
desc: 'A simple Kanban board for your team. Create, assign, and complete tasks.'
},
calendar: {
title: 'Calendar Sync',
desc: 'Never miss a meeting. Sync with Google Calendar and Outlook.'
},
aiAssistant: {
title: 'AI Assistant',
desc: 'Your personal AI companion. Ask questions, generate text, and summarize.'
},
terminal: {
title: 'Terminal',
desc: 'Run commands and scripts directly from the chat. For power users only.'
},
figma: {
title: 'Figma Preview',
desc: 'Embed live Figma files and prototypes. Get feedback instantly.'
},
quickNotes: {
title: 'Quick Notes',
desc: 'Jot down ideas and share them with your team. Supports markdown.'
}
}
},
skillStore: {
title: 'Skill Store',
searchPlaceholder: 'Search skill folders, templates, and toolkits...',
tabs: {
store: 'Store',
installed: 'Installed'
},
filters: {
all: 'All Skills',
engineering: 'Engineering',
design: 'Design',
management: 'Management',
marketing: 'Marketing',
finance: 'Finance'
},
syncPlaceholder: 'Paste sync URL...',
syncNow: 'Sync now',
installFolder: 'Install Folder',
installed: 'Installed',
skills: {
automation: {
title: 'Automation Skill',
desc: 'Streamline workflows with pre-built scripts. Auto-syncs with your repo.'
},
uiToolkit: {
title: 'UI Design Toolkit',
desc: 'Centralized design assets and brand guidelines. Syncs with Figma files.'
},
projectTracking: {
title: 'Project Tracking',
desc: 'Task lists and kanban boards for active sprints. Syncs with Jira or Trello.'
},
marketingAssets: {
title: 'Marketing Assets',
desc: 'Campaign materials and social media templates. Syncs with Drive/Dropbox.'
},
devOpsConfig: {
title: 'Dev Ops Config',
desc: 'Shared environment variables and docker configs. Syncs secure vaults.'
},
researchLibrary: {
title: 'Research Library',
desc: 'Competitor analysis and market trends. Syncs with Notion or Evernote pages.'
}
}
},
workspace: {
createTitle: 'Create New Team',
createSubtitle: 'Initialize a fresh collaborative environment',
recentTitle: 'Recent Workspaces',
more: 'More',
searchPlaceholder: 'Search teams...',
menu: {
settings: 'Workspace Settings',
invite: 'Invite Members',
copyId: 'Copy ID',
leave: 'Leave Workspace'
},
activeLabel: 'ACTIVE',
times: {
twoMinutes: '2m ago',
fourHours: '4h ago',
oneDay: '1d ago'
},
list: {
frontend: {
title: 'Frontend Squad',
desc: 'Main monorepo for the consumer-facing React application.'
},
mobile: {
title: 'Mobile API',
desc: 'GraphQL gateway and microservices for iOS/Android apps.'
},
infrastructure: {
title: 'Infrastructure',
desc: 'Terraform scripts and Kubernetes configuration files.'
}
},
recent: {
designSystems: 'Design Systems',
marketingTeam: 'Marketing Team',
backendCore: 'Backend Core'
}
},
settings: {
title: 'Settings',
preferences: 'Preferences',
preferencesSubtitle: 'Customize your account, notifications, and workspace preferences.',
accountSubtitle: 'Manage your profile, presence, and contact details.',
displayName: 'Display Name',
displayNamePlaceholder: 'Enter your display name',
emailAddress: 'Email Address',
emailPlaceholder: 'name@example.com',
jobTitle: 'Job Title',
jobTitlePlaceholder: 'e.g. Product Designer',
timeZone: 'Time Zone',
status: 'Status',
statusMessage: 'Status Message',
statusMessagePlaceholder: 'Share what you are working on',
statusOptions: {
online: 'Online',
away: 'Away',
dnd: 'Do Not Disturb'
},
language: 'Language',
languageDefault: 'Default',
changesApply: 'Changes apply after restart.',
spellCheck: 'Spell Check',
defaultMember: 'Default Member',
selectMember: 'Select Member',
refreshList: 'Refresh List',
cancel: 'Cancel',
saveChanges: 'Save Changes',
userSettings: 'User Settings',
myAccount: 'My Account',
appSettings: 'App Settings',
appearance: 'Appearance',
appearanceSubtitle: 'Switch themes and match the workspace look to your environment.',
members: 'Members',
notifications: 'Notifications',
keybinds: 'Keybinds',
leaveTeam: 'Leave Team',
logOut: 'Log Out',
memberName: 'Member Name',
memberNamePlaceholder: 'Enter member label',
commandInput: 'Command Line Input',
commandPlaceholder: 'e.g. /usr/local/bin/my-cli',
confirm: 'Confirm',
notificationsSubtitle: 'Choose when and how you want to be notified.',
notificationOptions: {
desktop: 'Desktop Notifications',
desktopDesc: 'Show system notifications for new messages.',
sound: 'Sound Alerts',
soundDesc: 'Play a sound when new messages arrive.',
mentionsOnly: 'Mentions Only',
mentionsOnlyDesc: 'Only notify when you are mentioned.',
previews: 'Message Previews',
previewsDesc: 'Display message content in alerts.',
weeklyDigest: 'Weekly Digest',
weeklyDigestDesc: 'Receive a weekly summary of activity.',
quietHours: 'Quiet Hours',
quietHoursDesc: 'Silence alerts during scheduled hours.'
},
quietHoursFrom: 'From',
quietHoursTo: 'To',
keybindsSubtitle: 'Configure shortcuts and keybinding profiles.',
keybindsProfile: 'Keybinding Profile',
keybindsEnable: 'Enable Keyboard Shortcuts',
keybindsHints: 'Show Shortcut Hints',
keybindsReset: 'Reset to Defaults',
keybindsListTitle: 'Shortcut Reference',
keybindProfiles: {
default: 'Default',
vscode: 'VS Code',
slack: 'Slack'
},
keybindActions: {
focusSearch: 'Focus search',
newMessage: 'New message',
toggleSidebar: 'Toggle sidebar',
toggleMute: 'Toggle mute',
jumpToLatest: 'Jump to latest',
openSettings: 'Open settings'
},
memberOptions: {
gemini: 'Gemini CLI',
codex: 'Codex',
claude: 'Claude Code',
custom: 'Custom CLI'
},
memberKind: {
default: 'Default Terminal',
custom: 'Custom Terminal'
},
memberActions: {
menuLabel: 'Terminal actions',
test: 'Test Terminal',
edit: 'Edit',
remove: 'Delete'
},
themeOptions: {
dark: {
label: 'Dark',
desc: 'Default theme designed for low-light focus.'
},
light: {
label: 'Light',
desc: 'Bright layout optimized for daylight work.'
},
system: {
label: 'System',
desc: 'Follows your operating system appearance.'
}
}
},
language: {
enUS: 'English (United States)',
zhCN: 'Chinese (Simplified)'
}
};
+535
View File
@@ -0,0 +1,535 @@
export default {
app: {
name: 'Nexus Dashboard'
},
nav: {
chat: '聊天与弹窗',
workspaces: '工作区',
store: '技能商店',
plugins: '插件',
settings: '设置'
},
common: {
userAvatarAlt: '用户',
remove: '删除'
},
chat: {
channelName: 'design-critique',
channelDisplay: '#design-critique',
channelDescription: '',
directMessageDescription: '与 {name} 的私信',
header: {
todo: '待办',
inventory: '技能库'
},
input: {
placeholder: '发送到 {channel}',
directPlaceholder: '发送给 @{name}',
send: '发送',
stop: '停止生成',
hint: 'Enter 发送 • Shift+Enter 换行',
quickPrompts: {
summarize: '总结最新讨论',
draftReply: '生成礼貌回复',
extractTasks: '提取行动项'
}
},
sidebar: {
workspaceName: '前端小队',
channels: '频道',
directMessages: '私信',
channelList: {
announcements: 'announcements',
generalChat: 'general-chat',
designCritique: 'design-critique',
resources: 'resources'
}
},
messages: {
dateSeparator: '2023年10月24日',
roadmapHint: '点击查看路线图',
userJoined: '{name} 加入了服务器',
joinedUser: 'James',
sampleMessage: {
user: 'Sarah Jenkins',
time: '11:05 AM',
text: '希望周五前完成!只差产品的最终确认。'
},
autoReply: '我已处理你的请求。还有其他需要吗?',
autoReplyQuestion: '我正在查看这个问题,稍后给你更新。',
jumpToLatest: '跳到最新消息',
typing: '{name} 正在输入...',
status: {
sending: '发送中...',
failed: '发送失败'
}
},
conversation: {
actions: {
pin: '置顶',
unpin: '取消置顶',
rename: '修改群聊名称',
mute: '消息免打扰',
unmute: '取消免打扰',
clear: '清空聊天记录',
deleteChannel: '删除群聊',
deleteDirect: '删除对话'
},
renameTitle: '修改群聊名称',
renameLabel: '群聊名称',
renamePlaceholder: '输入群聊名称'
}
},
members: {
title: '成员',
sections: {
owner: '群主 — {count}',
admin: '管理员 — {count}',
assistant: '助手 — {count}',
member: '普通成员 — {count}'
},
roles: {
owner: '群主',
admin: '管理员',
assistant: '助手',
member: '成员',
aiAssistant: 'AI 助手'
},
actions: {
sendMessage: '发送消息',
mention: '@',
rename: '更改名称'
},
manage: {
title: '管理成员',
displayName: '显示名称',
remove: '移出群组'
},
activity: {
reviewingPRs: '审核 PR',
listeningSpotify: '在听 Spotify',
fixingBugs: '修复 Bug',
doNotDisturb: '请勿打扰'
}
},
invite: {
menu: {
title: '邀请加入服务器',
subtitle: '生成唯一邀请链接',
admin: '以管理员身份邀请',
adminDesc: '完全服务器权限',
assistant: '以助手身份邀请',
assistantDesc: '管理权限',
member: '普通成员',
memberDesc: '标准访问权限'
},
admin: {
title: '以管理员身份邀请',
subtitle: '配置访问级别与时长',
uniqueLink: '唯一邀请链接',
regenerate: '重新生成',
userIdentifier: '用户标识',
userPlaceholder: '用户名或邮箱地址',
permissions: '权限等级',
send: '发送邀请',
permissionsList: {
fullAccess: {
title: '完全服务器权限',
desc: '可修改设置、频道与角色'
},
billing: {
title: '账单权限',
desc: '管理订阅与付款'
},
memberManagement: {
title: '成员管理',
desc: '踢出、封禁并分配低级角色'
}
}
},
assistant: {
title: '以助手身份邀请',
subtitle: '选择要加入工作区的 AI 模型',
instances: '实例数量',
unlimitedAccess: '无限访问',
unlimitedAccessDesc: '绕过使用限制',
sandboxed: '沙盒环境',
send: '发送邀请',
models: {
gemini: 'Gemini CLI',
codex: 'Codex',
claude: 'Claude Code',
custom: '自定义 CLI'
}
},
member: {
title: '以成员身份邀请'
}
},
roadmap: {
title: '项目路线图',
objectiveLabel: '目标:',
taskPlaceholder: '输入任务标题...',
newTask: '新任务',
status: {
done: '已完成',
inProgress: '进行中',
pending: '待处理'
},
actions: {
edit: '编辑任务',
changeOrder: '调整顺序',
markPriority: '标记为优先',
delete: '删除'
},
footer: '{count} 个任务 • 完成 {percent}%',
addTask: '添加任务'
},
skills: {
management: {
title: '技能管理',
subtitle: '为 {channel} 配置已启用技能',
tabs: {
current: '当前技能',
library: '我的技能库'
}
},
current: {
activeFolders: '已启用的文件夹',
syncAll: '全部同步',
updated: '2 小时前更新',
active: '启用中'
},
library: {
searchPlaceholder: '搜索你的技能库...',
importTitle: '导入技能',
importSubtitle: '从 URL 或本地文件',
browseShop: '浏览技能商店'
},
footer: {
documentation: '文档',
privacy: '隐私',
newFolder: '新建文件夹',
lastSynced: '上次同步:2 分钟前'
},
tags: {
typography: '排版',
colorPalette: '色板',
components: '组件'
},
items: {
designSystemCore: {
name: '设计系统核心'
},
uxResearchPatterns: {
name: 'UX 研究模式'
},
a11yGuidelines: {
name: '无障碍指南'
},
frontendToolkit: {
name: '前端工具包',
desc: '适用于 React、Vue 和 Tailwind CSS 的常用片段。'
},
iconAssetPack: {
name: '图标资源包',
desc: '现代界面设计的高品质线框与实心图标。'
},
motionPresets: {
name: '动效预设',
desc: '标准化的动画曲线与过渡时序。'
},
shellCommands: {
name: 'Shell 命令集',
desc: '快速访问常用 CLI 脚本和部署钩子。'
},
brandColors: {
name: '品牌色板',
desc: '公司色板与可访问性对比度规范。'
}
},
assets: {
frontendToolkit: '45 个资源',
iconAssetPack: '1.2k 图标',
motionPresets: '12 个预设',
shellCommands: '24 条命令',
brandColors: '8 个色板'
},
detail: {
sourceConfig: '来源配置',
source: {
github: 'GitHub 仓库',
command: '命令来源',
local: '本地路径'
},
repoLabel: '仓库地址',
repoPlaceholder: 'https://github.com/username/repository.git',
repoHint: '支持 GitHub、GitLab 与 Bitbucket 的 HTTPS 和 SSH 地址。',
syncPreferences: '同步偏好',
autoSync: '自动同步更新',
autoSyncDesc: '自动拉取来源的最新变更',
updateFrequency: '更新频率',
updateFrequencyDesc: '多久检查一次新版本',
frequency: {
every15: '每 15 分钟',
hour: '每小时',
daily: '每日',
manual: '仅手动'
},
targetBranch: '目标分支',
targetBranchDesc: '用于更新跟踪的分支',
deleteSkill: '删除技能',
cancel: '取消',
saveChanges: '保存修改'
}
},
marketplace: {
title: '插件市场',
searchPlaceholder: '搜索插件、集成和主题...',
browseStore: '浏览商店',
myPlugins: '我的插件',
categories: {
all: '全部插件',
productivity: '效率',
development: '开发',
design: '设计',
communication: '沟通',
music: '音乐'
},
install: '安装',
installed: '已安装',
plugins: {
github: {
title: 'GitHub 集成',
desc: '连接你的仓库,跟踪问题并直接管理 PR。'
},
spotify: {
title: 'Spotify 播放器',
desc: '一起听歌。控制播放并分享喜欢的曲目。'
},
taskManager: {
title: '任务管理',
desc: '适合团队的简洁看板。创建、分配并完成任务。'
},
calendar: {
title: '日历同步',
desc: '不错过会议。同步 Google 日历与 Outlook。'
},
aiAssistant: {
title: 'AI 助手',
desc: '你的 AI 伙伴。提问、生成文本并做摘要。'
},
terminal: {
title: '终端',
desc: '从聊天直接运行命令和脚本。适合高阶用户。'
},
figma: {
title: 'Figma 预览',
desc: '嵌入实时 Figma 文件与原型,即时反馈。'
},
quickNotes: {
title: '快速笔记',
desc: '记录想法并与团队分享。支持 Markdown。'
}
}
},
skillStore: {
title: '技能商店',
searchPlaceholder: '搜索技能文件夹、模板和工具包...',
tabs: {
store: '商店',
installed: '已安装'
},
filters: {
all: '全部技能',
engineering: '工程',
design: '设计',
management: '管理',
marketing: '营销',
finance: '财务'
},
syncPlaceholder: '粘贴同步 URL...',
syncNow: '立即同步',
installFolder: '安装文件夹',
installed: '已安装',
skills: {
automation: {
title: '自动化技能',
desc: '通过预置脚本简化流程,可与仓库自动同步。'
},
uiToolkit: {
title: 'UI 设计工具包',
desc: '集中式设计资产与品牌规范,同步 Figma 文件。'
},
projectTracking: {
title: '项目跟踪',
desc: '用于冲刺的任务清单与看板,可同步 Jira 或 Trello。'
},
marketingAssets: {
title: '营销素材',
desc: '活动物料与社媒模板,同步 Drive/Dropbox。'
},
devOpsConfig: {
title: 'Dev Ops 配置',
desc: '共享环境变量与 Docker 配置,同步安全密钥库。'
},
researchLibrary: {
title: '研究资料库',
desc: '竞品分析与市场趋势,Notion/Evernote 同步。'
}
}
},
workspace: {
createTitle: '创建新团队',
createSubtitle: '初始化全新的协作环境',
recentTitle: '最近的工作区',
more: '更多',
searchPlaceholder: '搜索团队...',
menu: {
settings: '工作区设置',
invite: '邀请成员',
copyId: '复制 ID',
leave: '退出工作区'
},
activeLabel: '活跃',
times: {
twoMinutes: '2 分钟前',
fourHours: '4 小时前',
oneDay: '1 天前'
},
list: {
frontend: {
title: '前端小队',
desc: '面向用户的 React 应用主仓库。'
},
mobile: {
title: '移动端 API',
desc: '面向 iOS/Android 的 GraphQL 网关与微服务。'
},
infrastructure: {
title: '基础设施',
desc: 'Terraform 脚本与 Kubernetes 配置文件。'
}
},
recent: {
designSystems: '设计系统',
marketingTeam: '市场团队',
backendCore: '后端核心'
}
},
settings: {
title: '设置',
preferences: '偏好设置',
preferencesSubtitle: '自定义账号、通知与工作区偏好。',
accountSubtitle: '管理你的资料、状态与联系方式。',
displayName: '显示名称',
displayNamePlaceholder: '输入显示名称',
emailAddress: '邮箱地址',
emailPlaceholder: 'name@example.com',
jobTitle: '职位',
jobTitlePlaceholder: '例如 产品设计师',
timeZone: '时区',
status: '状态',
statusMessage: '状态信息',
statusMessagePlaceholder: '分享你正在做的事情',
statusOptions: {
online: '在线',
away: '离开',
dnd: '请勿打扰'
},
language: '语言',
languageDefault: '默认',
changesApply: '重启后生效。',
spellCheck: '拼写检查',
defaultMember: '默认成员',
selectMember: '选择成员',
refreshList: '刷新列表',
cancel: '取消',
saveChanges: '保存修改',
userSettings: '用户设置',
myAccount: '我的账号',
appSettings: '应用设置',
appearance: '外观',
appearanceSubtitle: '切换主题并匹配你的工作环境。',
members: '成员',
notifications: '通知',
keybinds: '快捷键',
leaveTeam: '退出团队',
logOut: '退出账号',
memberName: '成员名称',
memberNamePlaceholder: '输入成员标签',
commandInput: '命令行输入',
commandPlaceholder: '例如 /usr/local/bin/my-cli',
confirm: '确认',
notificationsSubtitle: '选择你接收通知的方式与时机。',
notificationOptions: {
desktop: '桌面通知',
desktopDesc: '新消息时显示系统通知。',
sound: '声音提醒',
soundDesc: '新消息到达时播放提示音。',
mentionsOnly: '仅提醒提及',
mentionsOnlyDesc: '只有被提及时才提醒。',
previews: '消息预览',
previewsDesc: '在通知中显示消息内容。',
weeklyDigest: '每周摘要',
weeklyDigestDesc: '每周发送一次活动摘要。',
quietHours: '静默时段',
quietHoursDesc: '在设定时间段内关闭提醒。'
},
quietHoursFrom: '开始时间',
quietHoursTo: '结束时间',
keybindsSubtitle: '配置快捷键与键位方案。',
keybindsProfile: '键位方案',
keybindsEnable: '启用快捷键',
keybindsHints: '显示快捷键提示',
keybindsReset: '恢复默认',
keybindsListTitle: '快捷键参考',
keybindProfiles: {
default: '默认',
vscode: 'VS Code',
slack: 'Slack'
},
keybindActions: {
focusSearch: '聚焦搜索',
newMessage: '新建消息',
toggleSidebar: '切换侧边栏',
toggleMute: '切换静音',
jumpToLatest: '跳到最新消息',
openSettings: '打开设置'
},
memberOptions: {
gemini: 'Gemini CLI',
codex: 'Codex',
claude: 'Claude Code',
custom: '自定义 CLI'
},
memberKind: {
default: '默认终端',
custom: '自定义终端'
},
memberActions: {
menuLabel: '终端操作',
test: '测试终端',
edit: '更改',
remove: '删除'
},
themeOptions: {
dark: {
label: '深色',
desc: '默认深色主题,更适合低光环境。'
},
light: {
label: '浅色',
desc: '明亮布局,适合白天或高亮环境。'
},
system: {
label: '系统',
desc: '跟随操作系统外观设置。'
}
}
},
language: {
enUS: '英文(美国)',
zhCN: '中文(简体)'
}
};
+9
View File
@@ -0,0 +1,9 @@
import { createApp } from 'vue';
import App from './app/App.vue';
import { i18n } from './i18n';
import { initializeTheme } from './shared/composables/useTheme';
import './styles/global.css';
initializeTheme();
createApp(App).use(i18n).mount('#app');
+103
View File
@@ -0,0 +1,103 @@
<template>
<nav class="w-full md:w-[88px] md:h-full h-16 flex md:flex-col flex-row items-center md:py-6 py-2 md:gap-6 gap-2 bg-panel/50 glass-panel md:border-r border-t border-white/5 shrink-0 z-50 fixed md:static bottom-0 left-0 right-0">
<div class="mb-2 relative group cursor-pointer hidden md:block">
<img
:src="PRIMARY_USER_AVATAR_URL"
:alt="t('common.userAvatarAlt')"
class="w-[52px] h-[52px] rounded-[18px] object-cover border-2 border-white/10 group-hover:border-primary/50 transition-colors"
/>
<div class="absolute -top-1 -right-1 w-3.5 h-3.5 bg-red-500 border-[3px] border-panel rounded-full"></div>
</div>
<div class="w-8 h-[1px] bg-white/10 rounded-full hidden md:block"></div>
<div class="flex md:flex-col flex-row gap-4 w-full md:w-full justify-around md:justify-start">
<div v-for="item in navItems" :key="item.id" class="relative group flex justify-center w-full">
<div
v-if="activeTab === item.id"
class="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-primary rounded-r-full shadow-[0_0_15px_rgb(var(--color-primary)_/_0.6)] hidden md:block"
></div>
<button
type="button"
:title="t(item.tooltipKey)"
@click="handleNavClick(item.id)"
:class="[
'w-10 h-10 md:w-12 md:h-12 flex items-center justify-center rounded-2xl transition-all duration-300 group-hover:scale-105',
activeTab === item.id
? 'bg-gradient-to-br from-primary/80 to-primary-hover/80 text-on-primary shadow-glow'
: 'bg-white/5 text-white/40 hover:bg-white/10 hover:text-white'
]"
>
<span class="material-symbols-outlined text-[24px]">{{ item.icon }}</span>
</button>
</div>
<div class="relative group flex justify-center w-full md:hidden">
<button
type="button"
:title="t('nav.settings')"
@click="emitChange('settings')"
:class="[
'w-10 h-10 flex items-center justify-center rounded-2xl transition-all duration-300 group-hover:scale-105',
activeTab === 'settings'
? 'bg-gradient-to-br from-primary/80 to-primary-hover/80 text-on-primary shadow-glow'
: 'bg-white/5 text-white/40 hover:bg-white/10 hover:text-white'
]"
>
<span class="material-symbols-outlined text-[24px]">settings</span>
</button>
</div>
</div>
<div class="flex-1 hidden md:block"></div>
<button
type="button"
@click="emitChange('settings')"
:title="t('nav.settings')"
:class="[
'w-12 h-12 flex items-center justify-center rounded-2xl transition-all hidden md:flex',
activeTab === 'settings' ? 'text-white bg-white/10' : 'text-white/40 hover:text-white hover:bg-white/5'
]"
>
<span class="material-symbols-outlined text-[26px]">settings</span>
</button>
</nav>
</template>
<script setup lang="ts">
import { toRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { PRIMARY_USER_AVATAR_URL } from '../constants/avatars';
type TabId = 'chat' | 'workspaces' | 'store' | 'plugins' | 'settings';
type NavItem = {
id: TabId;
icon: string;
tooltipKey: string;
};
const props = defineProps<{ activeTab: TabId }>();
const emit = defineEmits<{ (e: 'change', tab: TabId): void }>();
const navItems: NavItem[] = [
{ id: 'chat', icon: 'chat_bubble', tooltipKey: 'nav.chat' },
{ id: 'workspaces', icon: 'folder_open', tooltipKey: 'nav.workspaces' },
{ id: 'store', icon: 'storefront', tooltipKey: 'nav.store' },
{ id: 'plugins', icon: 'extension', tooltipKey: 'nav.plugins' }
];
const { t } = useI18n();
const emitChange = (tab: TabId) => {
emit('change', tab);
};
const handleNavClick = (id: TabId) => {
// Preserve original behavior: the workspaces icon does not switch tabs.
if (id === 'workspaces') return;
emitChange(id);
};
const activeTab = toRef(props, 'activeTab');
</script>
+31
View File
@@ -0,0 +1,31 @@
import { computed } from 'vue';
import { i18n, LOCALE_STORAGE_KEY, setDocumentLang, type AppLocale } from '@/i18n';
export type LocaleOption = {
id: AppLocale;
labelKey: string;
flag: string;
};
export const localeOptions: LocaleOption[] = [
{ id: 'en-US', labelKey: 'language.enUS', flag: '🇺🇸' },
{ id: 'zh-CN', labelKey: 'language.zhCN', flag: '🇨🇳' }
];
export const useLocale = () => {
const locale = computed(() => i18n.global.locale.value as AppLocale);
const setLocale = (next: AppLocale) => {
i18n.global.locale.value = next;
if (typeof window !== 'undefined') {
window.localStorage.setItem(LOCALE_STORAGE_KEY, next);
}
setDocumentLang(next);
};
return {
locale,
setLocale,
localeOptions
};
};
+248
View File
@@ -0,0 +1,248 @@
import { computed, ref } from 'vue';
export type AccountStatus = 'online' | 'away' | 'dnd';
export type AccountSettings = {
displayName: string;
email: string;
title: string;
timezone: string;
status: AccountStatus;
statusMessage: string;
};
export type NotificationSettings = {
desktop: boolean;
sound: boolean;
mentionsOnly: boolean;
previews: boolean;
weeklyDigest: boolean;
quietHoursEnabled: boolean;
quietHoursStart: string;
quietHoursEnd: string;
};
export type KeybindProfile = 'default' | 'vscode' | 'slack';
export type KeybindSettings = {
enabled: boolean;
showHints: boolean;
profile: KeybindProfile;
};
export type CustomMember = {
id: string;
name: string;
command: string;
};
export type MemberSettings = {
defaultMemberId: string;
customMembers: CustomMember[];
};
export type LanguageSettings = {
spellCheck: boolean;
};
export type SettingsState = {
account: AccountSettings;
notifications: NotificationSettings;
keybinds: KeybindSettings;
members: MemberSettings;
language: LanguageSettings;
};
const SETTINGS_STORAGE_KEY = 'nexus-settings';
const DEFAULT_SETTINGS: SettingsState = {
account: {
displayName: '',
email: '',
title: '',
timezone: 'UTC',
status: 'online',
statusMessage: ''
},
notifications: {
desktop: true,
sound: false,
mentionsOnly: false,
previews: true,
weeklyDigest: true,
quietHoursEnabled: false,
quietHoursStart: '22:00',
quietHoursEnd: '07:00'
},
keybinds: {
enabled: true,
showHints: true,
profile: 'default'
},
members: {
defaultMemberId: 'gemini-cli',
customMembers: []
},
language: {
spellCheck: true
}
};
const BASE_MEMBER_IDS = ['gemini-cli', 'codex', 'claude-code'];
const ALLOWED_MEMBER_IDS = new Set(BASE_MEMBER_IDS);
const ALLOWED_STATUSES = new Set<AccountStatus>(['online', 'away', 'dnd']);
const ALLOWED_KEYBINDS = new Set<KeybindProfile>(['default', 'vscode', 'slack']);
const CUSTOM_MEMBER_PREFIX = 'custom-cli-';
const isTimeValue = (value: string) => /^\d{2}:\d{2}$/.test(value);
const safeString = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback);
export const cloneSettings = (value: SettingsState): SettingsState => JSON.parse(JSON.stringify(value)) as SettingsState;
const normalizeCustomMember = (candidate: Partial<CustomMember>, fallbackId: string): CustomMember | null => {
const id = safeString(candidate.id).trim() || fallbackId;
const name = safeString(candidate.name).trim();
const command = safeString(candidate.command).trim();
if (!id || (!name && !command)) {
return null;
}
return { id, name, command };
};
const buildCustomMembers = (candidate: SettingsState): CustomMember[] => {
const rawList = Array.isArray(candidate.members.customMembers) ? candidate.members.customMembers : [];
const normalized: CustomMember[] = [];
rawList.forEach((member, index) => {
const next = normalizeCustomMember(member, `${CUSTOM_MEMBER_PREFIX}${index + 1}`);
if (next) {
normalized.push(next);
}
});
if ((candidate.members as { customMember?: Partial<CustomMember> }).customMember) {
const legacy = normalizeCustomMember(
(candidate.members as { customMember?: Partial<CustomMember> }).customMember ?? {},
`${CUSTOM_MEMBER_PREFIX}${normalized.length + 1}`
);
if (legacy) {
normalized.push(legacy);
}
}
const seen = new Set<string>();
return normalized.filter((member) => {
if (seen.has(member.id)) {
return false;
}
seen.add(member.id);
return true;
});
};
const normalizeSettings = (candidate: SettingsState): SettingsState => {
const account = {
displayName: safeString(candidate.account.displayName).trim(),
email: safeString(candidate.account.email).trim().toLowerCase(),
title: safeString(candidate.account.title).trim(),
timezone: safeString(candidate.account.timezone).trim() || DEFAULT_SETTINGS.account.timezone,
status: ALLOWED_STATUSES.has(candidate.account.status) ? candidate.account.status : DEFAULT_SETTINGS.account.status,
statusMessage: safeString(candidate.account.statusMessage).trim()
};
const notifications = {
desktop: Boolean(candidate.notifications.desktop),
sound: Boolean(candidate.notifications.sound),
mentionsOnly: Boolean(candidate.notifications.mentionsOnly),
previews: Boolean(candidate.notifications.previews),
weeklyDigest: Boolean(candidate.notifications.weeklyDigest),
quietHoursEnabled: Boolean(candidate.notifications.quietHoursEnabled),
quietHoursStart: isTimeValue(safeString(candidate.notifications.quietHoursStart))
? safeString(candidate.notifications.quietHoursStart)
: DEFAULT_SETTINGS.notifications.quietHoursStart,
quietHoursEnd: isTimeValue(safeString(candidate.notifications.quietHoursEnd))
? safeString(candidate.notifications.quietHoursEnd)
: DEFAULT_SETTINGS.notifications.quietHoursEnd
};
const keybinds = {
enabled: Boolean(candidate.keybinds.enabled),
showHints: Boolean(candidate.keybinds.showHints),
profile: ALLOWED_KEYBINDS.has(candidate.keybinds.profile) ? candidate.keybinds.profile : DEFAULT_SETTINGS.keybinds.profile
};
const customMembers = buildCustomMembers(candidate);
const allowedMemberIds = new Set([...ALLOWED_MEMBER_IDS, ...customMembers.map((member) => member.id)]);
const requestedMemberId =
candidate.members.defaultMemberId === 'custom-cli' && customMembers.length > 0 ? customMembers[0].id : candidate.members.defaultMemberId;
const defaultMemberId = allowedMemberIds.has(requestedMemberId) ? requestedMemberId : DEFAULT_SETTINGS.members.defaultMemberId;
return {
account,
notifications,
keybinds,
members: {
defaultMemberId,
customMembers
},
language: {
spellCheck: Boolean(candidate.language.spellCheck)
}
};
};
const buildSettings = (candidate?: Partial<SettingsState>): SettingsState => {
const merged: SettingsState = {
account: { ...DEFAULT_SETTINGS.account, ...candidate?.account },
notifications: { ...DEFAULT_SETTINGS.notifications, ...candidate?.notifications },
keybinds: { ...DEFAULT_SETTINGS.keybinds, ...candidate?.keybinds },
members: { ...DEFAULT_SETTINGS.members, ...candidate?.members },
language: { ...DEFAULT_SETTINGS.language, ...candidate?.language }
};
return normalizeSettings(merged);
};
const loadSettings = (): SettingsState => {
if (typeof window === 'undefined') {
return cloneSettings(DEFAULT_SETTINGS);
}
const raw = window.localStorage.getItem(SETTINGS_STORAGE_KEY);
if (!raw) {
return cloneSettings(DEFAULT_SETTINGS);
}
try {
const parsed = JSON.parse(raw) as Partial<SettingsState>;
return buildSettings(parsed);
} catch {
return cloneSettings(DEFAULT_SETTINGS);
}
};
const settingsRef = ref<SettingsState>(loadSettings());
const persistSettings = (next: SettingsState) => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(next));
};
export const useSettings = () => {
const saveSettings = (next: SettingsState) => {
const normalized = normalizeSettings(next);
settingsRef.value = normalized;
persistSettings(normalized);
return normalized;
};
const resetSettings = () => {
const next = cloneSettings(DEFAULT_SETTINGS);
settingsRef.value = next;
persistSettings(next);
return next;
};
return {
settings: computed(() => settingsRef.value),
saveSettings,
resetSettings
};
};
+101
View File
@@ -0,0 +1,101 @@
import { computed, ref } from 'vue';
export type AppTheme = 'dark' | 'light' | 'system';
export const THEME_STORAGE_KEY = 'nexus-theme';
export type ThemeOption = {
id: AppTheme;
labelKey: string;
descriptionKey: string;
};
export const themeOptions: ThemeOption[] = [
{
id: 'dark',
labelKey: 'settings.themeOptions.dark.label',
descriptionKey: 'settings.themeOptions.dark.desc'
},
{
id: 'light',
labelKey: 'settings.themeOptions.light.label',
descriptionKey: 'settings.themeOptions.light.desc'
},
{
id: 'system',
labelKey: 'settings.themeOptions.system.label',
descriptionKey: 'settings.themeOptions.system.desc'
}
];
const getStoredTheme = (): AppTheme => {
if (typeof window === 'undefined') return 'dark';
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
if (stored === 'dark' || stored === 'light' || stored === 'system') {
return stored;
}
return 'dark';
};
const theme = ref<AppTheme>(getStoredTheme());
const getSystemTheme = (): 'dark' | 'light' => {
if (typeof window === 'undefined' || !window.matchMedia) {
return 'dark';
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
const applyTheme = (value: AppTheme) => {
if (typeof document === 'undefined') return;
const root = document.documentElement;
root.dataset.theme = value;
const resolved = value === 'system' ? getSystemTheme() : value;
root.dataset.resolvedTheme = resolved;
root.classList.toggle('dark', resolved === 'dark');
};
let mediaQuery: MediaQueryList | null = null;
let mediaHandler: ((event: MediaQueryListEvent) => void) | null = null;
const startSystemListener = () => {
if (typeof window === 'undefined' || !window.matchMedia || mediaQuery) return;
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaHandler = () => applyTheme('system');
mediaQuery.addEventListener('change', mediaHandler);
};
const stopSystemListener = () => {
if (!mediaQuery || !mediaHandler) return;
mediaQuery.removeEventListener('change', mediaHandler);
mediaQuery = null;
mediaHandler = null;
};
export const initializeTheme = () => {
applyTheme(theme.value);
if (theme.value === 'system') {
startSystemListener();
}
};
export const useTheme = () => {
const setTheme = (next: AppTheme) => {
theme.value = next;
if (typeof window !== 'undefined') {
window.localStorage.setItem(THEME_STORAGE_KEY, next);
}
applyTheme(next);
if (next === 'system') {
startSystemListener();
} else {
stopSystemListener();
}
};
return {
theme: computed(() => theme.value),
setTheme,
themeOptions
};
};
+1
View File
@@ -0,0 +1 @@
export const PRIMARY_USER_AVATAR_URL = 'https://picsum.photos/id/64/200/200';
+228
View File
@@ -0,0 +1,228 @@
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgb(var(--scrollbar-thumb));
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgb(var(--scrollbar-thumb-hover));
}
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgb(var(--scrollbar-thumb)) transparent;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
:root {
--color-background: 15 15 18;
--color-surface: 24 24 27;
--color-panel: 18 18 21;
--color-panel-strong: 20 20 23;
--color-panel-soft: 30 30 36;
--color-text: 248 250 252;
--color-text-muted: 148 163 184;
--color-border: 255 255 255;
--color-overlay: 255 255 255;
--color-primary: 56 189 248;
--color-primary-hover: 14 165 233;
--color-secondary: 100 116 139;
--scrollbar-thumb: 51 65 85;
--scrollbar-thumb-hover: 71 85 105;
--shadow-modal: 0 25px 50px -12px rgb(0 0 0 / 0.5);
--app-gradient: radial-gradient(1200px circle at top right, rgb(var(--color-primary) / 0.18), rgb(var(--color-background)) 55%, rgb(5 5 5) 100%);
--app-gradient-spot: radial-gradient(900px circle at 20% 20%, rgb(var(--color-primary) / 0.12), transparent 60%);
color-scheme: dark;
}
:root[data-theme='light'] {
--color-background: 248 250 253;
--color-surface: 255 255 255;
--color-panel: 240 244 249;
--color-panel-strong: 233 238 246;
--color-panel-soft: 255 255 255;
--color-text: 31 31 31;
--color-text-muted: 68 71 70;
--color-border: 116 119 117;
--color-overlay: 31 31 31;
--color-primary: 11 87 208;
--color-primary-hover: 10 79 184;
--color-secondary: 0 99 155;
--scrollbar-thumb: 196 199 197;
--scrollbar-thumb-hover: 116 119 117;
--shadow-modal: 0 25px 50px -12px rgb(15 23 42 / 0.16);
--app-gradient: radial-gradient(1200px circle at top right, rgb(var(--color-primary) / 0.12), rgb(var(--color-background)) 45%, rgb(var(--color-panel)) 100%);
--app-gradient-spot: radial-gradient(800px circle at 20% 15%, rgb(194 231 255 / 0.45), transparent 60%);
color-scheme: light;
}
:root[data-theme='system'] {
color-scheme: dark;
}
@media (prefers-color-scheme: light) {
:root[data-theme='system'] {
--color-background: 248 250 253;
--color-surface: 255 255 255;
--color-panel: 240 244 249;
--color-panel-strong: 233 238 246;
--color-panel-soft: 255 255 255;
--color-text: 31 31 31;
--color-text-muted: 68 71 70;
--color-border: 116 119 117;
--color-overlay: 31 31 31;
--color-primary: 11 87 208;
--color-primary-hover: 10 79 184;
--color-secondary: 0 99 155;
--scrollbar-thumb: 196 199 197;
--scrollbar-thumb-hover: 116 119 117;
--shadow-modal: 0 25px 50px -12px rgb(15 23 42 / 0.16);
--app-gradient: radial-gradient(1200px circle at top right, rgb(var(--color-primary) / 0.12), rgb(var(--color-background)) 45%, rgb(var(--color-panel)) 100%);
--app-gradient-spot: radial-gradient(800px circle at 20% 15%, rgb(194 231 255 / 0.45), transparent 60%);
color-scheme: light;
}
}
.app-shell {
background-color: rgb(var(--color-background));
background-image: var(--app-gradient), var(--app-gradient-spot);
background-repeat: no-repeat;
background-size: cover;
}
:root[data-resolved-theme='light'] .message-bubble--me {
background-color: rgb(var(--color-panel-strong)) !important;
border: 1px solid rgb(var(--color-border) / 0.35);
color: rgb(var(--color-text)) !important;
}
.text-on-primary {
color: #fff !important;
}
.hover\:text-on-primary:hover {
color: #fff !important;
}
.group:hover .group-hover\:text-on-primary {
color: #fff !important;
}
:root[data-resolved-theme='light'] .text-white {
color: rgb(var(--color-text) / 1) !important;
}
:root[data-resolved-theme='light'] .text-white\/90 {
color: rgb(var(--color-text) / 0.9) !important;
}
:root[data-resolved-theme='light'] .text-white\/80 {
color: rgb(var(--color-text) / 0.8) !important;
}
:root[data-resolved-theme='light'] .text-white\/70 {
color: rgb(var(--color-text) / 0.7) !important;
}
:root[data-resolved-theme='light'] .text-white\/60 {
color: rgb(var(--color-text) / 0.6) !important;
}
:root[data-resolved-theme='light'] .text-white\/50 {
color: rgb(var(--color-text) / 0.5) !important;
}
:root[data-resolved-theme='light'] .text-white\/40 {
color: rgb(var(--color-text) / 0.4) !important;
}
:root[data-resolved-theme='light'] .text-white\/30 {
color: rgb(var(--color-text) / 0.3) !important;
}
:root[data-resolved-theme='light'] .text-white\/20 {
color: rgb(var(--color-text) / 0.2) !important;
}
:root[data-resolved-theme='light'] .placeholder-white\/30::placeholder {
color: rgb(var(--color-text) / 0.3) !important;
}
:root[data-resolved-theme='light'] .placeholder-white\/20::placeholder {
color: rgb(var(--color-text) / 0.2) !important;
}
:root[data-resolved-theme='light'] .bg-white\/20 {
background-color: rgb(var(--color-overlay) / 0.2) !important;
}
:root[data-resolved-theme='light'] .bg-white\/10 {
background-color: rgb(var(--color-overlay) / 0.1) !important;
}
:root[data-resolved-theme='light'] .bg-white\/5 {
background-color: rgb(var(--color-overlay) / 0.05) !important;
}
:root[data-resolved-theme='light'] .bg-white\/\[0\.02\] {
background-color: rgb(var(--color-overlay) / 0.02) !important;
}
:root[data-resolved-theme='light'] .bg-white\/\[0\.03\] {
background-color: rgb(var(--color-overlay) / 0.03) !important;
}
:root[data-resolved-theme='light'] .bg-white\/\[0\.04\] {
background-color: rgb(var(--color-overlay) / 0.04) !important;
}
:root[data-resolved-theme='light'] .bg-white\/\[0\.05\] {
background-color: rgb(var(--color-overlay) / 0.05) !important;
}
:root[data-resolved-theme='light'] .bg-white\/\[0\.06\] {
background-color: rgb(var(--color-overlay) / 0.06) !important;
}
:root[data-resolved-theme='light'] .bg-white\/\[0\.07\] {
background-color: rgb(var(--color-overlay) / 0.07) !important;
}
:root[data-resolved-theme='light'] .border-white\/20 {
border-color: rgb(var(--color-border) / 0.45) !important;
}
:root[data-resolved-theme='light'] .border-white\/10 {
border-color: rgb(var(--color-border) / 0.3) !important;
}
:root[data-resolved-theme='light'] .border-white\/5 {
border-color: rgb(var(--color-border) / 0.2) !important;
}
:root[data-resolved-theme='light'] .ring-white\/20 {
--tw-ring-color: rgb(var(--color-border) / 0.45) !important;
}
:root[data-resolved-theme='light'] .ring-white\/10 {
--tw-ring-color: rgb(var(--color-border) / 0.3) !important;
}
:root[data-resolved-theme='light'] .ring-white\/5 {
--tw-ring-color: rgb(var(--color-border) / 0.2) !important;
}
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { ensureUniqueName, groupMessagesByDay, splitMentions } from '../features/chat/utils';
import type { Member, Message } from '../features/chat/types';
describe('chat utils', () => {
it('splits mentions while preserving plain text', () => {
const parts = splitMentions('Hello @Jane Doe and @Sam');
expect(parts).toEqual(['Hello ', '@Jane Doe and ', '@Sam']);
});
it('ensures unique member names case-insensitively', () => {
const members: Member[] = [
{ id: '1', name: 'Alex', role: '', roleType: 'member', avatar: '', status: 'offline' },
{ id: '2', name: 'Alex-1', role: '', roleType: 'member', avatar: '', status: 'offline' }
];
expect(ensureUniqueName('Alex', members)).toBe('Alex-2');
expect(ensureUniqueName('Jamie', members)).toBe('Jamie');
});
it('groups messages by day with separators', () => {
const dayOne = new Date('2024-02-01T09:00:00').getTime();
const dayTwo = new Date('2024-02-02T10:00:00').getTime();
const messages: Message[] = [
{
id: 1,
senderId: 'me',
user: 'You',
avatar: '',
text: 'First message',
time: '09:00',
createdAt: dayOne,
isAi: false,
status: 'sent'
},
{
id: 2,
senderId: 'me',
user: 'You',
avatar: '',
text: 'Second message',
time: '09:05',
createdAt: dayOne + 1000 * 60 * 5,
isAi: false,
status: 'sent'
},
{
id: 3,
senderId: 'assistant',
user: 'AI',
avatar: '',
text: 'Next day message',
time: '10:00',
createdAt: dayTwo,
isAi: true,
status: 'sent'
}
];
const grouped = groupMessagesByDay(messages, 'en-US');
const separatorCount = grouped.filter((item) => item.type === 'separator').length;
const messageCount = grouped.filter((item) => item.type === 'message').length;
expect(separatorCount).toBe(2);
expect(messageCount).toBe(3);
});
});
+35
View File
@@ -0,0 +1,35 @@
# Nexus Dashboard Suite (Vue 3)
This project is a Vue 3 + TypeScript rebuild of the original Nexus dashboard UI. The UI and behavior are kept consistent while the codebase is refactored into Vue components with Composition API.
## Run Locally
**Prerequisites:** Node.js, pnpm
1. Install dependencies:
`pnpm install`
2. (Optional) Set `GEMINI_API_KEY` in `.env.local`
3. Start the dev server:
`pnpm dev`
## Tests
Run unit tests:
`pnpm test`
## Lint & Format
- `pnpm lint`
- `pnpm format:check`
## Project Structure
- `src/app/App.vue`: App shell + navigation state
- `src/features`: Feature modules (store, marketplace, settings, workspace, chat)
- `src/shared`: Reusable components and composables
- `src/i18n`: Locale files and i18n setup
- `src/styles/global.css`: Scrollbar and layout utilities
See `docs/ENGINEERING_GUIDE.md` for conventions and structure details.
Legacy React code is moved to `C:\project\user\nexus-dashboard-suite-legacy`.
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node",
"vite/client"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.vue",
"vite.config.ts"
]
}
+28
View File
@@ -0,0 +1,28 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
strictPort: true,
},
plugins: [vue()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
}
},
test: {
environment: 'node',
include: ['src/tests/**/*.spec.ts']
}
};
});