Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d6d6d7ff7 | ||
|
|
b3eb758093 | ||
|
|
26fe9679b6 | ||
|
|
836fe675d7 | ||
|
|
4909031290 | ||
|
|
839dda286b | ||
|
|
a10d51719a | ||
|
|
569bdf962e | ||
|
|
253fa5c592 | ||
|
|
2fdc112979 | ||
|
|
f204431d34 | ||
|
|
f5cc00b985 | ||
|
|
fd3ba44e47 | ||
|
|
d89f9ee584 | ||
|
|
9ba1d74b16 | ||
|
|
ed9b95298c | ||
|
|
ae36f08025 | ||
|
|
da70a1e334 | ||
|
|
b7b2f3f183 | ||
|
|
826f9352e9 | ||
|
|
237d22f9af | ||
|
|
088efddd8d | ||
|
|
dc915ac59c | ||
|
|
1685cedca9 | ||
|
|
7ddc1a07e6 | ||
|
|
af0b8e3693 | ||
|
|
a936271de9 | ||
|
|
bd26392536 | ||
|
|
69da5e7f58 | ||
|
|
2ee18bcb1e | ||
|
|
eb17b476eb | ||
|
|
9f183af0d7 | ||
|
|
f7bc965a86 | ||
|
|
a09bf356e7 | ||
|
|
e43be6e145 | ||
|
|
5c1ad78de7 | ||
|
|
44f10d8196 | ||
|
|
1e9b6d4fc2 | ||
|
|
c32c7051a7 | ||
|
|
f74ef81d8b | ||
|
|
80ae4d5342 | ||
|
|
42bf0eccbb | ||
|
|
2088fa1c91 | ||
|
|
9c4632a816 | ||
|
|
880d0471af | ||
|
|
5443f6437f | ||
|
|
df90d71e75 | ||
|
|
b61cb1514f | ||
|
|
aa1ee352b6 | ||
|
|
8d2ec0d5c9 | ||
|
|
4ae42b14d3 | ||
|
|
3f720b72cf | ||
|
|
0da314e13f | ||
|
|
376879bb74 | ||
|
|
473124cd98 | ||
|
|
600aa41b1f | ||
|
|
392b961fb6 | ||
|
|
82b556f0c0 | ||
|
|
1a66bc4a2b | ||
|
|
b958c37bf0 | ||
|
|
fcb0483901 | ||
|
|
bf12771908 | ||
|
|
0e2035a480 | ||
|
|
df25f2346e | ||
|
|
4fb1945bba | ||
|
|
cd74cae930 | ||
|
|
4eeafe9221 |
@@ -5,7 +5,6 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'site/**'
|
- 'site/**'
|
||||||
- 'site-internal/**'
|
|
||||||
- '.github/workflows/deploy-site.yml'
|
- '.github/workflows/deploy-site.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -27,12 +26,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: |
|
cache-dependency-path: site/package-lock.json
|
||||||
site/package-lock.json
|
|
||||||
site-internal/package-lock.json
|
|
||||||
- run: cd site && npm install && VITE_BASE=/multi-casual-android/ npm run build
|
- run: cd site && npm install && VITE_BASE=/multi-casual-android/ npm run build
|
||||||
- run: cd site-internal && npm install && VITE_BASE=/multi-casual-android/xiaomi/ npm run build
|
|
||||||
- run: mkdir -p site/dist/xiaomi && cp -R site-internal/dist/. site/dist/xiaomi/
|
|
||||||
- uses: actions/upload-pages-artifact@v3
|
- uses: actions/upload-pages-artifact@v3
|
||||||
with:
|
with:
|
||||||
path: site/dist
|
path: site/dist
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
## [Unreleased] - 2026-06-11
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
#### 自定义服务器配置功能 (Custom Server Configuration)
|
|
||||||
- **服务器配置管理**
|
|
||||||
- 新增 `ServerConfigManager.kt` - 使用 DataStore 持久化存储服务器配置
|
|
||||||
- 新增 `ServerConfigHolder.kt` - 全局单例提供运行时配置访问
|
|
||||||
- 支持配置自定义 API、Web、WebSocket 服务器地址
|
|
||||||
- 支持从单个域名自动派生三个 URL(简单模式)
|
|
||||||
- 支持手动配置每个 URL(高级模式)
|
|
||||||
- 重置为默认 Multica 公开服务器功能
|
|
||||||
|
|
||||||
- **用户界面**
|
|
||||||
- 登录页面添加"自定义服务器"入口
|
|
||||||
- 设置页面添加"开发者选项 → 自定义服务器"入口
|
|
||||||
- Cupertino 风格的服务器配置页面
|
|
||||||
- 实时配置验证和错误提示
|
|
||||||
- 中英文双语支持
|
|
||||||
|
|
||||||
- **依赖注入改造**
|
|
||||||
- `ApiClient.java` 新增 `UrlProvider` 接口
|
|
||||||
- 支持运行时动态切换服务器地址
|
|
||||||
- `MainActivity.java` 和 `ComposePilotActivity.kt` 适配新的 URL 提供方式
|
|
||||||
|
|
||||||
- **应用启动优化**
|
|
||||||
- `MulticaApplication.java` 添加配置预加载
|
|
||||||
- 确保 ApiClient 创建前配置已就绪
|
|
||||||
|
|
||||||
#### 记住登录状态功能 (Remember Me)
|
|
||||||
- **持久化登录选项**
|
|
||||||
- `AuthStore.java` 新增 `rememberLogin()` 和 `setRememberLogin()` 方法
|
|
||||||
- 登录界面添加"记住登录状态"开关(默认开启)
|
|
||||||
- 支持中英文:"记住登录状态" / "Remember me"
|
|
||||||
|
|
||||||
- **自动登出机制**
|
|
||||||
- `ComposePilotActivity` 新增 `onStop()` 生命周期管理
|
|
||||||
- 未勾选"记住登录"时,应用退出自动清空 token
|
|
||||||
- 勾选时保持登录状态持久化
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
#### 网络安全配置
|
|
||||||
- **HTTP 支持**
|
|
||||||
- `app/build.gradle` - 将 `usesCleartextTraffic` 从 `false` 改为 `true`
|
|
||||||
- 支持自托管场景使用 HTTP 协议(包括本地测试)
|
|
||||||
- 允许连接到非 HTTPS 的自定义服务器
|
|
||||||
|
|
||||||
#### 登录流程优化
|
|
||||||
- 保存配置时会自动清空旧 token,要求重新登录(安全考虑)
|
|
||||||
- 重置服务器配置时同样清空 token
|
|
||||||
|
|
||||||
### Technical Details
|
|
||||||
|
|
||||||
#### 新增文件
|
|
||||||
```
|
|
||||||
app/src/main/java/ai/multica/app/
|
|
||||||
├── ServerConfigManager.kt # 服务器配置管理(DataStore)
|
|
||||||
└── ServerConfigHolder.kt # 全局配置单例
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 修改文件
|
|
||||||
```
|
|
||||||
app/build.gradle # DataStore 依赖 + cleartext 流量配置
|
|
||||||
app/src/main/AndroidManifest.xml # usesCleartextTraffic 占位符
|
|
||||||
app/src/main/java/ai/multica/app/
|
|
||||||
├── ApiClient.java # UrlProvider 接口 + 动态 URL
|
|
||||||
├── AuthStore.java # rememberLogin 存储
|
|
||||||
├── MainActivity.java # ApiClient 构造注入 UrlProvider
|
|
||||||
├── MulticaApplication.java # 配置预加载
|
|
||||||
└── ComposePilotActivity.kt # 服务器配置 UI + 记住登录 UI + onStop
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 新增依赖
|
|
||||||
```gradle
|
|
||||||
implementation "androidx.datastore:datastore-preferences:1.0.0"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 代码统计
|
|
||||||
- 新增代码:约 450 行
|
|
||||||
- 修改代码:约 80 行
|
|
||||||
- 新增文件:2 个
|
|
||||||
- 修改文件:7 个
|
|
||||||
|
|
||||||
### Security Considerations
|
|
||||||
|
|
||||||
1. **服务器切换安全**
|
|
||||||
- 切换服务器时强制清空 token,防止 token 泄露到错误服务器
|
|
||||||
- 配置保存后要求用户重新登录验证
|
|
||||||
|
|
||||||
2. **HTTP 流量警告**
|
|
||||||
- 允许 cleartext HTTP 是为了支持自托管和本地测试
|
|
||||||
- 生产环境建议使用 HTTPS
|
|
||||||
|
|
||||||
3. **登录状态管理**
|
|
||||||
- 默认"记住登录"开启,保持用户体验
|
|
||||||
- 提供选项关闭,支持共享设备场景
|
|
||||||
|
|
||||||
### User Experience
|
|
||||||
|
|
||||||
#### 自定义服务器工作流
|
|
||||||
1. 登录页或设置页点击"自定义服务器"
|
|
||||||
2. 简单模式:输入域名(如 `example.com`)自动派生三个 URL
|
|
||||||
3. 高级模式:手动配置每个 URL(支持非标准端口)
|
|
||||||
4. 保存后自动跳转登录页
|
|
||||||
5. 使用自定义服务器账号登录
|
|
||||||
|
|
||||||
#### 记住登录工作流
|
|
||||||
1. 登录时勾选/取消"记住登录状态"
|
|
||||||
2. 勾选:关闭应用后保持登录
|
|
||||||
3. 不勾选:关闭应用后自动登出
|
|
||||||
|
|
||||||
### Known Issues & Limitations
|
|
||||||
|
|
||||||
1. **构建环境**
|
|
||||||
- Gradle wrapper 在某些 macOS 环境下可能遇到权限问题
|
|
||||||
- 建议使用 Android Studio 进行构建
|
|
||||||
|
|
||||||
2. **服务器兼容性**
|
|
||||||
- 假设自托管服务完全兼容 Multica API
|
|
||||||
- 未实现 API 版本检测和兼容性验证
|
|
||||||
|
|
||||||
3. **连通性测试**
|
|
||||||
- 保存配置时仅做 URL 格式验证
|
|
||||||
- 不验证服务器可访问性(运行时连接失败才报错)
|
|
||||||
|
|
||||||
### Future Improvements (TODO)
|
|
||||||
|
|
||||||
- [ ] 添加服务器连通性测试(保存前验证)
|
|
||||||
- [ ] 使用 CompositionLocal 替代全局单例 `ServerConfigHolder`
|
|
||||||
- [ ] 实现 API 版本检测和兼容性提示
|
|
||||||
- [ ] 配置导入导出功能(便于团队共享)
|
|
||||||
- [ ] 多环境预设配置模板
|
|
||||||
|
|
||||||
### Testing Checklist
|
|
||||||
|
|
||||||
- [x] 简单模式域名派生 URL
|
|
||||||
- [x] 高级模式手动配置 URL
|
|
||||||
- [x] 重置为默认服务器
|
|
||||||
- [x] 配置持久化(应用重启)
|
|
||||||
- [x] 登录页服务器配置入口
|
|
||||||
- [x] 设置页服务器配置入口
|
|
||||||
- [x] HTTP cleartext 流量支持
|
|
||||||
- [x] 记住登录状态 UI
|
|
||||||
- [ ] 记住登录功能测试(需要编译安装)
|
|
||||||
- [ ] 自动登出测试(需要编译安装)
|
|
||||||
- [ ] 自定义服务器登录测试(需要自托管实例)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
本次更新主要面向以下用户场景:
|
|
||||||
1. **企业自托管部署** - 使用自有服务器运行 Multica
|
|
||||||
2. **本地开发测试** - 连接本地开发环境进行调试
|
|
||||||
3. **共享设备使用** - 支持不记住登录状态,保护隐私
|
|
||||||
|
|
||||||
所有功能均保持向后兼容,默认使用 Multica 公开服务器,普通用户无感知。
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
An independent Android companion app for Multica users.
|
An independent Android companion app for Multica users.
|
||||||
|
|
||||||
[](https://github.com/park0er/multi-casual-android/releases/latest) [](https://multi-casual-china-8temdtzs.zh-cn.edgeone.cool/) [](https://multi-casual-android.pages.dev/)
|
[](https://github.com/park0er/multi-casual-android/releases/latest) [](https://multi-casual-china-8temdtzs.zh-cn.edgeone.cool/) [](https://multi-casual-android.pages.dev/)
|
||||||
|
|
||||||
English | [简体中文](#简体中文)
|
English | [简体中文](#简体中文)
|
||||||
|
|
||||||
@@ -51,8 +51,8 @@ You can also build the app without analytics keys. In that case, analytics provi
|
|||||||
For normal installation, download the signed release APK from GitHub Releases:
|
For normal installation, download the signed release APK from GitHub Releases:
|
||||||
|
|
||||||
- Latest release: https://github.com/park0er/multi-casual-android/releases/latest
|
- Latest release: https://github.com/park0er/multi-casual-android/releases/latest
|
||||||
- Current signed APK: `Multi-Casual-Android-v0.1.5.apk`
|
- Current signed APK: `Multi-Casual-Android-v0.1.3.apk`
|
||||||
- SHA256: `3f50276216d866c0e405e1e99f38f2f2c1c3d5b4a4e79dc092495c7c6a3ebe49`
|
- SHA256: `317e42a926499aae1b3f3c9e1392eed169c2d2680c0565b9d2c44a31f5348bc4`
|
||||||
|
|
||||||
The debug APK produced by local development commands is not intended for public distribution.
|
The debug APK produced by local development commands is not intended for public distribution.
|
||||||
|
|
||||||
@@ -166,8 +166,8 @@ Analytics 是 opt-in。App 会先请求用户同意,然后才会上报 analyti
|
|||||||
普通用户安装时,请从 GitHub Releases 下载签名 release APK:
|
普通用户安装时,请从 GitHub Releases 下载签名 release APK:
|
||||||
|
|
||||||
- 最新 release:https://github.com/park0er/multi-casual-android/releases/latest
|
- 最新 release:https://github.com/park0er/multi-casual-android/releases/latest
|
||||||
- 当前签名 APK:`Multi-Casual-Android-v0.1.5.apk`
|
- 当前签名 APK:`Multi-Casual-Android-v0.1.3.apk`
|
||||||
- SHA256:`3f50276216d866c0e405e1e99f38f2f2c1c3d5b4a4e79dc092495c7c6a3ebe49`
|
- SHA256:`317e42a926499aae1b3f3c9e1392eed169c2d2680c0565b9d2c44a31f5348bc4`
|
||||||
|
|
||||||
本地开发命令生成的 debug APK 不用于公开分发。
|
本地开发命令生成的 debug APK 不用于公开分发。
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ android {
|
|||||||
applicationId "ai.multicasual.app"
|
applicationId "ai.multicasual.app"
|
||||||
resValue "string", "app_name", "Multi-Casual"
|
resValue "string", "app_name", "Multi-Casual"
|
||||||
manifestPlaceholders = [
|
manifestPlaceholders = [
|
||||||
usesCleartextTraffic: "true",
|
usesCleartextTraffic: "false",
|
||||||
deepLinkScheme: "multi-casual",
|
deepLinkScheme: "multi-casual",
|
||||||
umengDeepLinkScheme: "um.${umengPublicAppKey}"
|
umengDeepLinkScheme: "um.${umengPublicAppKey}"
|
||||||
]
|
]
|
||||||
@@ -122,7 +122,6 @@ dependencies {
|
|||||||
publicImplementation "com.umeng.umsdk:common:9.9.2"
|
publicImplementation "com.umeng.umsdk:common:9.9.2"
|
||||||
publicImplementation "com.umeng.umsdk:asms:1.8.7.2"
|
publicImplementation "com.umeng.umsdk:asms:1.8.7.2"
|
||||||
publicImplementation "com.posthog:posthog-android:3.47.0"
|
publicImplementation "com.posthog:posthog-android:3.47.0"
|
||||||
implementation "androidx.datastore:datastore-preferences:1.1.1"
|
|
||||||
implementation platform("androidx.compose:compose-bom:2024.10.01")
|
implementation platform("androidx.compose:compose-bom:2024.10.01")
|
||||||
implementation "androidx.activity:activity-compose:1.9.3"
|
implementation "androidx.activity:activity-compose:1.9.3"
|
||||||
implementation "androidx.compose.foundation:foundation"
|
implementation "androidx.compose.foundation:foundation"
|
||||||
|
|||||||
@@ -21,18 +21,10 @@ final class ApiClient {
|
|||||||
T parse(JSONObject json) throws Exception;
|
T parse(JSONObject json) throws Exception;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UrlProvider {
|
|
||||||
String getApiBaseUrl();
|
|
||||||
String getWebBaseUrl();
|
|
||||||
String getWsBaseUrl();
|
|
||||||
}
|
|
||||||
|
|
||||||
private final AuthStore authStore;
|
private final AuthStore authStore;
|
||||||
private final UrlProvider urlProvider;
|
|
||||||
|
|
||||||
ApiClient(AuthStore authStore, UrlProvider urlProvider) {
|
ApiClient(AuthStore authStore) {
|
||||||
this.authStore = authStore;
|
this.authStore = authStore;
|
||||||
this.urlProvider = urlProvider;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void sendCode(String email) throws Exception {
|
void sendCode(String email) throws Exception {
|
||||||
@@ -1387,7 +1379,7 @@ final class ApiClient {
|
|||||||
private JSONObject requestMultipart(String path, JSONObject query, JSONObject fields, String fieldName, String filename,
|
private JSONObject requestMultipart(String path, JSONObject query, JSONObject fields, String fieldName, String filename,
|
||||||
String contentType, byte[] data) throws Exception {
|
String contentType, byte[] data) throws Exception {
|
||||||
String boundary = "Boundary-" + System.currentTimeMillis();
|
String boundary = "Boundary-" + System.currentTimeMillis();
|
||||||
URL url = new URL(urlProvider.getApiBaseUrl() + path + queryString(query));
|
URL url = new URL(BuildConfig.MULTICA_API_BASE_URL + path + queryString(query));
|
||||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||||
conn.setRequestMethod("POST");
|
conn.setRequestMethod("POST");
|
||||||
conn.setConnectTimeout(15000);
|
conn.setConnectTimeout(15000);
|
||||||
@@ -1448,7 +1440,7 @@ final class ApiClient {
|
|||||||
|
|
||||||
private JSONObject requestObject(String method, String path, JSONObject query, JSONObject body,
|
private JSONObject requestObject(String method, String path, JSONObject query, JSONObject body,
|
||||||
Map<String, String> extraHeaders, int connectTimeoutMs, int readTimeoutMs) throws Exception {
|
Map<String, String> extraHeaders, int connectTimeoutMs, int readTimeoutMs) throws Exception {
|
||||||
URL url = new URL(urlProvider.getApiBaseUrl() + path + queryString(query));
|
URL url = new URL(BuildConfig.MULTICA_API_BASE_URL + path + queryString(query));
|
||||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||||
conn.setRequestMethod(method);
|
conn.setRequestMethod(method);
|
||||||
conn.setConnectTimeout(connectTimeoutMs);
|
conn.setConnectTimeout(connectTimeoutMs);
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
package ai.multica.app;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
|
|
||||||
final class AttachmentPreview {
|
|
||||||
static final String BASE_URL = "https://attachment-preview.local/";
|
|
||||||
|
|
||||||
enum Kind {
|
|
||||||
MARKDOWN,
|
|
||||||
HTML,
|
|
||||||
XML,
|
|
||||||
DOWNLOAD
|
|
||||||
}
|
|
||||||
|
|
||||||
private AttachmentPreview() {}
|
|
||||||
|
|
||||||
static Kind kindFor(String contentType, String filename) {
|
|
||||||
String type = mediaType(contentType);
|
|
||||||
if (isMarkdownType(type)) return Kind.MARKDOWN;
|
|
||||||
if (isHtmlType(type)) return Kind.HTML;
|
|
||||||
if (isXmlType(type)) return Kind.XML;
|
|
||||||
|
|
||||||
String name = filename == null ? "" : filename.trim().toLowerCase(java.util.Locale.ROOT);
|
|
||||||
if (name.endsWith(".md") || name.endsWith(".markdown")) return Kind.MARKDOWN;
|
|
||||||
if (name.endsWith(".html") || name.endsWith(".htm")) return Kind.HTML;
|
|
||||||
if (name.endsWith(".xml")) return Kind.XML;
|
|
||||||
return Kind.DOWNLOAD;
|
|
||||||
}
|
|
||||||
|
|
||||||
static boolean isLocalPreviewUrl(String url) {
|
|
||||||
if (url == null || url.trim().isEmpty()) return false;
|
|
||||||
try {
|
|
||||||
URI uri = URI.create(url.trim());
|
|
||||||
String scheme = uri.getScheme();
|
|
||||||
String host = uri.getHost();
|
|
||||||
return "https".equalsIgnoreCase(scheme) && "attachment-preview.local".equalsIgnoreCase(host);
|
|
||||||
} catch (IllegalArgumentException ignored) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static boolean isWebViewInternalDocumentUrl(String url) {
|
|
||||||
if (url == null) return false;
|
|
||||||
String value = url.trim().toLowerCase(java.util.Locale.ROOT);
|
|
||||||
return value.equals("about:blank") || value.startsWith("data:text/html");
|
|
||||||
}
|
|
||||||
|
|
||||||
static String xmlPreviewDocument(String xml) {
|
|
||||||
return "<!doctype html><html><head><meta charset=\"utf-8\">"
|
|
||||||
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
|
|
||||||
+ "<style>body{margin:0;padding:16px;background:#fff;color:#111;font:14px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace;}pre{white-space:pre-wrap;word-break:break-word;margin:0;}</style>"
|
|
||||||
+ "</head><body><pre>"
|
|
||||||
+ escapeHtml(xml == null ? "" : xml)
|
|
||||||
+ "</pre></body></html>";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String mediaType(String contentType) {
|
|
||||||
if (contentType == null) return "";
|
|
||||||
int semicolon = contentType.indexOf(';');
|
|
||||||
String value = semicolon >= 0 ? contentType.substring(0, semicolon) : contentType;
|
|
||||||
return value.trim().toLowerCase(java.util.Locale.ROOT);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isMarkdownType(String type) {
|
|
||||||
return "text/markdown".equals(type)
|
|
||||||
|| "text/x-markdown".equals(type)
|
|
||||||
|| "application/markdown".equals(type)
|
|
||||||
|| "application/x-markdown".equals(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isHtmlType(String type) {
|
|
||||||
return "text/html".equals(type) || "application/xhtml+xml".equals(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isXmlType(String type) {
|
|
||||||
return "text/xml".equals(type) || "application/xml".equals(type) || type.endsWith("+xml");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String escapeHtml(String value) {
|
|
||||||
return value.replace("&", "&")
|
|
||||||
.replace("<", "<")
|
|
||||||
.replace(">", ">")
|
|
||||||
.replace("\"", """)
|
|
||||||
.replace("'", "'");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,6 @@ final class AuthStore {
|
|||||||
private static final String LANGUAGE = "language";
|
private static final String LANGUAGE = "language";
|
||||||
private static final String INBOX_CACHE_PREFIX = "inbox_cache_";
|
private static final String INBOX_CACHE_PREFIX = "inbox_cache_";
|
||||||
private static final String CLOUDFRONT_COOKIE_HEADER = "cloudfront_cookie_header";
|
private static final String CLOUDFRONT_COOKIE_HEADER = "cloudfront_cookie_header";
|
||||||
private static final String REMEMBER_LOGIN = "remember_login";
|
|
||||||
|
|
||||||
private final SharedPreferences prefs;
|
private final SharedPreferences prefs;
|
||||||
|
|
||||||
@@ -83,14 +82,6 @@ final class AuthStore {
|
|||||||
prefs.edit().putString(CLOUDFRONT_COOKIE_HEADER, joinCookieHeader(cookies)).apply();
|
prefs.edit().putString(CLOUDFRONT_COOKIE_HEADER, joinCookieHeader(cookies)).apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean rememberLogin() {
|
|
||||||
return prefs.getBoolean(REMEMBER_LOGIN, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
void setRememberLogin(boolean remember) {
|
|
||||||
prefs.edit().putBoolean(REMEMBER_LOGIN, remember).apply();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Map<String, String> parseCookieHeader(String header) {
|
private static Map<String, String> parseCookieHeader(String header) {
|
||||||
Map<String, String> cookies = new LinkedHashMap<>();
|
Map<String, String> cookies = new LinkedHashMap<>();
|
||||||
if (header == null || header.trim().isEmpty()) return cookies;
|
if (header == null || header.trim().isEmpty()) return cookies;
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ import androidx.compose.material.icons.outlined.Check
|
|||||||
import androidx.compose.material.icons.outlined.CheckCircle
|
import androidx.compose.material.icons.outlined.CheckCircle
|
||||||
import androidx.compose.material.icons.outlined.ChatBubbleOutline
|
import androidx.compose.material.icons.outlined.ChatBubbleOutline
|
||||||
import androidx.compose.material.icons.outlined.Close
|
import androidx.compose.material.icons.outlined.Close
|
||||||
import androidx.compose.material.icons.outlined.Cloud
|
|
||||||
import androidx.compose.material.icons.outlined.ContentCopy
|
import androidx.compose.material.icons.outlined.ContentCopy
|
||||||
import androidx.compose.material.icons.outlined.Dashboard
|
import androidx.compose.material.icons.outlined.Dashboard
|
||||||
import androidx.compose.material.icons.outlined.Delete
|
import androidx.compose.material.icons.outlined.Delete
|
||||||
@@ -92,8 +91,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.SwitchDefaults
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -185,11 +182,7 @@ class ComposePilotActivity : ComponentActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
applyPilotSystemBars()
|
applyPilotSystemBars()
|
||||||
val authStore = AuthStore(this)
|
val authStore = AuthStore(this)
|
||||||
val api = ApiClient(authStore, object : ApiClient.UrlProvider {
|
val api = ApiClient(authStore)
|
||||||
override fun getApiBaseUrl() = ServerConfigHolder.get().apiBaseUrl
|
|
||||||
override fun getWebBaseUrl() = ServerConfigHolder.get().webBaseUrl
|
|
||||||
override fun getWsBaseUrl() = ServerConfigHolder.get().wsBaseUrl
|
|
||||||
})
|
|
||||||
val initialSettingsPageName = intent.getStringExtra("initial_settings_page")
|
val initialSettingsPageName = intent.getStringExtra("initial_settings_page")
|
||||||
?: getSharedPreferences(BuildConfig.MULTICA_AUTH_PREFS_NAME, Context.MODE_PRIVATE).getString("initial_settings_page", null)
|
?: getSharedPreferences(BuildConfig.MULTICA_AUTH_PREFS_NAME, Context.MODE_PRIVATE).getString("initial_settings_page", null)
|
||||||
val initialIssueId = intent.getStringExtra("initial_issue_id")
|
val initialIssueId = intent.getStringExtra("initial_issue_id")
|
||||||
@@ -333,14 +326,6 @@ class ComposePilotActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStop() {
|
|
||||||
super.onStop()
|
|
||||||
val authStore = AuthStore(this)
|
|
||||||
if (!authStore.rememberLogin() && !authStore.token().isNullOrBlank()) {
|
|
||||||
authStore.clearToken()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun applyPilotSystemBars() {
|
private fun applyPilotSystemBars() {
|
||||||
val darkTheme = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
val darkTheme = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
@@ -537,7 +522,6 @@ private enum class PilotSettingsPage {
|
|||||||
Labels,
|
Labels,
|
||||||
Skills,
|
Skills,
|
||||||
Feedback,
|
Feedback,
|
||||||
ServerConfig,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun pilotSettingsPageFromName(value: String?): PilotSettingsPage? {
|
private fun pilotSettingsPageFromName(value: String?): PilotSettingsPage? {
|
||||||
@@ -553,7 +537,6 @@ private fun pilotSettingsPageFromName(value: String?): PilotSettingsPage? {
|
|||||||
"labels" -> PilotSettingsPage.Labels
|
"labels" -> PilotSettingsPage.Labels
|
||||||
"skills" -> PilotSettingsPage.Skills
|
"skills" -> PilotSettingsPage.Skills
|
||||||
"feedback" -> PilotSettingsPage.Feedback
|
"feedback" -> PilotSettingsPage.Feedback
|
||||||
"server_config", "serverconfig" -> PilotSettingsPage.ServerConfig
|
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -698,8 +681,6 @@ private fun PilotLoginScreen(
|
|||||||
var codeSentTo by remember { mutableStateOf<String?>(null) }
|
var codeSentTo by remember { mutableStateOf<String?>(null) }
|
||||||
var loading by remember { mutableStateOf(false) }
|
var loading by remember { mutableStateOf(false) }
|
||||||
var message by remember { mutableStateOf<String?>(null) }
|
var message by remember { mutableStateOf<String?>(null) }
|
||||||
var showServerConfig by remember { mutableStateOf(false) }
|
|
||||||
var rememberLogin by remember { mutableStateOf(authStore.rememberLogin()) }
|
|
||||||
|
|
||||||
fun sendCode() {
|
fun sendCode() {
|
||||||
val cleanEmail = email.trim()
|
val cleanEmail = email.trim()
|
||||||
@@ -739,7 +720,6 @@ private fun PilotLoginScreen(
|
|||||||
if (token.isBlank()) {
|
if (token.isBlank()) {
|
||||||
message = if (zh) "登录失败:服务端未返回 token。" else "Sign in failed: no token returned."
|
message = if (zh) "登录失败:服务端未返回 token。" else "Sign in failed: no token returned."
|
||||||
} else {
|
} else {
|
||||||
authStore.setRememberLogin(rememberLogin)
|
|
||||||
authStore.saveToken(token)
|
authStore.saveToken(token)
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
onAuthenticated()
|
onAuthenticated()
|
||||||
@@ -752,15 +732,7 @@ private fun PilotLoginScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showServerConfig) {
|
Surface(color = MulticaColors.Background) {
|
||||||
PilotServerConfigSettings(
|
|
||||||
context = context,
|
|
||||||
authStore = authStore,
|
|
||||||
zh = zh,
|
|
||||||
onBack = { showServerConfig = false }
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Surface(color = MulticaColors.Background) {
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -877,32 +849,6 @@ private fun PilotLoginScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { rememberLogin = !rememberLogin }
|
|
||||||
.padding(vertical = 12.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = if (zh) "记住登录状态" else "Remember me",
|
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp),
|
|
||||||
color = MulticaColors.Text,
|
|
||||||
)
|
|
||||||
Switch(
|
|
||||||
checked = rememberLogin,
|
|
||||||
onCheckedChange = { rememberLogin = it },
|
|
||||||
colors = SwitchDefaults.colors(
|
|
||||||
checkedThumbColor = Color.White,
|
|
||||||
checkedTrackColor = MulticaColors.Accent,
|
|
||||||
uncheckedThumbColor = Color.White,
|
|
||||||
uncheckedTrackColor = MulticaColors.Muted.copy(alpha = 0.3f),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!message.isNullOrBlank()) {
|
if (!message.isNullOrBlank()) {
|
||||||
item {
|
item {
|
||||||
Surface(
|
Surface(
|
||||||
@@ -927,29 +873,6 @@ private fun PilotLoginScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { showServerConfig = true }
|
|
||||||
.padding(vertical = 12.dp),
|
|
||||||
horizontalArrangement = Arrangement.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.Cloud,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
tint = MulticaColors.Muted,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
|
||||||
Text(
|
|
||||||
text = if (zh) "自定义服务器" else "Custom Server",
|
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp),
|
|
||||||
color = MulticaColors.Muted,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1881,7 +1804,6 @@ private fun ComposePilotShell(
|
|||||||
PilotSettingsSubpage(
|
PilotSettingsSubpage(
|
||||||
page = activeSettingsPage,
|
page = activeSettingsPage,
|
||||||
api = api,
|
api = api,
|
||||||
authStore = authStore,
|
|
||||||
workspaceId = session.workspace.id,
|
workspaceId = session.workspace.id,
|
||||||
currentUserId = session.user.id,
|
currentUserId = session.user.id,
|
||||||
zh = zh,
|
zh = zh,
|
||||||
@@ -9819,7 +9741,6 @@ private fun PilotIssueDetail(
|
|||||||
|
|
||||||
fun copyIssueLink() {
|
fun copyIssueLink() {
|
||||||
val slug = workspaceSlug.ifBlank { workspaceId }
|
val slug = workspaceSlug.ifBlank { workspaceId }
|
||||||
// TODO: Replace BuildConfig with ServerConfigHolder or CompositionLocal for dynamic server config
|
|
||||||
val url = "${BuildConfig.MULTICA_WEB_BASE_URL}/$slug/issues/${data.issue.id}"
|
val url = "${BuildConfig.MULTICA_WEB_BASE_URL}/$slug/issues/${data.issue.id}"
|
||||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
clipboard.setPrimaryClip(ClipData.newPlainText("Multica issue link", url))
|
clipboard.setPrimaryClip(ClipData.newPlainText("Multica issue link", url))
|
||||||
@@ -12092,7 +12013,6 @@ private fun MulticaMarkdownImage(
|
|||||||
.data(imageUrl)
|
.data(imageUrl)
|
||||||
.crossfade(true)
|
.crossfade(true)
|
||||||
.apply {
|
.apply {
|
||||||
// TODO: Replace BuildConfig with ServerConfigHolder or CompositionLocal for dynamic server config
|
|
||||||
if (token.isNotBlank() && imageUrl.startsWith(BuildConfig.MULTICA_API_BASE_URL)) {
|
if (token.isNotBlank() && imageUrl.startsWith(BuildConfig.MULTICA_API_BASE_URL)) {
|
||||||
addHeader("Authorization", "Bearer $token")
|
addHeader("Authorization", "Bearer $token")
|
||||||
}
|
}
|
||||||
@@ -12130,7 +12050,6 @@ private fun MulticaMarkdownImage(
|
|||||||
private fun absoluteMarkdownImageUrl(url: String): String {
|
private fun absoluteMarkdownImageUrl(url: String): String {
|
||||||
val value = clean(url)
|
val value = clean(url)
|
||||||
return if (value.startsWith("/")) {
|
return if (value.startsWith("/")) {
|
||||||
// TODO: Replace BuildConfig with ServerConfigHolder or CompositionLocal for dynamic server config
|
|
||||||
BuildConfig.MULTICA_API_BASE_URL.trimEnd('/') + value
|
BuildConfig.MULTICA_API_BASE_URL.trimEnd('/') + value
|
||||||
} else {
|
} else {
|
||||||
value
|
value
|
||||||
@@ -14954,18 +14873,9 @@ private fun PilotSettings(
|
|||||||
if (zh) "$it 个待处理" else "$it pending"
|
if (zh) "$it 个待处理" else "$it pending"
|
||||||
} ?: "!",
|
} ?: "!",
|
||||||
icon = Icons.Outlined.Archive,
|
icon = Icons.Outlined.Archive,
|
||||||
showDivider = true,
|
|
||||||
contentDescription = "Settings My Invitations Pending Count",
|
contentDescription = "Settings My Invitations Pending Count",
|
||||||
onClick = { onOpenSettingsPage(PilotSettingsPage.MyInvitations) },
|
onClick = { onOpenSettingsPage(PilotSettingsPage.MyInvitations) },
|
||||||
)
|
)
|
||||||
SettingsWebTabRow(
|
|
||||||
title = if (zh) "自定义服务器" else "Custom Server",
|
|
||||||
subtitle = if (zh) "配置自托管的 Multica 实例" else "Configure self-hosted Multica instance",
|
|
||||||
icon = Icons.Outlined.Cloud,
|
|
||||||
showDivider = false,
|
|
||||||
contentDescription = "Settings Server Config Navigation Row",
|
|
||||||
onClick = { onOpenSettingsPage(PilotSettingsPage.ServerConfig) },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
@@ -15469,7 +15379,6 @@ private fun SettingsCompactCommandRow(
|
|||||||
private fun PilotSettingsSubpage(
|
private fun PilotSettingsSubpage(
|
||||||
page: PilotSettingsPage,
|
page: PilotSettingsPage,
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
authStore: AuthStore,
|
|
||||||
workspaceId: String,
|
workspaceId: String,
|
||||||
currentUserId: String,
|
currentUserId: String,
|
||||||
zh: Boolean,
|
zh: Boolean,
|
||||||
@@ -15477,7 +15386,6 @@ private fun PilotSettingsSubpage(
|
|||||||
onSwitchWorkspace: (String) -> Unit,
|
onSwitchWorkspace: (String) -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
|
||||||
when (page) {
|
when (page) {
|
||||||
PilotSettingsPage.WorkspaceDetails -> PilotWorkspaceSettings(api, workspaceId, currentUserId, zh, onBack, onReloadSession, onSwitchWorkspace)
|
PilotSettingsPage.WorkspaceDetails -> PilotWorkspaceSettings(api, workspaceId, currentUserId, zh, onBack, onReloadSession, onSwitchWorkspace)
|
||||||
PilotSettingsPage.MyInvitations -> PilotMyInvitationsSettings(api, zh, onBack, onReloadSession)
|
PilotSettingsPage.MyInvitations -> PilotMyInvitationsSettings(api, zh, onBack, onReloadSession)
|
||||||
@@ -15490,7 +15398,6 @@ private fun PilotSettingsSubpage(
|
|||||||
PilotSettingsPage.Labels -> PilotLabelsSettings(api, workspaceId, zh, onBack)
|
PilotSettingsPage.Labels -> PilotLabelsSettings(api, workspaceId, zh, onBack)
|
||||||
PilotSettingsPage.Skills -> PilotSkillsSettings(api, workspaceId, zh, onBack)
|
PilotSettingsPage.Skills -> PilotSkillsSettings(api, workspaceId, zh, onBack)
|
||||||
PilotSettingsPage.Feedback -> PilotFeedbackSettings(api, workspaceId, zh, onBack)
|
PilotSettingsPage.Feedback -> PilotFeedbackSettings(api, workspaceId, zh, onBack)
|
||||||
PilotSettingsPage.ServerConfig -> PilotServerConfigSettings(context, authStore, zh, onBack)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16824,270 +16731,6 @@ private fun NotificationWebSwitchRow(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MulticaCupertinoInfoRow(
|
|
||||||
title: String,
|
|
||||||
subtitle: String,
|
|
||||||
showDivider: Boolean = false,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = subtitle,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(top = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (showDivider) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(start = 16.dp)
|
|
||||||
.height(0.5.dp)
|
|
||||||
.background(MaterialTheme.colorScheme.outlineVariant)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PilotServerConfigSettings(
|
|
||||||
context: Context,
|
|
||||||
authStore: AuthStore,
|
|
||||||
zh: Boolean,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var currentConfig by remember { mutableStateOf(ServerConfigHolder.get()) }
|
|
||||||
var isLoading by remember { mutableStateOf(false) }
|
|
||||||
var isAdvancedMode by remember { mutableStateOf(false) }
|
|
||||||
var simpleDomainInput by remember { mutableStateOf("") }
|
|
||||||
var apiUrlInput by remember { mutableStateOf(currentConfig.apiBaseUrl) }
|
|
||||||
var webUrlInput by remember { mutableStateOf(currentConfig.webBaseUrl) }
|
|
||||||
var wsUrlInput by remember { mutableStateOf(currentConfig.wsBaseUrl) }
|
|
||||||
var message by remember { mutableStateOf<String?>(null) }
|
|
||||||
var isError by remember { mutableStateOf(false) }
|
|
||||||
var showResetDialog by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
fun saveConfig() {
|
|
||||||
isLoading = true
|
|
||||||
message = null
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
val configManager = ServerConfigManager(context)
|
|
||||||
if (isAdvancedMode) {
|
|
||||||
configManager.saveCustomServer(apiUrlInput.trim(), webUrlInput.trim(), wsUrlInput.trim())
|
|
||||||
} else {
|
|
||||||
if (simpleDomainInput.isBlank()) {
|
|
||||||
message = if (zh) "请输入域名" else "Please enter domain"
|
|
||||||
isError = true
|
|
||||||
isLoading = false
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
val derived = ServerConfig.fromDomain(simpleDomainInput)
|
|
||||||
configManager.saveCustomServer(derived.apiBaseUrl, derived.webBaseUrl, derived.wsBaseUrl)
|
|
||||||
}
|
|
||||||
val newConfig = configManager.getServerConfig()
|
|
||||||
ServerConfigHolder.update(newConfig)
|
|
||||||
currentConfig = newConfig
|
|
||||||
authStore.clearToken()
|
|
||||||
isLoading = false
|
|
||||||
message = if (zh) "服务器配置已保存,请重新登录" else "Server config saved, please login again"
|
|
||||||
isError = false
|
|
||||||
} catch (e: Exception) {
|
|
||||||
isLoading = false
|
|
||||||
message = e.message ?: (if (zh) "保存失败" else "Save failed")
|
|
||||||
isError = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun resetToDefault() {
|
|
||||||
isLoading = true
|
|
||||||
message = null
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
val configManager = ServerConfigManager(context)
|
|
||||||
configManager.resetToDefault()
|
|
||||||
val newConfig = configManager.getServerConfig()
|
|
||||||
ServerConfigHolder.update(newConfig)
|
|
||||||
currentConfig = newConfig
|
|
||||||
authStore.clearToken()
|
|
||||||
simpleDomainInput = ""
|
|
||||||
apiUrlInput = newConfig.apiBaseUrl
|
|
||||||
webUrlInput = newConfig.webBaseUrl
|
|
||||||
wsUrlInput = newConfig.wsBaseUrl
|
|
||||||
isAdvancedMode = false
|
|
||||||
isLoading = false
|
|
||||||
message = if (zh) "已重置为默认服务器" else "Reset to default server"
|
|
||||||
isError = false
|
|
||||||
showResetDialog = false
|
|
||||||
} catch (e: Exception) {
|
|
||||||
isLoading = false
|
|
||||||
message = e.message ?: (if (zh) "重置失败" else "Reset failed")
|
|
||||||
isError = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PilotListPage(
|
|
||||||
title = if (zh) "自定义服务器" else "Custom Server",
|
|
||||||
leading = { PilotSecondaryBackButton(zh = zh, onBack = onBack) },
|
|
||||||
) {
|
|
||||||
item {
|
|
||||||
SettingsWebTabGroup(
|
|
||||||
title = if (zh) "当前配置" else "Current Configuration",
|
|
||||||
subtitle = if (currentConfig.isCustomServer) {
|
|
||||||
if (zh) "使用自定义服务器" else "Using custom server"
|
|
||||||
} else {
|
|
||||||
if (zh) "使用默认 Multica 服务" else "Using default Multica service"
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
MulticaCupertinoInfoRow(title = "API", subtitle = currentConfig.apiBaseUrl, showDivider = true)
|
|
||||||
MulticaCupertinoInfoRow(title = "Web", subtitle = currentConfig.webBaseUrl, showDivider = true)
|
|
||||||
MulticaCupertinoInfoRow(title = "WebSocket", subtitle = currentConfig.wsBaseUrl, showDivider = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!message.isNullOrBlank()) {
|
|
||||||
item {
|
|
||||||
PilotInlineResultState(
|
|
||||||
message = message.orEmpty(),
|
|
||||||
isError = isError,
|
|
||||||
contentDescription = "Server Config Result",
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
|
||||||
if (!isAdvancedMode) {
|
|
||||||
Text(
|
|
||||||
text = if (zh) "简单模式:输入域名" else "Simple Mode: Enter Domain",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
modifier = Modifier.padding(bottom = 8.dp)
|
|
||||||
)
|
|
||||||
MulticaTextField(
|
|
||||||
value = simpleDomainInput,
|
|
||||||
onValueChange = { simpleDomainInput = it },
|
|
||||||
label = "example.com",
|
|
||||||
enabled = !isLoading,
|
|
||||||
contentDescription = "Server Domain Input",
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = if (zh) "将自动派生为:\nAPI: https://api.{域名}\nWeb: https://app.{域名}\nWS: wss://api.{域名}"
|
|
||||||
else "Will derive:\nAPI: https://api.{domain}\nWeb: https://app.{domain}\nWS: wss://api.{domain}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(top = 8.dp)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = if (zh) "高级模式:手动配置" else "Advanced Mode: Manual Configuration",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
modifier = Modifier.padding(bottom = 8.dp)
|
|
||||||
)
|
|
||||||
MulticaTextField(
|
|
||||||
value = apiUrlInput,
|
|
||||||
onValueChange = { apiUrlInput = it },
|
|
||||||
label = if (zh) "API 服务器地址" else "API Server URL",
|
|
||||||
enabled = !isLoading,
|
|
||||||
contentDescription = "API URL Input",
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
MulticaTextField(
|
|
||||||
value = webUrlInput,
|
|
||||||
onValueChange = { webUrlInput = it },
|
|
||||||
label = if (zh) "Web 服务器地址" else "Web Server URL",
|
|
||||||
enabled = !isLoading,
|
|
||||||
contentDescription = "Web URL Input",
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
MulticaTextField(
|
|
||||||
value = wsUrlInput,
|
|
||||||
onValueChange = { wsUrlInput = it },
|
|
||||||
label = if (zh) "WebSocket 服务器地址" else "WebSocket Server URL",
|
|
||||||
enabled = !isLoading,
|
|
||||||
contentDescription = "WS URL Input",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
MulticaPillButton(
|
|
||||||
text = if (isAdvancedMode) {
|
|
||||||
if (zh) "切换到简单模式" else "Switch to Simple"
|
|
||||||
} else {
|
|
||||||
if (zh) "切换到高级模式" else "Switch to Advanced"
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
isAdvancedMode = !isAdvancedMode
|
|
||||||
if (isAdvancedMode && simpleDomainInput.isNotBlank()) {
|
|
||||||
val derived = ServerConfig.fromDomain(simpleDomainInput)
|
|
||||||
apiUrlInput = derived.apiBaseUrl
|
|
||||||
webUrlInput = derived.webBaseUrl
|
|
||||||
wsUrlInput = derived.wsBaseUrl
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = !isLoading,
|
|
||||||
tone = MulticaButtonTone.Ghost,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
contentDescription = "Toggle Advanced Mode",
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
MulticaPillButton(
|
|
||||||
text = if (isLoading) "..." else if (zh) "保存配置" else "Save Configuration",
|
|
||||||
onClick = { saveConfig() },
|
|
||||||
enabled = !isLoading && (if (isAdvancedMode) {
|
|
||||||
apiUrlInput.isNotBlank() && webUrlInput.isNotBlank() && wsUrlInput.isNotBlank()
|
|
||||||
} else {
|
|
||||||
simpleDomainInput.isNotBlank()
|
|
||||||
}),
|
|
||||||
tone = MulticaButtonTone.Primary,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
contentDescription = "Save Server Config",
|
|
||||||
)
|
|
||||||
|
|
||||||
if (currentConfig.isCustomServer) {
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
MulticaPillButton(
|
|
||||||
text = if (zh) "重置为默认服务器" else "Reset to Default Server",
|
|
||||||
onClick = { showResetDialog = true },
|
|
||||||
enabled = !isLoading,
|
|
||||||
tone = MulticaButtonTone.Secondary,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
contentDescription = "Reset to Default",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showResetDialog) {
|
|
||||||
MulticaCupertinoAlertDialog(
|
|
||||||
visible = showResetDialog,
|
|
||||||
title = if (zh) "重置服务器配置" else "Reset Server Configuration",
|
|
||||||
message = if (zh) "确定要重置为默认的 Multica 公开服务器吗?" else "Reset to default Multica public server?",
|
|
||||||
cancelText = if (zh) "取消" else "Cancel",
|
|
||||||
confirmText = if (zh) "重置" else "Reset",
|
|
||||||
contentDescription = "Reset Server Config Dialog",
|
|
||||||
onDismissRequest = { showResetDialog = false },
|
|
||||||
onConfirm = { resetToDefault() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PilotNotificationSettings(
|
private fun PilotNotificationSettings(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
@@ -27644,6 +27287,5 @@ private fun settingsPageTitle(page: PilotSettingsPage): String {
|
|||||||
PilotSettingsPage.Labels -> "Labels"
|
PilotSettingsPage.Labels -> "Labels"
|
||||||
PilotSettingsPage.Skills -> "Skills"
|
PilotSettingsPage.Skills -> "Skills"
|
||||||
PilotSettingsPage.Feedback -> "Feedback"
|
PilotSettingsPage.Feedback -> "Feedback"
|
||||||
PilotSettingsPage.ServerConfig -> "Custom Server"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
package ai.multica.app;
|
|
||||||
|
|
||||||
import org.json.JSONObject;
|
|
||||||
|
|
||||||
final class IssuePayloads {
|
|
||||||
private IssuePayloads() {
|
|
||||||
}
|
|
||||||
|
|
||||||
static JSONObject createIssue(String title, String description, String workspaceId, String projectId,
|
|
||||||
String teamId, String status, String priority, Models.Assignee assignee,
|
|
||||||
String dueDate, String parentIssueId) throws Exception {
|
|
||||||
JSONObject body = new JSONObject();
|
|
||||||
body.put("title", title);
|
|
||||||
body.put("description", description == null || description.isEmpty() ? JSONObject.NULL : description);
|
|
||||||
body.put("workspace_id", workspaceId);
|
|
||||||
if (projectId != null) body.put("project_id", projectId);
|
|
||||||
if (teamId != null && !teamId.trim().isEmpty()) body.put("team_id", teamId.trim());
|
|
||||||
if (parentIssueId != null && !parentIssueId.isEmpty()) body.put("parent_issue_id", parentIssueId);
|
|
||||||
if (status != null) body.put("status", status);
|
|
||||||
if (priority != null) body.put("priority", priority);
|
|
||||||
if (dueDate != null && !dueDate.isEmpty()) body.put("due_date", dueDate); else body.put("due_date", JSONObject.NULL);
|
|
||||||
if (assignee != null && assignee.id != null) {
|
|
||||||
body.put("assignee_id", assignee.id);
|
|
||||||
body.put("assignee_type", assignee.type);
|
|
||||||
}
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -88,20 +88,7 @@ public final class MainActivity extends Activity {
|
|||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
authStore = new AuthStore(this);
|
authStore = new AuthStore(this);
|
||||||
api = new ApiClient(authStore, new ApiClient.UrlProvider() {
|
api = new ApiClient(authStore);
|
||||||
@Override
|
|
||||||
public String getApiBaseUrl() {
|
|
||||||
return ServerConfigHolder.INSTANCE.get().getApiBaseUrl();
|
|
||||||
}
|
|
||||||
@Override
|
|
||||||
public String getWebBaseUrl() {
|
|
||||||
return ServerConfigHolder.INSTANCE.get().getWebBaseUrl();
|
|
||||||
}
|
|
||||||
@Override
|
|
||||||
public String getWsBaseUrl() {
|
|
||||||
return ServerConfigHolder.INSTANCE.get().getWsBaseUrl();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
zh = authStore.isChinese();
|
zh = authStore.isChinese();
|
||||||
demoMode = getIntent() != null && getIntent().getBooleanExtra("demo", false);
|
demoMode = getIntent() != null && getIntent().getBooleanExtra("demo", false);
|
||||||
if (getIntent() != null && getIntent().getData() != null) handleDeepLink(getIntent().getData());
|
if (getIntent() != null && getIntent().getData() != null) handleDeepLink(getIntent().getData());
|
||||||
|
|||||||
@@ -230,31 +230,6 @@ final class Models {
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<Assignee> issueFormAssigneeOptions(String emptyLabel, User currentUser,
|
|
||||||
List<Member> members, List<Agent> agents, List<Squad> squads) {
|
|
||||||
List<Assignee> list = new ArrayList<>();
|
|
||||||
list.add(new Assignee(null, null, emptyLabel));
|
|
||||||
for (Squad squad : squads) {
|
|
||||||
if (squad.id == null || squad.id.isEmpty()) continue;
|
|
||||||
list.add(new Assignee(squad.id, "squad", squad.name));
|
|
||||||
}
|
|
||||||
for (Agent agent : agents) {
|
|
||||||
if (agent.id == null || agent.id.isEmpty()) continue;
|
|
||||||
list.add(new Assignee(agent.id, "agent", agent.name));
|
|
||||||
}
|
|
||||||
List<String> seenMemberIds = new ArrayList<>();
|
|
||||||
if (currentUser != null && currentUser.id != null && !currentUser.id.isEmpty()) {
|
|
||||||
list.add(new Assignee(currentUser.id, "member", currentUser.name));
|
|
||||||
seenMemberIds.add(currentUser.id);
|
|
||||||
}
|
|
||||||
for (Member member : members) {
|
|
||||||
if (member.id == null || member.id.isEmpty() || seenMemberIds.contains(member.id)) continue;
|
|
||||||
list.add(new Assignee(member.id, "member", member.displayName));
|
|
||||||
seenMemberIds.add(member.id);
|
|
||||||
}
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
|
|
||||||
static final class Page<T> {
|
static final class Page<T> {
|
||||||
final List<T> items;
|
final List<T> items;
|
||||||
final boolean hasMore;
|
final boolean hasMore;
|
||||||
|
|||||||
@@ -3,16 +3,9 @@ package ai.multica.app;
|
|||||||
import android.app.Application;
|
import android.app.Application;
|
||||||
|
|
||||||
public final class MulticaApplication extends Application {
|
public final class MulticaApplication extends Application {
|
||||||
private ServerConfigManager configManager;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onCreate() {
|
public void onCreate() {
|
||||||
super.onCreate();
|
super.onCreate();
|
||||||
|
|
||||||
// Preload server configuration to memory
|
|
||||||
configManager = new ServerConfigManager(this);
|
|
||||||
configManager.preloadToMemory();
|
|
||||||
|
|
||||||
AppAnalytics.initializeIfAllowed(this);
|
AppAnalytics.initializeIfAllowed(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
package ai.multica.app
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Global singleton to provide synchronous access to server configuration.
|
|
||||||
* Updated by ServerConfigManager on app startup and when config changes.
|
|
||||||
*
|
|
||||||
* TODO: Replace with CompositionLocal for better Compose integration.
|
|
||||||
* This is a temporary solution to avoid async DataStore reads on every API call.
|
|
||||||
*/
|
|
||||||
object ServerConfigHolder {
|
|
||||||
private var cached: ServerConfig = ServerConfig.getDefault()
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun get(): ServerConfig = cached
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun update(newConfig: ServerConfig) {
|
|
||||||
cached = newConfig
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
package ai.multica.app
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.datastore.core.DataStore
|
|
||||||
import androidx.datastore.preferences.core.Preferences
|
|
||||||
import androidx.datastore.preferences.core.edit
|
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
private val Context.serverConfigDataStore: DataStore<Preferences> by preferencesDataStore(
|
|
||||||
name = "multica_server_config"
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ServerConfig(
|
|
||||||
val apiBaseUrl: String,
|
|
||||||
val webBaseUrl: String,
|
|
||||||
val wsBaseUrl: String,
|
|
||||||
val isCustomServer: Boolean = false
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun getDefault(): ServerConfig = ServerConfig(
|
|
||||||
apiBaseUrl = "https://api.multica.ai",
|
|
||||||
webBaseUrl = "https://app.multica.ai",
|
|
||||||
wsBaseUrl = "wss://api.multica.ai",
|
|
||||||
isCustomServer = false
|
|
||||||
)
|
|
||||||
|
|
||||||
fun fromDomain(domain: String): ServerConfig {
|
|
||||||
val cleanDomain = domain.trim()
|
|
||||||
.removePrefix("http://")
|
|
||||||
.removePrefix("https://")
|
|
||||||
.removePrefix("ws://")
|
|
||||||
.removePrefix("wss://")
|
|
||||||
return ServerConfig(
|
|
||||||
apiBaseUrl = "https://api.$cleanDomain",
|
|
||||||
webBaseUrl = "https://app.$cleanDomain",
|
|
||||||
wsBaseUrl = "wss://api.$cleanDomain",
|
|
||||||
isCustomServer = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ServerConfigManager(private val context: Context) {
|
|
||||||
private val dataStore = context.serverConfigDataStore
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
||||||
|
|
||||||
private object Keys {
|
|
||||||
val API_BASE_URL = stringPreferencesKey("api_base_url")
|
|
||||||
val WEB_BASE_URL = stringPreferencesKey("web_base_url")
|
|
||||||
val WS_BASE_URL = stringPreferencesKey("ws_base_url")
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getServerConfig(): ServerConfig {
|
|
||||||
val preferences = dataStore.data.first()
|
|
||||||
val apiBaseUrl = preferences[Keys.API_BASE_URL]
|
|
||||||
val webBaseUrl = preferences[Keys.WEB_BASE_URL]
|
|
||||||
val wsBaseUrl = preferences[Keys.WS_BASE_URL]
|
|
||||||
|
|
||||||
return if (apiBaseUrl != null && webBaseUrl != null && wsBaseUrl != null) {
|
|
||||||
ServerConfig(
|
|
||||||
apiBaseUrl = apiBaseUrl,
|
|
||||||
webBaseUrl = webBaseUrl,
|
|
||||||
wsBaseUrl = wsBaseUrl,
|
|
||||||
isCustomServer = true
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ServerConfig.getDefault()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun saveCustomServer(
|
|
||||||
apiBaseUrl: String,
|
|
||||||
webBaseUrl: String,
|
|
||||||
wsBaseUrl: String
|
|
||||||
) {
|
|
||||||
validateUrls(apiBaseUrl, webBaseUrl, wsBaseUrl)
|
|
||||||
|
|
||||||
dataStore.edit { preferences ->
|
|
||||||
preferences[Keys.API_BASE_URL] = apiBaseUrl.trim()
|
|
||||||
preferences[Keys.WEB_BASE_URL] = webBaseUrl.trim()
|
|
||||||
preferences[Keys.WS_BASE_URL] = wsBaseUrl.trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun resetToDefault() {
|
|
||||||
dataStore.edit { preferences ->
|
|
||||||
preferences.remove(Keys.API_BASE_URL)
|
|
||||||
preferences.remove(Keys.WEB_BASE_URL)
|
|
||||||
preferences.remove(Keys.WS_BASE_URL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun preloadToMemory() {
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
val config = getServerConfig()
|
|
||||||
ServerConfigHolder.update(config)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Fallback to default on error
|
|
||||||
ServerConfigHolder.update(ServerConfig.getDefault())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun validateUrls(apiBaseUrl: String, webBaseUrl: String, wsBaseUrl: String) {
|
|
||||||
require(apiBaseUrl.isNotBlank()) { "API base URL cannot be empty" }
|
|
||||||
require(webBaseUrl.isNotBlank()) { "Web base URL cannot be empty" }
|
|
||||||
require(wsBaseUrl.isNotBlank()) { "WebSocket base URL cannot be empty" }
|
|
||||||
|
|
||||||
require(apiBaseUrl.matches(Regex("^https?://.*"))) {
|
|
||||||
"API base URL must start with http:// or https://"
|
|
||||||
}
|
|
||||||
require(webBaseUrl.matches(Regex("^https?://.*"))) {
|
|
||||||
"Web base URL must start with http:// or https://"
|
|
||||||
}
|
|
||||||
require(wsBaseUrl.matches(Regex("^wss?://.*"))) {
|
|
||||||
"WebSocket base URL must start with ws:// or wss://"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1021,8 +1021,6 @@ data class MulticaCupertinoActionSheetItem(
|
|||||||
val title: String,
|
val title: String,
|
||||||
val subtitle: String? = null,
|
val subtitle: String? = null,
|
||||||
val icon: ImageVector? = null,
|
val icon: ImageVector? = null,
|
||||||
val badgeText: String? = null,
|
|
||||||
val badgeColor: Color = MulticaColors.Accent,
|
|
||||||
val contentDescription: String? = null,
|
val contentDescription: String? = null,
|
||||||
val selected: Boolean = false,
|
val selected: Boolean = false,
|
||||||
val destructive: Boolean = false,
|
val destructive: Boolean = false,
|
||||||
@@ -1058,40 +1056,78 @@ fun MulticaCupertinoActionSheet(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
message = {
|
message = {
|
||||||
Column(
|
if (!message.isNullOrBlank()) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Text(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
text = message,
|
||||||
) {
|
style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp),
|
||||||
if (!message.isNullOrBlank()) {
|
color = MulticaColors.TextTertiary,
|
||||||
Text(
|
textAlign = TextAlign.Center,
|
||||||
text = message,
|
)
|
||||||
style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp),
|
|
||||||
color = MulticaColors.TextTertiary,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
}
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.heightIn(max = 420.dp),
|
|
||||||
) {
|
|
||||||
items(items.size) { index ->
|
|
||||||
val item = items[index]
|
|
||||||
MulticaCupertinoActionSheetItemContent(
|
|
||||||
item = item,
|
|
||||||
onClick = {
|
|
||||||
if (!item.enabled) return@MulticaCupertinoActionSheetItemContent
|
|
||||||
performMulticaTapFeedback(haptic)
|
|
||||||
item.onClick()
|
|
||||||
onDismissRequest()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
buttons = {
|
buttons = {
|
||||||
|
items.forEach { item ->
|
||||||
|
action(
|
||||||
|
onClick = {
|
||||||
|
performMulticaTapFeedback(haptic)
|
||||||
|
item.onClick()
|
||||||
|
onDismissRequest()
|
||||||
|
},
|
||||||
|
style = if (item.destructive) AlertActionStyle.Destructive else AlertActionStyle.Default,
|
||||||
|
enabled = item.enabled,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.semantics(mergeDescendants = true) {
|
||||||
|
contentDescription = "Multica Cupertino Action Sheet ${item.contentDescription ?: item.title}"
|
||||||
|
},
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
item.icon?.let { icon ->
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (item.destructive) MulticaColors.Danger else MulticaColors.TextTertiary,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = item.title,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontSize = 15.sp,
|
||||||
|
lineHeight = 19.sp,
|
||||||
|
fontWeight = if (item.selected) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
|
),
|
||||||
|
color = if (item.destructive) MulticaColors.Danger else MulticaColors.TextPrimary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
if (!item.subtitle.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = item.subtitle,
|
||||||
|
style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 13.sp),
|
||||||
|
color = MulticaColors.TextTertiary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (item.selected) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.CheckCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MulticaColors.Accent,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!cancelText.isNullOrBlank()) {
|
if (!cancelText.isNullOrBlank()) {
|
||||||
action(
|
action(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -1112,83 +1148,6 @@ fun MulticaCupertinoActionSheet(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MulticaCupertinoActionSheetItemContent(
|
|
||||||
item: MulticaCupertinoActionSheetItem,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(enabled = item.enabled, onClick = onClick)
|
|
||||||
.padding(horizontal = 12.dp, vertical = 11.dp)
|
|
||||||
.semantics(mergeDescendants = true) {
|
|
||||||
contentDescription = "Multica Cupertino Action Sheet ${item.contentDescription ?: item.title}"
|
|
||||||
},
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
item.icon?.let { icon ->
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = if (item.destructive) MulticaColors.Danger else MulticaColors.TextTertiary,
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (!item.badgeText.isNullOrBlank()) {
|
|
||||||
Text(
|
|
||||||
text = item.badgeText,
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(999.dp))
|
|
||||||
.background(item.badgeColor.copy(alpha = 0.16f))
|
|
||||||
.border(1.dp, item.badgeColor.copy(alpha = 0.32f), RoundedCornerShape(999.dp))
|
|
||||||
.padding(horizontal = 7.dp, vertical = 3.dp),
|
|
||||||
style = MaterialTheme.typography.labelSmall.copy(
|
|
||||||
fontSize = 10.sp,
|
|
||||||
lineHeight = 12.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
),
|
|
||||||
color = item.badgeColor,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Text(
|
|
||||||
text = item.title,
|
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
|
||||||
fontSize = 15.sp,
|
|
||||||
lineHeight = 19.sp,
|
|
||||||
fontWeight = if (item.selected) FontWeight.SemiBold else FontWeight.Normal,
|
|
||||||
),
|
|
||||||
color = if (item.destructive) MulticaColors.Danger else MulticaColors.TextPrimary,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
if (!item.subtitle.isNullOrBlank()) {
|
|
||||||
Text(
|
|
||||||
text = item.subtitle,
|
|
||||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 13.sp),
|
|
||||||
color = MulticaColors.TextTertiary,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (item.selected) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.CheckCircle,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MulticaColors.Accent,
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@OptIn(ExperimentalCupertinoApi::class)
|
@OptIn(ExperimentalCupertinoApi::class)
|
||||||
fun MulticaCupertinoAlertDialog(
|
fun MulticaCupertinoAlertDialog(
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<main>
|
<main>
|
||||||
<h1>Multi-Casual Android</h1>
|
<h1>Multi-Casual Android</h1>
|
||||||
<p>Main site is published at <a href="https://multi-casual-android.pages.dev">https://multi-casual-android.pages.dev</a> and <a href="https://multi-casual-china-8temdtzs.zh-cn.edgeone.cool/">https://multi-casual-china-8temdtzs.zh-cn.edgeone.cool/</a>.</p>
|
<p>Main site is published at <a href="https://multi-casual-android.pages.dev">https://multi-casual-android.pages.dev</a> and <a href="https://multi-casual-china-8temdtzs.zh-cn.edgeone.cool/">https://multi-casual-china-8temdtzs.zh-cn.edgeone.cool/</a>.</p>
|
||||||
<p>You can also install the signed APK directly from <a href="https://github.com/park0er/multi-casual-android/releases/download/v0.1.4/Multi-Casual-Android-v0.1.4.apk">GitHub Releases</a>.</p>
|
<p>You can also install the signed APK directly from <a href="https://github.com/park0er/multi-casual-android/releases/download/v0.1.2/Multi-Casual-Android-v0.1.2.apk">GitHub Releases</a>.</p>
|
||||||
</main>
|
</main>
|
||||||
<footer>
|
<footer>
|
||||||
<p>Site placeholder published from the public Android source repository.</p>
|
<p>Site placeholder published from the public Android source repository.</p>
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
.edgeone/
|
|
||||||
*.tsbuildinfo
|
|
||||||
public/downloads/*.apk
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
# site-internal — Multica Xiaomi internal landing page
|
|
||||||
|
|
||||||
A separate Vite + React landing page for the **Multica Xiaomi** internal
|
|
||||||
Android build. The public site at [`../site/`](../site/) targets the
|
|
||||||
Multi-Casual public release; this directory is a sibling project
|
|
||||||
published under the same GitHub Pages site at `/xiaomi/`.
|
|
||||||
|
|
||||||
## Visual scope
|
|
||||||
|
|
||||||
This page is a near-clone of the public site (`site/src/`) with three
|
|
||||||
intentional differences:
|
|
||||||
|
|
||||||
1. The "Relationship to Multica" section is removed.
|
|
||||||
2. The source link points to the private company GitLab project.
|
|
||||||
The signed APK download points to a separate artifact-only GitHub
|
|
||||||
release repository.
|
|
||||||
3. The iOS App button is left untouched and still points at the
|
|
||||||
public iOS repo — the same `github.com/park0er/multi-casual-ios-app`
|
|
||||||
source-of-truth is reused on purpose.
|
|
||||||
|
|
||||||
All screenshots, the promo video, the journey / workspace interactions,
|
|
||||||
the language toggle, and the layout are reused from `site/src/assets/`
|
|
||||||
so the visual stays in sync with the public site.
|
|
||||||
|
|
||||||
## Release metadata
|
|
||||||
|
|
||||||
`src/App.tsx` points the primary APK download button at the static
|
|
||||||
GitHub release asset:
|
|
||||||
|
|
||||||
```text
|
|
||||||
https://github.com/park0er/multica-xiaomi-apk/releases/download/v0.1.5-xiaomi/Multica-Xiaomi-v0.1.5.apk
|
|
||||||
```
|
|
||||||
|
|
||||||
The APK is copied from `app/build/outputs/apk/xiaomi/release/` into
|
|
||||||
`site-internal/public/downloads/` before deployment. Current metadata:
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|---|---|
|
|
||||||
| `release.sha256` | `13e004472d33e66aa40aa7ebd58f75e22cfde8cce3a153729481f1be4d2d1414` |
|
|
||||||
| `release.gitlabUrl` | `https://git.n.xiaomi.com/zhaoxisheng/multica-xiaomi-android` |
|
|
||||||
| `release.releasesUrl` | `https://github.com/park0er/multica-xiaomi-apk/releases/download/v0.1.5-xiaomi/Multica-Xiaomi-v0.1.5.apk` |
|
|
||||||
|
|
||||||
Also confirm the `release.apiUrl` matches the staging or production
|
|
||||||
Xiaomi Multica endpoint you want this build to talk to
|
|
||||||
(`http://staging-multica.ad.xiaomi.srv` is the build.gradle default).
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd site-internal
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
Output goes to `site-internal/dist/` and is gitignored.
|
|
||||||
|
|
||||||
## Deploy
|
|
||||||
|
|
||||||
The site is hosted as a subdirectory of the public GitHub Pages site:
|
|
||||||
|
|
||||||
```
|
|
||||||
https://park0er.github.io/multi-casual-android/xiaomi/
|
|
||||||
```
|
|
||||||
|
|
||||||
GitHub Actions builds the public site into the Pages root and this
|
|
||||||
internal Xiaomi site into `dist/xiaomi/` before deploying.
|
|
||||||
|
|
||||||
### Publish flow
|
|
||||||
|
|
||||||
1. Copy `site-internal/` into the public repo.
|
|
||||||
2. Commit and push the public repo.
|
|
||||||
3. GitHub Actions deploys both `site/` and `site-internal/`.
|
|
||||||
|
|
||||||
### EdgeOne status
|
|
||||||
|
|
||||||
Tencent EdgeOne hosting was attempted first but proved unreliable for
|
|
||||||
this internal distribution. The current source of truth for downloads is
|
|
||||||
GitHub Pages.
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta name="description" content="Multica Xiaomi is the Xiaomi-flavor Android build of the Multica companion app, distributed internally via the company GitLab." />
|
|
||||||
<link rel="icon" href="./favicon.png" type="image/png" />
|
|
||||||
<title>Multica Xiaomi · Internal Build</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "multica-xiaomi-internal-site",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.1.0",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite --host 127.0.0.1",
|
|
||||||
"build": "tsc -b && vite build",
|
|
||||||
"preview": "vite preview --host 127.0.0.1"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@vitejs/plugin-react": "latest",
|
|
||||||
"motion": "latest",
|
|
||||||
"react": "latest",
|
|
||||||
"react-dom": "latest",
|
|
||||||
"typescript": "latest",
|
|
||||||
"vite": "latest"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/react": "^19.2.16",
|
|
||||||
"@types/react-dom": "^19.2.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<meta http-equiv="refresh" content="0.8; url=https://park0er.github.io/multi-casual-android/xiaomi/" />
|
|
||||||
<title>跳转到 Multica Xiaomi</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
color-scheme: light;
|
|
||||||
--ink: #101218;
|
|
||||||
--muted: #5b6472;
|
|
||||||
--blue: #1647ff;
|
|
||||||
--paper: #f6f8fc;
|
|
||||||
--card: #ffffff;
|
|
||||||
--line: rgba(16, 18, 24, 0.12);
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
min-height: 100vh;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
background: var(--paper);
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
main {
|
|
||||||
width: min(520px, calc(100vw - 32px));
|
|
||||||
padding: 32px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--card);
|
|
||||||
box-shadow: 0 24px 70px rgba(16, 18, 24, 0.12);
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
margin: 0 0 12px;
|
|
||||||
font-size: 26px;
|
|
||||||
line-height: 1.2;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
margin: 0 0 24px;
|
|
||||||
color: var(--muted);
|
|
||||||
line-height: 1.7;
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
a {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 44px;
|
|
||||||
padding: 0 18px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--blue);
|
|
||||||
color: #fff;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
code {
|
|
||||||
display: block;
|
|
||||||
margin-top: 18px;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<h1>正在跳转到 Multica Xiaomi</h1>
|
|
||||||
<p>这是小米内网入口,目标页面托管在 GitHub Pages。</p>
|
|
||||||
<a href="https://park0er.github.io/multi-casual-android/xiaomi/">立即打开</a>
|
|
||||||
<code>https://park0er.github.io/multi-casual-android/xiaomi/</code>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
Before Width: | Height: | Size: 5.0 KiB |
@@ -1,662 +0,0 @@
|
|||||||
import logoImg from "./assets/logo.png";
|
|
||||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
|
||||||
import { motion, AnimatePresence, useMotionValue, useTransform, useReducedMotion } from "motion/react";
|
|
||||||
import inboxImg from "./assets/screenshots/01-inbox.jpg";
|
|
||||||
import issuesListImg from "./assets/screenshots/02-issues-list.jpg";
|
|
||||||
import issuesBoardImg from "./assets/screenshots/03-issues-board.jpg";
|
|
||||||
import issueDetailImg from "./assets/screenshots/04-issue-detail.jpg";
|
|
||||||
import issueSubImg from "./assets/screenshots/05-issue-detail-subissues.jpg";
|
|
||||||
import commentImg from "./assets/screenshots/06-comment-composer.jpg";
|
|
||||||
import mentionImg from "./assets/screenshots/07-mention-picker.jpg";
|
|
||||||
import projectsImg from "./assets/screenshots/08-projects-list.jpg";
|
|
||||||
import projectDetailImg from "./assets/screenshots/09-project-detail.jpg";
|
|
||||||
import agentsImg from "./assets/screenshots/10-agents-list.jpg";
|
|
||||||
import runtimesImg from "./assets/screenshots/11-runtimes-list.jpg";
|
|
||||||
import skillsImg from "./assets/screenshots/12-skills-list.jpg";
|
|
||||||
import skillDetailImg from "./assets/screenshots/13-skill-detail.jpg";
|
|
||||||
import settingsImg from "./assets/screenshots/14-settings.jpg";
|
|
||||||
import promoVideo from "./assets/video/multica-android-promo-draft.mp4";
|
|
||||||
|
|
||||||
const release = {
|
|
||||||
packageName: "ai.multica.app.xiaomi",
|
|
||||||
sha256: "13e004472d33e66aa40aa7ebd58f75e22cfde8cce3a153729481f1be4d2d1414",
|
|
||||||
gitlabUrl: "https://git.n.xiaomi.com/zhaoxisheng/multica-xiaomi-android",
|
|
||||||
releasesUrl: "https://github.com/park0er/multica-xiaomi-apk/releases/download/v0.1.5-xiaomi/Multica-Xiaomi-v0.1.5.apk",
|
|
||||||
iosUrl: "https://github.com/park0er/multi-casual-ios-app",
|
|
||||||
multicaUrl: "https://multica.ai",
|
|
||||||
apiUrl: "http://staging-multica.ad.xiaomi.srv",
|
|
||||||
};
|
|
||||||
|
|
||||||
type Locale = "en" | "zh";
|
|
||||||
|
|
||||||
const copy: Record<Locale, {
|
|
||||||
nav: { brand: string; download: string; trust: string; language: string };
|
|
||||||
hero: { eyebrow: string; title: string; subtitle: string; download: string; gitlab: string; multica: string; ios: string };
|
|
||||||
// relationship section intentionally removed for the internal build
|
|
||||||
journey: { kicker: string; title: string; subtitle: string; cta: string; steps: Array<{ title: string; subtitle: string }> };
|
|
||||||
workspace: { kicker: string; title: string; subtitle: string; items: Array<{ title: string; subtitle: string }> };
|
|
||||||
video: { kicker: string; title: string; body: string };
|
|
||||||
download: { kicker: string; title: string; package: string; api: string; sha: string; latest: string; source: string; note: string };
|
|
||||||
trust: { kicker: string; items: string[] };
|
|
||||||
footer: { tagline: string; multica: string; gitlab: string; ios: string; download: string };
|
|
||||||
}> = {
|
|
||||||
en: {
|
|
||||||
nav: { brand: "Multica Xiaomi", download: "Download", trust: "Scope", language: "中文" },
|
|
||||||
hero: {
|
|
||||||
eyebrow: "Internal Xiaomi build · Company-only distribution",
|
|
||||||
title: "Multica Xiaomi for the Xiaomi workspace.",
|
|
||||||
subtitle: "An Android app independently built from scratch by Zhao Xisheng in Xiaomi's Commercial Platform Department. Multica has not provided an official Android app to date; this internal build gives Xiaomi users a native path today.",
|
|
||||||
download: "Download APK",
|
|
||||||
gitlab: "GitLab",
|
|
||||||
multica: "Visit Multica",
|
|
||||||
ios: "iOS App",
|
|
||||||
},
|
|
||||||
journey: {
|
|
||||||
kicker: "Interactive walkthrough",
|
|
||||||
title: "Try the flow.",
|
|
||||||
subtitle: "Click each step or scroll to see the app in action.",
|
|
||||||
cta: "Start the walkthrough",
|
|
||||||
steps: [
|
|
||||||
{ title: "Inbox notifications", subtitle: "Stay on top of activity — new issues, comments, and agent progress show up as they happen." },
|
|
||||||
{ title: "Browse issues", subtitle: "See all tasks in a clean list view, ready for triage. Filter, search, and tap into any issue." },
|
|
||||||
{ title: "Switch to board view", subtitle: "Tap the view toggle to switch to a kanban board that shows status flow at a glance." },
|
|
||||||
{ title: "Open issue detail", subtitle: "Tap an issue card to see the full detail: title, status, labels, assignee, and body text." },
|
|
||||||
{ title: "Explore context", subtitle: "Context guide points zoom into focus areas — title and status, body content, then subtasks and comments — and return to the full view between each." },
|
|
||||||
{ title: "Scroll for more", subtitle: "Keep scrolling to see subtasks, subscribers, and the comment thread — all the structured context stays on one page." },
|
|
||||||
{ title: "Draft a comment", subtitle: "Tap the comment box. The keyboard appears and a comment types out character by character." },
|
|
||||||
{ title: "Mention anyone", subtitle: "Type @ and a picker appears with people, agents, and squads. Choose who to notify." },
|
|
||||||
{ title: "Workspace overview", subtitle: "Switch between Projects, Agents, Runtimes, Skills, and Settings with workspace chips." },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
workspace: {
|
|
||||||
kicker: "Workspace assets",
|
|
||||||
title: "Projects, agents, and everything in between.",
|
|
||||||
subtitle: "Switch between workspace views to see the full picture.",
|
|
||||||
items: [
|
|
||||||
{ title: "Projects", subtitle: "Browse projects with progress tracking" },
|
|
||||||
{ title: "Project detail", subtitle: "Task stats, status fields, and resources" },
|
|
||||||
{ title: "Agents", subtitle: "View agent status and recent activity" },
|
|
||||||
{ title: "Runtimes", subtitle: "Check runtime availability and versions" },
|
|
||||||
{ title: "Skills", subtitle: "Browse the workspace agent skill library" },
|
|
||||||
{ title: "Skill detail", subtitle: "Inspect skill structure and documentation" },
|
|
||||||
{ title: "Settings", subtitle: "Account, workspace, and preference controls" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
video: { kicker: "Product demo", title: "See it in 30 seconds.", body: "A fast walkthrough of inbox, issues, context, comments, mentions, and workspace views." },
|
|
||||||
download: { kicker: "Internal build", title: "Download the Xiaomi internal APK from GitHub Pages.", package: "Package", api: "Service API", sha: "APK SHA256", latest: "Download APK", source: "View source", note: "Internal distribution only — not for external release." },
|
|
||||||
trust: {
|
|
||||||
kicker: "Internal build scope",
|
|
||||||
items: [
|
|
||||||
"Designed and implemented from scratch by Zhao Xisheng of Xiaomi's Commercial Platform Department, with no upstream Android app to inherit from.",
|
|
||||||
"Multica has not provided an official Android app to date; this project is an independent Xiaomi internal Android client for Multica workflows.",
|
|
||||||
"Company-internal distribution only. APK download is served from a separate GitHub release artifact repository after EdgeOne proved unreliable.",
|
|
||||||
"Connects to the Xiaomi Multica service endpoint, with analytics routed through OneTrack and disabled for Umeng / PostHog.",
|
|
||||||
"Source is kept in the private company GitLab project; the signed APK is distributed from GitHub Pages.",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
footer: { tagline: "Built from scratch by Zhao Xisheng for the Xiaomi workspace. APK served via GitHub Pages.", multica: "Multica website", gitlab: "GitLab", ios: "iOS App", download: "Download APK" },
|
|
||||||
},
|
|
||||||
zh: {
|
|
||||||
nav: { brand: "Multica Xiaomi", download: "下载", trust: "分发说明", language: "English" },
|
|
||||||
hero: {
|
|
||||||
eyebrow: "小米内部构建 · 仅限公司内部分发",
|
|
||||||
title: "面向小米工作区的 Multica Xiaomi 内部版。",
|
|
||||||
subtitle: "由小米商业平台部赵锡盛完全从头自研的 Android App。Multica 官方到目前为止还没有提供 Android App;这套内部构建先为小米用户补上原生移动端入口。",
|
|
||||||
download: "下载 APK",
|
|
||||||
gitlab: "GitLab",
|
|
||||||
multica: "访问 Multica",
|
|
||||||
ios: "iOS App",
|
|
||||||
},
|
|
||||||
journey: {
|
|
||||||
kicker: "交互式演示",
|
|
||||||
title: "试试这个流程。",
|
|
||||||
subtitle: "点击每个步骤,或滚动页面查看 app 实际操作。",
|
|
||||||
cta: "开始演示",
|
|
||||||
steps: [
|
|
||||||
{ title: "Inbox 通知", subtitle: "新 issues、评论和 agent 进展会实时出现在收件箱,不错过任何动态。" },
|
|
||||||
{ title: "浏览 Issues", subtitle: "在列表视图中查看所有任务,快速筛选和进入任意 issue。" },
|
|
||||||
{ title: "切换看板视图", subtitle: "点击视图切换按钮,看板视图让你一眼看到任务状态流。" },
|
|
||||||
{ title: "打开 Issue 详情", subtitle: "点击 issue 卡片查看完整详情:标题、状态、标签、负责人和正文。" },
|
|
||||||
{ title: "探索上下文", subtitle: "Context 引导点依次放大标题与状态、正文内容、子任务与评论,然后缩回全貌。" },
|
|
||||||
{ title: "滚动查看更多", subtitle: "继续下滑查看子任务、订阅者和评论线程 —— 结构化上下文都在同一页。" },
|
|
||||||
{ title: "草拟评论", subtitle: "点击评论框,键盘出现,评论逐字输入。" },
|
|
||||||
{ title: "@ 提及任何人", subtitle: "输入 @ 弹出选择器,可选人、agent 或小队。" },
|
|
||||||
{ title: "Workspace 总览", subtitle: "通过 workspace chips 切换查看 Projects、Agents、Runtimes、Skills 和 Settings。" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
workspace: {
|
|
||||||
kicker: "Workspace 资产",
|
|
||||||
title: "项目、agents,以及它们之间的一切。",
|
|
||||||
subtitle: "切换 workspace 视图,掌握全貌。",
|
|
||||||
items: [
|
|
||||||
{ title: "Projects", subtitle: "浏览项目和进度追踪" },
|
|
||||||
{ title: "项目详情", subtitle: "任务统计、状态字段和资源入口" },
|
|
||||||
{ title: "Agents", subtitle: "查看 agent 状态和最近活动" },
|
|
||||||
{ title: "Runtimes", subtitle: "检查运行时可用性和版本" },
|
|
||||||
{ title: "Skills", subtitle: "浏览 workspace agent 技能库" },
|
|
||||||
{ title: "技能详情", subtitle: "查看技能结构和文档" },
|
|
||||||
{ title: "Settings", subtitle: "账号、工作区和偏好控制" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
video: { kicker: "产品演示", title: "30 秒看完核心流程。", body: "快速演示 inbox 通知、issues 看板、上下文探索、评论回复、@ 提及和 workspace 视图切换。" },
|
|
||||||
download: { kicker: "内部构建", title: "从 GitHub Pages 下载小米内部 APK。", package: "Package", api: "Service API", sha: "APK SHA256", latest: "下载 APK", source: "查看源码", note: "仅供公司内部分发,不对外发布。" },
|
|
||||||
trust: {
|
|
||||||
kicker: "内部构建说明",
|
|
||||||
items: [
|
|
||||||
"由小米商业平台部赵锡盛完全从头设计和实现,不是基于 Multica 官方 Android 客户端改造。",
|
|
||||||
"Multica 官方到目前为止还没有提供 Android App;本项目是面向小米内部 Multica 工作流的独立 Android 客户端。",
|
|
||||||
"仅供公司内部分发;由于腾讯云 EdgeOne 访问不稳定,APK 下载改由单独的 GitHub Release 产物仓库托管提供。",
|
|
||||||
"连接小米 Multica 服务端点,统计走 OneTrack,Umeng / PostHog 已关闭。",
|
|
||||||
"源码保存在私有公司 GitLab 项目中;签名 APK 从 GitHub Pages 分发。",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
footer: { tagline: "商业平台部赵锡盛从头自研,APK 由 GitHub Pages 分发。", multica: "Multica 官网", gitlab: "GitLab", ios: "iOS App", download: "下载 APK" },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const JOURNEY_STEPS = 9;
|
|
||||||
const TYPING_TEXT = "Looks good. I'll ask the agent to check the failing case and post a follow-up.";
|
|
||||||
const MENTION_ITEMS = [
|
|
||||||
{ name: "codex", color: "#1647ff" },
|
|
||||||
{ name: "Opus", color: "#7c3aed" },
|
|
||||||
{ name: "RollieOC", color: "#059669" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const WORKSPACE_SCREENSHOTS = [projectsImg, projectDetailImg, agentsImg, runtimesImg, skillsImg, skillDetailImg, settingsImg];
|
|
||||||
|
|
||||||
const fade = (reduced: boolean, delay = 0) => ({
|
|
||||||
initial: reduced ? {} : { opacity: 0, y: 18 },
|
|
||||||
whileInView: { opacity: 1, y: 0 },
|
|
||||||
viewport: { once: true, margin: "-60px" },
|
|
||||||
transition: { duration: 0.65, delay, ease: [0.25, 0.1, 0.25, 1] as const },
|
|
||||||
});
|
|
||||||
|
|
||||||
const reveal = (reduced: boolean) => ({
|
|
||||||
initial: reduced ? {} : { opacity: 0, y: 20 },
|
|
||||||
whileInView: { opacity: 1, y: 0 },
|
|
||||||
viewport: { once: true, margin: "-80px" },
|
|
||||||
transition: { duration: 0.7, ease: [0.25, 0.1, 0.25, 1] as const },
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ── PhoneDemo ── */
|
|
||||||
function PhoneDemo({
|
|
||||||
src,
|
|
||||||
reduced,
|
|
||||||
zoomScale = 1,
|
|
||||||
zoomX = 0,
|
|
||||||
zoomY = 0,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
src: string;
|
|
||||||
reduced: boolean;
|
|
||||||
zoomScale?: number;
|
|
||||||
zoomX?: number;
|
|
||||||
zoomY?: number;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="phone-shell-compact">
|
|
||||||
<div className="speaker" />
|
|
||||||
<div className="phone-screen-compact">
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
<motion.img
|
|
||||||
key={src}
|
|
||||||
src={src}
|
|
||||||
alt="App screenshot"
|
|
||||||
initial={reduced ? {} : { opacity: 0 }}
|
|
||||||
animate={{
|
|
||||||
opacity: 1,
|
|
||||||
scale: zoomScale,
|
|
||||||
x: zoomX,
|
|
||||||
y: zoomY,
|
|
||||||
}}
|
|
||||||
exit={reduced ? {} : { opacity: 0 }}
|
|
||||||
transition={{ duration: reduced ? 0.1 : 0.4, ease: [0.25, 0.1, 0.25, 1] as const }}
|
|
||||||
/>
|
|
||||||
</AnimatePresence>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Typewriter effect ── */
|
|
||||||
function TypewriterEffect({ active, text, reduced }: { active: boolean; text: string; reduced: boolean }) {
|
|
||||||
const charCount = useMotionValue(0);
|
|
||||||
const chars = useMemo(() => text.split(""), [text]);
|
|
||||||
const charRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
|
|
||||||
// Imperatively update char opacity from motion value (no hooks in loops)
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubscribe = charCount.on("change", (v) => {
|
|
||||||
const count = Math.round(v);
|
|
||||||
charRefs.current.forEach((el, i) => {
|
|
||||||
if (el) el.style.opacity = i < count ? "1" : "0";
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return unsubscribe;
|
|
||||||
}, [charCount]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
||||||
if (!active) { charCount.set(0); charRefs.current.forEach((el) => { if (el) el.style.opacity = "0"; }); return; }
|
|
||||||
if (reduced) {
|
|
||||||
charCount.set(chars.length);
|
|
||||||
charRefs.current.forEach((el, i) => { if (el) el.style.opacity = i < chars.length ? "1" : "0"; });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let i = 0;
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
i++;
|
|
||||||
charCount.set(i);
|
|
||||||
if (i >= chars.length) clearInterval(interval);
|
|
||||||
}, 42);
|
|
||||||
intervalRef.current = interval;
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [active, reduced, chars.length]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
className="typing-line"
|
|
||||||
animate={active ? { opacity: 1 } : { opacity: 0 }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
>
|
|
||||||
{chars.map((char, i) => (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
ref={(el) => { charRefs.current[i] = el; }}
|
|
||||||
style={{ opacity: 0 }}
|
|
||||||
>
|
|
||||||
{char === " " ? "\u00A0" : char}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
<motion.span
|
|
||||||
className="typing-cursor"
|
|
||||||
animate={active ? { opacity: [1, 0.18] } : { opacity: 0 }}
|
|
||||||
transition={active ? { duration: 0.64, repeat: Infinity, repeatType: "reverse" } : {}}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── PhoneMockup (hero) ── */
|
|
||||||
function PhoneMockup({ reduced }: { reduced: boolean }) {
|
|
||||||
return (
|
|
||||||
<div className="hero-phone-stack">
|
|
||||||
<motion.img
|
|
||||||
src={inboxImg} alt="Inbox preview"
|
|
||||||
initial={reduced ? {} : { opacity: 0, x: 60, rotate: -14 }}
|
|
||||||
whileInView={{ opacity: 0.72, x: 0, rotate: -8 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ duration: 0.85, delay: 0.2, ease: [0.25, 0.1, 0.25, 1] as const }}
|
|
||||||
/>
|
|
||||||
<motion.img
|
|
||||||
src={issuesListImg} alt="Issues list preview"
|
|
||||||
initial={reduced ? {} : { opacity: 0, x: 80, rotate: 8 }}
|
|
||||||
whileInView={{ opacity: 1, x: 0, rotate: 4 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ duration: 0.85, delay: 0.3, ease: [0.25, 0.1, 0.25, 1] as const }}
|
|
||||||
/>
|
|
||||||
<motion.img
|
|
||||||
src={issueDetailImg} alt="Issue detail preview"
|
|
||||||
initial={reduced ? {} : { opacity: 0, x: 60, rotate: 14 }}
|
|
||||||
whileInView={{ opacity: 0.86, x: 0, rotate: 10 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ duration: 0.85, delay: 0.4, ease: [0.25, 0.1, 0.25, 1] as const }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Main App ── */
|
|
||||||
export default function App() {
|
|
||||||
const reduced = useReducedMotion() ?? false;
|
|
||||||
const [lang, setLang] = useState<Locale>("en");
|
|
||||||
const [step, setStep] = useState(0);
|
|
||||||
const [activeWs, setActiveWs] = useState(0);
|
|
||||||
const [hasInteracted, setHasInteracted] = useState(false);
|
|
||||||
const completedRef = useRef<Set<number>>(new Set());
|
|
||||||
const journeyRef = useRef<HTMLDivElement>(null);
|
|
||||||
const hasAutoPlayedRef = useRef(false);
|
|
||||||
const t = copy[lang];
|
|
||||||
|
|
||||||
// Auto-play journey on first scroll into view
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasInteracted || hasAutoPlayedRef.current) return;
|
|
||||||
const el = journeyRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
if (entry.isIntersecting && entry.intersectionRatio > 0.3 && !hasAutoPlayedRef.current) {
|
|
||||||
hasAutoPlayedRef.current = true;
|
|
||||||
let currentStep = 0;
|
|
||||||
const advance = () => {
|
|
||||||
currentStep++;
|
|
||||||
if (currentStep < JOURNEY_STEPS) {
|
|
||||||
setStep(currentStep);
|
|
||||||
setTimeout(advance, 2200);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
setTimeout(advance, 2200);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ threshold: 0.3 }
|
|
||||||
);
|
|
||||||
observer.observe(el);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [hasInteracted]);
|
|
||||||
|
|
||||||
const handleStepClick = useCallback((index: number) => {
|
|
||||||
setHasInteracted(true);
|
|
||||||
setStep(index);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleWorkspaceClick = useCallback((index: number) => {
|
|
||||||
setActiveWs(index);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Phone screen source based on journey step
|
|
||||||
const phoneScreenSrc = useMemo(() => {
|
|
||||||
if (step <= 1) return step === 0 ? inboxImg : issuesListImg;
|
|
||||||
if (step === 2) return issuesBoardImg;
|
|
||||||
if (step <= 5) return step === 3 ? issueDetailImg : issueSubImg;
|
|
||||||
if (step === 6) return commentImg;
|
|
||||||
if (step === 7) return mentionImg;
|
|
||||||
return projectsImg;
|
|
||||||
}, [step]);
|
|
||||||
|
|
||||||
// Focus zoom transform for step 4 (context explore)
|
|
||||||
const focusZones = [
|
|
||||||
{ scale: 1.6, x: 0, y: -120 }, // title/status
|
|
||||||
{ scale: 1.45, x: 0, y: -40 }, // body
|
|
||||||
{ scale: 1.5, x: 0, y: 80 }, // subtasks/comments
|
|
||||||
];
|
|
||||||
const focusIdx = step === 4 ? 0 : -1; // simple: show first zone; cycle handled by auto-play
|
|
||||||
const zoom = step === 4 ? focusZones[focusIdx] || focusZones[0] : { scale: 1, x: 0, y: 0 };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main lang={lang === "zh" ? "zh-CN" : "en"}>
|
|
||||||
{/* Hero */}
|
|
||||||
<section className="hero">
|
|
||||||
<div className="hero-bg" />
|
|
||||||
<motion.nav className="topbar" {...fade(reduced, 0)}>
|
|
||||||
<div className="brand">
|
|
||||||
<img src={logoImg} alt="Multica Xiaomi" className="brand-mark" />
|
|
||||||
<span>{t.nav.brand}</span>
|
|
||||||
</div>
|
|
||||||
<div className="nav-links">
|
|
||||||
<a href="#download">{t.nav.download}</a>
|
|
||||||
<a href="#trust">{t.nav.trust}</a>
|
|
||||||
<button className="language-toggle" onClick={() => setLang((l) => (l === "en" ? "zh" : "en"))}>
|
|
||||||
{t.nav.language}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.nav>
|
|
||||||
<div className="hero-grid">
|
|
||||||
<div>
|
|
||||||
<motion.p className="eyebrow" {...fade(reduced, 0.04)}>{t.hero.eyebrow}</motion.p>
|
|
||||||
<motion.h1 {...fade(reduced, 0.14)}>{t.hero.title}</motion.h1>
|
|
||||||
<motion.p className="hero-subtitle" {...fade(reduced, 0.24)}>{t.hero.subtitle}</motion.p>
|
|
||||||
<motion.div className="cta-row" {...fade(reduced, 0.34)}>
|
|
||||||
<motion.a href="#download" className="pill pill-android" whileHover={{ y: -2 }} whileTap={{ scale: 0.98 }}><span className="android-icon"></span>{t.hero.download}</motion.a>
|
|
||||||
<motion.a className="button secondary" href={release.gitlabUrl} whileHover={{ y: -2 }} whileTap={{ scale: 0.98 }}><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" style={{verticalAlign: "middle", marginRight: 6}}><path d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 0 1-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 0 1 4.82 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0 1 18.6 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.51L23 13.45a.84.84 0 0 1-.35.94z"/></svg>{t.hero.gitlab}</motion.a>
|
|
||||||
<motion.a className="button ghost" href={release.multicaUrl} whileHover={{ y: -2 }} whileTap={{ scale: 0.98 }}>{t.hero.multica}</motion.a>
|
|
||||||
<motion.a href={release.iosUrl} className="pill pill-ios" whileHover={{ y: -2 }} whileTap={{ scale: 0.98 }}><span className="apple-icon"></span>{t.hero.ios}</motion.a>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
<PhoneMockup reduced={reduced} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* (Relationship section removed for the internal build.) */}
|
|
||||||
|
|
||||||
{/* Interactive Product Journey */}
|
|
||||||
<section ref={journeyRef} className="journey section-pad">
|
|
||||||
<motion.div className="journey-header" {...reveal(reduced)}>
|
|
||||||
<div className="section-kicker">{t.journey.kicker}</div>
|
|
||||||
<h2>{t.journey.title}</h2>
|
|
||||||
<p>{t.journey.subtitle}</p>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="journey-layout">
|
|
||||||
<div className="journey-steps">
|
|
||||||
{t.journey.steps.map((s, i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
className={`journey-step ${step === i ? "journey-step-active" : ""} ${i < step ? "journey-step-completed" : ""}`}
|
|
||||||
onClick={() => handleStepClick(i)}
|
|
||||||
initial={reduced ? {} : { opacity: 0, x: -16 }}
|
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ duration: 0.5, delay: i * 0.06 }}
|
|
||||||
>
|
|
||||||
<div className="journey-step-number">{i < step ? "✓" : i + 1}</div>
|
|
||||||
<div className="journey-step-content">
|
|
||||||
<h3>{s.title}</h3>
|
|
||||||
<p>{s.subtitle}</p>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="journey-phone-area">
|
|
||||||
<div className="journey-phone-container">
|
|
||||||
<PhoneDemo
|
|
||||||
src={phoneScreenSrc}
|
|
||||||
reduced={reduced}
|
|
||||||
zoomScale={step === 4 ? zoom.scale : 1}
|
|
||||||
zoomX={step === 4 ? zoom.x : 0}
|
|
||||||
zoomY={step === 4 ? zoom.y : 0}
|
|
||||||
>
|
|
||||||
{/* Hand + tap ripple for interactive steps */}
|
|
||||||
{step >= 1 && step <= 3 && (
|
|
||||||
<>
|
|
||||||
<motion.div
|
|
||||||
className="journey-hand"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
style={{ top: step === 1 ? "16%" : step === 2 ? "8%" : "35%", right: step === 2 ? "18%" : "22%" }}
|
|
||||||
>👆</motion.div>
|
|
||||||
<motion.div
|
|
||||||
className="journey-tap-ripple"
|
|
||||||
animate={{ scale: [1, 1.3, 1], opacity: [0.8, 0.3, 0.8] }}
|
|
||||||
transition={{ duration: 1.2, repeat: Infinity }}
|
|
||||||
style={{ top: step === 1 ? "18%" : step === 2 ? "10%" : "37%", right: step === 2 ? "14%" : "18%" }}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Typing effect for step 6 */}
|
|
||||||
{step === 6 && <TypewriterEffect active text={TYPING_TEXT} reduced={reduced} />}
|
|
||||||
|
|
||||||
{/* Mention picker overlay for step 7 */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{step === 7 && (
|
|
||||||
<motion.div
|
|
||||||
className="mention-overlay"
|
|
||||||
initial={{ y: 60, opacity: 0 }}
|
|
||||||
animate={{ y: 0, opacity: 1 }}
|
|
||||||
exit={{ y: 60, opacity: 0 }}
|
|
||||||
transition={{ duration: 0.35, ease: [0.25, 0.1, 0.25, 1] as const }}
|
|
||||||
>
|
|
||||||
<div className="mention-search">
|
|
||||||
<span>@</span> cod
|
|
||||||
</div>
|
|
||||||
<div className="mention-list">
|
|
||||||
{MENTION_ITEMS.map((m, i) => (
|
|
||||||
<motion.div
|
|
||||||
key={m.name}
|
|
||||||
className={`mention-item ${i === 0 ? "mention-item-active" : ""}`}
|
|
||||||
initial={{ opacity: 0, x: -12 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ delay: 0.15 + i * 0.1 }}
|
|
||||||
>
|
|
||||||
<div className="mention-avatar" style={{ background: m.color }}>{m.name[0].toUpperCase()}</div>
|
|
||||||
<div>
|
|
||||||
<div className="mention-name">{m.name}</div>
|
|
||||||
<div className="mention-role">{i === 0 ? "agent" : i === 1 ? "agent" : "squad"}</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Workspace chips overlay for step 8 */}
|
|
||||||
{step === 8 && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
style={{ position: "absolute", bottom: 8, left: 8, right: 8, zIndex: 6, display: "flex", gap: 4, justifyContent: "center", flexWrap: "wrap" }}
|
|
||||||
>
|
|
||||||
{["Projects", "Agents", "Runtimes", "Skills", "Settings"].map((label, i) => (
|
|
||||||
<motion.div
|
|
||||||
key={label}
|
|
||||||
className="workspace-chip"
|
|
||||||
style={{ fontSize: 10, padding: "4px 8px", borderRadius: 999 }}
|
|
||||||
initial={{ scale: 0 }}
|
|
||||||
animate={{ scale: 1 }}
|
|
||||||
transition={{ delay: 0.1 + i * 0.08 }}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</PhoneDemo>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Workspace Assets Grid */}
|
|
||||||
<motion.section className="workspace-section section-pad" {...reveal(reduced)}>
|
|
||||||
<div className="workspace-header">
|
|
||||||
<div className="section-kicker">{t.workspace.kicker}</div>
|
|
||||||
<h2>{t.workspace.title}</h2>
|
|
||||||
<p>{t.workspace.subtitle}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="workspace-chips">
|
|
||||||
{t.workspace.items.map((item, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
className={`workspace-chip ${activeWs === i ? "workspace-chip-active" : ""}`}
|
|
||||||
onClick={() => handleWorkspaceClick(i)}
|
|
||||||
>
|
|
||||||
{item.title}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="workspace-phone-wrap">
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
<motion.div
|
|
||||||
key={activeWs}
|
|
||||||
initial={reduced ? {} : { opacity: 0, scale: 0.96 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={reduced ? {} : { opacity: 0, scale: 0.96 }}
|
|
||||||
transition={{ duration: 0.35 }}
|
|
||||||
>
|
|
||||||
<PhoneDemo src={WORKSPACE_SCREENSHOTS[activeWs]} reduced={reduced} />
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div className="workspace-card-grid" {...reveal(reduced)}>
|
|
||||||
{t.workspace.items.map((item, i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
className={`workspace-card ${activeWs === i ? "workspace-card-active" : ""}`}
|
|
||||||
onClick={() => handleWorkspaceClick(i)}
|
|
||||||
initial={reduced ? {} : { opacity: 0, y: 16 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ duration: 0.5, delay: i * 0.06 }}
|
|
||||||
>
|
|
||||||
<img src={WORKSPACE_SCREENSHOTS[i]} alt={item.title} className="workspace-card-thumb" />
|
|
||||||
<div className="workspace-card-label">
|
|
||||||
<strong>{item.title}</strong>
|
|
||||||
<span>{item.subtitle}</span>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
</motion.section>
|
|
||||||
|
|
||||||
{/* Video Demo */}
|
|
||||||
<motion.section className="video-demo section-pad" {...reveal(reduced)}>
|
|
||||||
<div className="section-kicker">{t.video.kicker}</div>
|
|
||||||
<div className="video-copy">
|
|
||||||
<h2>{t.video.title}</h2>
|
|
||||||
<p>{t.video.body}</p>
|
|
||||||
</div>
|
|
||||||
<video className="promo-video" src={promoVideo} controls playsInline preload="metadata" />
|
|
||||||
</motion.section>
|
|
||||||
|
|
||||||
{/* Download */}
|
|
||||||
<section id="download" className="download section-pad">
|
|
||||||
<motion.div className="release-card" {...reveal(reduced)}>
|
|
||||||
<div className="section-kicker">{t.download.kicker}</div>
|
|
||||||
<h2>{t.download.title}</h2>
|
|
||||||
<div className="release-grid">
|
|
||||||
<div>
|
|
||||||
<span>{t.download.package}</span>
|
|
||||||
<code>{release.packageName}</code>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>{t.download.api}</span>
|
|
||||||
<code>{release.apiUrl}</code>
|
|
||||||
</div>
|
|
||||||
<div className="hash-cell">
|
|
||||||
<span>{t.download.sha}</span>
|
|
||||||
<code>{release.sha256}</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<pre><code>{`adb install -r Multica-Xiaomi-v0.1.5.apk`}</code></pre>
|
|
||||||
<div className="cta-row compact">
|
|
||||||
<a href={release.releasesUrl} className="pill pill-android"><span className="android-icon"></span>{t.download.latest}</a>
|
|
||||||
<a className="button secondary" href={release.gitlabUrl}>{t.download.source}</a>
|
|
||||||
</div>
|
|
||||||
<p className="star-hint">
|
|
||||||
{lang === "en"
|
|
||||||
? <>APK downloads are served by a separate GitHub release artifact repository; source lives on the company <a href={release.gitlabUrl} target="_blank" rel="noopener">GitLab</a>.</>
|
|
||||||
: <>APK 由单独的 GitHub Release 产物仓库分发;源码保存在公司 <a href={release.gitlabUrl} target="_blank" rel="noopener">GitLab</a>。</>
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Trust */}
|
|
||||||
<section id="trust" className="trust section-pad">
|
|
||||||
<div className="section-kicker">{t.trust.kicker}</div>
|
|
||||||
<div className="trust-grid">
|
|
||||||
{t.trust.items.map((item) => (
|
|
||||||
<motion.div key={item} className="trust-item" {...reveal(reduced)}>
|
|
||||||
<span className="check">✓</span>
|
|
||||||
<p>{item}</p>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="footer section-pad">
|
|
||||||
<div>
|
|
||||||
<strong>Multica Xiaomi</strong>
|
|
||||||
<p>{t.footer.tagline}</p>
|
|
||||||
</div>
|
|
||||||
<div className="footer-links">
|
|
||||||
<a href={release.multicaUrl}>{t.footer.multica}</a>
|
|
||||||
<a href={release.gitlabUrl}>{t.footer.gitlab}</a>
|
|
||||||
<a href={release.releasesUrl} className="pill pill-android"><span className="android-icon"></span>{t.footer.download}</a>
|
|
||||||
<a href={release.iosUrl} className="pill pill-ios"><span className="apple-icon"></span>{t.footer.ios}</a>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 81 KiB |
@@ -1,10 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import ReactDOM from "react-dom/client";
|
|
||||||
import App from "./App";
|
|
||||||
import "./styles.css";
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
|
||||||
</React.StrictMode>,
|
|
||||||
);
|
|
||||||
@@ -1,971 +0,0 @@
|
|||||||
:root {
|
|
||||||
--blue: #1647ff;
|
|
||||||
--blue-2: #0f33c8;
|
|
||||||
--yellow: #ffe45c;
|
|
||||||
--ink: #101218;
|
|
||||||
--muted: #687080;
|
|
||||||
--paper: #f6f8fc;
|
|
||||||
--card: #ffffff;
|
|
||||||
--line: rgba(16, 18, 24, 0.1);
|
|
||||||
--shadow: 0 24px 70px rgba(16, 18, 24, 0.14);
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
||||||
color: var(--ink);
|
|
||||||
background: var(--paper);
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
|
|
||||||
html { scroll-behavior: smooth; }
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
min-width: 320px;
|
|
||||||
overflow-x: hidden;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 82% 5%, rgba(255, 228, 92, 0.4), transparent 31%),
|
|
||||||
radial-gradient(circle at 10% 30%, rgba(22, 71, 255, 0.16), transparent 30%),
|
|
||||||
linear-gradient(180deg, #fbfcff 0%, #e8eeff 55%, #d4dff9 85%, #c4d4f5 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
body, button, input { font: inherit; }
|
|
||||||
|
|
||||||
button { cursor: pointer; }
|
|
||||||
|
|
||||||
a { color: inherit; text-decoration: none; }
|
|
||||||
|
|
||||||
.section-pad { padding: 80px clamp(22px, 5vw, 76px); }
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
min-height: 100vh;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
padding-top: 26px;
|
|
||||||
padding-left: clamp(16px, 5vw, 76px);
|
|
||||||
padding-right: clamp(16px, 5vw, 76px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-bg {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background-image:
|
|
||||||
linear-gradient(rgba(16, 18, 24, 0.045) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, rgba(16, 18, 24, 0.045) 1px, transparent 1px);
|
|
||||||
background-size: 56px 56px;
|
|
||||||
mask-image: linear-gradient(to bottom, black 0%, transparent 88%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar {
|
|
||||||
width: min(1160px, 100%);
|
|
||||||
margin: 0 auto 70px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 24px;
|
|
||||||
padding: 12px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(255, 255, 255, 0.72);
|
|
||||||
backdrop-filter: blur(18px);
|
|
||||||
box-shadow: 0 12px 36px rgba(16, 18, 24, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand, .nav-links { display: flex; align-items: center; gap: 10px; }
|
|
||||||
|
|
||||||
.brand { font-weight: 850; letter-spacing: -0.02em; }
|
|
||||||
|
|
||||||
.brand-mark {
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
border-radius: 10px;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-links a {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 8px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-links a:hover { color: var(--ink); }
|
|
||||||
|
|
||||||
.language-toggle {
|
|
||||||
color: var(--blue);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 850;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid rgba(22, 71, 255, 0.18);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(22, 71, 255, 0.07);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-toggle:hover { background: rgba(22, 71, 255, 0.12); }
|
|
||||||
|
|
||||||
.hero-grid {
|
|
||||||
width: min(1160px, 100%);
|
|
||||||
margin: 0 auto;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1.06fr) minmax(320px, 0.72fr);
|
|
||||||
gap: clamp(28px, 7vw, 96px);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow, .section-kicker {
|
|
||||||
margin: 0 0 16px;
|
|
||||||
color: var(--blue);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 900;
|
|
||||||
letter-spacing: 0.15em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3, p { margin-top: 0; }
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
max-width: 780px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
font-size: clamp(54px, 8.2vw, 112px);
|
|
||||||
line-height: 0.9;
|
|
||||||
letter-spacing: -0.075em;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: clamp(36px, 5vw, 72px);
|
|
||||||
line-height: 0.94;
|
|
||||||
letter-spacing: -0.065em;
|
|
||||||
margin-bottom: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
main[lang="zh-CN"] h1 {
|
|
||||||
max-width: 700px;
|
|
||||||
font-size: clamp(42px, 6.8vw, 86px);
|
|
||||||
line-height: 0.98;
|
|
||||||
letter-spacing: -0.07em;
|
|
||||||
}
|
|
||||||
|
|
||||||
main[lang="zh-CN"] h2 {
|
|
||||||
font-size: clamp(34px, 4.6vw, 62px);
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 1.08;
|
|
||||||
letter-spacing: -0.035em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-subtitle, .relationship p, .demo p, .journey-desc {
|
|
||||||
max-width: 650px;
|
|
||||||
color: #343b4a;
|
|
||||||
font-size: clamp(18px, 2vw, 23px);
|
|
||||||
line-height: 1.48;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cta-row { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 32px; }
|
|
||||||
.cta-row.compact { margin-top: 22px; }
|
|
||||||
|
|
||||||
.button {
|
|
||||||
font-size: 15px;
|
|
||||||
display: inline-flex;
|
|
||||||
min-height: 48px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 0 20px;
|
|
||||||
font-weight: 850;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
transition: color 0.18s ease, background 0.18s ease, border-color 0.18s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.primary { color: white; background: var(--blue); box-shadow: 0 14px 34px rgba(22, 71, 255, 0.32); }
|
|
||||||
.button.secondary { background: white; border-color: var(--line); }
|
|
||||||
.button.ghost { color: var(--muted); background: rgba(255, 255, 255, 0.54); border-color: var(--line); }
|
|
||||||
|
|
||||||
/* Phone mockup */
|
|
||||||
.phone-wrap {
|
|
||||||
width: min(390px, 82vw);
|
|
||||||
margin: 0 auto;
|
|
||||||
position: relative;
|
|
||||||
transform-origin: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-phone-stack {
|
|
||||||
min-height: 720px;
|
|
||||||
position: relative;
|
|
||||||
perspective: 1200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-phone-stack img {
|
|
||||||
position: absolute;
|
|
||||||
width: min(310px, 54vw);
|
|
||||||
height: 676px;
|
|
||||||
border-radius: 46px;
|
|
||||||
border: 12px solid #11141d;
|
|
||||||
background: white;
|
|
||||||
object-fit: cover;
|
|
||||||
object-position: top center;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-phone-stack img:nth-child(1) { left: 2%; top: 48px; transform: rotate(-8deg) translateZ(-20px); }
|
|
||||||
.hero-phone-stack img:nth-child(2) { left: 28%; top: 0; transform: rotate(4deg) translateZ(40px); }
|
|
||||||
.hero-phone-stack img:nth-child(3) { right: 0; top: 132px; transform: rotate(10deg) translateZ(10px); }
|
|
||||||
|
|
||||||
.phone-shell {
|
|
||||||
position: relative;
|
|
||||||
padding: 14px;
|
|
||||||
border-radius: 54px;
|
|
||||||
background: #11141d;
|
|
||||||
box-shadow: var(--shadow), inset 0 0 0 2px rgba(255,255,255,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.phone-shell-compact {
|
|
||||||
position: relative;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 48px;
|
|
||||||
background: #11141d;
|
|
||||||
box-shadow: 0 20px 56px rgba(16, 18, 24, 0.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
.speaker {
|
|
||||||
position: absolute;
|
|
||||||
top: 18px;
|
|
||||||
left: 50%;
|
|
||||||
width: 72px;
|
|
||||||
height: 8px;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(255,255,255,0.18);
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.phone-screen {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 40px;
|
|
||||||
background: white;
|
|
||||||
aspect-ratio: 9 / 19.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.phone-screen-compact {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 36px;
|
|
||||||
background: white;
|
|
||||||
aspect-ratio: 9 / 19.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.phone-screen img,
|
|
||||||
.phone-screen-compact img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
object-position: top center;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.phone-screen-shot {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
object-position: top center;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.tap-ring {
|
|
||||||
position: absolute;
|
|
||||||
right: -8px;
|
|
||||||
bottom: 158px;
|
|
||||||
width: 76px;
|
|
||||||
height: 76px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 2px solid var(--blue);
|
|
||||||
background: rgba(255, 228, 92, 0.7);
|
|
||||||
box-shadow: 0 0 0 16px rgba(255, 228, 92, 0.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Shared content sections */
|
|
||||||
.relationship, .features, .demo, .video-demo, .download, .trust, .footer {
|
|
||||||
width: min(1160px, calc(100% - 44px));
|
|
||||||
margin: 0 auto;
|
|
||||||
padding-left: 0;
|
|
||||||
padding-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.two-col, .demo-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 0.85fr) minmax(320px, 1fr);
|
|
||||||
gap: clamp(24px, 5vw, 76px);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Relationship section */
|
|
||||||
.relationship-card {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 30px;
|
|
||||||
background: rgba(255,255,255,0.76);
|
|
||||||
box-shadow: 0 18px 48px rgba(16,18,24,0.08);
|
|
||||||
padding: clamp(28px, 5vw, 48px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.relationship-card p {
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 0 16px;
|
|
||||||
font-size: clamp(16px, 1.8vw, 20px);
|
|
||||||
line-height: 1.65;
|
|
||||||
color: #343b4a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.relationship-card p:last-child { margin-bottom: 0; }
|
|
||||||
|
|
||||||
.relationship-card a {
|
|
||||||
color: var(--blue);
|
|
||||||
text-decoration: underline;
|
|
||||||
text-decoration-color: rgba(22, 71, 255, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.relationship-card a:hover { text-decoration-color: var(--blue); }
|
|
||||||
|
|
||||||
/* Interactive product journey */
|
|
||||||
.journey {
|
|
||||||
width: min(1160px, calc(100% - 44px));
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 40px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.journey-header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.journey-header h2 {
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.journey-header p {
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 0 auto;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.journey-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 440px);
|
|
||||||
gap: clamp(24px, 5vw, 64px);
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.journey-steps {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.journey-step {
|
|
||||||
display: flex;
|
|
||||||
gap: 18px;
|
|
||||||
padding: 20px 24px;
|
|
||||||
border-radius: 20px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: rgba(255,255,255,0.68);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.journey-step:hover {
|
|
||||||
background: rgba(255,255,255,0.92);
|
|
||||||
border-color: rgba(22, 71, 255, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.journey-step-active {
|
|
||||||
background: white;
|
|
||||||
border-color: var(--blue);
|
|
||||||
box-shadow: 0 12px 36px rgba(22, 71, 255, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.journey-step-completed {
|
|
||||||
opacity: 0.65;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.journey-step-number {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(22, 71, 255, 0.08);
|
|
||||||
color: var(--blue);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 900;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
flex: none;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.journey-step-active .journey-step-number {
|
|
||||||
background: var(--blue);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.journey-step-completed .journey-step-number {
|
|
||||||
background: var(--blue);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.journey-step-content h3 {
|
|
||||||
font-size: 18px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.journey-step-content p {
|
|
||||||
font-size: 18px;
|
|
||||||
line-height: 1.55;
|
|
||||||
color: var(--muted);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.journey-phone-area {
|
|
||||||
position: sticky;
|
|
||||||
top: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.journey-phone-container {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Journey phone overlays */
|
|
||||||
.journey-overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 6;
|
|
||||||
border-radius: 36px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.journey-hand {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 8;
|
|
||||||
font-size: 28px;
|
|
||||||
filter: drop-shadow(0 4px 8px rgba(0,0,0,0.2));
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.journey-tap-ripple {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 7;
|
|
||||||
width: 52px;
|
|
||||||
height: 52px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 2px solid var(--blue);
|
|
||||||
background: rgba(255, 228, 92, 0.55);
|
|
||||||
box-shadow: 0 0 0 12px rgba(255, 228, 92, 0.18);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Focus zoom */
|
|
||||||
.focus-zoom {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 5;
|
|
||||||
border-radius: 20px;
|
|
||||||
border: 3px solid var(--blue);
|
|
||||||
background: rgba(22, 71, 255, 0.06);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Workspace assets grid */
|
|
||||||
.workspace-section {
|
|
||||||
width: min(1160px, calc(100% - 44px));
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-header h2 {
|
|
||||||
max-width: 700px;
|
|
||||||
margin: 0 auto 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-header p {
|
|
||||||
max-width: 560px;
|
|
||||||
margin: 0 auto;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-chips {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-chip {
|
|
||||||
padding: 10px 18px;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 850;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: rgba(255,255,255,0.72);
|
|
||||||
color: var(--muted);
|
|
||||||
transition: background 0.18s, color 0.18s, border-color 0.18s, box-shadow 0.18s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-chip:hover {
|
|
||||||
background: white;
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-chip-active {
|
|
||||||
background: var(--blue);
|
|
||||||
color: white;
|
|
||||||
border-color: var(--blue);
|
|
||||||
box-shadow: 0 10px 24px rgba(22, 71, 255, 0.28);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-phone-wrap {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-phone-wrap .phone-shell-compact {
|
|
||||||
width: min(260px, 60vw);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mention picker overlay */
|
|
||||||
.mention-overlay {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 10;
|
|
||||||
background: rgba(255,255,255,0.97);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
border-radius: 24px 24px 36px 36px;
|
|
||||||
padding: 16px 14px;
|
|
||||||
box-shadow: 0 -12px 36px rgba(16, 18, 24, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mention-search {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: #f4f6fa;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mention-search span {
|
|
||||||
color: var(--blue);
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mention-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mention-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 14px;
|
|
||||||
transition: background 0.12s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mention-item-active {
|
|
||||||
background: rgba(22, 71, 255, 0.07);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mention-avatar {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 999px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 900;
|
|
||||||
color: white;
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mention-name {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mention-role {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mention-highlight {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 5;
|
|
||||||
border-radius: 16px;
|
|
||||||
border: 2px solid var(--blue);
|
|
||||||
background: rgba(22, 71, 255, 0.06);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Typing demo */
|
|
||||||
.typing-line {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 6;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--ink);
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
line-height: 1;
|
|
||||||
white-space: pre;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.typing-cursor {
|
|
||||||
display: inline-block;
|
|
||||||
width: 2px;
|
|
||||||
height: 18px;
|
|
||||||
background: var(--blue);
|
|
||||||
border-radius: 999px;
|
|
||||||
margin-left: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Features grid (legacy, kept for reference) */
|
|
||||||
.feature-grid, .trust-grid, .release-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-card, .trust-item, .release-card {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 30px;
|
|
||||||
background: rgba(255,255,255,0.76);
|
|
||||||
box-shadow: 0 18px 48px rgba(16,18,24,0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-card { padding: 28px; min-height: 250px; }
|
|
||||||
.feature-card span { color: var(--blue); font-weight: 950; font-size: 13px; letter-spacing: 0.12em; }
|
|
||||||
.feature-card p, .trust-item p { color: var(--muted); line-height: 1.65; }
|
|
||||||
|
|
||||||
.demo { overflow: hidden; }
|
|
||||||
.screenshot-stack { min-height: 560px; position: relative; overflow: hidden; border-radius: 42px; }
|
|
||||||
.shot {
|
|
||||||
position: absolute;
|
|
||||||
width: min(310px, 52vw);
|
|
||||||
height: 620px;
|
|
||||||
border-radius: 34px;
|
|
||||||
border: 10px solid #11141d;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
background: white;
|
|
||||||
object-fit: cover;
|
|
||||||
object-position: top center;
|
|
||||||
}
|
|
||||||
.shot.back { right: 210px; top: 40px; transform: rotate(-7deg); opacity: 0.78; }
|
|
||||||
.shot.front { right: 12px; top: 0; transform: rotate(5deg); }
|
|
||||||
|
|
||||||
.video-demo {
|
|
||||||
padding-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-copy {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 0.95fr) minmax(320px, 1fr);
|
|
||||||
gap: clamp(24px, 5vw, 76px);
|
|
||||||
align-items: end;
|
|
||||||
margin-bottom: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-copy p {
|
|
||||||
color: #343b4a;
|
|
||||||
font-size: clamp(17px, 1.7vw, 21px);
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.promo-video, .video-placeholder {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 16 / 9;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 34px;
|
|
||||||
background: #0e1320;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.promo-video { object-fit: cover; z-index: 1; position: relative; }
|
|
||||||
|
|
||||||
.video-placeholder {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
color: white;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 70% 24%, rgba(255, 228, 92, 0.22), transparent 34%),
|
|
||||||
linear-gradient(135deg, #0e1320, #172453);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-placeholder span {
|
|
||||||
width: 86px;
|
|
||||||
height: 86px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
border-radius: 999px;
|
|
||||||
color: var(--blue);
|
|
||||||
background: var(--yellow);
|
|
||||||
font-size: 44px;
|
|
||||||
font-weight: 950;
|
|
||||||
box-shadow: 0 18px 42px rgba(255, 228, 92, 0.24);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-placeholder strong {
|
|
||||||
display: block;
|
|
||||||
color: white;
|
|
||||||
font-size: clamp(22px, 3vw, 42px);
|
|
||||||
letter-spacing: -0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download { padding-top: 40px; }
|
|
||||||
.release-card { padding: clamp(28px, 5vw, 54px); }
|
|
||||||
.release-grid { margin: 30px 0 22px; }
|
|
||||||
.release-grid div {
|
|
||||||
min-width: 0;
|
|
||||||
padding: 18px;
|
|
||||||
border-radius: 20px;
|
|
||||||
background: #f7f9ff;
|
|
||||||
border: 1px solid rgba(22, 71, 255, 0.13);
|
|
||||||
}
|
|
||||||
.release-grid span { display: block; color: var(--muted); font-size: 12px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; margin-bottom: 8px; }
|
|
||||||
.release-grid code, pre code { font-family: "SFMono-Regular", ui-monospace, Menlo, Consolas, "PingFang SC", monospace; }
|
|
||||||
.release-grid code { display: block; overflow-wrap: anywhere; color: var(--ink); font-size: 14px; }
|
|
||||||
.hash-cell { grid-column: span 3; }
|
|
||||||
pre {
|
|
||||||
overflow: auto;
|
|
||||||
margin: 0;
|
|
||||||
padding: 18px 20px;
|
|
||||||
border-radius: 20px;
|
|
||||||
color: #eef4ff;
|
|
||||||
background: #0e1320;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trust-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
||||||
.trust-item { display: flex; gap: 14px; padding: 22px; align-items: flex-start; }
|
|
||||||
.check {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
flex: none;
|
|
||||||
color: white;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--blue);
|
|
||||||
font-weight: 900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 24px;
|
|
||||||
align-items: flex-start;
|
|
||||||
color: var(--muted);
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
.footer strong { color: var(--ink); }
|
|
||||||
.footer-links { display: flex; gap: 18px; flex-wrap: wrap; font-weight: 800; color: var(--ink); }
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.topbar { border-radius: 24px; align-items: flex-start; flex-direction: column; }
|
|
||||||
.nav-links { flex-wrap: wrap; }
|
|
||||||
.hero-grid, .two-col, .demo-grid, .video-copy, .feature-grid, .trust-grid, .release-grid { grid-template-columns: 1fr; }
|
|
||||||
.hero { padding-left: 16px; padding-right: 16px; padding-top: 20px; min-height: auto; }
|
|
||||||
.hero-grid { width: 100%; }
|
|
||||||
.hero-grid > div:first-child { padding-right: 0; }
|
|
||||||
.hero-phone-stack { min-height: 400px; overflow: visible; }
|
|
||||||
.hero-phone-stack img { height: 480px; width: min(240px, 42vw); border-radius: 36px; border-width: 10px; }
|
|
||||||
.hero-phone-stack img:nth-child(1) { left: -5%; top: 36px; transform: rotate(-8deg) translateZ(-20px); }
|
|
||||||
.hero-phone-stack img:nth-child(2) { left: 22%; top: 0; transform: rotate(4deg) translateZ(40px); }
|
|
||||||
.hero-phone-stack img:nth-child(3) { right: -5%; top: 100px; transform: rotate(10deg) translateZ(10px); }
|
|
||||||
.eyebrow { font-size: 11px; letter-spacing: 0.1em; }
|
|
||||||
h1 { font-size: clamp(32px, 8vw, 52px) !important; }
|
|
||||||
.hero-subtitle { font-size: 15px; }
|
|
||||||
.journey-layout { grid-template-columns: 1fr; }
|
|
||||||
.journey-phone-area { position: relative; top: 0; order: -1; }
|
|
||||||
.journey-phone-container { justify-content: center; }
|
|
||||||
.section-pad { padding: 48px 16px; }
|
|
||||||
.hash-cell { grid-column: auto; }
|
|
||||||
.screenshot-stack { min-height: 680px; }
|
|
||||||
.shot { width: min(300px, 74vw); }
|
|
||||||
.shot.back { right: auto; left: 4%; }
|
|
||||||
.shot.front { right: 4%; top: 160px; }
|
|
||||||
.footer { flex-direction: column; }
|
|
||||||
.workspace-chips { justify-content: flex-start; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
*, *::before, *::after {
|
|
||||||
animation-duration: 0.01ms !important;
|
|
||||||
animation-iteration-count: 1 !important;
|
|
||||||
scroll-behavior: auto !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Workspace card grid */
|
|
||||||
.workspace-card-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
|
||||||
gap: 14px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-card {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 18px;
|
|
||||||
background: rgba(255, 255, 255, 0.72);
|
|
||||||
overflow: hidden;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.18s, box-shadow 0.18s, background 0.18s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-card:hover {
|
|
||||||
background: white;
|
|
||||||
border-color: rgba(22, 71, 255, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-card-active {
|
|
||||||
border-color: var(--blue);
|
|
||||||
box-shadow: 0 10px 28px rgba(22, 71, 255, 0.12);
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-card-thumb {
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 9 / 19.7;
|
|
||||||
object-fit: cover;
|
|
||||||
object-position: top center;
|
|
||||||
display: block;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-card-label {
|
|
||||||
padding: 10px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-card-label strong {
|
|
||||||
display: block;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 850;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-card-label span {
|
|
||||||
display: block;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--muted);
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.workspace-card-grid {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.star-hint {
|
|
||||||
margin-top: 20px;
|
|
||||||
font-size: 18px;
|
|
||||||
color: var(--muted);
|
|
||||||
text-align: center;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.star-hint a {
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Footer pills ── */
|
|
||||||
.pill {
|
|
||||||
display: inline-flex; align-items: center; gap: 7px;
|
|
||||||
padding: 10px 22px; border-radius: 999px;
|
|
||||||
font-weight: 850; font-size: 15px; letter-spacing: -0.01em;
|
|
||||||
text-decoration: none; transition: all 0.22s;
|
|
||||||
}
|
|
||||||
.pill-android {
|
|
||||||
background: var(--blue); color: #fff;
|
|
||||||
box-shadow: 0 12px 30px rgba(22,71,255,0.30);
|
|
||||||
}
|
|
||||||
.pill-android:hover { background: var(--blue-2); transform: translateY(-1px); }
|
|
||||||
.pill-android .android-icon {
|
|
||||||
width: 15px; height: 15px; display: inline-block; background: #fff;
|
|
||||||
-webkit-mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMThjMCAuNTUuNDUgMSAxIDFoMXYzLjVjMCAuODMuNjcgMS41IDEuNSAxLjVzMS41LS42NyAxLjUtMS41VjE5aDJ2My41YzAgLjgzLjY3IDEuNSAxLjUgMS41czEuNS0uNjcgMS41LTEuNVYxOWgxYy41NSAwIDEtLjQ1IDEtMVY4SDZ2MTB6TTMuNSA4QzIuNjcgOCAyIDguNjcgMiA5LjV2N2MwIC44My42NyAxLjUgMS41IDEuNVM1IDE3LjMzIDUgMTYuNXYtN0M1IDguNjcgNC4zMyA4IDMuNSA4em0xNyAwYy0uODMgMC0xLjUuNjctMS41IDEuNXY3YzAgLjgzLjY3IDEuNSAxLjUgMS41czEuNS0uNjcgMS41LTEuNXYtN2MwLS44My0uNjctMS41LTEuNS0xLjV6bS00Ljk3LTUuODRsMS4zLTEuM2MuMi0uMi4yLS41MSAwLS43MS0uMi0uMi0uNTEtLjItLjcxIDBsLTEuNDggMS40OEE1Ljg0IDUuODQgMCAwMDEyIDFjLS45NiAwLTEuODYuMjMtMi42Ni42M0w3Ljg1LjE1Yy0uMi0uMi0uNTEtLjItLjcxIDAtLjIuMi0uMi41MSAwIC43MWwxLjMxIDEuMzFBNS45ODMgNS45ODMgMCAwMDYgN2gxMmMwLTIuMTItMS4xLTMuOTgtMi40Ny00Ljg0ek0xMCA1SDlWNGgxdjF6bTUgMGgtMVY0aDF2MXoiLz48L3N2Zz4=) center/contain no-repeat;
|
|
||||||
mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMThjMCAuNTUuNDUgMSAxIDFoMXYzLjVjMCAuODMuNjcgMS41IDEuNSAxLjVzMS41LS42NyAxLjUtMS41VjE5aDJ2My41YzAgLjgzLjY3IDEuNSAxLjUgMS41czEuNS0uNjcgMS41LTEuNVYxOWgxYy41NSAwIDEtLjQ1IDEtMVY4SDZ2MTB6TTMuNSA4QzIuNjcgOCAyIDguNjcgMiA5LjV2N2MwIC44My42NyAxLjUgMS41IDEuNVM1IDE3LjMzIDUgMTYuNXYtN0M1IDguNjcgNC4zMyA4IDMuNSA4em0xNyAwYy0uODMgMC0xLjUuNjctMS41IDEuNXY3YzAgLjgzLjY3IDEuNSAxLjUgMS41czEuNS0uNjcgMS41LTEuNXYtN2MwLS44My0uNjctMS41LTEuNS0xLjV6bS00Ljk3LTUuODRsMS4zLTEuM2MuMi0uMi4yLS41MSAwLS43MS0uMi0uMi0uNTEtLjItLjcxIDBsLTEuNDggMS40OEE1Ljg0IDUuODQgMCAwMDEyIDFjLS45NiAwLTEuODYuMjMtMi42Ni42M0w3Ljg1LjE1Yy0uMi0uMi0uNTEtLjItLjcxIDAtLjIuMi0uMi41MSAwIC43MWwxLjMxIDEuMzFBNS45ODMgNS45ODMgMCAwMDYgN2gxMmMwLTIuMTItMS4xLTMuOTgtMi40Ny00Ljg0ek0xMCA1SDlWNGgxdjF6bTUgMGgtMVY0aDF2MXoiLz48L3N2Zz4=) center/contain no-repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill-ios {
|
|
||||||
position: relative; overflow: hidden;
|
|
||||||
background: rgba(255,255,255,0.22);
|
|
||||||
backdrop-filter: blur(20px) saturate(1.8);
|
|
||||||
-webkit-backdrop-filter: blur(20px) saturate(1.8);
|
|
||||||
color: #1a1a2e; font-weight: 850;
|
|
||||||
border: 1px solid rgba(255,255,255,0.55);
|
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08),
|
|
||||||
inset 0 1px 0 rgba(255,255,255,0.9),
|
|
||||||
inset 0 -1px 0 rgba(0,0,0,0.04);
|
|
||||||
}
|
|
||||||
.pill-ios:hover {
|
|
||||||
background: rgba(255,255,255,0.38);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 8px 28px rgba(0,0,0,0.12),
|
|
||||||
inset 0 1px 0 rgba(255,255,255,1),
|
|
||||||
inset 0 -1px 0 rgba(0,0,0,0.04);
|
|
||||||
}
|
|
||||||
.pill-ios::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute; top: 0; left: -40%; width: 180%; height: 50%;
|
|
||||||
background: linear-gradient(180deg, rgba(255,255,255,0.7) 0%, rgba(255,255,255,0) 100%);
|
|
||||||
border-radius: 999px; pointer-events: none;
|
|
||||||
}
|
|
||||||
.pill-ios::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute; bottom: -2px; left: 10%; width: 80%; height: 6px;
|
|
||||||
background: linear-gradient(90deg, #ff6b9d, #c44dff, #6b9dff, #45d4a8);
|
|
||||||
filter: blur(5px); opacity: 0.4; border-radius: 999px;
|
|
||||||
pointer-events: none; z-index: -1;
|
|
||||||
}
|
|
||||||
.pill-ios .apple-icon {
|
|
||||||
width: 14px; height: 14px; display: inline-block; background: #1a1a2e;
|
|
||||||
-webkit-mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTE4LjcxIDE5LjVjLS44MyAxLjI0LTEuNzEgMi40NS0zLjA1IDIuNDctMS4zNC4wMy0xLjc3LS43OS0zLjI5LS43OS0xLjUzIDAtMiAuNzctMy4yNy44Mi0xLjMxLjA1LTIuMy0xLjMyLTMuMTQtMi41M0M0LjI1IDE3IDIuOTQgMTIuNDUgNC43IDkuMzljLjg3LTEuNTIgMi40My0yLjQ4IDQuMTItMi41MSAxLjI4LS4wMiAyLjUuODcgMy4yOS44Ny43OCAwIDIuMjYtMS4wNyAzLjgxLS45MS42NS4wMyAyLjQ3LjI2IDMuNjQgMS45OC0uMDkuMDYtMi4xNyAxLjI4LTIuMTUgMy44MS4wMyAzLjAyIDIuNjUgNC4wMyAyLjY4IDQuMDQtLjAzLjA3LS40MiAxLjQ0LTEuMzggMi44M00xMyAzLjVjLjczLS44MyAxLjk0LTEuNDYgMi45NC0xLjUuMTMgMS4xNy0uMzQgMi4zNS0xLjA0IDMuMTktLjY5Ljg1LTEuODMgMS41MS0yLjk1IDEuNDItLjE1LTEuMTUuNDEtMi4zNSAxLjA1LTMuMTF6Ii8+PC9zdmc+) center/contain no-repeat;
|
|
||||||
mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTE4LjcxIDE5LjVjLS44MyAxLjI0LTEuNzEgMi40NS0zLjA1IDIuNDctMS4zNC4wMy0xLjc3LS43OS0zLjI5LS43OS0xLjUzIDAtMiAuNzctMy4yNy44Mi0xLjMxLjA1LTIuMy0xLjMyLTMuMTQtMi41M0M0LjI1IDE3IDIuOTQgMTIuNDUgNC43IDkuMzljLjg3LTEuNTIgMi40My0yLjQ4IDQuMTItMi41MSAxLjI4LS4wMiAyLjUuODcgMy4yOS44Ny43OCAwIDIuMjYtMS4wNyAzLjgxLS45MS42NS4wMyAyLjQ3LjI2IDMuNjQgMS45OC0uMDkuMDYtMi4xNyAxLjI4LTIuMTUgMy44MS4wMyAzLjAyIDIuNjUgNC4wMyAyLjY4IDQuMDQtLjAzLjA3LS40MiAxLjQ0LTEuMzggMi44M00xMyAzLjVjLjczLS44MyAxLjk0LTEuNDYgMi45NC0xLjUuMTMgMS4xNy0uMzQgMi4zNS0xLjA0IDMuMTktLjY5Ljg1LTEuODMgMS41MS0yLjk1IDEuNDItLjE1LTEuMTUuNDEtMi4zNSAxLjA1LTMuMTF6Ii8+PC9zdmc+) center/contain no-repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.pill { padding: 8px 16px; font-size: 14px; }
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
|
||||||
"allowJs": false,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"strict": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "Bundler",
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "react-jsx"
|
|
||||||
},
|
|
||||||
"include": ["src"],
|
|
||||||
"references": []
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { defineConfig } from 'vite';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
|
|
||||||
export default defineConfig(() => ({
|
|
||||||
plugins: [react()],
|
|
||||||
base: process.env.VITE_BASE || '/',
|
|
||||||
build: {
|
|
||||||
outDir: 'dist',
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
host: '127.0.0.1',
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"Name":"multi-casual-china","ProjectId":"pages-wu0vu5wirtvg"}
|
||||||
@@ -19,9 +19,9 @@ import promoVideo from "./assets/video/multica-android-promo-draft.mp4";
|
|||||||
|
|
||||||
const release = {
|
const release = {
|
||||||
packageName: "ai.multicasual.app",
|
packageName: "ai.multicasual.app",
|
||||||
sha256: "3f50276216d866c0e405e1e99f38f2f2c1c3d5b4a4e79dc092495c7c6a3ebe49",
|
sha256: "317e42a926499aae1b3f3c9e1392eed169c2d2680c0565b9d2c44a31f5348bc4",
|
||||||
githubUrl: "https://github.com/park0er/multi-casual-android",
|
githubUrl: "https://github.com/park0er/multi-casual-android",
|
||||||
releasesUrl: "https://github.com/park0er/multi-casual-android/releases/download/v0.1.5/Multi-Casual-Android-v0.1.5.apk",
|
releasesUrl: "https://github.com/park0er/multi-casual-android/releases/download/v0.1.3/Multi-Casual-Android-v0.1.3.apk",
|
||||||
iosUrl: "https://github.com/park0er/multi-casual-ios-app",
|
iosUrl: "https://github.com/park0er/multi-casual-ios-app",
|
||||||
multicaUrl: "https://multica.ai",
|
multicaUrl: "https://multica.ai",
|
||||||
apiUrl: "https://api.multica.ai",
|
apiUrl: "https://api.multica.ai",
|
||||||
@@ -640,7 +640,7 @@ export default function App() {
|
|||||||
<code>{release.sha256}</code>
|
<code>{release.sha256}</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<pre><code>{`adb install -r Multi-Casual-Android-v0.1.5.apk`}</code></pre>
|
<pre><code>{`adb install -r Multi-Casual-Android-v0.1.3.apk`}</code></pre>
|
||||||
<div className="cta-row compact">
|
<div className="cta-row compact">
|
||||||
<a href={release.releasesUrl} className="pill pill-android"><span className="android-icon"></span>{t.download.latest}</a>
|
<a href={release.releasesUrl} className="pill pill-android"><span className="android-icon"></span>{t.download.latest}</a>
|
||||||
<a className="button secondary" href={release.githubUrl}>{t.download.source}</a>
|
<a className="button secondary" href={release.githubUrl}>{t.download.source}</a>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 196 KiB |