67 Commits
Author SHA1 Message Date
Xisheng-Zhao 7d6d6d7ff7 Update release metadata for v0.1.3 2026-06-19 21:04:03 +08:00
Xisheng-Zhao b3eb758093 Remove intermediate video build artifacts 2026-06-09 13:57:01 +08:00
Xisheng-Zhao 26fe9679b6 Update site source for deployment 2026-06-09 13:56:46 +08:00
Xisheng-Zhao 836fe675d7 Update site source for deployment 2026-06-09 13:32:28 +08:00
Xisheng-Zhao 4909031290 Update site source for deployment 2026-06-09 13:27:12 +08:00
Xisheng-Zhao 839dda286b Remove .edgeone config 2026-06-09 13:18:23 +08:00
Xisheng-Zhao a10d51719a Update site source for deployment 2026-06-09 13:18:11 +08:00
Xisheng-Zhao 569bdf962e Remove Multica Web app link from Chinese README 2026-06-09 02:34:43 +08:00
Xisheng-Zhao 253fa5c592 Strengthen privacy statement in README 2026-06-08 23:15:53 +08:00
Xisheng-Zhao 2fdc112979 Remove .edgeone config from public repo 2026-06-08 23:08:27 +08:00
Xisheng-Zhao f204431d34 Update site source for deployment 2026-06-08 23:08:12 +08:00
Xisheng-Zhao f5cc00b985 Fix GitHub Pages: add Vite config with base path, fix favicon, add VITE_BASE env 2026-06-08 22:53:06 +08:00
Xisheng-Zhao fd3ba44e47 Add language toggle to centered header 2026-06-08 22:48:26 +08:00
Xisheng-Zhao d89f9ee584 Center README title and badges 2026-06-08 22:43:10 +08:00
Xisheng-Zhao 9ba1d74b16 Use single-line README badges 2026-06-08 22:40:02 +08:00
Xisheng-Zhao ed9b95298c Add metadata badges to README 2026-06-08 22:37:47 +08:00
Xisheng-Zhao ae36f08025 Update public README layout 2026-06-08 22:35:21 +08:00
Xisheng-Zhao da70a1e334 Finalize independent Android public repository 2026-06-08 22:17:30 +08:00
Xisheng-Zhao b7b2f3f183 Build GitHub Pages site with npm install 2026-06-08 21:54:48 +08:00
Xisheng-Zhao 826f9352e9 Fix GitHub Pages site build cache path 2026-06-08 21:52:55 +08:00
Xisheng-Zhao 237d22f9af Switch download button to direct APK URL 2026-06-08 21:51:10 +08:00
Xisheng-Zhao 088efddd8d Merge marketing site and promo video update 2026-06-08 21:09:00 +08:00
Xisheng-Zhao dc915ac59c Fix mobile layout: hero padding, phone stack sizing, section spacing 2026-06-08 20:30:35 +08:00
Xisheng-Zhao 1685cedca9 Replace browser tab favicon with app logo 2026-06-08 20:21:52 +08:00
Xisheng-Zhao 7ddc1a07e6 Polish hero CTAs: scroll to download, GitHub logo, underline star link, download button labels 2026-06-08 20:15:45 +08:00
Xisheng-Zhao af0b8e3693 Add GitHub Actions workflow for Pages deployment 2026-06-08 19:47:52 +08:00
Xisheng-Zhao a936271de9 Enlarge star hint text and add GitHub repo hyperlink 2026-06-08 19:18:03 +08:00
Xisheng-Zhao bd26392536 Fix license reference from MIT to GPLv3 2026-06-08 18:01:01 +08:00
Xisheng-Zhao 69da5e7f58 Add GitHub star call-to-action in download section 2026-06-08 18:00:27 +08:00
Xisheng-Zhao 2ee18bcb1e Fix dark nav bar: white gradient overlay on phone-screen bottom 2026-06-08 17:55:50 +08:00
Xisheng-Zhao eb17b476eb Clip dark nav bar at phone screen bottom (mask gradient + object-position shift) 2026-06-08 17:48:05 +08:00
Xisheng-Zhao 9f183af0d7 Update embedded promo video with aligned typing container 2026-06-08 17:38:12 +08:00
Xisheng-Zhao f7bc965a86 Merge branch 'honest-blowfish' 2026-06-08 17:38:02 +08:00
Xisheng-Zhao a09bf356e7 Polish website: blue-tinted background gradient, smaller workspace phone
- Body gradient now stays blue-tinted (was fading to white at bottom)
- Workspace phone shell reduced from 320px to 260px for full-screen visibility
- Matches reference design in d700 worktree
2026-06-08 17:36:55 +08:00
Xisheng-Zhao e43be6e145 Make new chat creation lazy 2026-06-08 17:19:07 +08:00
Xisheng-Zhao 5c1ad78de7 Replace blue M icon with app logo in nav bar 2026-06-08 17:16:38 +08:00
Xisheng-Zhao 44f10d8196 Align typing comment box with screenshot input area 2026-06-08 17:07:04 +08:00
Xisheng-Zhao 1e9b6d4fc2 Fix typing overlay and loop background music to 32s 2026-06-08 16:40:58 +08:00
Xisheng-Zhao c32c7051a7 Re-render promo video with background music and Chinese titles 2026-06-08 16:31:31 +08:00
Xisheng-Zhao f74ef81d8b Polish hero phone stack, workspace sizing, and background gradient 2026-06-08 16:15:03 +08:00
Xisheng-Zhao 80ae4d5342 Embed final product promo video 2026-06-08 13:27:29 +08:00
Xisheng-Zhao 42bf0eccbb Refresh marketing site with product-led story
- Rewrite App.tsx: interactive 9-step product journey, bilingual EN/ZH
- All 14 real product screenshots integrated
- Relationship section with upstream contribution context
- Workspace assets grid with chip navigation (Projects/Agents/Runtimes/Skills/Settings)
- TypewriterEffect with true per-character input via motion value
- Mention picker overlay animation
- Auto-play journey on scroll (IntersectionObserver)
- prefers-reduced-motion support throughout
- No 'public' language anywhere in the build output
2026-06-08 13:12:37 +08:00
Xisheng-Zhao 2088fa1c91 Prepare public marketing screenshots
- Copy 14 real product screenshots to site/src/assets/screenshots/
- Copy 14 real product screenshots to promo-video/assets/public-screens/
- Remove old placeholder PNGs from site assets
- No content sanitization applied (per user confirmation)
2026-06-08 13:05:19 +08:00
Xisheng-Zhao 9c4632a816 Merge branch 'honest-blowfish' 2026-06-08 12:37:15 +08:00
Xisheng-Zhao 880d0471af Add cancel control to issue comment composer 2026-06-08 12:31:45 +08:00
Xisheng-Zhao 5443f6437f Avoid stale pending chat refresh state 2026-06-08 12:02:07 +08:00
Xisheng-Zhao df90d71e75 Refine chat composer streaming experience 2026-06-08 11:59:18 +08:00
Xisheng-Zhao b61cb1514f Merge branch 'giant-armadillo' 2026-06-08 11:43:37 +08:00
Xisheng-Zhao aa1ee352b6 fix: reply sort direction now follows the comment sort pill (descending param)
Replies within a comment thread were always sorted ascending regardless of
the descending parameter. Now they respect it, matching root comment sort.
2026-06-07 17:32:04 +08:00
Xisheng-Zhao 8d2ec0d5c9 Improve chat and comment composer experience 2026-06-07 04:34:22 +08:00
Xisheng-Zhao 4ae42b14d3 Add squad mentions and assignee support 2026-06-07 04:08:17 +08:00
Xisheng-Zhao 3f720b72cf Checkpoint public Android release state 2026-06-07 03:38:36 +08:00
Xisheng-Zhao 0da314e13f Prepare public-only GitHub publication 2026-06-03 18:45:29 +08:00
Xisheng-Zhao 376879bb74 Complete public analytics SDK setup 2026-06-03 18:16:40 +08:00
Xisheng-Zhao 473124cd98 Wire Umeng public app key and preinit 2026-06-03 17:50:56 +08:00
Xisheng-Zhao 600aa41b1f Prepare Multi-Casual public analytics release 2026-06-03 02:15:20 +08:00
Xisheng-Zhao 392b961fb6 Add analytics tab view analytics 2026-05-18 17:46:23 +08:00
Xisheng-Zhao 82b556f0c0 add launcher icons and clean inbox rows 2026-05-18 00:08:56 +08:00
Xisheng-Zhao 1a66bc4a2b fix issue mention links in markdown 2026-05-17 18:48:20 +08:00
Xisheng-Zhao b958c37bf0 support rich issue comment links 2026-05-17 18:32:17 +08:00
Xisheng-Zhao fcb0483901 align issue comment attachment controls 2026-05-17 18:07:36 +08:00
Xisheng-Zhao bf12771908 polish issue detail screenshot feedback 2026-05-17 17:51:37 +08:00
Xisheng-Zhao 0e2035a480 verify feature sync selectable text and localization 2026-05-17 05:27:21 +08:00
Xisheng-Zhao df25f2346e add android public and public build flavors 2026-05-17 05:16:51 +08:00
Xisheng-Zhao 4fb1945bba add markdown table parity verification 2026-05-17 05:03:47 +08:00
Xisheng-Zhao cd74cae930 polish Android iOS-style issue skills and forms 2026-05-17 04:55:52 +08:00
Xisheng-Zhao 4eeafe9221 initial android app snapshot 2026-05-17 04:20:20 +08:00
60 changed files with 87 additions and 3849 deletions
+1 -6
View File
@@ -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
-160
View File
@@ -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 公开服务器,普通用户无感知。
+5 -5
View File
@@ -4,7 +4,7 @@
An independent Android companion app for Multica users. An independent Android companion app for Multica users.
[![release](https://img.shields.io/badge/release-v0.1.5-E4DCC5?style=flat-square&labelColor=2B2A28)](https://github.com/park0er/multi-casual-android/releases/latest) [![cn site](https://img.shields.io/badge/cn%20site-multi--casual--china--8temdtzs.zh--cn.edgeone.cool-E4DCC5?style=flat-square&labelColor=2B2A28)](https://multi-casual-china-8temdtzs.zh-cn.edgeone.cool/) [![global site](https://img.shields.io/badge/global%20site-multi--casual--android.pages.dev-E4DCC5?style=flat-square&labelColor=2B2A28)](https://multi-casual-android.pages.dev/) [![release](https://img.shields.io/badge/release-v0.1.3-E4DCC5?style=flat-square&labelColor=2B2A28)](https://github.com/park0er/multi-casual-android/releases/latest) [![cn site](https://img.shields.io/badge/cn%20site-multi--casual--china--8temdtzs.zh--cn.edgeone.cool-E4DCC5?style=flat-square&labelColor=2B2A28)](https://multi-casual-china-8temdtzs.zh-cn.edgeone.cool/) [![global site](https://img.shields.io/badge/global%20site-multi--casual--android.pages.dev-E4DCC5?style=flat-square&labelColor=2B2A28)](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
- 最新 releasehttps://github.com/park0er/multi-casual-android/releases/latest - 最新 releasehttps://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 不用于公开分发。
+1 -2
View File
@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
}
@@ -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,14 +732,6 @@ private fun PilotLoginScreen(
} }
} }
if (showServerConfig) {
PilotServerConfigSettings(
context = context,
authStore = authStore,
zh = zh,
onBack = { showServerConfig = false }
)
} else {
Surface(color = MulticaColors.Background) { Surface(color = MulticaColors.Background) {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
@@ -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,10 +1056,6 @@ fun MulticaCupertinoActionSheet(
) )
}, },
message = { message = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (!message.isNullOrBlank()) { if (!message.isNullOrBlank()) {
Text( Text(
text = message, text = message,
@@ -1069,59 +1063,22 @@ fun MulticaCupertinoActionSheet(
color = MulticaColors.TextTertiary, color = MulticaColors.TextTertiary,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Spacer(modifier = Modifier.height(8.dp))
} }
LazyColumn( },
modifier = Modifier buttons = {
.fillMaxWidth() items.forEach { item ->
.heightIn(max = 420.dp), action(
) {
items(items.size) { index ->
val item = items[index]
MulticaCupertinoActionSheetItemContent(
item = item,
onClick = { onClick = {
if (!item.enabled) return@MulticaCupertinoActionSheetItemContent
performMulticaTapFeedback(haptic) performMulticaTapFeedback(haptic)
item.onClick() item.onClick()
onDismissRequest() onDismissRequest()
}, },
) style = if (item.destructive) AlertActionStyle.Destructive else AlertActionStyle.Default,
} enabled = item.enabled,
}
}
},
buttons = {
if (!cancelText.isNullOrBlank()) {
action(
onClick = {
performMulticaTapFeedback(haptic)
onDismissRequest()
},
style = AlertActionStyle.Cancel,
enabled = true,
) {
Text(
text = cancelText,
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 15.sp, fontWeight = FontWeight.SemiBold),
color = MulticaColors.Accent,
)
}
}
},
)
}
@Composable
private fun MulticaCupertinoActionSheetItemContent(
item: MulticaCupertinoActionSheetItem,
onClick: () -> Unit,
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(enabled = item.enabled, onClick = onClick)
.padding(horizontal = 12.dp, vertical = 11.dp)
.semantics(mergeDescendants = true) { .semantics(mergeDescendants = true) {
contentDescription = "Multica Cupertino Action Sheet ${item.contentDescription ?: item.title}" contentDescription = "Multica Cupertino Action Sheet ${item.contentDescription ?: item.title}"
}, },
@@ -1136,24 +1093,6 @@ private fun MulticaCupertinoActionSheetItemContent(
modifier = Modifier.size(16.dp), 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) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text( Text(
text = item.title, text = item.title,
@@ -1188,6 +1127,26 @@ private fun MulticaCupertinoActionSheetItemContent(
} }
} }
} }
}
if (!cancelText.isNullOrBlank()) {
action(
onClick = {
performMulticaTapFeedback(haptic)
onDismissRequest()
},
style = AlertActionStyle.Cancel,
enabled = true,
) {
Text(
text = cancelText,
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 15.sp, fontWeight = FontWeight.SemiBold),
color = MulticaColors.Accent,
)
}
}
},
)
}
@Composable @Composable
@OptIn(ExperimentalCupertinoApi::class) @OptIn(ExperimentalCupertinoApi::class)
+1 -1
View File
@@ -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>
-5
View File
@@ -1,5 +0,0 @@
node_modules/
dist/
.edgeone/
*.tsbuildinfo
public/downloads/*.apk
-78
View File
@@ -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.
-14
View File
@@ -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>
-1001
View File
File diff suppressed because it is too large Load Diff
-22
View File
@@ -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"
}
}
-77
View File
@@ -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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

-662
View File
@@ -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 服务端点,统计走 OneTrackUmeng / 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>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

-10
View File
@@ -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>,
);
-971
View File
@@ -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
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
-21
View File
@@ -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": []
}
-13
View File
@@ -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',
},
}));
+1
View File
@@ -0,0 +1 @@
{"Name":"multi-casual-china","ProjectId":"pages-wu0vu5wirtvg"}
+3 -3
View File
@@ -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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.