Skip to content
Clone

refactor: remove unused files and clean up exports

ZhenYi committed
parent 631aa98 commit b453d5d Browse files

300 files changed

+511 −19057

This change is very large, so some diffs are not shown. Clone the repository to see the full change.

File tree 300
Modified charts/core/values.yaml View charts/core/values.yaml
@@ -36,7 +36,7 @@
3636 APP_PUSH__VAPID_PUBLIC_KEY: ""
3737 APP_PUSH__VAPID_SUBJECT: "mailto:ops@example.com"
3838 # 事件投递:消息事件写入 event_outbox,由 apihost 内嵌 relay 投递到 RocketMQ。
39 APP_ROCKETMQ__ENABLED: "false"
39 APP_ROCKETMQ__ENABLED: "true"
4040 APP_ROCKETMQ__NAME_SERVER: "rocketmq-namesrv:9876"
4141 APP_ROCKETMQ__TOPIC_PREFIX: "gitdata"
4242 # 认证中心:浏览器走 OIDC,服务间走 gRPC(x-rpc-key)。
Modified config/default.toml View config/default.toml
@@ -72,7 +72,7 @@
7272content_md = "Placeholder privacy policy."
7373
7474[web]
75web_base_url = "http://localhost:3000"
75web_base_url = "http://localhost:7051"
7676cors_allow_origins = []
7777cors_allow_credentials = false
7878
@@ -120,11 +120,7 @@
120120presence_ttl_secs = 300
121121
122122[chat.ws]
123ticket_ttl_secs = 60
124123heartbeat_secs = 30
125max_subscriptions = 64
126max_frame_bytes = 262144
127history_limit = 200
128124
129125[chat.upload]
130126max_size = 104857600
@@ -157,20 +153,20 @@
157153max_blob_size = 104857600
158154git_command_timeout_secs = 300
159155git_rpc_timeout_secs = 3600
160base_url = "http://localhost:3000"
156base_url = "http://localhost:7051"
161157http_clone_base_url = ""
162158ssh_clone_host = ""
163159ssh_clone_port = 0
164160ssh_clone_user = "git"
165161graphql_complexity_limit = 1000
166162graphql_introspection = true
167kenshell_endpoint = "http://127.0.0.1:50091"
163kenshell_endpoint = "http://127.0.0.1:7013"
168164kenshell_rpc_key = ""
169165kenshell_timeout_secs = 3600
170166
171167[git.ssh]
172168bind_addr = "0.0.0.0"
173port = 22
169port = 7053
174170host_key_path = "/opt/gitdata/ssh_host_ed25519"
175171banner = ""
176172max_sessions = 64
@@ -190,7 +186,7 @@
190186[apihost]
191187enabled = true
192188bind_addr = "0.0.0.0"
193port = 3000
189port = 7051
194190
195191[internal]
196192# /api/v1/internal/* 的服务密钥,至少 32 字符;留空则拒绝所有 internal 请求。
@@ -198,14 +194,18 @@
198194
199195[oauth]
200196enabled = false
201issuer = "http://127.0.0.1:18081"
197issuer = "http://127.0.0.1:7021"
202198client_id = "gitdata-web"
203199client_secret = ""
204200redirect_uri = "http://localhost:5173/api/v1/auth/callback"
201
202# 本机同时跑两套前端:vite dev(5173) 和网关托管的构建产物(7041)。
203# 请求 Host 命中这里时,回调地址按该主机改写,两套都能各自登录。
204redirect_uri_hosts = ["127.0.0.1:5173", "localhost:5173", "127.0.0.1:7041", "localhost:7041"]
205205token_endpoint = ""
206206jwks_url = ""
207207audience = "gitdata.ai"
208grpc_endpoint = "http://127.0.0.1:18082"
208grpc_endpoint = "http://127.0.0.1:7022"
209209rpc_key = ""
210210session_cookie = "gitdata_session"
211211refresh_cookie = "gitdata_refresh"
@@ -224,7 +224,7 @@
224224[githost]
225225enabled = true
226226http_bind_addr = "0.0.0.0"
227http_port = 9418
227http_port = 7052
228228
229229[rocketmq]
230230enabled = false
Added deploy/compose.yaml View deploy/compose.yaml
@@ -0,0 +1,52 @@
1# KenApi 单机部署(本机 stack 版)
2#
3# docker compose up -d # 用已有镜像启动
4# docker compose up -d --build # 从仓库根目录 Dockerfile 重新构建
5#
6# 端口(7xxx 段):7051 = apihost(REST + /healthz + /readyz + /openapi)
7# config 里另留了 7052(githost HTTP)与 7053(git SSH)两个口,但当前 apihost
8# 二进制不监听它们(git 读写走 KenShell),改由 KenShell 的 7011/7012 承担。
9#
10# 为什么用 host 网络:PostgreSQL / Redis / RustFS / KenOAuth / KenShell 都在
11# 宿主机 127.0.0.1 上(与其它服务一致)。
12#
13# 首次部署前置(本机已执行):
14# 1. 建库 + 迁移
15# docker exec postgres psql -U postgres -c 'CREATE DATABASE gitdata'
16# docker run --rm --network host -v "$PWD/config:/app/config:ro" \
17# --entrypoint /usr/local/bin/migrate kenapi:local
18# 2. S3 桶(RustFS):gitdata(与 config/local.toml 的 [s3].bucket 一致)
19# 3. KenOAuth 里要有 client_id = gitdata-web 的公开客户端(见 KenOAuth 配置)
20#
21# 配置:镜像内 config/default.toml + 挂载的 config/local.toml(本机地址与密钥)。
22
23name: kenapi
24
25services:
26 apihost:
27 image: ${KENAPI_IMAGE:-kenapi:local}
28 build:
29 context: ..
30 dockerfile: Dockerfile
31 container_name: kenapi
32 restart: unless-stopped
33 network_mode: host
34 environment:
35 RUST_LOG: ${RUST_LOG:-info}
36 TZ: ${TZ:-Asia/Shanghai}
37 KENAPI_HEALTH_PORT: ${KENAPI_HEALTH_PORT:-7051}
38 # 兜底回调地址:请求 Host 命中 oauth.redirect_uri_hosts(5173 / 7041)时,
39 # KenApi 会按该主机改写;命中不了才用这个值。
40 APP_OAUTH__REDIRECT_URI: http://127.0.0.1:7041/api/v1/auth/callback
41 # 进程内没有家目录,避免各 SDK 往 / 下写缓存。
42 HOME: /tmp
43 volumes:
44 # default.toml 已烘进镜像;local.toml 提供本机数据库/Redis/S3/OAuth 地址。
45 # 整个 config 目录只读挂载,顺便保留未来新增的本地配置文件。
46 - ../config:/app/config:ro
47 working_dir: /app
48 logging:
49 driver: json-file
50 options:
51 max-size: "20m"
52 max-file: "5"
Modified lib/auth/src/oauth/callback.rs View lib/auth/src/oauth/callback.rs
@@ -45,7 +45,11 @@
4545 return Err(ApiError::bad_request("authorization state is unknown or expired"));
4646 };
4747
48 let tokens = exchange_code(&service, &code, &login_state.verifier)
48 let redirect_uri = login_state
49 .redirect_uri
50 .clone()
51 .unwrap_or_else(|| service.config().oauth.redirect_uri.clone());
52 let tokens = exchange_code(&service, &code, &login_state.verifier, &redirect_uri)
4953 .await
5054 .map_err(ApiError::from)?;
5155 let id_token = tokens
Modified lib/auth/src/oauth/login.rs View lib/auth/src/oauth/login.rs
@@ -6,7 +6,7 @@
66use config::oauth::OAuthConfig;
77
88use crate::errors::ApiError;
9use crate::oauth::{LoginState, pkce_challenge, random_token, state};
9use crate::oauth::{LoginState, pkce_challenge, random_token, resolve_redirect_uri, state};
1010use crate::services::AuthService;
1111
1212#[derive(Debug, Deserialize)]
@@ -34,12 +34,14 @@
3434pub async fn signup(
3535 State(service): State<AuthService>,
3636 Query(query): Query<SignupQuery>,
37 headers: axum::http::HeaderMap,
3738) -> Result<Response, ApiError> {
3839 let config = &service.config().oauth;
3940 if !config.enabled {
4041 return Err(ApiError::not_found("oauth is disabled"));
4142 }
4243 let redirect = sanitize_redirect(query.redirect.as_deref());
44 let redirect_uri = resolve_redirect_uri(config, &headers);
4345 let state_token = random_token(32);
4446 let verifier = random_token(48);
4547 let nonce = random_token(16);
@@ -50,13 +52,14 @@
5052 redirect: redirect.clone(),
5153 verifier: verifier.clone(),
5254 nonce: nonce.clone(),
55 redirect_uri: Some(redirect_uri.clone()),
5356 },
5457 )
5558 .await
5659 .map_err(ApiError::from)?;
5760
5861 // 注册后直接拿授权码,省掉一次「请再点一次登录」。
59 let authorization = authorize_url(config, &state_token, &verifier, &nonce, false);
62 let authorization = authorize_url(config, &redirect_uri, &state_token, &verifier, &nonce, false);
6063
6164 let mut serializer = form_urlencoded::Serializer::new(String::new());
6265 serializer.append_pair("callback_url", &authorization);
@@ -71,12 +74,14 @@
7174pub async fn login(
7275 State(service): State<AuthService>,
7376 Query(query): Query<LoginQuery>,
77 headers: axum::http::HeaderMap,
7478) -> Result<Response, ApiError> {
7579 let config = &service.config().oauth;
7680 if !config.enabled {
7781 return Err(ApiError::not_found("oauth is disabled"));
7882 }
7983 let redirect = sanitize_redirect(query.redirect.as_deref());
84 let redirect_uri = resolve_redirect_uri(config, &headers);
8085 let state_token = random_token(32);
8186 let verifier = random_token(48);
8287 let nonce = random_token(16);
@@ -87,6 +92,7 @@
8792 redirect: redirect.clone(),
8893 verifier: verifier.clone(),
8994 nonce: nonce.clone(),
95 redirect_uri: Some(redirect_uri.clone()),
9096 },
9197 )
9298 .await
@@ -94,6 +100,7 @@
94100
95101 let location = authorize_url(
96102 config,
103 &redirect_uri,
97104 &state_token,
98105 &verifier,
99106 &nonce,
@@ -104,6 +111,7 @@
104111
105112fn authorize_url(
106113 config: &OAuthConfig,
114 redirect_uri: &str,
107115 state_token: &str,
108116 verifier: &str,
109117 nonce: &str,
@@ -112,8 +120,11 @@
112120 let mut serializer = form_urlencoded::Serializer::new(String::new());
113121 serializer.append_pair("response_type", "code");
114122 serializer.append_pair("client_id", &config.client_id);
115 serializer.append_pair("redirect_uri", &config.redirect_uri);
116 serializer.append_pair("scope", "openid profile email");
123 serializer.append_pair("redirect_uri", redirect_uri);
124 // `event` 是 KenEvent 实时通道要求的 scope(KenEvent 用 gRPC
125 // IdentityService.Authenticate 校验时 required_scopes=[Event]);
126 // 少要这一个 scope,前端 socket 会被 KenOAuth 判成无权限。
127 serializer.append_pair("scope", "openid profile email event");
117128 serializer.append_pair("state", state_token);
118129 serializer.append_pair("nonce", nonce);
119130 serializer.append_pair("code_challenge_method", "S256");
Modified lib/auth/src/oauth/mod.rs View lib/auth/src/oauth/mod.rs
@@ -3,6 +3,8 @@
33use axum::response::{IntoResponse, Redirect, Response};
44use serde::{Deserialize, Serialize};
55
6use config::oauth::OAuthConfig;
7
68use crate::errors::AuthError;
79use crate::services::AuthService;
810
@@ -22,6 +24,12 @@
2224 pub redirect: String,
2325 pub verifier: String,
2426 pub nonce: String,
27 /// 本次握手实际发给 KenOAuth 的 redirect_uri。
28 ///
29 /// 换码时必须原样回传(OAuth 要求两者完全一致),所以随 state 一起存。
30 /// `#[serde(default)]` 保证旧 state 仍能解析。
31 #[serde(default)]
32 pub redirect_uri: Option<String>,
2533}
2634
2735#[derive(Debug, Deserialize)]
@@ -75,12 +83,13 @@
7583 service: &AuthService,
7684 code: &str,
7785 verifier: &str,
86 redirect_uri: &str,
7887) -> Result<TokenResponse, AuthError> {
7988 let config = &service.config().oauth;
8089 let form = [
8190 ("grant_type", "authorization_code"),
8291 ("code", code),
83 ("redirect_uri", config.redirect_uri.as_str()),
92 ("redirect_uri", redirect_uri),
8493 ("client_id", config.client_id.as_str()),
8594 ("code_verifier", verifier),
8695 ];
@@ -129,4 +138,37 @@
129138 .json::<TokenResponse>()
130139 .await
131140 .map_err(|error| AuthError::Internal(format!("invalid token response: {error}")))
132}
No newline at end of file
141}
142/// 本次请求应该用哪个 `redirect_uri`。
143///
144/// 取 `x-forwarded-host`(vite / 网关都会带),退化为 `host`;
145/// 只有命中 `oauth.redirect_uri_hosts` 白名单才按该主机改写,路径沿用配置里的。
146pub fn resolve_redirect_uri(config: &OAuthConfig, headers: &HeaderMap) -> String {
147 let host = headers
148 .get("x-forwarded-host")
149 .and_then(|value| value.to_str().ok())
150 .or_else(|| {
151 headers
152 .get(axum::http::header::HOST)
153 .and_then(|value| value.to_str().ok())
154 })
155 // 代理链可能给逗号分隔的列表,取第一跳。
156 .map(|value| value.split(',').next().unwrap_or(value).trim().to_ascii_lowercase());
157
158 let Some(host) = host else {
159 return config.redirect_uri.clone();
160 };
161 if host.is_empty()
162 || !config
163 .redirect_uri_hosts
164 .iter()
165 .any(|allowed| allowed.trim().eq_ignore_ascii_case(&host))
166 {
167 return config.redirect_uri.clone();
168 }
169
170 let path = url::Url::parse(&config.redirect_uri)
171 .map(|url| url.path().to_string())
172 .unwrap_or_else(|_| "/api/v1/auth/callback".to_string());
173 format!("http://{host}{path}")
174}
Added lib/chat/src/handlers/connection.rs View lib/chat/src/handlers/connection.rs
@@ -0,0 +1,43 @@
1use axum::extract::{Json, State};
2use export::rest::{AppResponseData, NoData};
3use serde::Deserialize;
4use utoipa::ToSchema;
5
6use crate::errors::ApiError;
7use crate::services::service::ChatService;
8
9#[derive(Debug, Clone, Deserialize, ToSchema)]
10pub struct ConnectionCmd {
11 pub user_id: i64,
12 pub session_id: String,
13}
14
15pub async fn open(
16 State(service): State<ChatService>,
17 Json(cmd): Json<ConnectionCmd>,
18) -> Result<AppResponseData<NoData>, ApiError> {
19 service
20 .connection_open(cmd.user_id, &cmd.session_id)
21 .await?;
22 Ok(AppResponseData::empty())
23}
24
25pub async fn heartbeat(
26 State(service): State<ChatService>,
27 Json(cmd): Json<ConnectionCmd>,
28) -> Result<AppResponseData<NoData>, ApiError> {
29 service
30 .connection_touch(cmd.user_id, &cmd.session_id)
31 .await?;
32 Ok(AppResponseData::empty())
33}
34
35pub async fn close(
36 State(service): State<ChatService>,
37 Json(cmd): Json<ConnectionCmd>,
38) -> Result<AppResponseData<NoData>, ApiError> {
39 service
40 .connection_close(cmd.user_id, &cmd.session_id)
41 .await?;
42 Ok(AppResponseData::empty())
43}
Modified lib/chat/src/handlers/mod.rs View lib/chat/src/handlers/mod.rs
@@ -1,5 +1,6 @@
11pub mod attachments;
22pub mod bookmarks;
3pub mod connection;
34pub mod dms;
45pub mod drafts;
56pub mod emojis;
Modified lib/chat/src/services/presence.rs View lib/chat/src/services/presence.rs
@@ -491,3 +491,51 @@
491491 Ok(presences)
492492 }
493493}
494
495impl ChatService {
496 /// 聊天连接建立:置在线并把这条连接登记进在线占位集合。
497 ///
498 /// 占位集合与 `presence` 键是两套语义:`chat:ws:{uid}` 回答"有多少条
499 /// 连接还活着"(推送 worker 用它决定是否跳过系统通知),`chat:presence:{uid}`
500 /// 回答"用户此刻的状态是什么"。同一连接同时续期两者。
501 pub async fn connection_open(&self, uid: i64, session: &str) -> Result<(), ChatError> {
502 self.mark_online(uid).await;
503 let ttl = self.config().chat.online_ttl_secs();
504 export::redis::zset_touch(
505 &self.state.redis,
506 &export::push::online_key(uid),
507 session,
508 ttl,
509 )
510 .await?;
511 Ok(())
512 }
513
514 /// 心跳续期:两套状态一起续,避免"有连接但状态过期"的中间态。
515 pub async fn connection_touch(&self, uid: i64, session: &str) -> Result<(), ChatError> {
516 self.touch_presence(uid).await;
517 let ttl = self.config().chat.online_ttl_secs();
518 export::redis::zset_touch(
519 &self.state.redis,
520 &export::push::online_key(uid),
521 session,
522 ttl,
523 )
524 .await?;
525 Ok(())
526 }
527
528 /// 连接关闭:注销该连接占位;只有最后一条连接消失才算离线。
529 ///
530 /// 多标签页场景下先关的那一页不能把用户判成离线——这是把
531 /// `mark_offline` 放在 `zset_release` 返回值之后的原因。
532 pub async fn connection_close(&self, uid: i64, session: &str) -> Result<(), ChatError> {
533 let live =
534 export::redis::zset_release(&self.state.redis, &export::push::online_key(uid), session)
535 .await?;
536 if live == 0 {
537 self.mark_offline(uid).await;
538 }
539 Ok(())
540 }
541}
Modified lib/chat/src/services/service.rs View lib/chat/src/services/service.rs
@@ -20,12 +20,7 @@
2020
2121impl ChatService {
2222 pub fn new(state: AppState) -> Self {
23 let realtime = Realtime::new(
24 state.redis.clone(),
25 state.database.write().clone(),
26 state.events_enabled,
27 )
28 .expect("chat realtime client");
23 let realtime = Realtime::new(state.database.write().clone(), state.events_enabled);
2924 Self { state, realtime }
3025 }
3126
@@ -41,10 +36,7 @@
4136 &self.state.storage
4237 }
4338
44 pub async fn load_user_briefs(
45 &self,
46 uids: &[i64],
47 ) -> Result<HashMap<i64, UserDto>, ChatError> {
39 pub async fn load_user_briefs(&self, uids: &[i64]) -> Result<HashMap<i64, UserDto>, ChatError> {
4840 if uids.is_empty() {
4941 return Ok(HashMap::new());
5042 }
Modified lib/chat/src/share/events.rs View lib/chat/src/share/events.rs
@@ -1,5 +1,6 @@
11use sea_orm::DatabaseConnection;
22use serde_json::Value;
3use uuid::Uuid;
34
45use crate::errors::ChatError;
56use crate::share::realtime::RoomEvent;
@@ -25,17 +26,43 @@
2526 }
2627}
2728
28pub fn user_kind(event: &Value) -> &'static str {
29pub fn user_kind(event: &Value) -> Option<&'static str> {
2930 match event.get("kind").and_then(Value::as_str) {
30 Some("mention") => "chat.mention.create",
31 Some("reminder") => "chat.reminder.create",
32 Some("room_added") => "chat.membership.create",
33 Some("read_marker") => "chat.read_state.update",
34 Some("presence") => "chat.presence.update",
35 _ => "chat.notification.create",
31 Some("mention") => Some("chat.mention.create"),
32 Some("reminder") => Some("chat.reminder.create"),
33 Some("room_added") => Some("chat.membership.create"),
34 Some("read_marker") => Some("chat.read_state.update"),
35 Some("presence") => Some("chat.presence.update"),
36 _ => None,
3637 }
3738}
3839
40/// 房间事件指向的具体消息;`None` 表示事件只描述房间级的状态变化。
41fn room_message_id(event: &RoomEvent) -> Option<Uuid> {
42 let from_value = |message: &Value| -> Option<Uuid> {
43 message
44 .get("id")
45 .and_then(Value::as_str)
46 .and_then(|id| Uuid::parse_str(id).ok())
47 };
48 match event {
49 RoomEvent::MessagePublished { message, .. } | RoomEvent::MessageEdited { message, .. } => {
50 from_value(message)
51 }
52 RoomEvent::MessageDeleted { message_id, .. }
53 | RoomEvent::MessagePinned { message_id, .. }
54 | RoomEvent::ReactionUpdated { message_id, .. } => Some(*message_id),
55 _ => None,
56 }
57}
58
59fn room_thread_id(event: &RoomEvent) -> Option<Uuid> {
60 match event {
61 RoomEvent::ThreadUpdated { thread_id, .. } => Some(*thread_id),
62 _ => None,
63 }
64}
65
3966fn room_actor(event: &RoomEvent) -> Option<i64> {
4067 match event {
4168 RoomEvent::Typing { user_id, .. }
@@ -62,6 +89,12 @@
6289 if let Some(actor) = room_actor(event) {
6390 labels.insert("user_id", actor);
6491 }
92 if let Some(message_id) = room_message_id(event) {
93 labels.insert("message_id", message_id);
94 }
95 if let Some(thread_id) = room_thread_id(event) {
96 labels.insert("thread_id", thread_id);
97 }
6598 if let Some(request_id) = export::events::request_id() {
6699 labels.insert("request_id", request_id);
67100 }
@@ -87,6 +120,14 @@
87120 if !enabled {
88121 return Ok(());
89122 }
123 let Some(kind) = user_kind(event) else {
124 let unknown = event
125 .get("kind")
126 .and_then(Value::as_str)
127 .unwrap_or_default();
128 tracing::warn!(kind = %unknown, %uid, "unknown chat user event kind; skipping the outbox write");
129 return Ok(());
130 };
90131
91132 let mut labels = queue::Labels::new().with("user_id", uid);
92133 if let Some(room_id) = event.get("room_id").and_then(Value::as_str) {
@@ -96,14 +137,6 @@
96137 labels.insert("request_id", request_id);
97138 }
98139
99 export::events::enqueue(
100 db,
101 enabled,
102 user_kind(event),
103 labels,
104 event,
105 format!("chat:user:{uid}"),
106 )
107 .await?;
140 export::events::enqueue(db, enabled, kind, labels, event, format!("chat:user:{uid}")).await?;
108141 Ok(())
109142}
Modified lib/chat/src/share/realtime.rs View lib/chat/src/share/realtime.rs
@@ -1,21 +1,6 @@
1use std::collections::HashMap;
2use std::sync::Arc;
3
41use serde::{Deserialize, Serialize};
5use tokio::sync::{Mutex, broadcast};
6use tokio_util::sync::CancellationToken;
72use uuid::Uuid;
83
9use crate::errors::ChatError;
10
11pub fn room_channel(room_id: Uuid) -> String {
12 format!("chat:room:{room_id}")
13}
14
15pub fn user_channel(uid: i64) -> String {
16 export::notify::user_channel(uid)
17}
18
194#[derive(Debug, Clone, Serialize, Deserialize)]
205#[serde(tag = "kind", rename_all = "snake_case")]
216pub enum RoomEvent {
@@ -124,186 +109,35 @@
124109 }
125110}
126111
127struct ChannelEntry {
128 tx: broadcast::Sender<String>,
129 cancel: CancellationToken,
130}
131
132struct Inner {
133 redis: export::AppRedis,
134 pubsub_client: redis::Client,
135 db: sea_orm::DatabaseConnection,
136 events_enabled: bool,
137 rooms: Mutex<HashMap<Uuid, ChannelEntry>>,
138 users: Mutex<HashMap<i64, ChannelEntry>>,
139}
140
112/// 房间/用户事件的唯一出口:写 `event_outbox`,由 outbox relay 投递给 KenEvent。
113///
114/// 这里不再维护进程内的订阅表,也不再向 Redis 发 `PUBLISH`——实时通道归
115/// KenEvent(MQ → socket),本服务只负责把"发生了什么"持久化下来。
141116#[derive(Clone)]
142117pub struct Realtime {
143 inner: Arc<Inner>,
118 db: sea_orm::DatabaseConnection,
119 events_enabled: bool,
144120}
145121
146122impl Realtime {
147 pub fn new(
148 redis: export::AppRedis,
149 db: sea_orm::DatabaseConnection,
150 events_enabled: bool,
151 ) -> Result<Self, ChatError> {
152 let url = redis.config().url().to_string();
153 let first = url
154 .split(',')
155 .map(str::trim)
156 .find(|s| !s.is_empty())
157 .ok_or_else(|| ChatError::Internal("empty redis url".to_string()))?;
158 let pubsub_client = redis::Client::open(first)?;
159 Ok(Self {
160 inner: Arc::new(Inner {
161 redis,
162 pubsub_client,
163 db,
164 events_enabled,
165 rooms: Mutex::new(HashMap::new()),
166 users: Mutex::new(HashMap::new()),
167 }),
168 })
123 pub fn new(db: sea_orm::DatabaseConnection, events_enabled: bool) -> Self {
124 Self { db, events_enabled }
169125 }
170126
171127 pub async fn publish_room(&self, event: &RoomEvent) {
172128 if let Err(error) =
173 crate::share::events::enqueue_room(&self.inner.db, self.inner.events_enabled, event)
174 .await
129 crate::share::events::enqueue_room(&self.db, self.events_enabled, event).await
175130 {
176131 tracing::warn!(%error, room_id = %event.room_id(), "chat room event enqueue failed");
177132 }
178 self.publish(&room_channel(event.room_id()), event).await;
179133 }
180134
181135 pub async fn publish_user<T: Serialize>(&self, uid: i64, event: &T) {
182136 if let Ok(value) = serde_json::to_value(event)
183 && let Err(error) = crate::share::events::enqueue_user(
184 &self.inner.db,
185 self.inner.events_enabled,
186 uid,
187 &value,
188 )
189 .await
137 && let Err(error) =
138 crate::share::events::enqueue_user(&self.db, self.events_enabled, uid, &value).await
190139 {
191140 tracing::warn!(%error, %uid, "chat user event enqueue failed");
192141 }
193 self.publish(&user_channel(uid), event).await;
194 }
195
196 async fn publish<T: Serialize>(&self, channel: &str, event: &T) {
197 let payload = match serde_json::to_vec(event) {
198 Ok(bytes) => bytes,
199 Err(error) => {
200 tracing::warn!(%error, %channel, "chat realtime serialize failed");
201 return;
202 }
203 };
204 let mut conn = self.inner.redis.connection_clone();
205 let result: Result<i64, redis::RedisError> = match &mut conn {
206 export::RedisConnection::Standalone(c) => {
207 redis::cmd("PUBLISH")
208 .arg(channel)
209 .arg(payload.as_slice())
210 .query_async(c)
211 .await
212 }
213 export::RedisConnection::Cluster(c) => {
214 redis::cmd("PUBLISH")
215 .arg(channel)
216 .arg(payload.as_slice())
217 .query_async(c)
218 .await
219 }
220 };
221 if let Err(error) = result {
222 tracing::warn!(%error, %channel, "chat realtime publish failed");
223 }
224 }
225
226 pub async fn subscribe_room(
227 &self,
228 room_id: Uuid,
229 ) -> Result<broadcast::Receiver<String>, ChatError> {
230 let channel = room_channel(room_id);
231 let mut map = self.inner.rooms.lock().await;
232 if let Some(entry) = map.get(&room_id) {
233 return Ok(entry.tx.subscribe());
234 }
235 let (tx, cancel) = spawn_pump(&self.inner.pubsub_client, &channel).await?;
236 let rx = tx.subscribe();
237 map.insert(room_id, ChannelEntry { tx, cancel });
238 Ok(rx)
239 }
240
241 pub async fn unsubscribe_room(&self, room_id: Uuid) {
242 let mut map = self.inner.rooms.lock().await;
243 let remove = map
244 .get(&room_id)
245 .is_some_and(|entry| entry.tx.receiver_count() == 0);
246 if remove && let Some(entry) = map.remove(&room_id) {
247 entry.cancel.cancel();
248 }
249 }
250
251 pub async fn subscribe_user(
252 &self,
253 uid: i64,
254 ) -> Result<broadcast::Receiver<String>, ChatError> {
255 let channel = user_channel(uid);
256 let mut map = self.inner.users.lock().await;
257 if let Some(entry) = map.get(&uid) {
258 return Ok(entry.tx.subscribe());
259 }
260 let (tx, cancel) = spawn_pump(&self.inner.pubsub_client, &channel).await?;
261 let rx = tx.subscribe();
262 map.insert(uid, ChannelEntry { tx, cancel });
263 Ok(rx)
264 }
265
266 pub async fn unsubscribe_user(&self, uid: i64) {
267 let mut map = self.inner.users.lock().await;
268 let remove = map
269 .get(&uid)
270 .is_some_and(|entry| entry.tx.receiver_count() == 0);
271 if remove && let Some(entry) = map.remove(&uid) {
272 entry.cancel.cancel();
273 }
274142 }
275}
276
277async fn spawn_pump(
278 client: &redis::Client,
279 channel: &str,
280) -> Result<(broadcast::Sender<String>, CancellationToken), ChatError> {
281 let mut pubsub = client.get_async_pubsub().await?;
282 pubsub.subscribe(channel).await?;
283 let (tx, _) = broadcast::channel::<String>(4096);
284 let cancel = CancellationToken::new();
285 let pump_tx = tx.clone();
286 let pump_cancel = cancel.clone();
287 let channel_name = channel.to_string();
288 tokio::spawn(async move {
289 use futures::StreamExt;
290 let mut stream = pubsub.on_message();
291 loop {
292 tokio::select! {
293 _ = pump_cancel.cancelled() => break,
294 msg = stream.next() => {
295 let Some(msg) = msg else { break };
296 let payload: Result<String, _> = msg.get_payload();
297 if let Ok(payload) = payload
298 && pump_tx.send(payload).is_err()
299 && pump_tx.receiver_count() == 0
300 {
301 tracing::debug!(channel = %channel_name, "chat realtime channel idle");
302 }
303 }
304 }
305 }
306 tracing::debug!(channel = %channel_name, "chat realtime pump exited");
307 });
308 Ok((tx, cancel))
309143}
Modified lib/chat/src/routes.rs View lib/chat/src/routes.rs
@@ -293,3 +293,20 @@
293293
294294 guarded.merge(open).merge(space_room_routes)
295295}
296
297/// 内部路由:只由服务间调用(`x-gitdata-service-key` 校验),不暴露给浏览器。
298///
299/// 连接生命周期是聊天在线态与推送"在线免打扰"的唯一写入点:事件通道由
300/// KenEvent 负责,只有它知道某条 socket 何时建立、续期、断开。
301pub fn internal_router(_service: ChatService) -> Router<ChatService> {
302 Router::new()
303 .route("/chat/connections", post(crate::handlers::connection::open))
304 .route(
305 "/chat/connections/heartbeat",
306 post(crate::handlers::connection::heartbeat),
307 )
308 .route(
309 "/chat/connections/close",
310 post(crate::handlers::connection::close),
311 )
312}
Modified lib/config/src/chat.rs View lib/config/src/chat.rs
@@ -1,24 +1,16 @@
11use serde::Deserialize;
22
3/// 聊天连接的心跳周期。存活的在线占位与 presence 状态都按它的两倍续期,
4/// 由 `online_ttl_secs()` 派生。
35#[derive(Clone, Debug, Deserialize)]
46#[serde(default)]
57pub struct ChatWsConfig {
6 pub ticket_ttl_secs: u64,
78 pub heartbeat_secs: u64,
8 pub max_subscriptions: usize,
9 pub max_frame_bytes: usize,
10 pub history_limit: u64,
119}
1210
1311impl Default for ChatWsConfig {
1412 fn default() -> Self {
15 Self {
16 ticket_ttl_secs: 60,
17 heartbeat_secs: 30,
18 max_subscriptions: 64,
19 max_frame_bytes: 262_144,
20 history_limit: 200,
21 }
13 Self { heartbeat_secs: 30 }
2214 }
2315}
2416
@@ -103,8 +95,8 @@
10395 if self.max_message_len == 0 {
10496 return Err("chat max_message_len must be positive".to_string());
10597 }
106 if self.ws.ticket_ttl_secs == 0 {
107 return Err("chat ws ticket_ttl_secs must be positive".to_string());
98 if self.ws.heartbeat_secs == 0 {
99 return Err("chat ws heartbeat_secs must be positive".to_string());
108100 }
109101 Ok(())
110102 }
Modified lib/config/src/oauth.rs View lib/config/src/oauth.rs
@@ -17,6 +17,13 @@
1717 pub client_id: String,
1818 pub client_secret: String,
1919 pub redirect_uri: String,
20 /// 允许按请求 Host 切换回调地址的白名单(`host:port`)。
21 ///
22 /// 本机同时存在两套前端:vite dev(5173)和网关托管的构建产物(7041)。
23 /// 命中这里的主机时,`redirect_uri` 会换成 `http://<该主机>/api/v1/auth/callback`,
24 /// 于是两套前端都能各自登录;未命中时回退到 `redirect_uri`。
25 #[serde(default)]
26 pub redirect_uri_hosts: Vec<String>,
2027 pub token_endpoint: String,
2128 pub jwks_url: String,
2229 pub audience: String,
@@ -46,6 +53,7 @@
4653 client_id: "gitdata-web".to_string(),
4754 client_secret: String::new(),
4855 redirect_uri: String::new(),
56 redirect_uri_hosts: Vec::new(),
4957 token_endpoint: String::new(),
5058 jwks_url: String::new(),
5159 audience: "gitdata.ai".to_string(),
Modified lib/export/src/events.rs View lib/export/src/events.rs
@@ -1,20 +1,15 @@
11use models::system::event_outbox;
22use queue::{Envelope, Labels};
3use sea_orm::{ActiveModelTrait, ConnectionTrait, DbErr, Set};
3use sea_orm::{ActiveModelTrait, ConnectionTrait, DbErr, EntityTrait, Set};
44use serde::Serialize;
5use uuid::Uuid;
56
6pub async fn enqueue<T: Serialize>(
7 db: &impl ConnectionTrait,
8 enabled: bool,
7fn active_model<T: Serialize>(
98 kind: &str,
109 labels: Labels,
11 payload: T,
12 ordering_key: impl Into<String>,
13) -> Result<(), DbErr> {
14 if !enabled {
15 return Ok(());
16 }
17
10 payload: &T,
11 ordering_key: &str,
12) -> Result<event_outbox::ActiveModel, DbErr> {
1813 let source = kind.split('.').next().unwrap_or_default().to_string();
1914 let envelope = Envelope::new(kind, payload)
2015 .with_source(source)
@@ -25,22 +20,103 @@
2520 let stored = serde_json::to_value(&envelope)
2621 .map_err(|error| DbErr::Custom(format!("encoding outbox event: {error}")))?;
2722
28 event_outbox::ActiveModel {
23 Ok(event_outbox::ActiveModel {
2924 id: Set(envelope.id),
3025 subject: Set(subject),
3126 kind: Set(kind.to_string()),
32 ordering_key: Set(ordering_key.into()),
27 ordering_key: Set(ordering_key.to_string()),
3328 envelope: Set(stored),
3429 occurred_at: Set(envelope.occurred_at),
3530 available_at: Set(envelope.occurred_at),
3631 ..Default::default()
32 })
33}
34
35pub async fn enqueue<T: Serialize>(
36 db: &impl ConnectionTrait,
37 enabled: bool,
38 kind: &str,
39 labels: Labels,
40 payload: T,
41 ordering_key: impl Into<String>,
42) -> Result<(), DbErr> {
43 if !enabled {
44 return Ok(());
45 }
46
47 active_model(kind, labels, &payload, &ordering_key.into())?
48 .insert(db)
49 .await?;
50
51 Ok(())
52}
53
54pub fn request_id() -> Option<String> {
55 crate::middleware::request_id()
56}
57
58fn room_labels(room_id: Uuid) -> Labels {
59 let mut labels = Labels::new().with("room_id", room_id);
60 if let Some(request_id) = request_id() {
61 labels.insert("request_id", request_id);
62 }
63 labels
64}
65
66/// 房间级事件的统一入队口:标签固定 `room_id`,ordering key 为 `chat:room:{room_id}`。
67///
68/// 供不在 `chat` crate 里的写入点(例如 `space` 的频道删除)复用,
69/// 避免它们为了一个标签类型去直接依赖 `queue`。
70pub async fn enqueue_room<T: Serialize>(
71 db: &impl ConnectionTrait,
72 enabled: bool,
73 kind: &str,
74 room_id: Uuid,
75 payload: T,
76) -> Result<(), DbErr> {
77 if !enabled {
78 return Ok(());
3779 }
80
81 active_model(
82 kind,
83 room_labels(room_id),
84 &payload,
85 &format!("chat:room:{room_id}"),
86 )?
3887 .insert(db)
3988 .await?;
4089
4190 Ok(())
4291}
4392
44pub fn request_id() -> Option<String> {
45 crate::middleware::request_id()
93/// 房间变化扇出给一批收件人:每条一个 `ordering_key`,一次批量写入 outbox。
94///
95/// 用批量而不是逐条 `enqueue`,是因为公开频道创建时收件人等于整个空间的
96/// 活跃成员数——逐条写入会变成 N 次往返。
97pub async fn enqueue_chat_users<T: Serialize>(
98 db: &impl ConnectionTrait,
99 enabled: bool,
100 kind: &str,
101 uids: &[i64],
102 room_id: Uuid,
103 payload: &T,
104) -> Result<(), DbErr> {
105 if !enabled || uids.is_empty() {
106 return Ok(());
107 }
108
109 let mut models = Vec::with_capacity(uids.len());
110 for uid in uids {
111 let labels = room_labels(room_id).with("user_id", *uid);
112 models.push(active_model(
113 kind,
114 labels,
115 payload,
116 &format!("chat:user:{uid}"),
117 )?);
118 }
119
120 event_outbox::Entity::insert_many(models).exec(db).await?;
121 Ok(())
46122}
Modified lib/queue/src/envelope.rs View lib/queue/src/envelope.rs
@@ -169,7 +169,7 @@
169169 properties.push(("room_id".to_string(), room));
170170 }
171171
172 for key in ["issue_id", "pull_id"] {
172 for key in ["issue_id", "pull_id", "message_id", "thread_id"] {
173173 let value = self
174174 .labels
175175 .get(key)
Modified lib/space/src/services/rooms.rs View lib/space/src/services/rooms.rs
@@ -230,6 +230,20 @@
230230 })
231231 .await?;
232232
233 export::events::enqueue_chat_users(
234 self.db().write(),
235 self.state.events_enabled,
236 "chat.membership.create",
237 &member_uids
238 .iter()
239 .copied()
240 .filter(|uid| *uid != caller)
241 .collect::<Vec<_>>(),
242 room_id,
243 &serde_json::json!({ "kind": "room_added", "room_id": room_id }),
244 )
245 .await?;
246
233247 Ok(RoomDto::from(inserted))
234248 }
235249
@@ -262,6 +276,15 @@
262276 am.updated_at = Set(now);
263277 am.update(self.db().write()).await?;
264278
279 export::events::enqueue_room(
280 self.db().write(),
281 self.state.events_enabled,
282 "chat.room.update",
283 room_id,
284 serde_json::json!({ "room_id": room_id, "deleted": true }),
285 )
286 .await?;
287
265288 Ok(())
266289 }
267290}
Modified lib/webapi/src/lib.rs View lib/webapi/src/lib.rs
@@ -1,6 +1,5 @@
11pub mod cleanup;
22pub mod openapi;
3pub mod spa;
43
54use axum::Router;
65use axum::extract::{Request, State};
@@ -103,7 +102,7 @@
103102
104103 let public: Router = Router::new()
105104 .merge(auth::routes::router(auth.clone()).with_state(auth.clone()))
106 .merge(chat::routes::router(chat.clone(), space.clone()).with_state(chat))
105 .merge(chat::routes::router(chat.clone(), space.clone()).with_state(chat.clone()))
107106 .merge(issues::routes::router(issues.clone()).with_state(issues))
108107 .merge(space::routes::router(space.clone()).with_state(space))
109108 .merge(user::routes::router(user.clone()).with_state(user))
@@ -112,6 +111,7 @@
112111 .merge(cleanup_router);
113112 let internal: Router = Router::new()
114113 .merge(git::routes::internal_router(git.clone()).with_state(git))
114 .merge(chat::routes::internal_router(chat.clone()).with_state(chat))
115115 .route_layer(axum::middleware::from_fn_with_state(
116116 internal_service_key,
117117 require_service_key,
@@ -122,7 +122,6 @@
122122 .nest("/api/v1/internal", internal)
123123 .nest("/pages", auth::routes::pages_router(auth.clone()))
124124 .merge(export::health::router(health_state))
125 .fallback(spa::fallback)
126125 .layer(cors)
127126 .layer(RequestBodyLimitLayer::new(MAX_REQUEST_BODY_BYTES))
128127 .layer(
Deleted lib/webapi/src/spa.rs
@@ -1,117 +0,0 @@
1use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri, header};
2use axum::response::{IntoResponse, Response};
3use std::collections::HashMap;
4use std::hash::{Hash, Hasher};
5use std::sync::OnceLock;
6
7const CACHE_IMMUTABLE: &str = "public, max-age=31536000, immutable";
8const CACHE_NO_CACHE: &str = "no-cache";
9const CACHE_REVALIDATE: &str = "public, max-age=3600, must-revalidate";
10/// Service worker path, mirrored from `web/public/sw.js`.
11const SERVICE_WORKER: &str = "sw.js";
12/// PWA manifest path, mirrored from `web/public/manifest.webmanifest`.
13const WEB_MANIFEST: &str = "manifest.webmanifest";
14
15fn etags() -> &'static HashMap<&'static str, HeaderValue> {
16 static ETAGS: OnceLock<HashMap<&'static str, HeaderValue>> = OnceLock::new();
17 ETAGS.get_or_init(|| {
18 let mut m = HashMap::new();
19 let index = web::index();
20 m.insert(index.path, etag_of(index.content));
21 for p in web::paths() {
22 if let Some(a) = web::file(p) {
23 m.insert(a.path, etag_of(a.content));
24 }
25 }
26 m
27 })
28}
29
30fn etag_of(content: &[u8]) -> HeaderValue {
31 let mut h = std::collections::hash_map::DefaultHasher::new();
32 content.hash(&mut h);
33 h.write_u64(content.len() as u64);
34 HeaderValue::from_maybe_shared(format!("W/\"{:016x}\"", h.finish()).into_bytes())
35 .expect("valid etag header")
36}
37
38fn cache_control(path: &str) -> &'static str {
39 if path.starts_with("assets/") {
40 CACHE_IMMUTABLE
41 } else if path == "index.html" || path == SERVICE_WORKER || path == WEB_MANIFEST {
42 // The service worker is the push receiver: it must never be served from
43 // an hour-long cache, or a fixed push handler keeps running old logic.
44 // The manifest is what makes iOS treat the site as installable, so it
45 // rides along on the same rule.
46 CACHE_NO_CACHE
47 } else {
48 CACHE_REVALIDATE
49 }
50}
51
52pub async fn fallback(method: Method, uri: Uri, headers: HeaderMap) -> Response {
53 if !matches!(method, Method::GET | Method::HEAD) {
54 return StatusCode::METHOD_NOT_ALLOWED.into_response();
55 }
56
57 let path = uri.path();
58 if path.starts_with("/api") {
59 return StatusCode::NOT_FOUND.into_response();
60 }
61
62 let asset = match web::file(path) {
63 Some(asset) => asset,
64 None => {
65 if path.rsplit('/').next().is_some_and(|s| s.contains('.')) {
66 return StatusCode::NOT_FOUND.into_response();
67 }
68 web::index()
69 }
70 };
71
72 serve(asset, &headers)
73}
74
75fn serve(asset: web::Asset, headers: &HeaderMap) -> Response {
76 let etag = etags()
77 .get(asset.path)
78 .cloned()
79 .unwrap_or_else(|| etag_of(asset.content));
80 let cache_control = HeaderValue::from_static(cache_control(asset.path));
81
82 if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH)
83 && etag_matches(if_none_match, &etag)
84 {
85 return (
86 StatusCode::NOT_MODIFIED,
87 [(header::CACHE_CONTROL, cache_control), (header::ETAG, etag)],
88 )
89 .into_response();
90 }
91
92 (
93 [
94 (header::CONTENT_TYPE, HeaderValue::from_static(asset.mime)),
95 (header::CACHE_CONTROL, cache_control),
96 (header::ETAG, etag),
97 ],
98 asset.content,
99 )
100 .into_response()
101}
102
103fn etag_matches(if_none_match: &HeaderValue, etag: &HeaderValue) -> bool {
104 let Ok(value) = if_none_match.to_str() else {
105 return false;
106 };
107 let Ok(target) = etag.to_str() else {
108 return false;
109 };
110 fn strong(s: &str) -> &str {
111 s.strip_prefix("W/").unwrap_or(s)
112 }
113 value
114 .split(',')
115 .map(str::trim)
116 .any(|c| c == "*" || strong(c) == strong(target))
117}
Modified lib/webapi/Cargo.toml View lib/webapi/Cargo.toml
@@ -21,7 +21,6 @@
2121tower-http = { workspace = true }
2222tracing = { workspace = true }
2323utoipa = { workspace = true }
24web = { workspace = true }
2524serde = { workspace = true, features = ["derive"] }
2625serde_json = { workspace = true }
2726uuid = { workspace = true }
Modified scripts/dev.ps1 View scripts/dev.ps1
@@ -28,18 +28,15 @@
2828$Services = [ordered]@{
2929 migrate = @{ Exe = 'migrate'; Port = 0; Health = $false }
3030 apihost = @{ Exe = 'apihost'; Port = 4081; Health = $true }
31 web = @{ Bun = 'web'; Port = 5173; Health = $false }
3231}
33$Order = @('migrate', 'apihost', 'web')
32$Order = @('migrate', 'apihost')
3433$Backends = @('migrate', 'apihost')
3534$Aliases = @{
3635 api = 'apihost'
37 ui = 'web'
3836}
3937
4038$Urls = [ordered]@{
4139 apihost = 'http://127.0.0.1:4081 (Swagger: /api/docs)'
42 web = 'http://127.0.0.1:5173'
4340}
4441
4542$Infra = @(
@@ -204,17 +201,9 @@
204201 $out = Join-Path $LogDir "$Name.log"
205202 $err = Join-Path $LogDir "$Name.err.log"
206203
207 if ($svc.ContainsKey('Bun')) {
208 $exe = (Get-Command bun.exe -ErrorAction SilentlyContinue).Source
209 if (-not $exe) { throw '未找到 bun.exe,请先安装 bun 并加入 PATH' }
210 $dir = Join-Path $Root $svc.Bun
211 if (-not (Test-Path -LiteralPath $dir)) { throw "缺少目录 $dir" }
212 $procId = Start-Daemon -Name $Name -Exe $exe -Arguments 'run dev' -WorkingDirectory $dir -Out $out -Err $err
213 } else {
214 $exe = Join-Path $BinDir ($svc.Exe + '.exe')
215 if (-not (Test-Path -LiteralPath $exe)) { throw "缺少 $exe,请先执行 just build" }
216 $procId = Start-Daemon -Name $Name -Exe $exe -Arguments '' -WorkingDirectory $Root -Out $out -Err $err
217 }
204 $exe = Join-Path $BinDir ($svc.Exe + '.exe')
205 if (-not (Test-Path -LiteralPath $exe)) { throw "缺少 $exe,请先执行 just build" }
206 $procId = Start-Daemon -Name $Name -Exe $exe -Arguments '' -WorkingDirectory $Root -Out $out -Err $err
218207
219208 Register-ServiceProcess $Name $procId
220209 Write-Info "[$Name] 启动 pid=$procId"
@@ -280,12 +269,8 @@
280269 foreach ($n in $Backends) { if (-not $names.Contains($n)) { $names.Add($n) } }
281270 continue
282271 }
283 if ($key -eq 'frontends') {
284 foreach ($n in @('web')) { if (-not $names.Contains($n)) { $names.Add($n) } }
285 continue
286 }
287272 if (-not $Services.Contains($key)) {
288 throw "未知服务: $part;可用: $($Order -join ', ')(别名: api/git/chat/sync/hook/ui/backends/frontends)"
273 throw "未知服务: $part;可用: $($Order -join ', ')(别名: api/git/chat/sync/hook/backends)"
289274 }
290275 if (-not $names.Contains($key)) { $names.Add($key) }
291276 }
@@ -297,7 +282,7 @@
297282function Invoke-Check {
298283 $ok = $true
299284 Write-Info '== 工具链 =='
300 foreach ($tool in @('cargo', 'bun', 'ssh-keygen', 'wsl')) {
285 foreach ($tool in @('cargo', 'ssh-keygen', 'wsl')) {
301286 $found = Get-Command $tool -ErrorAction SilentlyContinue
302287 if ($found) { Write-Info (" {0,-12} OK" -f $tool) } else { Write-Err (" {0,-12} 缺失" -f $tool); $ok = $false }
303288 }
Modified scripts/dev.sh View scripts/dev.sh
@@ -26,7 +26,7 @@
2626DEV_HOST_KEY="$DEV_DIR/ssh_host_ed25519"
2727COMPOSE_FILE="$ROOT/docker-compose.dev.yml"
2828
29ORDER="apihost web"
29ORDER="apihost"
3030BACKENDS="apihost"
3131INFRA="postgresql:5432 redis:6379 s3:9000"
3232
@@ -52,7 +52,6 @@
5252port_of() {
5353 case "$1" in
5454 apihost) echo 4081 ;;
55 web) echo 5173 ;;
5655 *) echo 0 ;;
5756 esac
5857}
@@ -66,7 +65,6 @@
6665alias_of() {
6766 case "$1" in
6867 api) echo apihost ;;
69 ui) echo web ;;
7068 *) echo "$1" ;;
7169 esac
7270}
@@ -74,7 +72,6 @@
7472url_of() {
7573 case "$1" in
7674 apihost) echo "http://127.0.0.1:4081 (Swagger: /api/docs)" ;;
77 web) echo "http://127.0.0.1:5173" ;;
7875 esac
7976}
8077
@@ -193,7 +190,6 @@
193190 port="$(port_of "$name")"
194191 [ "$port" = "0" ] && return 0
195192 case "$name" in
196 web) port_open "$port" ;;
197193 *)
198194 if command -v curl >/dev/null 2>&1; then
199195 # --noproxy '*':有些开发机设置了 http_proxy,否则 localhost 也会被代理
@@ -231,7 +227,7 @@
231227 ensure_path
232228
233229 info '== 工具链 =='
234 for t in cargo bun ssh-keygen; do
230 for t in cargo ssh-keygen; do
235231 if command -v "$t" >/dev/null 2>&1; then
236232 printf ' %-12s OK\n' "$t"
237233 else
@@ -415,54 +411,6 @@
415411 printf '%s\n' "$pid"
416412}
417413
418# 前端依赖自检:
419# 1) node_modules 缺失 -> bun install
420# 2) 原生绑定与当前平台不匹配(跨平台切换工作区常见)-> 重装
421# 3) bun 本身不是本平台可执行文件(例如 WSL 里装的是 bun.exe)-> 明确报错
422ensure_frontend_deps() {
423 local dir="$1" name="$2"
424 ensure_path
425
426 if ! command -v bun >/dev/null 2>&1; then
427 fail " 未找到 bun,无法启动 $name(macOS: brew install oven-sh/bun/bun)"
428 return 1
429 fi
430
431 # bun 必须是本平台原生可执行文件;WSL 里指向 bun.exe 时无法跑 Linux 原生模块
432 local bun_path bun_kind
433 bun_path="$(command -v bun)"
434 bun_kind="$(file -b "$bun_path" 2>/dev/null || echo unknown)"
435 case "$bun_kind" in
436 *PE32*|*MS-DOS*)
437 if [ "$(uname -s)" != "Darwin" ]; then
438 fail " bun 指向 Windows 可执行文件($bun_path)"
439 info " 该环境无法运行 Linux/macOS 原生前端依赖,请安装本平台原生 bun:"
440 info " curl -fsSL https://bun.sh/install | bash"
441 return 1
442 fi
443 ;;
444 esac
445
446 if [ ! -d "$dir/node_modules" ] || [ ! -e "$dir/node_modules/.bin/vite" ]; then
447 info "[$name] 安装前端依赖 ..."
448 (cd "$dir" && bun install) || { fail " [$name] bun install 失败"; return 1; }
449 return 0
450 fi
451
452 # 冒烟测试本地 vite:原生绑定不匹配时会直接失败
453 if (cd "$dir" && run_with_timeout 60 ./node_modules/.bin/vite --version >/dev/null 2>&1); then
454 return 0
455 fi
456
457 warn "[$name] 本地 vite 无法运行(原生绑定与当前平台不匹配),重新安装依赖 ..."
458 (cd "$dir" && bun install) || { fail " [$name] bun install 失败"; return 1; }
459 if (cd "$dir" && run_with_timeout 60 ./node_modules/.bin/vite --version >/dev/null 2>&1); then
460 return 0
461 fi
462 fail " [$name] 依赖仍然不可用,见 just logs $name"
463 return 1
464}
465
466414start_one() {
467415 local name="$1" port pid dir cmd
468416 port="$(port_of "$name")"
@@ -481,13 +429,6 @@
481429 service_env || return 1
482430
483431 case "$name" in
484 web)
485 dir="$ROOT/web"
486 [ -d "$dir" ] || { fail " 缺少目录 $dir"; return 1; }
487 ensure_frontend_deps "$dir" "$name" || return 1
488 # --strictPort:端口被占用时直接失败,避免 vite 悄悄换到别的端口
489 cmd="bun run dev -- --strictPort --port $(port_of "$name")"
490 ;;
491432 *)
492433 [ -x "$BIN_DIR/$name" ] || { fail " 缺少 $BIN_DIR/$name,请先执行 just build"; return 1; }
493434 dir="$ROOT"
@@ -582,12 +523,11 @@
582523 case "$key" in
583524 all) out="$out $ORDER" ;;
584525 backends) out="$out $BACKENDS" ;;
585 frontends) out="$out web" ;;
586526 *)
587527 if ! contains "$key" $ORDER; then
588528 fail "未知服务: $part"
589529 info "可用: $ORDER"
590 info "别名: api git chat sync hook ui / backends / frontends"
530 info "别名: api git chat sync hook / backends"
591531 return 1
592532 fi
593533 out="$out $key"
Deleted web/public/file-icons/angular.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#E53935" d="M13,12L7.5,15,2,12,1,4,7.5,1h0L14,4ZM7.506,3H7.5L4,11H5.307l0.7-1.79H8.988L9.693,11H11Zm0,2.6L8.53,8.107H6.482ZM7.5,15h0Z" /></svg>
Deleted web/public/file-icons/ansible.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 512 512" preserveAspectRatio="xMidYMid meet"><path fill="#FF5370" d="M242.2801361,99.7152863L367.7326355,396.881134L178.7697449,254.1788025L242.2801361,99.7152863z M464.9584351,465.8799133L272.0751038,19.7394505c-10.9573669-26.1266441-49.0272217-26.5111885-60.3739777,0L0,508.2203369h72.1351776l83.896347-201.5082092l250.1209106,194.4512939C434.5034485,527.9057007,480.6419373,500.9562683,464.9584351,465.8799133z"/></svg>
Deleted web/public/file-icons/archive.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#C792EA" d="M10.5,1H3A1,1,0,0,0,2,2V14a1,1,0,0,0,1,1H13a1,1,0,0,0,1-1V4.5ZM13,14H3V2H6V3H7V2h3l3,3v9ZM7,4V3H8V4H7ZM6,4H7V5H6V4ZM7,6V5H8V6H7ZM6,6H7V7H6V6ZM7,8V7H8V8H7ZM6,9.28A2,2,0,0,0,5,11v1H9V11A2,2,0,0,0,7,9V8H6V9.28ZM8,10v1H6V10H8Z" /></svg>
Deleted web/public/file-icons/asciidoc.svg
@@ -1 +0,0 @@
1<svg version="1.1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="16px" height="16px"><g transform="matrix(.079038 0 0 .079038 .13689 .38864)" fill="#f44336" stroke-miterlimit="1.5" stroke-width="0"><path d="m164.74 23.425c-1.585-.01125-3.1996.29044-4.7608.93647-2.4094.99696-4.3208 2.6712-5.6202 4.7056-.53792.7509-1.0007 1.5743-1.3698 2.4662l-45.966 111.09h-44.325c-6.7585 0-12.2 5.4416-12.2 12.2 0 6.7585 5.4416 12.199 12.2 12.199h34.229l-.0055.014h26.406l.006-.014h26.424c6.7586 0 12.199-5.4401 12.199-12.199 0-6.7586-5.4401-12.2-12.199-12.2h-16.33l31.143-75.263 80.655 194.92c2.5841 6.245 9.6922 9.1916 15.937 6.6076 6.245-2.5841 9.1917-9.6922 6.6076-15.937l-91.853-221.98c-1.938-4.6837-6.4216-7.5125-11.176-7.544zm-121.06 165.21c-6.7585 0-12.199 5.4416-12.199 12.2 0 6.7586 5.4401 12.2 12.199 12.2h34.21l-16.752 40.487c-2.5841 6.2451.36407 13.35 6.609 15.934 6.2451 2.5841 13.35-.36112 15.934-6.6061l20.614-49.816h26.445c6.7585 0 12.199-5.4416 12.199-12.2 0-6.7585-5.4402-12.2-12.199-12.2z"/></g></svg>
Deleted web/public/file-icons/asm.svg
@@ -1 +0,0 @@
1<svg version="1.1" viewBox="0 0 24 24" width="16px" height="16px" xmlns="http://www.w3.org/2000/svg"><path d="m1.6992 1.5469v20.904h3.3828v-2.0879h-1.6914v-16.727h1.6914v-2.0898zm17.219 0v2.0898h1.6934v16.727h-1.6934v2.0898h3.3828v-20.906z" fill="#ff6e40"/><path d="m15.165 4.0077c-.4999 0-.9297.075595-1.2894.22496-.35969.14936-.65231.37249-.87788.67121-.22556.30177-.39017.67243-.49381 1.1083-.10364.43589-.15546.94859-.15546 1.5399 0 .57306.04938 1.0821.14997 1.5271.10059.44199.26458.81143.49015 1.1101.22252.29872.5127.52429.87239.6767.35968.15241.79496.23044 1.304.23044.51819 0 .95408-.0756 1.3077-.22496.35359-.15241.64317-.37554.86873-.67121.21947-.28958.38102-.66084.48466-1.112.10364-.45418.15546-.96688.15546-1.5399s-.05121-1.0784-.1518-1.5143c-.10059-.43894-.26214-.80777-.48466-1.1065-.22557-.30482-.51514-.53343-.86873-.68584-.35359-.15546-.79009-.2341-1.3113-.2341zm-6.8145.12071c-.012193.18289-.057916.3353-.13717.45723-.079253.12193-.19813.22008-.35664.29628-.13412.064012-.30177.10973-.50295.13717-.20118.024385-.4115.036578-.63097.036578v1.1101h1.4357v3.5993h-1.4357v1.1888h4.5448v-1.1888h-1.4046v-5.6367zm6.8145 1.1376c.37493 0 .64317.17618.80472.52673.16155.34749.24142.93274.24142 1.7558 0 .81386-.0823 1.3991-.2469 1.7558-.1646.35664-.4304.53404-.79923.53404s-.63646-.1774-.80106-.53404-.2469-.94067-.2469-1.7484c0-.82911.08169-1.4156.24325-1.7631.16155-.35054.42979-.52673.80472-.52673zm-6.3299 7.6394c-.4999 0-.9297.07377-1.2894.22313-.35969.14936-.65231.37432-.87788.67304-.22557.30177-.39017.6706-.49381 1.1065s-.15546.94859-.15546 1.5399c0 .57306.04938 1.0821.14997 1.5271.10059.44199.26458.81325.49015 1.112.22252.29872.51453.52429.87422.6767s.79314.22861 1.3022.22861c.51819 0 .95408-.0756 1.3077-.22496.35359-.15241.64317-.37553.86873-.67121.21947-.28958.38102-.66084.48466-1.112.10364-.45418.15546-.96688.15546-1.5399s-.04938-1.0784-.14997-1.5143c-.10059-.43894-.26397-.80777-.48649-1.1065-.22556-.30482-.51331-.53343-.8669-.68584-.35359-.15546-.79192-.23227-1.3132-.23227zm5.8452.11888c-.01219.18289-.05792.3353-.13717.45723-.07926.12193-.19813.22008-.35664.29628-.13412.06401-.30177.10973-.50295.13717-.20118.02438-.4115.03658-.63097.03658v1.112h1.4357v3.5975h-1.4357v1.1888h4.5448v-1.1888h-1.4046v-5.6367h-1.5125zm-5.8452 1.1376c.37493 0 .64317.17618.80472.52673.16155.34749.24142.93274.24142 1.7558 0 .81386-.080471 1.3991-.24507 1.7558-.1646.35664-.43223.53404-.80106.53404s-.63646-.1774-.80106-.53404-.24507-.93884-.24507-1.7466c0-.82911.079862-1.4174.24142-1.7649.16155-.35054.42979-.52673.80472-.52673z" fill="#ff6e40"/></svg>
Deleted web/public/file-icons/audio.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#D52A29" d="M7.462,3.409v9.181a0.535,0.535,0,0,1-.917.38l-2.8-2.81h-2.2A0.547,0.547,0,0,1,1,9.62V6.38a0.547,0.547,0,0,1,.538-0.54h2.2l2.8-2.81A0.538,0.538,0,0,1,7.462,3.409ZM10.692,8a2.157,2.157,0,0,1-.358,1.194,1.989,1.989,0,0,1-.946.789,0.466,0.466,0,0,1-.21.042A0.524,0.524,0,0,1,8.8,9.869a0.516,0.516,0,0,1-.16-0.384,0.455,0.455,0,0,1,.1-0.3,0.987,0.987,0,0,1,.244-0.211q0.143-.089.286-0.194a0.851,0.851,0,0,0,.244-0.3,1.2,1.2,0,0,0,0-.962,0.851,0.851,0,0,0-.244-0.3Q9.127,7.114,8.984,7.025A0.987,0.987,0,0,1,8.74,6.814a0.455,0.455,0,0,1-.1-0.3A0.516,0.516,0,0,1,8.8,6.131a0.524,0.524,0,0,1,.379-0.156,0.467,0.467,0,0,1,.21.042,1.934,1.934,0,0,1,.946.785A2.177,2.177,0,0,1,10.692,8Zm2.154,0a4.267,4.267,0,0,1-.715,2.384,4.153,4.153,0,0,1-1.893,1.591,0.584,0.584,0,0,1-.21.042,0.526,0.526,0,0,1-.387-0.16,0.52,0.52,0,0,1-.16-0.38,0.535,0.535,0,0,1,.328-0.5,5.216,5.216,0,0,0,.639-0.371,3.232,3.232,0,0,0,0-5.215,5.224,5.224,0,0,0-.639-0.371,0.535,0.535,0,0,1-.328-0.5,0.547,0.547,0,0,1,.538-0.54,0.6,0.6,0,0,1,.219.042,4.153,4.153,0,0,1,1.893,1.591A4.267,4.267,0,0,1,12.846,8ZM15,8a6.5,6.5,0,0,1-3.912,5.958,0.6,0.6,0,0,1-.219.042,0.547,0.547,0,0,1-.538-0.54,0.571,0.571,0,0,1,.328-0.5,1.884,1.884,0,0,1,.189-0.089,1.906,1.906,0,0,0,.189-0.089,6.388,6.388,0,0,0,.69-0.43,5.416,5.416,0,0,0,0-8.709,6.4,6.4,0,0,0-.69-0.43,1.9,1.9,0,0,0-.189-0.089,1.875,1.875,0,0,1-.189-0.089,0.571,0.571,0,0,1-.328-0.5A0.547,0.547,0,0,1,10.869,2a0.6,0.6,0,0,1,.219.042A6.5,6.5,0,0,1,15,8Z" /></svg>
Deleted web/public/file-icons/authors.svg
@@ -1 +0,0 @@
1<svg version="1.1" viewBox="0 0 24 24" width="16px" height="16px" xmlns="http://www.w3.org/2000/svg"><path d="m15.787 13.71c-.27458 0-.58704 0-.91844.04734 1.0983.79535 1.8653 1.8463 1.8653 3.2666v2.3671h5.681v-2.3671c0-2.2061-4.4217-3.3139-6.6279-3.3139m-7.5747 0c-2.2061 0-6.6279 1.1078-6.6279 3.3139v2.3671h13.256v-2.3671c0-2.2061-4.4217-3.3139-6.6279-3.3139m0-1.8937a2.8405 2.8405 0 0 0 2.8405 -2.8405 2.8405 2.8405 0 0 0 -2.8405 -2.8405 2.8405 2.8405 0 0 0 -2.8405 2.8405 2.8405 2.8405 0 0 0 2.8405 2.8405m7.5747 0a2.8405 2.8405 0 0 0 2.8405 -2.8405 2.8405 2.8405 0 0 0 -2.8405 -2.8405 2.8405 2.8405 0 0 0 -2.8405 2.8405 2.8405 2.8405 0 0 0 2.8405 2.8405z" stroke-width=".94684" fill="#F78AE0"/></svg>
Deleted web/public/file-icons/azure.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 512 512"><g transform="scale(0.8) translate(64 64)" fill="#64b5f6"><path d="M0,384h42.6666679v85.3333435H128V512H0V384z M28.4439907,170.6666718h130.9245453L264.6651917,18.9583359C272.5806885,7.1249919,285.8956604,0,300.1510315,0h190.515625C502.4512024,0,512,9.5833178,512,21.333334v190.4998474c0,14.2502441-7.1167908,27.5835724-18.973877,35.5003662L341.3333435,352.6250916v130.9162598c0,15.7098389-12.734314,28.4586487-28.4441528,28.4586487h-99.5558624l-42.6666565-42.6666565L224,416l-42.6666718-42.6666565l-53.33461,53.333313L85.3320541,384l53.3346176-53.3333435L96,288l-53.3333321,53.3333435L0,298.6666565v-99.5417633C0,183.416748,12.7343788,170.6666718,28.4439907,170.6666718z M448,128c0-49.0845795-53.5018921-79.9290009-96.0722351-55.3867111s-42.570343,86.2311478,0,110.7734299S448,177.0845795,448,128z"/></g></svg>
Deleted web/public/file-icons/babel.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#F59F29" d="M6.077,2a12.4,12.4,0,0,1-1.359.9L4.724,2.992a0.354,0.354,0,0,0,.134-0.055,0.073,0.073,0,0,1,.093.079l0.093-.055,0.047-.006L5.1,3a5.289,5.289,0,0,1-.951.776L4.2,3.86l-0.047.006-0.1-.037c0,0.031-.041.049-0.134,0.055L3.925,3.933l0.1,0.122a0.358,0.358,0,0,1-.14-0.031,0.91,0.91,0,0,0-.607.471L3.336,4.581a1.253,1.253,0,0,1,.3-0.251L3.651,4.465a0.394,0.394,0,0,0-.134.055L3.627,4.691a3.3,3.3,0,0,1,.694-0.6,0.213,0.213,0,0,1,.192.122L4.607,4.2a7.3,7.3,0,0,1,2.03-1.149L6.643,3.145a0.7,0.7,0,0,1-.257.3,0.381,0.381,0,0,0,.064.177,16.864,16.864,0,0,1-.91,2.414A69.478,69.478,0,0,1,2,13.711a0.372,0.372,0,0,0,.052.134A1.19,1.19,0,0,0,2.5,13.668l0.052-.006L2.56,13.753l0.087-.006,0.093-.055c0,0.03.035,0.043,0.093,0.037L2.84,13.821a0.846,0.846,0,0,1-.152.422,2.249,2.249,0,0,0-.309.715L2.385,15l0.087-.006a5.889,5.889,0,0,0,.869-1.314A13.578,13.578,0,0,0,5.89,12.672a2.326,2.326,0,0,0,1.347-.483L7.232,12.14l-0.222.067-0.052.006-0.006-.049a1.962,1.962,0,0,0,.9-0.306C8.7,11.175,9.331,10.692,9.763,10.4c1.336-1.027,1.966-2.017,1.89-2.958a5.5,5.5,0,0,0-.881-1.155,1.024,1.024,0,0,1,.6-0.556l1.161-1.069A3.267,3.267,0,0,0,13,2.98L12.983,2.8a1.675,1.675,0,0,0-1.131-1.32A4.225,4.225,0,0,0,9.594,1,13.315,13.315,0,0,0,6.077,2h0Zm6.252,0.079-0.035.092L11,1.6q1.295,0.055,1.33.477h0ZM7.092,5.846a1.516,1.516,0,0,1,.3-0.856L8.357,2.711C8.346,2.57,8.55,2.46,8.976,2.387l0.14-.012L9.127,2.5C9.547,2.436,9.8,2.4,9.9,2.387c0.77-.067,1.166.092,1.2,0.489l0.088-.006-0.018-.232,0.093-.006a0.605,0.605,0,0,1,.362.477,0.732,0.732,0,0,1-.2.477c-0.058.006-.093-0.037-0.1-0.128l-0.093.006-0.023.275a2.048,2.048,0,0,1-.846.941,1.1,1.1,0,0,1-.3.348,5.37,5.37,0,0,1-1,.782,12.71,12.71,0,0,0-2.012.819A0.32,0.32,0,0,0,6.87,6.6L6.864,6.512a1.167,1.167,0,0,1,.227-0.666h0Zm5.36-2.811L12.458,3.12A0.835,0.835,0,0,1,12.2,2.687L12.189,2.6a0.513,0.513,0,0,1,.263.434h0ZM3.936,4.123L3.942,4.165a0.4,0.4,0,0,0-.134.055l-0.047.006L3.756,4.141Zm2.817-.073L6.765,4.184a0.358,0.358,0,0,0-.169.2L6.59,4.288a0.462,0.462,0,0,0,.122-0.238H6.753ZM6.322,5L6.339,5.186l-0.047.006L6.275,5.009Zm-0.111.33C6.2,5.485,6.164,5.565,6.089,5.571l-0.047.006a0.483,0.483,0,0,0,.076-0.232Zm-0.192.477L6.024,5.852,5.943,6,5.855,6,5.849,5.962a0.12,0.12,0,0,0,.122-0.147Zm-0.2.428L5.8,6.463,5.75,6.469,5.733,6.243ZM10.55,6.4l0.193,0.122a0.085,0.085,0,0,1-.082.1,0.379,0.379,0,0,0-.192-0.079l-0.012-.134Zm-3.7.519L6.853,7.007l-0.181.018L6.666,6.94ZM9.833,7.239a0.312,0.312,0,0,1,.2.171l0.006,0.043a0.378,0.378,0,0,1-.338-0.2Zm-3.849.99a9.9,9.9,0,0,0,2.479-.77l0.274-.024a1.536,1.536,0,0,1,1.277.342L10.031,7.96a1.829,1.829,0,0,1-.694,1.118l-1.166.972a11.3,11.3,0,0,1-1.406.9A10.2,10.2,0,0,1,4.187,12.2L4.135,12.2c0.047-.171.659-1.491,1.849-3.972h0Zm1.26-.519L7.249,7.753l-0.187.018L7.057,7.728Zm4.188-.2L11.46,7.875a0.186,0.186,0,0,1-.128.147L11.28,7.386a0.215,0.215,0,0,1,.152.128h0Zm-7.1,3.153a0.65,0.65,0,0,1-.187.477L4.141,11.1a0.532,0.532,0,0,1,.192-0.434h0ZM5.5,12.391a14.7,14.7,0,0,0,2.788-1.528l0.006,0.086A1.3,1.3,0,0,1,7.9,11.3a7.016,7.016,0,0,0-1.365.813,3.535,3.535,0,0,0-1.248.568,13.712,13.712,0,0,0-1.47.678A0.518,0.518,0,0,1,3.581,13.3a0.412,0.412,0,0,1,.2-0.391,0.711,0.711,0,0,1,.373.061,3.485,3.485,0,0,1,.723-0.245l-0.006-.092L4.6,12.653a3.854,3.854,0,0,1,.8-0.391l0.14-.012,0.006,0.043a0.379,0.379,0,0,0-.4.226,0.077,0.077,0,0,0,.093.079A1.14,1.14,0,0,0,5.5,12.391h0ZM4.065,11.285l0.006,0.043a0.085,0.085,0,0,1-.082.1l-0.006-.043a0.09,0.09,0,0,1,.082-0.1h0Zm2.181,0.623a2.021,2.021,0,0,0-.624.287L5.575,12.2,5.569,12.11a0.869,0.869,0,0,0,.572-0.275,0.078,0.078,0,0,1,.1.073h0Zm-2.263.66,0.14-.012L4.129,12.6a0.355,0.355,0,0,0-.134.055l-0.087.006a0.089,0.089,0,0,1,.076-0.1h0Zm-0.927.544,0.1,0.043c-0.035.275-.111,0.416-0.245,0.428a0.574,0.574,0,0,0-.286-0.061l-0.012-.135a0.094,0.094,0,0,1,.087-0.1c0.058-.006.093,0.037,0.1,0.128a1.972,1.972,0,0,1,.257-0.3h0Z" /></svg>
Deleted web/public/file-icons/backup.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="16px" height="16px" viewBox="0 0 24 24"><path fill="#C792EA" d="M15,9H5V5H15M12,19A3,3 0 0,1 9,16A3,3 0 0,1 12,13A3,3 0 0,1 15,16A3,3 0 0,1 12,19M17,3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V7L17,3Z"/></svg>
Deleted web/public/file-icons/bazel.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 512 512" preserveAspectRatio="xMidYMid meet"><path fill="#6AAB20" d="M83.6978149,228.824295c36.3322296,7.4014587,61.1593933,5.9598083,61.1593933,5.9598083c-94.3032761-13.4781799-110.1357498-41.320816-121.6382904-62.9264832C8.0514946,143.3680878,2.6641622,0,2.6641622,0s-5.9186449,89.671524-0.1125021,141.8856964C8.3593454,194.1137238,47.357235,221.4211426,83.6978149,228.824295z M274.063324,195.7407837c-38.0500336,9.7766266-69.0756073,66.3207092-69.0756073,66.3207092s31.9804688,14.5921936,60.5530243-8.5007019c30.9768982-25.0361481,34.6283264-79.9442902,34.6283264-79.9442902s-66.8310852-0.6063995-88.8181763,19.2705841c-21.9889221,19.8786316-15.4016876,49.390686-15.4016876,49.390686l-13.4342499,19.2349243c-2.0222778-30.0514832-7.5787048-52.6724396-17.9315796-71.4244385C133.2236176,133.2867737,90.2476349,101.961586,27.4281731,77.9576645c12.2708435,4.9637375,100.968277,43.5324173,124.6949768,138.8482056c14.7856598,59.3974915-7.2576904,175.3479614-7.2576904,175.3479614c-7.9875641,58.3248901,31.5249786,110.8381653,89.8416748,118.8175354c58.7404633,8.0373535,111.3265839-31.8817139,119.2400665-89.8005371c12.3120422-92.3569641-94.5181885-157.2965698-171.3170166-97.2639465c0.537796-12.3614197,0.801239-23.8008118,0.776535-34.458252C216.6356812,198.3008728,274.063324,195.7407837,274.063324,195.7407837z M18.5241413,12.8636665c25.7809772,50.7039032,76.0863037,30.4903183,138.0764313,90.8517303c19.9111938,19.3880539,29.3041077,52.4884872,32.1512909,86.4058914c2.0496979-34.3911285-6.1924133-71.02491-29.8105316-95.0125351C103.3910141,38.6893082,47.9636803,55.8845825,18.5241413,12.8636665z M210.6614838,473.4238892c-51.4693909-29.6726685-51.4694061-104.2571411-0.0000153-133.9298401c51.4693909-29.6726685,116.1554565,7.6195679,116.1554565,66.9649353S262.1308899,503.0965576,210.6614838,473.4238892z"/></svg>
Deleted web/public/file-icons/binary.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#7C95A1" d="M6,12H7v1H4V12H5V10H4V9H6v3Zm8-7.5V14a1,1,0,0,1-1,1H3a1,1,0,0,1-1-1V2A1,1,0,0,1,3,1h7.5ZM13,5L10,2H3V14H13V5ZM10,4H8V5H9V7H8V8h3V7H10V4ZM4,4H7V8H4V4ZM5,7H6V5H5V7ZM8,9h3v4H8V9Zm1,3h1V10H9v2Z" /></svg>
Deleted web/public/file-icons/blender.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 512 512" width="16px" height="16px"><path fill="#FFA000" d="M449.4992676,177.2926483L289.102356,54.1051254c-10.5066528-8.0675964-28.1593018-8.0314255-39.6957245,0.0432281c-11.6811066,8.1575813-13.0271759,21.6817932-2.6470337,30.2099152l66.7076569,54.2856674l-203.6734619,0.0002289c-16.8124084,0.0070496-32.9743958,11.2650146-36.1634521,25.2135925c-3.2974625,14.201767,8.1144104,25.8479614,25.5953369,25.9022217l103.0806656-0.0002594L17.6315975,331.506134c-17.3544369,13.2802734-22.9556332,35.3868713-12.024416,49.362793c11.0848351,14.228241,34.6818542,14.2555847,52.2170372,0.0794067l100.4048004-82.1651611c0,0-1.4731598,11.0837402-1.3557129,17.7419739c3.0185699,56.2037354,45.8679657,108.3059692,98.9225769,131.3808289c91.4195404,40.492218,214.8487701,3.4240417,249.5575714-102.1027832C521.8587036,290.4483643,509.2049255,224.6224518,449.4992676,177.2926483z M279.4641418,373.5093384c-72.2036285-37.7927246-72.2036438-132.7875366-0.0000305-170.5802612s162.9481812,9.7046661,162.9481812,85.2901306S351.6677551,411.302063,279.4641418,373.5093384z M301.4525757,339.4995117c-46.0544739-24.0875549-46.0544891-84.6333313-0.0000305-108.7208557s103.9351196,6.1853485,103.9351196,54.3604126S347.507019,363.5870361,301.4525757,339.4995117z"/></svg>
Deleted web/public/file-icons/c.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#E84140" d="M9.8,6.6a1.9,1.9,0,0,0-1.818-1.7C6.344,4.86,5.5,6.143,5.5,7.977s0.931,3.3,2.528,3.3S9.8,9.445,9.8,9.445l3.06,0.183a3.838,3.838,0,0,1-1.1,2.92,5.389,5.389,0,0,1-3.95,1.461,5.084,5.084,0,0,1-4.092-1.487A5.74,5.74,0,0,1,2,8.121a5.841,5.841,0,0,1,2.029-4.78A5.479,5.479,0,0,1,7.63,2,5.1,5.1,0,0,1,13,6.634L9.8,6.6h0Z" /></svg>
Deleted web/public/file-icons/certificate.svg
@@ -1 +0,0 @@
1<svg version="1.1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="16px" height="16px"><path d="m4 2.5c-1.11 0-2 .89-2 2v10a2 2 0 0 0 2 2h8v5l3-3 3 3v-5h2a2 2 0 0 0 2 -2v-10a2 2 0 0 0 -2 -2h-16m8 2 3 2 3-2v3.5l3 1.5-3 1.5v3.5l-3-2-3 2v-3.5l-3-1.5 3-1.5v-3.5m-8 0h5v2h-5v-2m0 4h3v2h-3v-2m0 4h5v2h-5z" fill="#ff5722"/></svg>
Deleted web/public/file-icons/circleci.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#7A8387" d="M4.513,6.44a3.7,3.7,0,1,1,3.367,5.2A3.723,3.723,0,0,1,4.513,9.52a0.315,0.315,0,0,0-.3-0.22H1.348a0.322,0.322,0,0,0-.325.42A7.108,7.108,0,0,0,15,8,7.058,7.058,0,0,0,7.9,1,7.15,7.15,0,0,0,1.014,6.28a0.314,0.314,0,0,0,.335.38h2.86A0.328,0.328,0,0,0,4.513,6.44Zm2.544,3a1.649,1.649,0,0,1,0-2.873A1.672,1.672,0,1,1,7.057,9.437Z" /></svg>
Deleted web/public/file-icons/clojure.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#567dd1" d="M5.079,4.673a5.581,5.581,0,0,0-.394.42A4.309,4.309,0,0,0,3.9,9.616a4.277,4.277,0,0,0,3.649,2.806,4.341,4.341,0,0,0,2.336-.367,0.333,0.333,0,0,1,.223-0.026,6.551,6.551,0,0,0,3.413.066c0.066-.013.118-0.026,0.223-0.039-0.039.066-.066,0.1-0.092,0.144A6.825,6.825,0,0,1,8.911,14.94a6.98,6.98,0,0,1-7.534-4.76,6.344,6.344,0,0,1-.315-2.846,3.185,3.185,0,0,1,.827-1.665A3.188,3.188,0,0,1,4.908,4.62C4.96,4.633,5,4.646,5.079,4.673Zm6.143,6.5c0.249-.38.538-0.721,0.748-1.115A4.452,4.452,0,0,0,6.378,3.873a0.364,0.364,0,0,1-.236,0,3.67,3.67,0,0,0-3.583-.066,0.7,0.7,0,0,1-.144.066A0.041,0.041,0,0,1,2.388,3.86a6.74,6.74,0,0,1,1.5-1.495A6.983,6.983,0,0,1,14.871,6.626a5.723,5.723,0,0,1,.079,2.164,2.674,2.674,0,0,1-2.008,2.255A4.222,4.222,0,0,1,11.222,11.177ZM6.9,4.463a8.7,8.7,0,0,1,.971-0.144,3.673,3.673,0,0,1,3.806,3.121A3.58,3.58,0,0,1,10.329,10.9a0.274,0.274,0,0,1-.249.039,1.2,1.2,0,0,1-.643-0.551A7.774,7.774,0,0,1,8.544,8.5C8.334,7.872,8.15,7.23,7.953,6.587A5.9,5.9,0,0,0,6.969,4.62C6.956,4.594,6.942,4.581,6.929,4.555A0.181,0.181,0,0,1,6.9,4.463Zm-1.089,6.53c-0.21-.21-0.42-0.393-0.617-0.6a3.7,3.7,0,0,1,.551-5.3,0.226,0.226,0,0,1,.315-0.013,2.181,2.181,0,0,1,.774.879c0.223,0.459.433,0.918,0.63,1.39a0.373,0.373,0,0,1-.026.262c-0.368.734-.748,1.456-1.1,2.19C6.142,10.167,6,10.56,5.814,10.993ZM7.73,8.174a8.675,8.675,0,0,0,1.457,3.37c-0.328.053-.6,0.118-0.893,0.157A3.824,3.824,0,0,1,6.9,11.544a0.188,0.188,0,0,1-.158-0.21,3.141,3.141,0,0,1,.21-1.3c0.223-.59.473-1.167,0.722-1.757Z" /></svg>
Deleted web/public/file-icons/cmake.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 512 512" preserveAspectRatio="xMidYMid meet"><path fill="#497CE3" d="M272.7279663,22.0677414L512,497.9448547l-205.4220581-84.1534729L272.7279663,22.0677414z M5.8364735,468.0686035L262.5133057,249.47435L240.9240265,0L5.8364735,468.0686035z M152.0533295,382.4232178L0,512h467.8563232L152.0533295,382.4232178z"/></svg>
Deleted web/public/file-icons/commitlint.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16" style="enable-background:new 0 0 16 16;"><path d="M9.41,9.06h2.67v0.39H9.41C9.41,9.45,9.41,9.06,9.41,9.06z M10.98,9.84l0.8,1.17h-0.45l-0.58-0.76l-0.59,0.76 H9.71l0.8-1.17H10.98z" fill="#CE3D40" transform="translate(-3 -3) scale(1.5)"/><text transform="matrix(2 0 0 2 0 11)" font-family="'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace" font-size="4.4444px" fill="#50B2DD">cl </text></svg>
Deleted web/public/file-icons/config.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 512 512"><path fill="#1E88E5" d="M36.5714302,475.4285583h73.1428528V365.7142944H36.5714302V475.4285583z M109.7142868,36.5714302H36.5714302v182.8571472h73.1428528V36.5714302z M292.5714417,36.5714302h-73.142868v73.1428528h73.142868V36.5714302z M0,329.1428528h146.2857208V256H0V329.1428528z M219.4285736,475.4285583h73.142868V256h-73.142868V475.4285583z M182.8571472,219.4285736h146.2857056v-73.1428528H182.8571472V219.4285736z M475.4285583,36.5714302h-73.1428528V256h73.1428528V36.5714302z M365.7142944,292.5714417v73.1428528H512v-73.1428528H365.7142944z M402.2857056,475.4285583h73.1428528v-73.1428528h-73.1428528V475.4285583z"/></svg>
Deleted web/public/file-icons/cpp.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 455 512" width="16px" height="16px"><path fill="#1E9CEF" d="M435.2793884,113.2840881L247.1911163,4.6924829c-10.8347626-6.2566438-28.5671387-6.2566438-39.4019012,0L19.7009449,113.2840881C8.864645,119.5393753,0,134.8957062,0,147.4076385v217.1847382c0.8429857,14.5713501,8.2855635,26.6393127,19.7009449,34.1234741l188.0882721,108.5915833c10.8347626,6.2567749,28.5671387,6.2567749,39.4019012,0l188.0882721-108.5915833c12.2479248-8.1462708,19.2193298-20.4076233,19.7009277-34.1234741V147.4076385C454.5403748,134.2429352,446.6565552,120.9648895,435.2793884,113.2840881z M75.8300552,256.0007019c-0.8911743-148.0888062,197.459137-213.4790344,282.9962769-75.8776245l-65.6346436,37.9804688c-39.6215057-65.5336304-138.0008087-42.1011963-141.5315704,37.8971558c1.0603333,73.7290649,97.7535858,107.9782104,141.536026,37.8927917L358.83078,331.872406C283.4476624,462.7354736,79.6317978,415.6530457,75.8300552,256.0007019z M379.1502686,264.425293h-16.8520813v16.8521729h-16.8491821V264.425293h-16.8521118v-16.850647h16.8521118v-16.8505554h16.8491821v16.8505554h16.8520813V264.425293z M442.34198,264.425293h-16.8520813v16.8521729h-16.8490906V264.425293h-16.8522034v-16.850647h16.8522034v-16.8505554h16.8490906v16.8505554H442.34198V264.425293z"/></svg>
Deleted web/public/file-icons/csharp.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 455 512" width="16px" height="16px"><path fill="#0277bd" d="M335.2219849,244.6636963h14.1854858L347.5634155,262h-14.1361694L335.2219849,244.6636963z M454.9803467,147.4076538v217.1847534c-0.4816284,13.7158203-7.4530029,25.9771729-19.7009888,34.1234131L247.1911011,507.3074341c-10.8347778,6.2567139-28.5671387,6.2567139-39.4018555,0L19.7009277,398.7158203C8.2855835,391.2316895,0.8429565,379.1637573,0,364.5924072V147.4076538c0-12.5119629,8.864624-27.8682861,19.7009277-34.1235352L207.7892456,4.6925049c10.8347168-6.2566528,28.5670776-6.2566528,39.4018555,0l188.0882568,108.5916138C446.6565552,120.9649048,454.5403442,134.2429199,454.9803467,147.4076538z M358.8307495,331.8723755l-65.6346436-37.9788818c-43.7824097,70.0854492-140.475647,35.8363037-141.5360107-37.8928223c3.5307617-79.998291,101.9100952-103.4307861,141.5316162-37.8971558l65.6345825-37.9804077C273.2891846,42.5216675,74.9389038,107.9119263,75.8300781,256.0006714C79.6317749,415.6530762,283.4476929,462.7354736,358.8307495,331.8723755z M374.5,232.5919189h-11.2527466L365.2755127,213h-12.5l-2.0839844,19.5919189h-14.2197876L338.5,213H326l-2.0839844,19.5919189H312v12.0717773h10.631958L320.7879028,262H308.5v12.0717773h11.0037842L317.2244873,295.5h12.7346802l2.2183838-21.4282227h14.1017456L344,295.5h12.7346802l2.2183838-21.4282227H371V262h-10.7972412l1.7947388-17.3363037H374.5V232.5919189z"/></svg>
Deleted web/public/file-icons/css.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#1E9CEF" d="M3.785,2H14l-1.805,9.164L6.738,13,2,11.164,2.482,8.717H4.5L4.3,9.728l2.864,1.107,3.3-1.107L10.925,7.4h-8.2L3.12,5.355h8.206l0.258-1.313h-8.2Z" /></svg>
Deleted web/public/file-icons/csv.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#6AAB20" d="M15,13v1H1V1H2V13H15ZM6,12H4V7H6v5Zm3,0H7V3H9v9Zm3,0H10V5h2v7Z" /></svg>
Deleted web/public/file-icons/cypress.svg
@@ -1 +0,0 @@
1<svg width="16px" height="16px" viewBox="0 0 200 200" version="1.1" xmlns="http://www.w3.org/2000/svg"><g id="image" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"><g id="Logo" transform="translate(10.000000, 10.000000)" fill="#616161"><circle cx="90" cy="90" r="86"></circle><path d="M64,56 C71.5490744,56 78.4876056,58.7774238 83.9597969,63.4229874 L75.9013457,74.4302155 C73.151103,71.0556355 69.5027384,69 65.5,69 C56.9395864,69 50,78.4020203 50,90 C50,101.59798 56.9395864,111 65.5,111 C69.8072991,111 73.7042455,108.619645 76.5132895,104.776905 L84.2559877,116.322496 C78.739004,121.120951 71.6859434,124 64,124 C46.326888,124 32,108.777681 32,90 C32,71.2223185 46.326888,56 64,56 Z" id="C" fill="#FFFFFF"></path><path d="M85.5,58.5874023 L103.203613,58.5874023 L115.477051,109.667969 L129.157715,58.5874023 L147.118652,58.5874023 L123.617188,128.739258 C122.775391,130.755859 121.020508,133.252604 118.352539,136.229492 C114.350586,140.694824 113.276198,140.816073 109.259277,143.235352 C105.242356,145.65463 104.953681,145.72072 100.856934,146.766602 C98.1257683,147.463856 95.3404493,147.712879 92.5009766,147.513672 L90,136.229492 C94.2547967,135.295813 97.6177524,134.291744 100.088867,133.217285 C102.559982,132.142827 104.939214,130.650151 107.226562,128.739258 L85.5,58.5874023 Z" id="Y" fill="#FFFFFF"></path></g></g></svg>
Deleted web/public/file-icons/dart.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 13 13"><path fill="#26A2C1" fill-rule="evenodd" d="M10.276 10.906l-.46 1.382-4.159.112L3.6 10.156V4.23l6.676 6.676zm.533-.598L4.101 3.6h6.055L12.4 5.657l-.112 4.158-1.479.493zM2.8 3.481V8.86l-.514-.169L.6 6.781l2.2-3.3zM8.86 2.8H3.481l3.3-2.2 1.91 1.686.169.514z"/></svg>
Deleted web/public/file-icons/db.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0" y="0" viewBox="0 0 32 32" width="16px" height="16px"><path fill="#F78C6C" d="M16 27.534c-5.323 0-9.641-1.44-9.641-3.214 0-0.97 0-2.031 0-3.214 0-0.279 0.14-0.545 0.341-0.803 1.075 1.384 4.813 2.41 9.301 2.41s8.226-1.026 9.301-2.41c0.201 0.259 0.34 0.524 0.34 0.803 0 0.931 0 1.918 0 3.214 0 1.773-4.318 3.214-9.641 3.214zM16 21.106c-5.323 0-9.641-1.441-9.641-3.214 0-0.97 0-2.030 0-3.214 0-0.17 0.064-0.336 0.151-0.5v0c0.049-0.103 0.113-0.204 0.19-0.304 1.075 1.383 4.813 2.41 9.301 2.41s8.226-1.027 9.301-2.41c0.077 0.1 0.141 0.201 0.19 0.304v0c0.086 0.164 0.151 0.33 0.151 0.5 0 0.931 0 1.917 0 3.214 0 1.773-4.318 3.214-9.641 3.214zM16 14.679c-5.323 0-9.641-1.44-9.641-3.214 0-0.508 0-1.044 0-1.607 0-0.511 0-1.041 0-1.607 0-1.774 4.318-3.214 9.641-3.214s9.641 1.44 9.641 3.214c0 0.501 0 1.033 0 1.607 0 0.492 0 1.011 0 1.607 0 1.774-4.318 3.214-9.641 3.214zM16 6.645c-3.55 0-6.427 0.718-6.427 1.607s2.878 1.607 6.427 1.607c3.55 0 6.427-0.718 6.427-1.607s-2.878-1.607-6.427-1.607z"></path></svg>
Deleted web/public/file-icons/default.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 490 512"><path transform="scale(0.8) translate(64 64)" fill="#79919D" d="M8.4680052,174.1060028c8.3255835,19.2559814,46.7862816,26.7483368,160.8391113,82.0335541c0,0-138.2648315,61.3529663-142.0308075,63.6340942c-65.4265747,49.1589355,1.5050316,117.3392639,47.8816071,89.5440063c56.2685394-37.6378784,121.9360962-87.95224,130.1949005-91.0718689c-7.2445068,92.8936768-21.7913361,153.0290833-10.2219086,168.1343994c22.9569855,39.6418457,82.3962555,32.1495361,91.3085785-14.6549683c1.3557434-7.811676-14.8056335-155.0718079-14.5473938-155.3515625c56.5022278,39.9590454,130.146698,99.2402344,146.1841125,99.0127258c32.9403381,6.5753479,77.8944092-34.0498047,50.9804688-78.0308533c-9.3688049-15.4259033-16.6842957-17.6853333-161.6783752-80.6993103c0,0,145.6676331-65.9152069,154.6844482-74.2433624c34.04422-29.5803223,7.518158-103.9334717-57.6946106-81.3664322c-3.8951111,2.0228653-132.4760132,94.6872025-132.4760132,94.6872025s23.0351868-154.1286621,10.0928345-170.1356964c-24.2171631-41.8839188-83.9985657-29.2983551-90.9858551,15.4940605l14.396759,153.4578247L73.1780853,100.9386292C28.7813396,83.9737701-19.1696625,125.4314041,8.4680052,174.1060028z"/></svg>
Deleted web/public/file-icons/diff.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 512 512" preserveAspectRatio="xMidYMid meet"><path fill="#78909C" transform="scale(0.8) translate(64 64)" d="M296.8922729,0v270.8111572l48.5891724,130.8978271h32.5286255c11.0651245-17.0986328,30.2905884-28.4238892,52.1730957-28.4238892c34.3060913,0,62.1166992,27.8106079,62.1166992,62.1166992s-27.8106079,62.1166992-62.1166992,62.1166992c-20.7470093,0-39.1054077-10.1813354-50.3865967-25.8095093h-82.9042969V512H512V0H296.8922729z M482.0996094,124.9291992h-62.6533203v62.6538086h-30v-62.6538086h-62.6542969v-30h62.6542969V32.2753906h30v62.6538086h62.6533203V124.9291992z M81.3110352,20c21.6721191,0,40.7420654,11.1056519,51.855957,27.9291992h67.9685059V0H0v511.999939h201.135498V213.9963379l-35.6599121-96.0671387h-33.4262085c-11.2486572,15.9074097-29.7735596,26.3041992-50.7383423,26.3041992c-34.3060913,0-62.1166992-27.8106079-62.1166992-62.1166992S47.0049438,20,81.3110352,20z M33.5410156,421.7089844h134.0537109v30H33.5410156V421.7089844z M466.8527832,435.4017944c0,20.2521362-16.4175415,36.6696777-36.6696167,36.6696777c-14.392334,0-26.8409424-8.2956543-32.8439941-20.3624878h-86.6311646L179.3847656,97.9291992h-64.9815674c-5.9052124,12.3359985-18.5027466,20.8571777-33.0921631,20.8571777c-20.2520752,0-36.6696777-16.4175415-36.6696777-36.6696777c0-20.2520752,16.4176025-36.6696167,36.6696777-36.6696167c15.2217407,0,28.2753906,9.2756348,33.8218994,22.4821167h85.1160889l131.3222656,353.7797852h64.5949707c5.4263916-13.4671021,18.6071777-22.9768066,34.0169067-22.9768066C450.4352417,398.7321777,466.8527832,415.1497192,466.8527832,435.4017944z"/></svg>
Deleted web/public/file-icons/docker.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="16px" height="16px" viewBox="0 0 24 24"><path fill="#1e9cef" d="M2.2,9.76H4.44V11.94H2.2V9.76H2.2M4.95,9.76H7.2V11.94H4.95V9.76H4.95M4.95,6.96H7.2V9.14H4.95V6.96M7.71,9.76H9.95V11.94H7.71V9.76H7.71M7.71,6.96H9.95V9.14H7.71V6.96M10.46,9.76H12.71V11.94H10.46V9.76H10.46M10.46,6.96H12.71V9.14H10.46V6.96M13.22,9.76H15.46V11.94H13.22V9.76H13.22M10.46,4.16H12.71V6.34H10.46V4.16M5.33,15.09C5.68,15.09 5.96,15.36 5.96,15.7C5.96,16.03 5.68,16.31 5.33,16.31C5,16.31 4.71,16.03 4.71,15.7C4.71,15.36 5,15.09 5.33,15.09M22.69,10.89V10.89L23,11.06L22.84,11.37C22.2,12.58 20.87,12.95 19.57,12.89C17.62,17.6 13.37,19.84 8.23,19.84C5.57,19.84 3.13,18.87 1.74,16.58L1.72,16.54L1.5,16.14C1.05,15.13 0.89,14.03 1,12.92L1.03,12.59H15.8C16.33,12.59 16.87,12.5 17.36,12.34L18.12,12C17.81,11.6 17.64,11.09 17.6,10.59C17.53,9.91 17.67,9 18.15,8.5L18.39,8.22L18.67,8.44C19.38,9 19.97,9.77 20.08,10.65C20.93,10.41 21.94,10.46 22.69,10.89M5.34,16.85V16.85C6,16.85 6.5,16.33 6.5,15.7C6.5,15.06 6,14.54 5.33,14.54C4.68,14.54 4.15,15.06 4.15,15.7C4.15,16.34 4.68,16.85 5.34,16.85Z"/></svg>
Deleted web/public/file-icons/editorconfig.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 512 512" ><path fill="#7A8387" d="M148.7434387,77.3362274c0,16.8612823,8.1194,31.8380661,20.6572266,41.2354279c-53.15728,8.1588821-112.9549637,48.4623566-126.8272705,83.5488739C23.4681168,191.7642975,0.0069703,202.828186,0.0069703,221.024765c-0.4999932,17.261261,25.9996471,32.6360474,47.1423607,15.6387939c5.146431,6.5579224,12.6313286,12.1708221,21.6827011,16.7067719c-0.2234955,7.5623779-0.3194962,29.3780823,2.8129654,33.4430237c4.0994415,5.3219604,48.681839,4.9014587,50.5333099,0.0855103c1.0279846-2.674469,1.664978-14.2243042,2.0564728-20.4737244c14.4478073,0.5859985,29.2690964-0.6924744,43.1629105-4.0754395c-2.4454651,5.4659119-4.4194489,14.6798096-2.7649536,25.3296509c-46.6633987,31.1257935-72.6270218,97.4796753-25.4596558,155.2278748c-29.3066025,10.3643799-57.7577209,25.2121887-56.6812363,43.5784302c42.0439301-0.8764954,194.5203552,0,194.5203552,0c9.3258667,0,18.1827393-1.2174988,26.5201416-3.4999695C685.1946411,433.557373,299.7818909,138.2208862,512,48.2936211c-246.4151306,44.3403969,92.1537476,353.0061951-162.3268127,398.4656067c63.3594666-55.3053894,41.6233215-224.2262115-62.3576355-300.1264648c26.8766479-4.6989136,47.3343506-28.174118,47.3343506-56.3912277c0.7992249-41.2291374-49.3719177-79.0293121-93.5527039-44.2494011C212.770401,8.4631271,150.5719299,24.614109,148.7434387,77.3362274z"/></svg>
Deleted web/public/file-icons/elm.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#26A2C1" d="M8,6.66l2.59-2.578H5.4ZM2.317,1L4.942,3.6h5.672L8,1H2.317Zm6,6,2.836,2.812,2.836-2.824L11.164,4.152ZM14,6.332V1H8.656ZM7.66,7L2,1.317V12.66Zm3.808,3.152L14,12.66V7.633ZM8,7.316L2.317,13H13.66Z" /></svg>
Deleted web/public/file-icons/erlang.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#D52A29" d="M2.516,11.99a6.286,6.286,0,0,1-1.964-4.7A6.145,6.145,0,0,1,2.187,3.011H0.011V11.99H2.516Zm11.454,0A9.384,9.384,0,0,0,15.418,10.2L13.01,9.131a4.741,4.741,0,0,1-3.791,2.352c-2.488-.008-3.466-1.9-3.461-4.329h9.3a3.69,3.69,0,0,0,0-.534A5.17,5.17,0,0,0,13.9,3.009l0,0h2.094v8.979H13.971ZM5.909,5.069A2.125,2.125,0,0,1,8.135,3.225a1.878,1.878,0,0,1,2.016,1.844H5.909Z" /></svg>
Deleted web/public/file-icons/eslint.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#7963E6" d="M10.9,9.595L8.027,11.212,5.156,9.595V6.36L8.027,4.742,10.9,6.36V9.595Zm4.015-1.244-3.177,5.358a0.628,0.628,0,0,1-.559.292H4.823a0.631,0.631,0,0,1-.559-0.295L1.086,8.356a0.628,0.628,0,0,1,0-.636L4.263,2.339A0.672,0.672,0,0,1,4.823,2h6.354a0.672,0.672,0,0,1,.559.339l3.177,5.393A0.6,0.6,0,0,1,14.913,8.351Zm-2.631,2.1V5.644L8,3.234l-4.28,2.41V10.45L8,12.86Z" /></svg>
Deleted web/public/file-icons/ex.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#C792EA" d="M9.412,4.3c1.062,2.294,3.826,3.252,3.576,6.237-0.294,3.515-2.78,4.4-4.154,4.462a4.412,4.412,0,0,1-4.717-3.739C3.311,7.535,6.835,1.817,8.865,1A7.552,7.552,0,0,0,9.412,4.3ZM7.6,13.79c0.929,0.194,1.642.329,1.726-.047,0.127-.567-2.04-0.886-3.489-1.044A4.514,4.514,0,0,0,7.6,13.79Z" /></svg>
Deleted web/public/file-icons/excel.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#6AAB20" d="M13.4,8.488H11.172V7.349H13.4v1.14Zm0,0.651H11.172v1.14H13.4V9.14Zm0-5.372H11.172v1.14H13.4V3.767Zm0,1.791H11.172V6.7H13.4V5.558Zm0,5.372H11.172v1.14H13.4V10.93Zm1.537,2.247a0.643,0.643,0,0,1-.727.358H9.264V15H8.277L1,13.7V2.3L8.319,1H9.264V2.3h4.774a1.435,1.435,0,0,1,.8.156A1.526,1.526,0,0,1,15,3.277l-0.006,8.475A7.612,7.612,0,0,1,14.935,13.177Zm-8.1-2.554C6.391,9.711,5.944,8.806,5.507,7.894Q6.156,6.563,6.79,5.226q-0.541.027-1.081,0.067C5.44,5.962,5.127,6.613,4.917,7.3c-0.2-.653-0.455-1.281-0.692-1.918Q3.7,5.417,3.176,5.449C3.545,6.283,3.938,7.1,4.3,7.943,3.874,8.757,3.48,9.582,3.072,10.4c0.348,0.015.7,0.029,1.045,0.034,0.248-.648.556-1.271,0.773-1.932a19.494,19.494,0,0,0,.793,2.045C6.065,10.575,6.447,10.6,6.83,10.623Zm7.407-7.552H9.264v0.7h1.272v1.14H9.264V5.558h1.272V6.7H9.264V7.349h1.272v1.14H9.264V9.14h1.272v1.14H9.264V10.93h1.272v1.14H9.264v0.753h4.973V3.071Z" /></svg>
Deleted web/public/file-icons/firebase.svg
@@ -1 +0,0 @@
1<svg version="1.1" viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg" width="16px" height="16px"><g transform="translate(-1.575 103.4)"><g transform="translate(-2.5879e-8 -1.5259e-6)" fill="#fcca3f"><path d="m72.55 208.77 44.456-292.29 56.209 90.445 22.275-44.495 135.11 246.85z"/><path d="m195.7 276.73 134.9-67.45-46.5-224.83-211.55 224.32z"/><path d="m173.22 6.9319-100.66 201.84 136.35-144.58z"/></g></g></svg>
Deleted web/public/file-icons/folder.svg
@@ -1 +0,0 @@
1<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.4142" version="1.1" width="16px" height="16px" viewBox="0 0 24 24" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m10 4h-6c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2v-10c0-1.11-.9-2-2-2h-8l-2-2z" fill="#FFB300" fill-rule="nonzero"/></svg>
Deleted web/public/file-icons/font.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#7A8387" d="M1,7H7V9H5v5H3V9H1V7ZM15,4H11.066V14H8.934V4H5V2H15V4Z" /></svg>
Deleted web/public/file-icons/fsharp.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 27 32"><path fill="#1E9CEF" d="M6.789 10.152l-6.114 6.12 12.262 12.262v-6.131l-6.131-6.131 6.131-6.131-0.011-3.054-0.016-3.049-6.12 6.114zM10.73 14.099l-2.173 2.173 4.379 4.379v-4.368c0-2.398-0.005-4.363-0.016-4.363-0.005 0-0.991 0.98-2.19 2.179zM20.178 22.419l6.114-6.12-12.262-12.262v6.131l6.131 6.131-6.131 6.131 0.011 3.054 0.016 3.049 6.12-6.114z"></path></svg>
Deleted web/public/file-icons/git.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#D52A29" d="M14.718,7.356L8.6,1.241a0.9,0.9,0,0,0-1.276,0l-1.27,1.27L7.668,4.122A1.071,1.071,0,0,1,9.025,5.487L10.577,7.04a1.072,1.072,0,1,1-.643.605L8.487,6.2v3.81A1.071,1.071,0,1,1,7.6,9.975V6.13a1.074,1.074,0,0,1-.583-1.407L5.434,3.135,1.241,7.327a0.9,0.9,0,0,0,0,1.276l6.115,6.115a0.9,0.9,0,0,0,1.276,0l6.086-6.086A0.9,0.9,0,0,0,14.718,7.356Z" /></svg>
Deleted web/public/file-icons/github.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 512 512"><path d="M337.2174377,504.2731934c141.4747009-47.3608398,220.8829651-214.6595154,146.0966492-359.3669128C393.331665-29.2045479,147.0076141-40.5344696,40.9949989,124.5613861c-92.9263611,144.7162628-19.280138,332.2263184,134.5387421,379.6889648c12.7788849,2.3462219,17.4485779-5.5580139,17.4485779-12.3005371c0-6.0819092-0.2277985-26.2411499-0.3644562-47.5620117c-71.0697784,15.4439697-86.0809937-30.1363831-86.0809937-30.1363831C94.8969498,384.7301636,78.1545181,376.87146,78.1545181,376.87146c-23.2343178-15.8540039,1.7539444-15.5350647,1.7539444-15.5350647c25.6489487,1.7766724,39.1795349,26.3322449,39.1795349,26.3322449c22.8015594,39.0655518,59.8397675,27.7673035,74.3726501,21.2070007c2.3234253-16.4689941,8.929306-27.7673035,16.2184601-34.1225891c-56.7191162-6.4463196-116.3994446-28.3822937-116.3994446-126.308197c0-27.9267426,9.9770508-50.682785,26.2866669-68.586853c-2.5967407-6.4919434-11.4121704-32.4824982,2.5284424-67.6529617c0,0,21.4348602-6.8564072,70.2724915,26.1955795c41.7310028-11.2334137,84.340744-11.614624,127.9255524,0.045578c48.7465515-33.0975647,70.2269287-26.1955795,70.2269287-26.1955795c13.9634094,35.1931839,5.1707458,61.1609573,2.551178,67.6301193c16.3779602,17.8813324,26.263916,40.6600952,26.263916,68.5868988c0,98.1764374-59.7714539,119.7934723-116.6956177,126.1259918c9.2026367,7.927002,17.3346558,23.4621582,17.3346558,47.2887573c0,34.1680908-0.3416748,61.7076416-0.3416748,70.1357727C319.6322021,498.8290405,324.2791138,506.7788391,337.2174377,504.2731934z"/></svg>
Deleted web/public/file-icons/gitlab.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 32 32"><g fill="#e24329"><polygon points="16 28.896 16 28.896 21.156 13.029 10.844 13.029 16 28.896"/><polygon points="16 28.896 10.844 13.029 3.619 13.029 16 28.896"/><path d="M3.619,13.029h0L2.052,17.851a1.067,1.067,0,0,0,.388,1.193L16,28.9,3.619,13.029Z"/><path d="M3.619,13.029h7.225L7.739,3.473a.534.534,0,0,0-1.015,0L3.619,13.029Z"/><polygon points="16 28.896 21.156 13.029 28.381 13.029 16 28.896"/><path d="M28.381,13.029h0l1.567,4.822a1.067,1.067,0,0,1-.388,1.193L16,28.9,28.381,13.029Z"/><path d="M28.381,13.029H21.156l3.105-9.557a.534.534,0,0,1,1.015,0l3.105,9.557Z"/></g></svg>
Deleted web/public/file-icons/go.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 512 512"><path fill="#82AAFF" d="M37.4353638,218.1991882l7.265358-9.2706146l92.3047104-0.0437622l-5.9430084,9.2706146L37.4353638,218.1991882z M121.1525116,241.0051422l3.0618439-9.2706146l-116.9489975,0.080719L0,241.0858765L121.1525116,241.0051422z M117.7918701,263.8918152l0.8838425-9.2706146l-53.1967468,0.0437622l-5.1631699,9.2706146L117.7918701,263.8918152z M457.0930786,336.4914551c-51.1795654,28.1930542-121.1036682,16.253479-139.7984009-44.8755798c-11.047821,21.741333-29.0864868,39.5604553-50.583313,49.7214966c-50.9333038,24.0750122-120.0850525,3.0805054-128.9793243-62.896698c-6.5131073-63.2374115,44.6686096-102.3140564,74.9248657-112.1797485c48.084671-14.9787292,95.508255,2.6219635,111.9070587,46.4269714l-51.6507568,13.5379028c-5.4868469-15.2711792-35.3119354-26.268158-58.2382355-9.6925201c-36.0574646,22.2469788-35.3910217,78.2315826,8.1730957,86.5452118c22.6175537,2.8996582,37.8105316-8.2763062,49.1994781-25.4665222l-56.0868073,0.1200256l18.0216522-38.8605042h84.0563354c36.4062195-102.7062836,178.3916626-98.2090759,192.8215332-5.9193268C515.9889526,265.7539368,504.7913208,310.216217,457.0930786,336.4914551z M410.4469299,209.4202423c-38.8612366,8.3619385-48.5465698,42.6897278-42.5482178,64.68367c6.3834839,23.4061584,31.1098328,38.7536316,62.4820557,24.0765686C480.6612549,274.6574402,467.4814453,197.1478729,410.4469299,209.4202423z"/></svg>
Deleted web/public/file-icons/gradle.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 14"><path fill="#26A2C1" d="M14.59,2.257A2.291,2.291,0,0,0,12.579.989c-0.769-.015-1.4.445-1.284,0.751a4.954,4.954,0,0,0,.261.547c0.13,0.2.357,0.044,0.439,0a1.214,1.214,0,0,1,.79-0.146,1.187,1.187,0,0,1,.892.736A2.071,2.071,0,0,1,10.21,4.86a5.525,5.525,0,0,0-5.437-.809c-0.988.35-1.448,0.7-1.057,1.509a17.146,17.146,0,0,0,.872,1.677C5.405,8.688,7.2,6.567,7.2,6.567,5.865,8.7,4.719,8.185,4.279,7.442a11.8,11.8,0,0,1-.707-1.451C0.188,7.288,1.1,12.969,1.1,12.969l1.682,0.007c0.426-2.107,1.963-2.02,2.224.007l1.284,0.007c1.133-4.112,4.009.015,4.009,0.015l1.675,0.007c-0.467-2.807.94-3.69,1.826-5.33S15.544,4.022,14.59,2.257Zm-5.21,5.5a0.921,0.921,0,0,1-.529-1.181s0.206,0.073.549,0.19a0.361,0.361,0,0,0-.055.255,0.392,0.392,0,0,0,.426.357,0.406,0.406,0,0,0,.35-0.357q0.206,0.077.432,0.153A0.945,0.945,0,0,1,9.38,7.755Z"/></svg>
Deleted web/public/file-icons/graphql.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 512 512" preserveAspectRatio="xMidYMid meet"><path fill="#7963E6" d="M448.8439636,316.8224487V195.2007751c4.1664429-0.9976196,8.261322-2.5892639,12.1588745-4.8298035c21.8334961-12.6817932,29.2857056-40.5293884,16.7347107-62.3629761c-12.6817932-21.8335114-40.5293884-29.2856979-62.3629761-16.7346954c-3.9276428,2.2813263-7.3816528,5.0595474-10.3458252,8.199173L299.7269287,58.6746483c6.6014709-22.0191994-4.2705994-45.8786011-25.93573-55.0507469c-23.2362671-9.8371754-49.9692535,0.9930778-59.8065186,24.2293434c-4.2989197,10.1543674-4.6501923,20.976244-1.7446899,30.7642651l-105.3036652,60.7988625c-14.1323471-14.8911362-37.0941086-18.8992767-55.6967049-8.2790527c-21.8707733,12.4861298-29.594038,40.3907852-17.1079903,62.2616348c6.5032425,11.3911438,17.1919365,18.9331055,29.0294724,21.7921753V316.843689c-21.1422234,5.1567993-36.1582489,24.7825623-34.7811852,47.2064209c1.5436802,25.1366272,23.1808853,44.1854553,48.4346657,42.6998596c11.9412766-0.7872009,22.4965439-6.0666504,30.1384964-14.0744019l105.2865143,60.7889709c-6.179657,20.8371582,3.254303,43.6761169,23.3864441,53.7241821c22.5769043,11.2682495,49.9920044,2.0106506,61.2019348-20.4494324c5.6194763-11.2592163,6.1613159-23.706665,2.4940491-34.7550659l104.6708069-60.4334717c8.4454346,9.4900513,20.7602234,15.4396667,34.4360657,15.3096619c25.1828308-0.2393799,45.5301208-20.8381042,45.290741-46.0209656C483.6122131,339.418335,468.8146667,321.6131592,448.8439636,316.8224487z M118.2862167,350.396698c-2.1131897-8.6637573-6.6496735-16.2986755-12.8217545-22.1973877L243.3044586,89.4605408c8.539032,2.4894333,17.3392487,2.3663864,25.4445038,0.0061417L406.5761108,328.183197c-6.2328186,5.9347534-10.7914734,13.5970764-12.8850403,22.213501H118.2862167z M398.6398621,173.6362762c6.4229736,11.1732941,16.8890381,18.5683594,28.5012817,21.455658v121.7996063c-0.5890503,0.1454773-1.1730042,0.3025208-1.7528992,0.4704895L287.5503235,78.6268539c0.4143982-0.3965149,0.8177795-0.805954,1.2182007-1.2193069l105.5436096,60.9374695C391.0511475,149.8006439,392.2377319,162.4992828,398.6398621,173.6362762z M224.4852142,78.6515884L86.6713104,317.3453674c-0.5980835-0.1741333-1.2000732-0.3384399-1.8074112-0.4884338V195.1277618c3.9461441-0.9847717,7.8250885-2.5061035,11.5292816-4.6208191c18.6504745-10.6475983,26.9533005-32.4238892,21.2955933-52.2387695l105.4884872-60.9055481C223.6018524,77.8017502,224.0415497,78.2284241,224.4852142,78.6515884z M289.7375793,435.6283264c-3.6878357-4.0439148-8.162323-7.497467-13.3588867-10.091095c-18.3183594-9.1427917-39.7821503-4.8483276-53.2470856,9.1560974l-105.338295-60.8189087c0.1706009-0.5874634,0.3323517-1.1784363,0.47966-1.7749329h275.4713135c0.2489624,1.0110474,0.5194092,2.0136108,0.8342896,2.9970398L289.7375793,435.6283264z"/></svg>
Deleted web/public/file-icons/gulpfile.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#C74748" d="M12,3.993c-1.011.741-6.417,0.7-7-.039,0.149-.246,2.446-0.169,2.333-0.468A3.284,3.284,0,0,0,6.75,1.927l-1.439-1.6L5.716,0s0.755,0.765,1,.991A3.614,3.614,0,0,1,7.956,3.175a0.47,0.47,0,0,0,.544.468C10.185,3.763,11.824,3.732,12,3.993ZM6.245,12.18A0.523,0.523,0,0,1,5.817,11.6L5,4.305a12.984,12.984,0,0,0,7,0l-0.7,7.251a0.708,0.708,0,0,1-.622.663A9.4,9.4,0,0,1,6.245,12.18Zm4.394,0.974c-0.077.624-.117,1.286-0.156,1.949a0.469,0.469,0,0,1-.311.429L8.772,16H8.007S7.1,15.61,6.75,15.454a0.5,0.5,0,0,1-.194-0.273c-0.166-1.4.057-2.292-.428-2.807V12.3a9.648,9.648,0,0,0,4.939-.078A2.014,2.014,0,0,0,10.639,13.154Z"/></svg>
Deleted web/public/file-icons/haskell.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#AB76A0" d="M1,13L4.294,8,1,3H3.471L6.765,8,3.471,13H1Zm3.294,0L7.588,8,4.294,3H6.765l6.588,10H10.882L8.824,9.875,6.765,13H4.294Zm7.961-2.916-1.1-1.667H15v1.667H12.255Zm-1.647-2.5L9.51,5.918H15V7.584H10.608Z" /></svg>
Deleted web/public/file-icons/hcl.svg
@@ -1 +0,0 @@
1<svg width="16px" height="16px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve"><g id="Logo" transform="matrix(0.188962,0,0,0.188962,1.9,1.31422)" fill="#AB76A0"><path d="M44.5,0L0,25.7L0,87.4L16.7,97.1L16.7,35.3L44.5,19.3L44.5,0Z" /><path d="M62.3,0L62.3,49.2L44.5,49.2L44.5,30.8L27.8,40.5L27.8,103.4L44.5,113.1L44.5,64.1L62.3,64.1L62.3,82.3L79.1,72.7L79.1,9.7L62.3,0Z"/><path d="M62.3,113.1L106.9,87.4L106.9,25.7L90.1,16.1L90.1,77.8L62.3,93.8L62.3,113.1Z"/></g></svg>
Deleted web/public/file-icons/helm.svg
@@ -1 +0,0 @@
1<svg width="16px" height="16px" version="1.1" viewBox="0 0 300 300" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(2.1985 0 0 2.1985 16.994 11.498)"><path d="m59.595 125.11c-.90254-.62553-1.9708-2.7016-2.579-5.0119-.5411-2.0555-.74632-7.4735-.38373-10.131.13481-.98807.21141-1.8302.17024-1.8715-.04117-.0412-.88105-.17216-1.8664-.29093-5.6769-.6843-11.736-2.6139-16.349-5.2065-.97848-.54991-1.8949-.99984-2.0365-.99984-.1416 0-.63996.73644-1.1075 1.6365-2.1733 4.1843-5.9689 8.3388-8.8542 9.6915-1.0534.49386-2.4567.61406-3.1059.26603-.97881-.52471-1.2688-2.6021-.68438-4.902.40029-1.5752 2.009-4.8889 3.3138-6.8256.57602-.85503 1.714-2.3052 2.5288-3.2226l1.4815-1.668-.77229-.73792c-2.0348-1.9443-5.8565-6.4417-5.8565-6.8921 0-.16124 5.2141-3.7835 5.594-3.8862.19331-.05226.66073.41024 1.2929 1.2793 1.3704 1.8839 5.0344 5.5067 7.1898 7.1091 16.435 12.218 39.6 9.1491 52.072-6.898.66258-.8525 1.3078-1.55 1.4338-1.55.24995 0 5.4107 3.4988 5.5905 3.7901.16079.26059-2.0948 3.1837-4.0216 5.2118-.91459.96266-1.6619 1.8285-1.6607 1.9242.0012.09564.71977.93041 1.5968 1.855 2.784 2.935 5.5238 7.5173 6.2846 10.511.58446 2.3.29443 4.3773-.68438 4.902-.21128.11326-.77192.20505-1.2459.20398-2.9996-.007-7.9744-4.8243-10.927-10.581-.5689-1.1094-1.1185-2.017-1.2214-2.017s-.68196.31713-1.2869.70473c-4.2898 2.7487-10.694 5.1199-16.234 6.0104-1.1781.18939-2.2391.41926-2.3576.51081-.15326.11835-.13355.6734.0682 1.9202.35985 2.224.22107 7.5064-.25334 9.6423-.48972 2.2049-1.2419 3.9137-2.1693 4.9281-1.066 1.1661-1.8955 1.3329-2.9598.59529zm-58.97-44.958c-.05324-.13974-.07358-7.4541-.04521-16.254l.05159-16h8.3861l.053 5.8901c.03965 4.4064.11509 5.9296.2995 6.0469.13558.08626 2.2347.15821 4.6647.1599 3.4417.0024 4.4671-.05589 4.6392-.26367.14747-.17798.23865-2.191.27404-6.05l.05303-5.7833h8.3861v32.4h-8.3861l-.05285-6.183c-.03552-4.155-.1254-6.2706-.27404-6.45-.17232-.20797-1.203-.26695-4.6649-.26695s-4.4925.05898-4.6649.26695c-.14864.17939-.23852 2.295-.27404 6.45l-.05285 6.183-4.1479.05407c-3.2097.04184-4.1698-.0034-4.2447-.2zm33.943-.0023c-.05275-.13847-.0727-7.4518-.04432-16.252l.05159-16h20.766l.05451 3.5471.05451 3.5471-6.1444.0529-6.1444.0529-.05655 2.3841c-.04205 1.7729.0091 2.4258.19967 2.5469.14092.08956 2.527.16422 5.3025.16591l5.0463.0031-.05453 3.55-.05452 3.55-5.1914.1-5.1914.1v5.6l6.3894.1 6.3894.1v7l-10.638.05176c-8.4041.04089-10.658-.0012-10.734-.2zm27.954 4.6e-5c-.05276-.13849-.07272-7.4518-.04434-16.252l.05159-16h8.3861l.09983 12.6.09983 12.6 6.0899.1 6.0899.1v7l-10.338.0518c-8.1645.04091-10.359-.0012-10.434-.2zm26.555-.0018c-.05238-.1375-.07202-7.45-.04365-16.25l.05159-16 4.3927-.04908c2.416-.02699 4.492.018 4.6133.1.29606.20012 4.7308 12.279 5.7089 15.549 1.0175 3.402.93835 3.2008 1.1688 2.97.1081-.10828.58031-1.4508 1.0494-2.9834 1.0336-3.3774 5.1223-15.086 5.392-15.44.14787-.19448 1.2243-.2412 4.608-.2l4.4151.05375v32.4l-3.7389.05439-3.7389.0544-.13258-.52912c-.27547-1.0994.0941-12.674.51028-15.98.65488-5.2023.55547-5.2828-.98835-.8-1.6696 4.8479-4.9886 13.762-5.3056 14.25-.27223.41883-.43568.45-2.36.45-1.4168 0-2.1289-.07868-2.2626-.25-.24718-.3167-4.3232-11.41-5.4372-14.798-.7858-2.3898-1.2134-3.2505-1.1993-2.4138.0034.19899.18682 1.8468.40768 3.6618.41367 3.3995.77906 14.796.50894 15.874l-.13168.5255h-3.6964c-2.7732 0-3.7201-.06244-3.7916-.25zm2.6414-36.94c-.78664-1.3339-2.7658-3.824-4.4399-5.5859-5.3921-5.6753-11.886-9.2624-19.858-10.97-2.2826-.48883-2.8884-.53537-7.0883-.5445-4.9449-.01076-6.0338.11237-9.8637 1.1153-5.1845 1.3577-9.9116 3.767-14.097 7.1848-2.0121 1.6432-5.2171 5.0232-6.5866 6.9461-.84338 1.1842-1.2679 1.6227-1.4895 1.5384-.72786-.27661-5.601-3.5527-5.5979-3.7634.0068-.47339 3.289-4.59 5.5161-6.9185l2.3038-2.4086-1.5234-1.6521c-2.7837-3.0188-5.4529-7.5178-6.1966-10.444-.58446-2.3-.29443-4.3773.68438-4.9021.21128-.11326.77192-.20505 1.2459-.20398 2.9996.0068 7.9744 4.8243 10.927 10.581.5689 1.1094 1.1049 2.017 1.1912 2.017.08625 0 .86659-.40515 1.7341-.90033 4.4592-2.5454 11.619-4.8137 16.493-5.2251.8414-.07102 1.6352-.19439 1.7641-.27415.17681-.10946.15066-.64573-.10664-2.187-.4741-2.84-.32693-8.4019.28349-10.713.96708-3.662 2.5201-5.7132 4.1009-5.4162 1.4744.27706 2.9045 2.6836 3.6484 6.1396.48548 2.2555.49819 8.5991.02139 10.677-.17673.77-.26249 1.462-.19059 1.5377.0719.07575 1.0292.27907 2.1274.45183 6.3631 1.001 11.189 2.7769 17.104 6.2944.40216.23914.78073.35452.84127.2564s.60602-1.1707 1.2122-2.3836c2.3402-4.6824 6.1076-8.9371 9.1956-10.385 1.0534-.49386 2.4567-.61405 3.1059-.26603.97881.52471 1.2688 2.6021.68438 4.9021-.4267 1.6792-2.0686 4.9958-3.5398 7.1503-.68688 1.0059-2.0817 2.6707-3.0996 3.6995l-1.8508 1.8706 1.821 1.9358c1.8404 1.9565 5.0912 6.1065 5.6864 7.2593.26943.52189.27872.66729.05574.87275-.45654.42067-5.3664 3.3037-5.6263 3.3037-.13518 0-.40232-.26542-.59363-.58982z" fill="#00acc1"/></g></svg>
Deleted web/public/file-icons/history.svg
@@ -1 +0,0 @@
1<svg version="1.1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="16px" height="16px"><path d="m11 7.5v5.11l4.71 2.79.79-1.28-4-2.37v-4.25m0-5c-3.53 0-6.59 1.92-8.23 4.77l-2.27-2.27v6.5h6.5l-2.75-2.75c1.21-2.52 3.75-4.25 6.75-4.25a7.5 7.5 0 0 1 7.5 7.5 7.5 7.5 0 0 1 -7.5 7.5c-3.27 0-6.03-2.09-7.06-5h-2.1c1.1 4.03 4.77 7 9.16 7 5.24 0 9.5-4.25 9.5-9.5a9.5 9.5 0 0 0 -9.5 -9.5z" fill="#8bc34a"/></svg>
Deleted web/public/file-icons/html.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#F4BF75" d="M11.631,5.1l0.136-1.531H4.233l0.4,4.672H9.849l-0.187,2L7.983,10.7l-1.67-.464L6.2,9.015H4.71L4.9,11.448l3.085,0.875H8.017V12.314l3.06-.866L11.5,6.688H6.014L5.886,5.1h5.744ZM2,1H14L12.909,13.583,7.983,15,3.091,13.583Z" /></svg>
Deleted web/public/file-icons/husky.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 32 32"><path d="M26.4,20c-.025.939-1.839,1.591-1.1,2.524,1.63.229.889,1.309,1.782,1.677-1.579,2.57-4.126,2.891-6.549,1.282a2.888,2.888,0,0,0-2.149-.423c-.37.593.181.662.533.748,1.7.339,3.421,2.972,9.185.464.769-.323.652-1.274.319-2.022.639-.272.678-.982.772-1.895C29.821,20.3,27.762,20.475,26.4,20Zm-9.063-4.467a7.737,7.737,0,0,0-2.422,1.013c.965.567,1.348,1.243,2.422,1.032s1.054-.9.631-1.706a.635.635,0,0,0-.631-.339ZM21.874,2c-1.312.06-2.44,3.574-3.778,6.065-3.846,1.6-4.809-.475-6.065-.134C9.892,3.572,6.716-.8,5.858,4.4c.189,3.926-1.378,4.25-1.239,9.853,0,0-2.8,5.626-1.655,10.605A6.646,6.646,0,0,0,8.492,30c.2-1.435,2.449-4.671,1.833-5.426-1.538-1.884-1.055-6.5,2.84-6.976.533-3.883,3.174-3.152,2.9-4.275-.847-3.518,2.806-1.876,3.419-.723,1.1,1.828,1.877,3.625,2.777,4.476a9.313,9.313,0,0,0,2.79,1.709c-.327-.2-3.127-2.573-3.947-4.476a12.545,12.545,0,0,1-.443-3.723c1.421-.223,1.994,2.168,3.537,4.337a.442.442,0,0,1,0,.386c-.212-.112-.286-.394-.542-.428-.233,0-.421.33-.421.737s.307.553.73.743a3.555,3.555,0,0,0,1.792,1.679l-1.9-5.559c-.07-3.482.3-6.963-1.719-10.445A.763.763,0,0,0,21.874,2Zm.018,1.11c.105,0,.2.069.289.231,1.053,1.979,1.092,6.625,1.084,8.268-.5-1.138-1.249-3.052-3.611-3.635,0,0,1.38-4.849,2.238-4.864ZM7.743,3.552c.48,0,1.026.28,1.658,1.434C10.414,6.832,11.14,8.06,11.493,8.7c.722,1.3-.316.272-.641,3.611a1.58,1.58,0,0,1-.8,1.311A10.85,10.85,0,0,1,7.735,8.552c-1.008,1.5.653,3.813.682,5.794a4.259,4.259,0,0,1-2.261.028c-.52-.26-.691-3.43.273-5.891.8-2.05.421-4.656.778-4.77C7.453,3.635,7.455,3.554,7.743,3.552Z" fill="#7f7f7f"/></svg>
Deleted web/public/file-icons/image.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="16px" height="16px" viewBox="0 0 24 24"><path fill="#92AC5A" d="M8.5,13.5L11,16.5L14.5,12L19,18H5M21,19V5C21,3.89 20.1,3 19,3H5A2,2 0 0,0 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19Z"/></svg>
Deleted web/public/file-icons/java.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 369 512"><path fill="#c792ea" d="M118.8347473,391.8314514c-11.0463791,2.9020386-30.8727112,10.20224-25.4985275,25.1454468c10.2657928,16.2324829,32.2333145,18.5989685,49.5846481,22.1087646c43.9986115,6.1643982,89.6991577-0.4361572,129.8277893-19.565979c-13.7843018-4.3330078-26.6967163-12.5171509-39.4153748-17.5175171c-37.0448914,7.5523071-76.5756073,10.7302246-113.4392471,0.8476257c1.886116-4.4017639,18.7827988-14.5131226,4.6802292-11.4407043C122.6610413,391.5495911,120.7478333,391.6901245,118.8347473,391.8314514z M106.964592,338.0658875c-11.0428543,4.4443665-30.7042847,9.7420044-28.8187332,25.1456604c6.8268356,15.418457,27.3534775,16.8304138,41.7882004,20.1987305c54.8505478,7.9640503,111.1017227,0.9543457,163.7583618-15.4662476c-11.6207581-4.3516235-24.0296326-8.4572144-32.6334991-17.870636c-46.6656952,9.2505188-95.9490356,13.9017639-142.6123657,4.0263062c-6.5641327-3.4587402,6.7220764-9.9211731,7.4588852-13.4144897C129.080307,329.40625,111.3105392,339.0346069,106.964592,338.0658875z M292.5256653,112.0490875c-38.8645325,11.7290421-80.969223,27.7937775-104.0384216,62.9931488c-16.4302063,25.6138611-4.5041351,58.4992371,14.7626343,78.8990021c13.353775,13.1353302,8.0883484,34.6885223-5.5801544,45.4180756c-14.3238525,12.5110168,2.2148438,10.3175659,10.5245056,4.3087463c20.3131256-12.5423279,43.0028687-35.4559326,34.3283844-61.5947418c-6.8266144-19.7382965-32.4475403-36.0962372-23.3964081-59.1630554c17.582016-31.7272034,54.2102509-45.6840668,81.799118-67.223175C311.537384,108.6017914,296.6333923,113.0118713,292.5256653,112.0490875z M40.4912758,451.5814209c16.9685745-11.2787476,37.8758583-14.2047729,57.9201622-11.4427795c-9.3910446-6.807312-17.9786682-14.5080872-30.3689423-12.7851868c-23.2782326,2.2700806-48.2075577,8.3582153-65.1916656,25.4277649c-8.4207268,9.9065857,3.5251145,21.223877,13.6324844,22.3912354c86.1140213,16.2096252,175.8414001,18.5883484,262.3297424,3.4610901c22.5798035-5.8760681,49.5704956-8.4633484,65.9025574-26.6990662c7.6173096-12.914978-10.2987366-22.3733215-20.7990112-23.1882935c-4.3379517,1.0841675,7.7060852,8.7731018,7.3783875,14.4295654c-10.5741882,11.6734619-29.2931824,11.3467407-44.0762939,15.0453491C177.5,472.5,69.1221924,466.7949829,40.4912758,451.5814209z M275.505188,315.8980103c8.3691711-4.5243835,17.2249451-8.0758362,24.1567688-14.833252c-27.1533813-1.6239624-53.7112274,7.3096313-80.7257233,8.265625c-43.8738403,2.6915588-88.8258209,7.7044067-132.3088989-0.6369629c-6.724678-1.5560303,5.468811-5.0622559,7.2046509-6.4278564c15.6399765-6.9520569,32.918335-8.5035706,47.9603882-16.9523315c-6.2371521-1.4189758-12.2854004-4.0568848-18.7899857-3.2489929c-28.1797333,1.5705261-57.2032547,8.4977112-80.7361908,24.2276001c-8.3256264,7.8776245,2.0441666,18.0975342,10.6659737,19.1419983C112.7521286,341.9960938,248.5,333.6666565,275.505188,315.8980103z M320.5681458,277.4030457c-14.2689819-0.0597839-27.9729309,6.7670593-33.7684937,20.4739075c-6.6846313,9.065033,11.1994019-4.1524353,17.1698608-4.0154419c19.0513916-7.9066162,42.1271667,12.8203735,33.0571899,32.3510132c-9.3682251,23.7306519-34.7591553,35.1193542-53.8241882,48.7372131c-15.8569946,12.9924316-10.7930908,13.5957642,6.1331177,7.4459229c30.6443787-8.963562,67.3738403-23.3135071,77.4277649-56.6795959C374.0092163,298.2519531,347.3074646,274.8536682,320.5681458,277.4030457z M224.7153015,0c1.2240601,5.6507764,2.4481659,11.305665,3.6722412,16.9564419c21.279129,84.3768921-84.720871,103.7102203-110.0494919,163.5247192c-10.4239807,30.5713806,13.7318192,58.4686279,33.0548782,79.341568c13.2946777,13.4771118,26.795578,27.1580811,42.3131409,38.3348694c-7.078476-35.874939-47.0305634-58.9912109-43.4407196-97.5544434c7.1279144-37.0378418,45.9594879-53.2945557,68.2353821-80.0289307C255.5,79.5,263.7047424,36.5491676,224.7153015,0z M54.1986313,487.1745911c22.1386642,21.4512939,55.1589203,20.0031128,83.6328964,23.3801575c64.998642,2.807373,131.7580719,3.3970337,194.2473602-16.1755371c17.8270874-5.7960205,40.5970764-19.8230286,35.604248-42.0212402c1.4787292-13.2836304-5.2355347,4.1975098-8.0593872,6.4917603C321.0131531,497.125,149.0128784,502.3786621,54.1986313,487.1745911z"/></svg>
Deleted web/public/file-icons/jenkins.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><g fill="#D24A39"><path d=" M 8.129 6.775 C 8.072 7.393 8.218 7.593 8.341 8.3 C 10.37 8.937 10.015 5.52 8.129 6.775 L 8.129 6.775 L 8.129 6.775 L 8.129 6.775 L 8.129 6.775 L 8.129 6.775 Z M 5.882 6.04 C 5.007 5.561 3.694 5.045 2.569 4.829 C 1.171 4.563 1.301 6.737 1.361 8.034 C 1.409 9.061 1.942 10.148 2.173 10.829 C 2.281 11.15 2.306 11.486 2.579 11.549 C 3.06 11.66 4.649 11.026 5.102 10.779 C 6.059 10.256 6.801 9.428 7.615 8.87 C 7.625 8.601 7.638 8.328 7.647 8.059 C 7.159 7.815 6.595 7.644 5.875 7.612 C 6.363 7.368 7.08 7.371 7.52 7.08 C 7.523 6.972 7.53 6.864 7.533 6.756 C 6.725 6.69 6.417 6.332 5.882 6.04 L 5.882 6.04 L 5.882 6.04 L 5.882 6.04 L 5.882 6.04 L 5.882 6.04 Z M 14.665 7.355 C 14.627 6.538 14.411 4.858 13.904 4.563 C 12.842 3.945 10.956 5.812 10.158 6.097 C 10.243 6.347 10.389 6.544 10.398 6.864 C 10.871 6.747 11.448 6.826 11.857 7.029 C 11.384 7.083 10.858 7.08 10.544 7.305 C 10.43 7.637 10.573 8.088 10.497 8.55 C 11.622 8.867 12.906 9.035 14.326 9.077 C 14.605 8.718 14.7 8.034 14.665 7.355 L 14.665 7.355 L 14.665 7.355 L 14.665 7.355 L 14.665 7.355 L 14.665 7.355 Z " stroke-width="0.3" stroke-miterlimit="4"/></g></svg>
Deleted web/public/file-icons/jest.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 512 512" width="16px" height="16px"><path fill="#D52A29" d="M242.3771973,187.7129517L178.7664642,0h270.246582l-63.8298035,188.2586975c-2.7666931,0.6633148-5.4637146,1.4998779-8.0830383,2.5057831L314.045166,62.1327477L250.8069458,190.00354C248.0740662,189.0613403,245.2614441,188.2865448,242.3771973,187.7129517z M34.5503006,494.9925232c85.0008163,59.7053528,179.2832031-55.823822,307.7098389-70.3771973c73.7493591-4.3694763,123.8783569-49.5187988,95.3154907-119.4093323c-53.1042786,32.7087402-102.7355652-10.1530151-102.3883362-53.1634369h-40.353241c-1.3252869,38.3990021-30.2075806,68.6092072-74.9790649,64.1187897c-35.0441284,52.4248352-75.6598053,81.8801575-121.7457352,96.8512268c-31.1488266-14.3746948-60.4627075-49.3892822-47.1717567-96.1579895c-5.8899345-0.6573792-11.5409737-2.1054382-16.8552513-4.2308044C-8.7602959,392.6226501-14.0536633,455.8467102,34.5503006,494.9925232z M97.8312988,252.0487061c0-30.3845367-33.1189346-49.4779663-59.4710083-34.2857056s-26.3520622,53.3791504,0.0000076,68.5714111C64.7123642,301.5266418,97.8312988,282.4332275,97.8312988,252.0487061z M269.238678,251.6722412c0-30.3845367-33.1189423-49.4779663-59.4710083-34.2857056s-26.352066,53.3791504,0.0000153,68.5713806C236.119751,301.150177,269.238678,282.0567627,269.238678,251.6722412z M440.0026855,251.6722412c0-30.3845367-33.118927-49.4779663-59.4710083-34.2857056s-26.3520508,53.3791504,0,68.5713806C406.8837585,301.150177,440.0026855,282.0567627,440.0026855,251.6722412z"/></svg>
Deleted web/public/file-icons/js.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="16px" height="16px" viewBox="0 0 24 24"><path fill="#FFCB6B" d="M3,3H21V21H3V3M7.73,18.04C8.13,18.89 8.92,19.59 10.27,19.59C11.77,19.59 12.8,18.79 12.8,17.04V11.26H11.1V17C11.1,17.86 10.75,18.08 10.2,18.08C9.62,18.08 9.38,17.68 9.11,17.21L7.73,18.04M13.71,17.86C14.21,18.84 15.22,19.59 16.8,19.59C18.4,19.59 19.6,18.76 19.6,17.23C19.6,15.82 18.79,15.19 17.35,14.57L16.93,14.39C16.2,14.08 15.89,13.87 15.89,13.37C15.89,12.96 16.2,12.64 16.7,12.64C17.18,12.64 17.5,12.85 17.79,13.37L19.1,12.5C18.55,11.54 17.77,11.17 16.7,11.17C15.19,11.17 14.22,12.13 14.22,13.4C14.22,14.78 15.03,15.43 16.25,15.95L16.67,16.13C17.45,16.47 17.91,16.68 17.91,17.26C17.91,17.74 17.46,18.09 16.76,18.09C15.93,18.09 15.45,17.66 15.09,17.06L13.71,17.86Z"/></svg>
Deleted web/public/file-icons/json.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="16px" height="16px" viewBox="0 0 24 24"><path fill="#92AA5D" d="M5,3H7V5H5V10A2,2 0 0,1 3,12A2,2 0 0,1 5,14V19H7V21H5C3.93,20.73 3,20.1 3,19V15A2,2 0 0,0 1,13H0V11H1A2,2 0 0,0 3,9V5A2,2 0 0,1 5,3M19,3A2,2 0 0,1 21,5V9A2,2 0 0,0 23,11H24V13H23A2,2 0 0,0 21,15V19A2,2 0 0,1 19,21H17V19H19V14A2,2 0 0,1 21,12A2,2 0 0,1 19,10V5H17V3H19M12,15A1,1 0 0,1 13,16A1,1 0 0,1 12,17A1,1 0 0,1 11,16A1,1 0 0,1 12,15M8,15A1,1 0 0,1 9,16A1,1 0 0,1 8,17A1,1 0 0,1 7,16A1,1 0 0,1 8,15M16,15A1,1 0 0,1 17,16A1,1 0 0,1 16,17A1,1 0 0,1 15,16A1,1 0 0,1 16,15Z"/></svg>
Deleted web/public/file-icons/json5.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="16px" height="16px"><path fill="#4DB6AC" d="m321.09936 131.90734s-67.56036-0.39661-123.72917 0l-12.93997 108.17215 38.96526 9.15974c9.87643-13.37481 60.83719-23.19304 54.97203 28.66291 0 0 0.82377 34.48225-28.35276 29.25035-18.59605-3.33461-20.51279-23.74599-20.51279-23.74599h-49.14273c-1.51745 78.90658 153.80559 93.53206 147.08464-12.34287-2.95633-46.57076-34.94587-82.99677-95.03406-59.04492l4.79796-42.01849h83.89159zm-236.04465 238.29859c-7.475063-3.07979-12.40562-9.08973-15.120819-15.84781-5.849037-17.8524-3.925607-36.15617-3.925607-55.68539-0.1529-20.15749-11.021702-37.03709-27.04305-45.07172 29.763104-14.2169 27.601467-39.88921 27.04305-74.58643-0.224184-15.64069 5.876414-36.08778 19.046426-42.01849l-9.159742-25.58911c-34.390891 10.37953-52.179329 30.42104-52.341346 67.6076-0.936274 13.47607 1.702966 28.76328-1.308527 41.4369-3.867986 11.16656-11.824035 16.65507-22.245091 16.72016v33.00413c17.391655 0.29244 23.441863 14.88665 23.553618 28.49696 0.24717 16.86797-1.18112 36.01751 2.617055 51.32363 7.93549 25.346 15.360524 37.24811 49.724291 45.94407zm25.298372-144.69446c-17.84523-9.34967-17.845235-32.85079-1e-5 -42.20047 17.84523-9.34968 40.27289 2.40087 40.27289 21.10024 0 18.69936-22.42765 30.44992-40.27288 21.10023zm-1e-4 118.48903c-17.845045-9.34967-17.845052-32.85079-1e-5 -42.20047 17.84504-9.34968 40.27246 2.40087 40.27246 21.10023 0 18.69937-22.42742 30.44992-40.27245 21.10024zm316.59231 26.20544c7.47507-3.0798 12.40562-9.08974 15.12082-15.84781 5.84904-17.85241 3.92561-36.15618 3.92561-55.68539 0.1529-20.1575 11.0217-37.03709 27.04305-45.07172-29.7631-14.2169-27.60147-39.88921-27.04305-74.58643 0.22418-15.6407-5.87641-36.08779-19.04643-42.01849l9.15975-25.58911c36.38931 19.03938 52.17933 30.42104 52.34134 67.6076 0.93628 13.47606-1.70296 28.76328 1.30853 41.43689 3.86798 11.16656 11.82403 16.65508 22.24509 16.72017v33.00412c-17.39165 0.29245-23.44186 14.88666-23.55362 28.49697-0.24717 16.86796 1.18112 36.0175-2.61705 51.32363-7.93549 25.346-15.36053 37.24811-49.72429 45.94406zm-19.55132-32.42257c-0.30498 18.1572-8.28902 36.90795-18.31949 50.16047-3.58634 4.84641-7.46349 9.06281-11.63141 12.64917-7.7193-6.24437-15.34122-9.46018-24.71674-11.63141-1.83573-14.83376 11.29915-22.19225 13.95768-33.14954 2.80015-16.0654 1.59932-32.03846 1.59932-49.14273h39.25602c-0.0482 10.37134-0.0973 20.74276-0.14538 31.11404z"/></svg>
Deleted web/public/file-icons/jsx.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 512 512" preserveAspectRatio="xMidYMid meet"><path fill="#61DAFB" d="M300.7026672,254.8256073c0-35.0435791-37.9459534-57.064743-68.1388092-39.5429382c-30.1928406,17.5217743-30.1928406,61.5641022,0.0000153,79.0858612C262.7567139,311.8903198,300.7026672,289.8691711,300.7026672,254.8256073z M404.7128601,170.6555023c30.0607605-143.6903687-36.072876-188.7814789-148.5000916-84.7713013C150.3989563-13.3162594,75.0466537,17.9469166,107.1114731,170.0542908C-40.749897,214.6295471-30.9532928,298.539856,108.3139038,341.4005432c-31.8643951,140.0830994,27.6558838,191.1863708,147.8988647,85.3725281c116.6357117,104.6113892,178.5369263,55.6321106,146.6964417-85.9737244C554.8162231,289.2951965,541.1886597,214.7445526,404.7128601,170.6555023z M384.2715454,165.2445679c-23.8307495-5.375412-46.8896179-9.2882996-70.3421326-11.4230804c-12.3563232-18.0675659-26.7483521-36.115387-43.2874756-52.9069061C360.7797852,14.4541903,413.9045715,41.4697456,384.2715454,165.2445679z M151.6013794,282.4814758c8.918045,19.0384521,20.6417389,36.4736938,29.4595337,51.1032715c-14.3586273-1.4685364-29.8273621-3.6986389-46.2935486-6.6133423C140.0435486,312.2022095,145.6609344,297.3711853,151.6013794,282.4814758z M150.3989563,230.7770081c-6.5595093-15.7457123-12.5452271-30.9425354-17.4352417-45.0911255c16.9986267-3.5235443,33.6560059-6.7227173,48.0971985-7.8157959C170.3130951,194.7540894,160.1044769,212.4068451,150.3989563,230.7770081z M163.0244598,257.2304688c14.7019501-29.4436951,30.2539062-57.3918915,47.4959869-82.3664551c30.5939941-1.7593842,60.8627319-1.6035156,90.7834473,0.6012115c16.781189,23.3826599,32.4436035,51.329834,47.4959717,81.7652435c-11.2641907,23.2770996-27.2587585,50.103241-45.6923218,78.7591553c-30.7770996,2.3305664-62.3356171,2.0082397-94.3907471,0C192.4701996,311.0995178,177.2120972,284.8831787,163.0244598,257.2304688z M360.2229614,282.4814758c5.8381348,14.1664429,11.2332153,28.6053467,16.2327881,43.2874756c-15.4307861,3.2492371-30.652771,5.6168823-45.6923218,7.2145996C341.0576782,317.0340271,350.8233337,300.098938,360.2229614,282.4814758z M330.7634277,177.8700867c16.3929443,2.2440643,32.2282715,5.046402,47.4959717,8.4170074c-5.107666,15.1780396-10.9108887,30.0122375-17.4352112,44.4899139C351.5182495,212.422699,341.526947,194.7579803,330.7634277,177.8700867z M284.4698792,151.416626c-18.7246399-0.7345581-37.7732086-0.7109985-57.1154175,0c8.4677277-11.1366272,17.9058075-22.7698364,28.2570953-34.8704605C265.6993713,127.435112,275.2571106,139.155426,284.4698792,151.416626z M241.1824036,100.9145813c-16.5391388,16.7915192-30.9311523,34.2381287-43.2874756,52.3056946c-23.4525146,2.1347809-47.1125183,6.0476685-70.9433594,11.4230804C98.6944656,38.9894409,151.0445709,14.4541903,241.1824036,100.9145813z M113.7248383,321.5604553c-125.2531357-37.676178-119.2409821-96.3948212-1.2024307-131.0648499c7.2205963,21.9109497,15.9619217,44.1636047,25.8522415,65.5324097C128.7643127,278.1272278,120.2563705,300.8252869,113.7248383,321.5604553z M129.3564301,346.8114929c21.2429504,5.2105103,43.7547455,7.711853,67.3360748,10.2206421c14.188797,20.0240784,28.8825989,38.6404724,44.4898987,54.7105713C149.3969421,499.7204285,98.229538,467.7562256,129.3564301,346.8114929z M255.611557,396.7123108c-10.8765411-11.2629395-21.0314331-24.0250854-30.6619568-37.8765259c21.4670105,0.452301,42.2002716,0.5025024,61.9251251,0C277.3218689,372.3880005,266.8888245,385.0007324,255.611557,396.7123108z M270.6419373,411.7427063c16.7466125-18.195282,31.5098267-36.6409607,44.4898987-55.3117981c22.1543274-1.6300659,44.5992737-5.033905,67.3360901-10.2206421C413.8760376,474.9438171,356.8478088,493.9532471,270.6419373,411.7427063z M397.498291,320.3580322c-6.440094-20.7351685-14.5727844-42.2308044-24.0486145-64.3300171c9.7519226-21.3688049,18.1315613-43.0202484,25.2510376-64.9311981C513.9336548,225.9672852,523.1522217,281.6798401,397.498291,320.3580322z"/></svg>
Deleted web/public/file-icons/julia.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#7963E6" d="M6.419,6.93a3.167,3.167,0,1,1,4.736-2.751A3.191,3.191,0,0,1,6.419,6.93Zm0.89,3.891A3.162,3.162,0,0,0,2.574,8.07a3.191,3.191,0,0,0,0,5.5A3.162,3.162,0,0,0,7.309,10.821Zm7.691,0a3.167,3.167,0,1,0-4.736,2.751A3.162,3.162,0,0,0,15,10.821Z" /></svg>
Deleted web/public/file-icons/key.svg
@@ -1 +0,0 @@
1<svg version="1.1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="16px" height="16px"><path d="m7 14a2 2 0 0 1 -2 -2 2 2 0 0 1 2 -2 2 2 0 0 1 2 2 2 2 0 0 1 -2 2m5.65-4c-.82-2.33-3.04-4-5.65-4a6 6 0 0 0 -6 6 6 6 0 0 0 6 6c2.61 0 4.83-1.67 5.65-4h4.35v4h4v-4h2v-4z" fill="#26a69a"/></svg>
Deleted web/public/file-icons/kotlin.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><g fill="#497CE3" fill-rule="evenodd"><polygon fill-rule="evenodd" points="1 15 8.023 7.977 15 15"/><polygon fill-rule="evenodd" points="1 1 8.023 1 1 8.583"/><path fill-rule="evenodd" d="M 8.1913021,1 14.796875,0.98828125 7.8202083,7.9649479 0.99609375,14.800781 0.99999995,8.7677604 Z"/></g></svg>
Deleted web/public/file-icons/kubernetes.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 512 512" width="16px" height="16px"><path fill="#64B5F6" fill-rule="evenodd" clip-rule="evenodd" d="M0.8002262,309.8923035l43.9221573-191.0728455c2.2659149-9.8478012,9.2421684-18.5207672,18.4169388-22.9041367l178.0283203-85.0611115c9.162323-4.4253368,18.8868713-4.2012329,29.5525208,0l178.0492859,85.0146942c9.1747437,4.3833694,16.1510315,13.0563354,18.4214783,22.907753l43.9850159,191.0700989c2.2704163,9.854187-0.2240906,20.6764832-6.5773315,28.5744019L381.4212646,491.6528015c-6.3578491,7.8869934-16.4360962,12.6893311-26.6181335,12.6820374l-197.6046143,0.0464478c-10.1856995,0.0036316-20.263916-4.8096008-26.6181488-12.7038879L7.3566275,338.4667053C0.4271332,329.8007507-1.2119793,320.1770325,0.8002262,309.8923035z M246.7908478,327.4226379l-29.9634857,54.1347656c26.5865173,8.5083313,52.7902527,8.9454041,78.5525513,0.0746765l-30.0133972-54.232666C259.9699707,319.7934265,250.8058167,320.5657043,246.7908478,327.4226379z M202.0333557,297.0080261l-60.8836517,10.3323975c9.3949432,25.9963989,26.9610596,47.1853943,49.015976,61.3372803l23.5861359-57.0057068C216.3495483,303.169342,210.7651062,296.2111816,202.0333557,297.0080261z M298.2266235,311.3211975l23.8421936,57.5843506c22.9803162-14.639801,40.1764526-36.5173035,49.1661682-61.7697449l-61.415863-10.3797913C300.8644409,295.899231,295.505127,303.3179016,298.2266235,311.3211975z M234.6686707,260.9705505l4.2368011,18.396759l17.0862579,8.2145081l17.0338745-8.1903381l4.2327881-18.396759l-11.7930908-14.7425842h-18.9753571L234.6686707,260.9705505z M318.0094299,261.1207581l59.6750183,17.1870728c2.7200623-27.3953552-3.9840393-52.782608-17.7667236-76.6615143l-46.0664673,41.2268677C308.4487,249.1948395,308.8798218,257.0343628,318.0094299,261.1207581z M198.052597,243.0505981l-46.3184662-41.4284668c-13.7466431,22.393631-20.2595978,49.2559204-17.239502,76.8872986l59.3755951-17.1366882C201.8505707,258.1444397,204.4632416,249.9580994,198.052597,243.0505981z M291.044342,214.2700195l50.4020386-35.7340393c-19.014679-18.6215057-43.8305969-30.7694397-70.6616211-34.0727692l3.5009155,61.7697144C275.6146851,216.6031342,283.5397339,218.2578125,291.044342,214.2700195z M170.1308594,178.511795l50.728653,35.9628601c8.2972717,5.1307983,16.1743164-0.3522491,16.8605042-8.1429443l3.5009155-61.8684998C213.4891968,148.2541351,189.0535278,159.7226562,170.1308594,178.511795z M444.8219604,320.897583c-5.0469055-1.6317444-20.7459412-8.5874634-31.1474609-10.1308289c-3.200531-0.2519836-4.8315125,1.275177-6.6036377,2.444519l-5.0402222-0.8820496c-11.3001404,35.5142822-35.3671265,66.2676392-67.991333,85.5302429l1.9646606,4.7650452c-0.7479553,1.987854-1.8658752,3.894043-0.9092407,6.9534302c4.4415283,10.7107544,11.0513306,19.1382446,16.7858887,28.1263733c10.6455688,17.8268127-10.2826843,30.1829834-20.553772,12.2665405c-3.9439087-11.0152893-7.3485107-24.7470398-12.2075195-32.6793213c-1.7993774-2.657196-4.0039673-2.9959106-6.0240784-3.6541748l-2.5201416-4.5361938c-36.7866821,13.5519409-73.0581055,13.2178345-108.8411407-0.2792358l-2.6733246,4.8395996c-1.9888611,0.5312195-3.9061584,1.0745544-5.0835571,2.4768066c-5.5699158,7.0865479-10.1005096,26.4445496-13.9946136,34.9981689c-8.1739502,15.7394104-28.410614,4.976532-21.3180695-10.0572815c4.698288-9.6661377,10.8059387-14.6474304,18.5146942-31.7844849c0.574585-1.9727783,0.0745239-4.9916687-1.0513916-6.6702271l2.1420898-5.1430664c-31.3954315-18.6013184-56.1861572-48.2478333-68.0155182-84.9274292l-5.1420288,0.8820496c-1.1999435-1.3040771-4.1500931-2.5987244-6.7810822-2.394104c-6.2962189,0.9132996-13.4451752,1.7973022-20.5620117,6.0482483c-32.8491211,19.6211243-44.6738281-22.4781799-4.5361862-19.8574219c15.6425247,1.0213928,21.9873123-1.3021851,26.209053-8.5925293l4.9373932-1.4364624c-5.532135-38.3297424,3.8315582-75.9458008,24.0669708-106.3997345l-3.7801514-3.3749084c-0.2399216-1.4727478-0.5554428-4.8748932-2.36689-6.8042755c-14.8753433-12.7072144-21.0864182-12.6812286-29.7644043-20.0106049c-13.6674194-10.7004089,0.8745651-29.2745819,14.5419846-18.5741882c8.0941925,6.4007568,12.5733109,14.4322662,26.2836609,24.6970062c2.77211,1.6138916,4.9020844,0.9647217,7.0089111,0.6814575l4.2569427,3.0241089c21.9705963-23.1391144,55.3659973-43.2722321,98.3858032-47.3033447l0.2761841-4.9887695c1.5715179-1.5201187,3.3315735-3.7055893,3.831543-6.099678c1.2021637-12.1556931-2.8962402-26.6172409-2.922287-35.9628372c0.2112427-16.9591484,22.9543304-17.9244766,23.5871429,0c0.2657776,10.4563904-3.1790466,16.0211334-2.9223022,35.9628372c0.4646912,3.1733246,2.3194885,4.4293747,3.8547363,5.8980637l0.2762146,5.2650146c37.5021362,3.3315735,72.3269043,20.4793396,97.9059448,47.303299l4.4847717-3.2015076c1.5201111,0.0906982,4.8708496,0.5513916,7.1590881-0.7792206c5.9284973-3.8070374,14.7999268-13.2020416,23.6889648-22.6305389c13.197937-13.6151199,31.9387512,2.4880981,17.3141174,16.3534851c-7.3193665,6.2936249-16.6999512,8.8239136-29.9388123,20.1375885c-2.1934814,2.3426819-2.0271912,4.5634003-2.2207031,6.6782684l-4.1813965,3.7559814c21.6262817,33.6914978,30.0501709,67.2161713,24.5710144,106.1437073l4.7640076,1.3860474c0.8548279,1.212677,2.6148682,4.1581421,5.0875549,5.1188354c10.2700195,3.2744141,20.5796814,3.3516541,34.1751099,4.7866516C472.3372192,300.4338989,464.5526733,326.9841003,444.8219604,320.897583z"/></svg>
Deleted web/public/file-icons/less.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 512 512"><path fill="#1E9CEF" d="M75.3651581,353.5397644v23.2966919c-12.30159-0.3070068-24.337101-0.8187561-35.3491669-6.7136536c-12.2606564-6.5704041-16.2308922-17.9595947-16.9888878-30.8051453c-0.5098705-8.6406555-0.5454731-28.7980652-0.5731163-47.2618713c-0.8801537-15.4332886-6.8978958-22.2288513-22.085556-24.8488159c-0.1228121-0.6959534-0.3479644-1.4532471-0.3479644-2.2310486L0,245.7968903l1.0848321-0.9824982c15.9859285-3.8685608,21.2258759-10.3775482,21.4100895-27.0798798c0.1228123-10.7050476-0.1842136-21.41008,0.1023464-32.1151428c0.1842117-7.2458649,0.4912472-14.5736084,1.7807598-21.6761932c2.8451252-15.6993713,14.266573-25.1967773,32.5449829-27.6529999c5.4036903-0.7163849,10.84832-1.1667023,16.2929611-1.2485657c12.6904907-0.1842194,25.3946304-0.0477753,38.9243393-0.0477753l0,0l-0.0136337,148.6698303c0,17.8894958,1.9854507,20.5299377,19.7521362,25.6266174l-4.2574539,24.9101868c-9.476944-2.1491699-18.6059113-3.1112061-26.8547287-6.3657227c-15.7403107-6.181488-18.2170029-20.4480896-18.2784119-35.1035461l-0.0818787-134.3555145c-7.2253952,0.5526581-13.8776703,0.4912567-20.2638512,1.7193604c-7.3482094,1.3918457-11.5442581,7.430069-11.7898827,16.2724915c-0.3479691,12.322052-0.1228142,24.644104-0.3274918,36.9661713c-0.1023483,5.6493225-0.3479691,11.3191071-1.0234337,16.9070282c-1.269043,10.5413055-6.0382156,19.1176147-14.9215622,24.8692474v1.5351562c13.3250198,9.3132019,15.6993713,23.2727051,15.9449959,38.0101013c0.1842117,11.7080078,0.1128082,23.4364319,0.0613976,35.1445007C50.0058098,348.1803284,55.4121017,352.8453979,75.3651581,353.5397644z M163.6596375,274.2003784c-1.8831024,15.5356445,10.0773468,29.8089905,26.6978149,32.8997192c15.7198334,2.927002,30.8870239,0.2251587,46.5863953-4.6872864l3.5205841,21.4305725c0.14328,1.0848389-1.7193451,3.1521301-3.0498047,3.5615234c-23.0271149,7.2049255-46.2589111,9.5792847-69.3678894,0.7164001c-14.246109-5.4651184-24.5417938-15.63797-30.0273438-29.8840637c-8.6991425-22.6586609-8.8014832-45.4606323,1.4532623-67.6485291c14.9829712-32.442627,52.3175812-40.8756714,80.2571411-27.8781433c16.8865356,7.8599243,25.03302,22.4744568,27.9395447,39.9955444c1.657959,10.0295868,1.514679,20.3457336,2.2515411,31.5010681L163.6596375,274.2003784z M219.4841766,252.3263092c0.3479614-18.9129181-10.3775482-30.7437439-27.1822357-30.6413879c-16.0677948,0.1023407-29.1267395,13.857193-28.7173615,30.6413879H219.4841766z M436.703125,377.0548096v-23.3887329l6.7068787-0.4707947c12.6290894-1.023407,18.2579346-6.6318054,18.5854187-19.2813721c0.3070068-12.1173401,0-24.2347412,0.2251282-36.3521423c0.1023254-5.8335266,0.4503174-11.7284546,1.3099976-17.5005798c1.4942017-9.988678,6.5908508-18.0122986,15.9654541-24.0914764c-16.5794983-10.5208282-17.1321716-26.5886383-17.2958984-42.7587891c-0.1228333-11.1144104,0.1228333-22.2288208-0.1023254-33.3227844c-0.3070068-15.0853271-5.8744812-20.5913391-21.0007324-21.2668304l-4.1755981-0.2865448V135.062027c13.1408081,0.2865601,26.158783,0.6959381,37.6825867,8.1464691c10.7254944,6.9388275,14.0823364,17.9304504,14.5940552,29.7612762c0.6345215,14.3279724,0.3070068,28.6764221,0.5731201,43.0043945c0.0818481,4.2165222,0.2865295,8.5558472,1.3713684,12.58815C493.9058228,238.9398651,501.0903015,244.3026276,512,244.671051v22.6996155c-10.4389648,0.4502869-17.9099731,5.4241638-20.7141418,15.8222046c-1.1053162,4.0323181-1.3918762,8.3511658-1.4737549,12.5472107c-0.2865601,14.1437683,0,28.2875366-0.5116882,42.4108276c-0.7982788,22.4130554-12.6086121,34.878418-34.919342,37.4779053C448.6498718,376.3042908,442.8437195,376.5635376,436.703125,377.0548096z M425.602417,260.9026184c-11.7462769-5.6058197-26.5678406-10.8558807-31.071228-13.3659058c-7.450592-3.6843719-10.4594727-9.1495056-9.354187-15.3105164c1.0029602-5.6902466,5.7926025-9.2517395,13.9186096-10.5617523c12.8952026-2.0673523,25.1967773,0.777771,37.6825562,4.9124603l5.3934937-23.456955c-15.1058044-4.5849457-29.8942871-7.7166901-45.1023865-5.5469971c-7.8394775,1.1053009-15.9040833,3.1521301-23.0066833,6.5499268c-23.9891357,11.4623413-24.1937866,49.1654053-2.6199646,61.3441925c7.3277588,4.1551208,15.2695618,7.2254028,22.8838196,10.8688049c6.3453064,3.0497742,12.8952332,5.7926025,18.8720398,9.4360046c4.8714905,2.967926,6.5090332,7.9827576,5.2399597,13.7548523c-1.2281494,5.6083984-5.4241638,8.1669617-10.4184875,8.6582336c-7.5733948,0.7573242-15.3719177,1.4736938-22.8634033,0.5731201c-9.0675354-1.0848389-17.9303894-3.848114-27.2435913-5.9768066l-4.7487183,23.6206665c21.6352234,8.1464233,43.1271973,9.4768982,64.8443298,4.7077332c18.21698-4.0118103,28.5740967-14.5940857,30.0273438-28.9629211C450.1237183,281.6372681,443.819397,269.9906616,425.602417,260.9026184z M273.3983459,265.4875183c-21.5943146-12.3834839-21.2463531-49.7999878,3.0907288-61.8968811c10.8278503-5.3832397,22.2902527-7.3891449,34.0596313-6.4680634c11.1144104,0.8801575,22.116272,3.1726685,33.8242798,4.9329681l-5.6390686,24.5623016c-12.5062866-4.2574615-24.6236267-6.9388428-37.3551025-4.994339c-8.5149231,1.3099823-13.2840881,4.9124451-14.3074951,10.8892517c-1.023407,6.0586853,2.0468445,11.3805084,9.7225647,15.1262665c8.2726746,3.8447571,19.5921936,7.7871704,30.5595398,13.0998383c18.8720093,9.39505,25.4628296,22.065094,22.4539795,42.9020996c-2.0673218,14.3688965-14.5940552,25.4014282-33.0567017,28.3079529c-18.749176,2.9474792-37.3551025,2.4562683-55.6539612-3.3977661c-4.482605-1.4328003-6.3657074-3.1726379-4.9942932-8.085083l3.9094849-17.6643677c0,0,18.1146545,4.871521,27.1821899,5.9358826c7.4914856,0.880127,15.2900085,0.1842346,22.8633728-0.5731201c5.3218384-0.5116882,9.4564819-3.5000916,10.5822449-9.2927246c1.0848389-5.5879211-0.8596802-10.4389648-5.4650879-13.1203613C298.7205505,276.7219849,279.9794922,269.770752,273.3983459,265.4875183z"/></svg>
Deleted web/public/file-icons/license.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 512 512"><path fill="#F5C076" d="M256 8C119.033 8 8 119.033 8 256s111.033 248 248 248 248-111.033 248-248S392.967 8 256 8zm0 448c-110.532 0-200-89.451-200-200 0-110.531 89.451-200 200-200 110.532 0 200 89.451 200 200 0 110.532-89.451 200-200 200zm107.351-101.064c-9.614 9.712-45.53 41.396-104.065 41.396-82.43 0-140.484-61.425-140.484-141.567 0-79.152 60.275-139.401 139.762-139.401 55.531 0 88.738 26.62 97.593 34.779a11.965 11.965 0 0 1 1.936 15.322l-18.155 28.113c-3.841 5.95-11.966 7.282-17.499 2.921-8.595-6.776-31.814-22.538-61.708-22.538-48.303 0-77.916 35.33-77.916 80.082 0 41.589 26.888 83.692 78.277 83.692 32.657 0 56.843-19.039 65.726-27.225 5.27-4.857 13.596-4.039 17.82 1.738l19.865 27.17a11.947 11.947 0 0 1-1.152 15.518z"/></svg>
Deleted web/public/file-icons/lint-staged.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 24 24"><path d="M22.655,1.934l-2.793-0.91c-0.133-0.043-0.283-0.028-0.406,0.043 c-0.123,0.072-0.21,0.192-0.238,0.332c-0.106,0.517-1.497,3.341-2.381,4.316c-0.559,0.617-1.156,1.097-1.789,1.605 c-0.427,0.343-0.856,0.689-1.278,1.079c-0.764,0.707-1.632,2.346-2.179,3.485c-0.443-0.148-0.924-0.234-1.446-0.234 c-1.962,0-4.199,1.061-5.983,2.838c-0.029,0.03-0.055,0.063-0.075,0.099l-0.105,0.121c-1.258,1.462-1.449,2.825-1.618,4.028 c-0.154,1.104-0.288,2.058-1.216,2.983c-0.133,0.132-0.18,0.328-0.123,0.506c0.057,0.179,0.21,0.311,0.395,0.341 c1.554,0.256,3.186,0.059,4.418-0.541c0.789-0.385,1.438-0.864,1.923-1.286c0.011,0.052,0.016,0.104,0.015,0.16 c-0.005,0.482-0.441,1.003-0.609,1.164c-0.125,0.12-0.18,0.296-0.145,0.465c0.036,0.169,0.156,0.309,0.318,0.368 C7.389,22.912,7.653,23,8.081,23c0.717,0,1.832-0.246,3.085-1.408c0.024-0.017,0.045-0.035,0.066-0.056 c1.785-1.778,2.85-4.007,2.85-5.963c0-0.813-0.195-1.533-0.55-2.139c0.346-0.642,1.034-1.653,2.259-2.526 c0.717-0.512,1.407-0.933,2.015-1.305c1.178-0.72,2.194-1.341,2.816-2.261c0.85-1.256,2.251-3.937,2.374-4.869 C23.027,2.235,22.884,2.008,22.655,1.934z" fill="#713a27"/></svg>
Deleted web/public/file-icons/lock.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 512 512" xml:space="preserve"><g fill="#D52B2A"><path d="M403.88,196.563h-9.484v-44.388c0-82.099-65.151-150.681-146.582-152.145c-2.225-0.04-6.671-0.04-8.895,0 C157.486,1.494,92.336,70.076,92.336,152.175v44.388h-9.485c-14.616,0-26.538,15.082-26.538,33.709v222.632 c0,18.606,11.922,33.829,26.539,33.829h321.028c14.616,0,26.539-15.223,26.539-33.829V230.272 C430.419,211.646,418.497,196.563,403.88,196.563z M273.442,341.362v67.271c0,7.703-6.449,14.222-14.158,14.222H227.45 c-7.71,0-14.159-6.519-14.159-14.222v-67.271c-7.477-7.36-11.83-17.537-11.83-28.795c0-21.334,16.491-39.666,37.459-40.513 c2.222-0.09,6.673-0.09,8.895,0c20.968,0.847,37.459,19.179,37.459,40.513C285.272,323.825,280.919,334.002,273.442,341.362z M331.886,196.563h-84.072h-8.895h-84.072v-44.388c0-48.905,39.744-89.342,88.519-89.342c48.775,0,88.521,40.437,88.521,89.342 V196.563z"/></g></svg>
Deleted web/public/file-icons/log.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="16px" height="16px" viewBox="0 0 24 24"><path fill="#7A8387" d="M13,9H18.5L13,3.5V9M6,2H14L20,8V20A2,2 0 0,1 18,22H6C4.89,22 4,21.1 4,20V4C4,2.89 4.89,2 6,2M15,18V16H6V18H15M18,14V12H6V14H18Z"/></svg>
Deleted web/public/file-icons/lua.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#497CE3" d="M14.451,6.634l0.408-.087a6.982,6.982,0,0,1,.131.911l-0.416.032A6.562,6.562,0,0,0,14.451,6.634ZM13.26,4.278A1.541,1.541,0,1,1,14.8,2.737,1.541,1.541,0,0,1,13.26,4.278ZM8,13.261A5.262,5.262,0,1,1,13.259,8,5.262,5.262,0,0,1,8,13.261Zm2.179-8.983A1.541,1.541,0,1,0,11.718,5.82,1.541,1.541,0,0,0,10.177,4.278Zm-0.02-2.511,0.136-.394a7.019,7.019,0,0,1,.848.358L10.954,2.1A6.614,6.614,0,0,0,10.157,1.768ZM8.464,1.421L8.494,1a7.043,7.043,0,0,1,.912.125l-0.084.409A6.623,6.623,0,0,0,8.464,1.421Zm-1.8-.3A7.037,7.037,0,0,1,7.573,1L7.6,1.416a6.618,6.618,0,0,0-.859.109ZM4.918,1.7A7.038,7.038,0,0,1,5.77,1.351L5.9,1.746a6.62,6.62,0,0,0-.8.329ZM3.387,2.715a7.011,7.011,0,0,1,.732-0.558L4.35,2.5a6.594,6.594,0,0,0-.689.524ZM2.173,4.093a7.006,7.006,0,0,1,.561-0.73L3.048,3.639a6.588,6.588,0,0,0-.528.686ZM1.361,10.264l0.395-.135a6.675,6.675,0,0,0,.334.8l-0.374.185A7.1,7.1,0,0,1,1.361,10.264Zm0-4.524a7.071,7.071,0,0,1,.353-0.85L2.087,5.075a6.654,6.654,0,0,0-.332.8ZM1.416,7.57L1,7.542a7.055,7.055,0,0,1,.121-0.913L1.53,6.711A6.635,6.635,0,0,0,1.416,7.57ZM1.53,9.293l-0.409.082A6.987,6.987,0,0,1,1,8.463l0.416-.027A6.569,6.569,0,0,0,1.53,9.293Zm1.522,3.07-0.313.276a7.017,7.017,0,0,1-.562-0.729l0.346-.232A6.592,6.592,0,0,0,3.052,12.364Zm1.3,1.134-0.23.348a7.008,7.008,0,0,1-.733-0.557l0.274-.315A6.587,6.587,0,0,0,4.355,13.5Zm1.553,0.757-0.132.4A7.077,7.077,0,0,1,4.924,14.3l0.183-.375A6.644,6.644,0,0,0,5.908,14.254Zm1.7,0.329L7.579,15a7.056,7.056,0,0,1-.913-0.116l0.08-.409A6.608,6.608,0,0,0,7.6,14.584Zm1.807,0.285A6.987,6.987,0,0,1,8.5,15l-0.03-.416a6.568,6.568,0,0,0,.857-0.119Zm1.735-.6a7.106,7.106,0,0,1-.847.359l-0.137-.394a6.669,6.669,0,0,0,.8-0.338Zm1.52-1.031a7.019,7.019,0,0,1-.726.566l-0.234-.345a6.6,6.6,0,0,0,.683-0.532Zm1.2-1.392a7.023,7.023,0,0,1-.553.736L13,12.3a6.569,6.569,0,0,0,.52-0.692Zm0.744-6.18-0.393.14a6.7,6.7,0,0,0-.342-0.8l0.372-.189A7.09,7.09,0,0,1,14.609,5.661Zm0.051,4.524a7.074,7.074,0,0,1-.343.854l-0.376-.181a6.658,6.658,0,0,0,.323-0.8ZM14.584,8.357L15,8.38a7.048,7.048,0,0,1-.111.914l-0.41-.077A6.618,6.618,0,0,0,14.584,8.357Z" /></svg>
Deleted web/public/file-icons/makefile.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" fill="#E79627" d="M16 8.5l-6 6-3-3L8.5 10l1.5 1.5L14.5 7 16 8.5zM5.7 12.2l.8.8H2c-.55 0-1-.45-1-1V3c0-.55.45-1 1-1h7c.55 0 1 .45 1 1v6.5l-.8-.8c-.39-.39-1.03-.39-1.42 0L5.7 10.8a.996.996 0 0 0 0 1.41v-.01zM4 4h5V3H4v1zm0 2h5V5H4v1zm0 2h3V7H4v1zM3 9H2v1h1V9zm0-2H2v1h1V7zm0-2H2v1h1V5zm0-2H2v1h1V3z"/></svg>
Deleted web/public/file-icons/manifest.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" fill="#E79627" d="M16 8.5l-6 6-3-3L8.5 10l1.5 1.5L14.5 7 16 8.5zM5.7 12.2l.8.8H2c-.55 0-1-.45-1-1V3c0-.55.45-1 1-1h7c.55 0 1 .45 1 1v6.5l-.8-.8c-.39-.39-1.03-.39-1.42 0L5.7 10.8a.996.996 0 0 0 0 1.41v-.01zM4 4h5V3H4v1zm0 2h5V5H4v1zm0 2h3V7H4v1zM3 9H2v1h1V9zm0-2H2v1h1V7zm0-2H2v1h1V5zm0-2H2v1h1V3z"/></svg>
Deleted web/public/file-icons/markdown.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#EA7130" d="M14.85,3H1.15A1.157,1.157,0,0,0,0,4.15v7.69A1.159,1.159,0,0,0,1.15,13H14.84a1.15,1.15,0,0,0,1.15-1.15V4.15A1.142,1.142,0,0,0,14.85,3ZM9,11H7V8L5.5,9.92,4,8v3H2V5H4L5.5,7,7,5H9v6Zm2.99,0.5L9.5,8H11V5h2V8h1.5Z" /></svg>
Deleted web/public/file-icons/maven.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 360 512"><path fill="#F13C6F" d="M70.8422852,91.1702881C55.843689,74.7624512,53.9046631,52.7462158,44.2553711,33.8062744C34.867981,22.3399048,28.5319214,4.4642334,13.2547607,0C-5.8599243,15.7384644,1.4462891,42.5808105,1.0450439,63.7676392c-0.480957,15.3880005,19.7106934,30.1970215,8.133667,35.0219116c-1.3444214,21.9306641,4.4389038,43.2974854,5.7515869,65.1013794c-0.7067261,7.6870117,5.321167,14.6171875,12.0731201,19.9018555c-7.0709839-4.4486694-12.1395874-5.5687256-9.3896484,9.3347168c8.2279053,20.7558594,12.7774658,38.312439,15.5394897,61.486084c-0.9908447,10.0826416,12.8423462,20.1210938,1.3060303,22.0404053c1.2675781,18.8921509,8.4639282,53.4690552,4.7752075,61.8683472c3.0830078,20.0181274,7.9544067,39.6204834,10.3406372,60.1077881c-3.1328125,9.1242065,9.2814941,17.9712524,13.7381592,19.8540039c-19.3442993,5.1941528,28.9726562,19.6915894,4.1535645,16.9492188c-19.9642334,2.2678833,12.927063,11.9716187-7.3468018,13.5900269c-14.4672241,8.8822021,33.1304321-8.7407227,9.2192383,7.0792236c-12.053894,11.097229,24.5638428-7.8700562,8.6452637,5.836853c-9.6615601,12.9082031-41.1399536,33.6271362-37.7105713,40.5331421c19.0174561-5.644043,38.9521484-35.597229,53.4827271-33.8460083C94.7012939,481.3969116,86.2468262,508.6986694,93.677063,512c4.9411011-9.9920044,7.625-42.6300659,13.4000244-37.7817383c1.5488281,13.8336792,12.2641602,20.758667,6.6328125,2.7886963c-0.519104-11.8986816-0.5070801-28.130188,1.7632446-6.6176758c-0.7494507,6.0136108,6.206665,19.8655396,6.2215576,5.267395c19.5498657,17.0748901-7.7260742-25.1254883,13.2377319-9.692688c17.6345825,1.7999268-27.7027588-27.1150513-5.3307495-14.8902588c13.1751099,10.8604736-0.5394287-7.1346436,12.2619629,0.100769c19.9055786-0.7549438,29.5895996-16.0579834,40.6376953-27.1497192c-12.3630981,0.1192627,18.1174316-8.6791992,19.6149902-19.0098267c2.7990723-9.9113159,28.170105-24.3800659,20.4730225-29.4836426c7.0083618-4.2649536,22.3372192-27.5477295,27.7705078-42.1526489c-1.6924438,0.878479-3.6662598,1.6455078-5.6011963,2.2863159c8.6654053-4.793396,22.1964111-23.2942505,19.6549683-27.9321289c-3.3717651,1.3684692-5.6065063,2.3373413-6.9703979,2.9559937c4.6345825-2.4767456,16.8106079-10.1027222,15.0774536-19.1229248c14.8258667-21.6530762,16.0441284-32.5065308,29.1609497-53.0270386c2.1384888-6.5324097,8.1127319-16.2891846,5.8858032-17.8690796c6.6516724-5.7837524,13.1221313-15.5874634,11.3134155-21.4089355c7.8139648-13.5961914,22.0784302-44.5274048,17.5317993-52.5700684c-13.7595825,10.6231689,15.2628784-11.7531128,12.9639893-21.6533203c5.8445435-11.8571777-1.3756714-10.2488403-3.0427856-9.479187c8.3499756-10.9927368,12.2097778-29.5064697,14.5921021-32.9477539c9.0338135-14.8449707,14.2667236-33.3856201,14.7493896-51.0662231c-12.140625,22.4423218,18.338562-33.322998-10.5183105-26.1749878c-16.7132568,9.6662598-33.206604,21.7625122-43.1881104,38.8220825c-9.59552,7.7678833-11.5185547,18.2521362-10.9033813,29.2930908c-1.4564209-7.2137451-3.8648682-9.3753052-9.0655518,4.164978c-0.9199829,2.0882568-1.107666,5.8499756-0.9260254,10.0762939c-1.2069092-4.7791138-3.6361694-6.133667-7.7846069,4.1889648c-21.0518799,13.1835938-28.2564697,38.8844604-45.2484741,55.9900513c-5.1338501,5.8391113-6.2913208,14.2801514-5.6646729,22.6863403c-0.8070679-4.3951416-1.7782593-7.6514893-3.4637451-8.2758789c-10.8856812,11.4144897-18.4693604,27.0004883-21.9749756,40.3648682c-2.7110596-4.1896973-13.3931885,21.9191895-19.7195435,29.3912964c-6.0801392,5.809021-11.6957397,18.2388306-13.7779541,27.4992065c-2.0285645-5.2306519-7.3485718-3.3236694-12.4279175,6.8150635c-7.2010498,7.3704834-13.2991333,16.2649536-13.6262817,26.1257935c-1.6113892-5.414978-3.7752686-6.5831299-9.1365967,5.809082c-10.8071289,17.6820068-11.0615234-15.067627-15.9459839-13.3956909c3.3508911-12.7646484-5.6611938-33.0438232-7.2479858-23.2591553c1.6274414-20.1395874-7.6974487-38.6194458-13.2074585-59.0842285C98.1222305,142.1433716,78.9754868,116.123436,70.8422852,91.1702881z"/></svg>
Deleted web/public/file-icons/mdx.svg
@@ -1 +0,0 @@
1<svg version="1.1" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" width="16px" height="16px"><path fill="#ffca28" d="m21.412109 150.62109c-11.757433 0-21.412109 9.65468-21.412109 21.41211v172.55469c0 11.75743 9.6546757 21.41211 21.412109 21.41211h469.17578c11.75743 0 21.41207-9.65468 21.41211-21.41211v-172.55469c0-11.75743-9.65468-21.41211-21.41211-21.41211h-469.17578zm329.1543 40.76563 45.4414 45.44336 45.44336-45.44336 15.89649 15.89844-45.44141 45.44336 44.35937 44.36132-15.89453 15.89649-44.36132-44.36133-44.36329 44.36133-15.89648-15.89649 44.36133-44.36132-45.44336-45.44336 15.89844-15.89844zm-103.15821 1.39453h22.71289v84.98047l31.06055-30.21875 15.67774 16.11523-58.29297 56.71094-55.36524-56.63672 16.07813-15.7168 28.1289 28.7754v-84.00977zm-196.90039 3.00781 60.624998 59.8711 61.75196-59.35352v118.39453h-23.07813v-64.20312l-38.89258 37.38086-37.328122-36.86524v63.6875h-23.078126v-118.91211z" /></svg>
Deleted web/public/file-icons/mongodb.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 219 512"><path fill="#90A959" d="M116.0700684,0C105.8483963,61.3248596,0,112.5376205,0,262.0224915c0,135.1828918,74.4267578,176.4035339,89.7619019,182.7953796C117.8650055,415.4259338,113.5133362,172.4797516,116.0700684,0z M106.0466614,500.9353638c0-7.0283813,0-49.1882324,0-49.1882324c37.0464325-16.6083069,15.6325531-310.5220337,13.7176208-418.4836426c13.0445099,34.567955,98.7104187,121.4339752,98.7104187,228.7589874s-47.2733154,157.1498413-88.1548157,186.5313416v50.1483154C129.1284943,517.3720703,107.5698166,514.7475586,106.0466614,500.9353638z"/></svg>
Deleted web/public/file-icons/netlify.svg
@@ -1 +0,0 @@
1<svg version="1.1" viewBox="0 0 40 40" width="16px" height="16px" xmlns="http://www.w3.org/2000/svg"><path d="m27.84 14.658-.01276-.0054c-.0072-.0028-.01457-.0054-.02095-.01184a.10019.10019 0 0 1-.02549-.08471l.70406-4.3045 3.3017 3.3026-3.4338 1.4609a.075597.075597 0 0 1-.03006.0054h-.01365c-.0045-.0028-.0092-.0063-.01821-.01549a1.563 1.563 0 0 0-.45086-.34701zm4.7891-.26231 3.5303 3.5303c.7332.73412 1.1003 1.1003 1.2342 1.5247.02004.06285.03644.12569.04919.19036l-8.4369-3.5731a.66307.66307 0 0 0-.01365-.0054c-.0337-.01365-.07287-.02914-.07287-.06376 0-.03461.04009-.05101.07378-.06466l.01093-.0045zm4.6697 6.3784c-.18216.34247-.53738.69768-1.1385 1.2997l-3.9803 3.9793-5.1479-1.072-.02733-.0054c-.04554-.0072-.0938-.01549-.0938-.05647a1.5538 1.5538 0 0 0-.59659-1.0867c-.02094-.02094-.01549-.05374-.0092-.08379 0-.0045 0-.0092.0018-.01276l.96819-5.944.0037-.02004c.0054-.04554.01365-.09837.05466-.09837a1.5757 1.5757 0 0 0 1.0565-.60569c.0083-.0092.01365-.01913.0246-.0246.02914-.01365.06376 0 .0938.01276l8.7893 3.7179zm-6.0341 6.1944-6.5451 6.5451 1.1203-6.8857.0018-.0092c.00091-.0092.0028-.01821.0054-.02641.0092-.02186.03279-.03097.05555-.04007l.01093-.0045a1.685 1.685 0 0 0 .63302-.47089c.02186-.02549.04827-.05009.08198-.05466a.081973.081973 0 0 1 .02641 0l4.6087.94725zm-7.9304 7.9304-.73776.73776-8.1563-11.788a.38618.38618 0 0 0-.0092-.01276c-.01276-.0173-.02641-.03461-.02368-.05466.000911-.01457.01007-.02733.02004-.03825l.0092-.01184c.0246-.03643.04554-.07287.06831-.11203l.01821-.03188.0028-.0028c.01276-.02186.0246-.04281.04645-.05466.01913-.0092.04554-.0054.0665-.000911l9.0362 1.8635a.14937.14937 0 0 1 .06923.03006c.01184.01184.01457.0246.0173.03917a1.6003 1.6003 0 0 0 .93632 1.0702c.02549.01276.01457.04098.0028.07104a.21677.21677 0 0 0-.01365.04098c-.11385.69222-1.0902 6.6471-1.3526 8.2547zm-1.5411 1.5402c-.54376.53829-.86436.82338-1.2269.93814a1.8216 1.8216 0 0 1-1.0984 0c-.42444-.1348-.7915-.50095-1.5247-1.2351l-8.1909-8.1909 2.1395-3.3181c.01007-.01639.02004-.03097.03643-.04281.02277-.01639.05555-.0092.08288 0a2.2169 2.2169 0 0 0 1.4919-.0756c.0246-.0092.04918-.01549.06831.0018a.17305.17305 0 0 1 .0255.02914l8.1964 11.894zm-12.831-9.2775-1.879-1.879 3.7107-1.583a.076508.076508 0 0 1 .03006-.0063c.03097 0 .04919.03097.06558.0592a2.6505 2.6505 0 0 0 .11841.16759l.01184.01457c.01093.01549.0037.03097-.0072.04554l-2.0493 3.1815zm-2.7106-2.7106-2.3772-2.3772c-.4044-.4044-.69768-.69768-.9017-.94998l7.2282 1.4992a.76508.76508 0 0 0 .02733.0045c.04463.0072.0938.01549.0938.05738 0 .04554-.05374.0665-.09928.0838l-.02094.0092zm-3.6943-4.5495a1.8216 1.8216 0 0 1 .081973-.45085c.1348-.42444.50095-.79058 1.2351-1.5247l3.0421-3.0421a1981.5 1981.5 0 0 0 4.2134 6.0906c.0246.03279.05192.06922.02368.09655-.13298.14664-.26596.30694-.35977.48091a.14573.14573 0 0 1-.04554.05647c-.01184.0072-.0246.0045-.03825.0018h-.0018l-8.1509-1.7096zm5.1734-5.8319 4.0905-4.0905c.38436.1685 1.7834.75962 3.0348 1.2879.94725.40076 1.8107.76508 2.0821.88349.02733.01092.05192.02186.06376.04919.0072.01639.0037.03734 0 .05466a1.8244 1.8244 0 0 0 .47636 1.665c.02733.02733 0 .0665-.02368.10019l-.01276.01913-4.1533 6.4331c-.01093.01821-.02095.0337-.03917.04554-.02186.01365-.05282.0073-.07833.00091a2.0712 2.0712 0 0 0-.49457-.06739c-.14937 0-.3115.02733-.47544.05738h-.000912c-.01821.0028-.03461.0063-.04918-.0045a.19127.19127 0 0 1-.04098-.04645zm4.9166-4.9166 5.2955-5.2955c.7332-.7332 1.1003-1.1003 1.5247-1.2342a1.8216 1.8216 0 0 1 1.0984 0c.42444.13389.7915.50095 1.5247 1.2342l1.1476 1.1476-3.7662 5.8328a.14118.14118 0 0 1-.03734.04371c-.02277.01549-.05466.0092-.08198 0a1.91 1.91 0 0 0-1.7488.337c-.0246.0255-.06102.01092-.09199-.0027-.49184-.21404-4.3173-1.8307-4.8646-2.063zm11.391-3.3481 3.4775 3.4775-.83795 5.1898v.01365a.12296.12296 0 0 1-.0072.03461c-.0092.01821-.02733.02186-.04554.02733a1.6668 1.6668 0 0 0-.49912.24865.14027.14027 0 0 0-.01822.01549c-.01007.01093-.02004.02094-.03643.02277a.10383.10383 0 0 1-.03917-.0063l-5.2991-2.2515-.01007-.0045c-.0337-.01365-.07378-.03006-.07378-.06466a2.002 2.002 0 0 0-.28235-.83339c-.02549-.0419-.05374-.08562-.03188-.12843zm-3.5813 7.8385 4.9676 2.104c.02733.01276.05738.0246.06922.05282a.096546.096546 0 0 1 0 .05192c-.01457.07287-.02733.15575-.02733.23954v.13935c0 .03461-.03552.04919-.06831.06285l-.01007.0037c-.78694.33609-11.048 4.7116-11.064 4.7116-.01549 0-.03188 0-.04736-.01549-.02733-.02733 0-.06558.0246-.10019a.69222.69222 0 0 0 .01276-.01822l4.0823-6.321.0073-.01093c.02368-.03825.05101-.08107.09472-.08107l.04098.0063c.09291.01276.17488.0246.25776.0246.61935 0 1.1932-.30148 1.5393-.817a.14573.14573 0 0 1 .03097-.03643c.02459-.01821.06103-.0092.08926.0037zm-5.6889 8.3658 11.185-4.7699s.0164 0 .03188.01549c.06103.06103.11294.10201.16304.14026l.0246.01549c.02277.01276.04554.02733.04736.05101 0 .0092 0 .01457-.0018.02277l-.95818 5.8857-.0037.02368c-.0063.04554-.01276.09745-.05555.09745a1.5748 1.5748 0 0 0-1.2505.77146l-.0045.0072c-.01276.02095-.0246.04098-.04554.05193-.01913.0092-.04371.0054-.06376.000909l-8.9196-1.8398c-.0092-.0018-.13844-.47271-.14846-.47362z" fill="#00bfa5"/></svg>
Deleted web/public/file-icons/nginx.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#6BAD20" d="M11.661,14H4.47L0.98,8.081,4.454,2h7.494l1.987,4.5H11.342L10.326,4.505H5.987L4.977,6.273,3.954,8.066l2.018,3.443h4.081l0.774-1.524H7.644A1.273,1.273,0,0,1,6.337,8.747,1.385,1.385,0,0,1,7.644,7.48H15Z" /></svg>
Deleted web/public/file-icons/nim.svg
@@ -1 +0,0 @@
1<svg version="1.1" viewBox="0 0 24 24" width="16px" height="16px" xmlns="http://www.w3.org/2000/svg"><path fill="#ffca28" d="m4.3831 15.808-2.1763-11.969 5.9847 7.6169 3.8085-7.6169 3.8085 7.6169 5.9847-7.6169-2.1763 11.969h-15.234m15.234 3.2644a1.0881 1.0881 0 0 1 -1.0881 1.0881h-13.058a1.0881 1.0881 0 0 1 -1.0881 -1.0881v-1.0881h15.234z"/></svg>
Deleted web/public/file-icons/nodejs.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#73B923" d="M8.005,16.01A1.2,1.2,0,0,1,7.4,15.845l-1.932-1.16c-0.288-.163-0.147-0.222-0.052-0.255a3.788,3.788,0,0,0,.873-0.4,0.148,0.148,0,0,1,.145.011l1.483,0.894a0.188,0.188,0,0,0,.18,0l5.784-3.388a0.186,0.186,0,0,0,.088-0.159V4.608a0.191,0.191,0,0,0-.089-0.162L8.1,1.061a0.179,0.179,0,0,0-.178,0L2.138,4.448a0.188,0.188,0,0,0-.091.161v6.777a0.18,0.18,0,0,0,.091.157l1.585,0.929c0.86,0.436,1.386-.078,1.386-0.594V5.187a0.168,0.168,0,0,1,.168-0.169H6.01a0.168,0.168,0,0,1,.167.169v6.69A1.617,1.617,0,0,1,4.465,13.71a2.445,2.445,0,0,1-1.333-.368l-1.517-.886a1.244,1.244,0,0,1-.607-1.071V4.608a1.241,1.241,0,0,1,.607-1.07L7.4,0.146a1.25,1.25,0,0,1,1.216,0L14.4,3.538a1.243,1.243,0,0,1,.608,1.07v6.776a1.247,1.247,0,0,1-.608,1.071l-5.784,3.39a1.2,1.2,0,0,1-.609.165h0Zm1.787-4.669c-2.532,0-3.062-1.18-3.062-2.169A0.167,0.167,0,0,1,6.9,9H7.645a0.168,0.168,0,0,1,.166.144c0.113,0.773.449,1.163,1.98,1.163,1.218,0,1.737-.28,1.737-0.936,0-.379-0.146-0.659-2.041-0.848-1.582-.159-2.562-0.514-2.562-1.8,0-1.185.984-1.891,2.633-1.891,1.853,0,2.77.653,2.886,2.054a0.173,0.173,0,0,1-.044.13,0.167,0.167,0,0,1-.123.055H11.527a0.167,0.167,0,0,1-.162-0.133c-0.18-.813-0.619-1.073-1.806-1.073-1.331,0-1.485.47-1.485,0.823,0,0.428.183,0.552,1.979,0.793,1.778,0.238,2.623.576,2.623,1.844s-1.05,2.012-2.883,2.012h0Z" /></svg>
Deleted web/public/file-icons/npm.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="16px" height="16px" viewBox="0 0 24 24"><path fill="#D14748" d="M3,3V21H21V3H3M6,6H18V18H15V9H12V18H6V6Z"/></svg>
Deleted web/public/file-icons/objc.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 512 512"><path fill="#7986CB" d="M0.183509,36.4387398l40.5910034,364.0538635c2.1180725,14.1902466,10.7534027,24.4816895,21.928791,30.0428772L221.20961,508.4911804c10.1640778,4.3006287,20.711853,5.0415955,31.8829041,0l158.1288605-77.955719c12.9690247-6.4874268,20.6891174-19.9518738,20.0875244-34.1004028l39.4437866-361.601532C470.7526855,16.5506878,454.309906,0,434.2577515,0L36.7192612,0.0023145C16.6671143,0.0023145-2.0625093,16.9998131,0.183509,36.4387398z M324.6032104,304.4647217v-23.5907898c0.2681885-29.1891937-10.1066895-33.8141327-35.3861389-32.9857788v-27.4655457c21.5648804,0.432724,35.2910767-0.8408356,35.3861389-32.802948v-25.4308319c-0.0026245-30.0236053,13.5238342-45.8441238,47.3643188-45.3414841l24.6995544,0.0000076v27.831192c-14.4381714-0.6665802-24.4599304,0.8945923-28.7571411,5.3433075c-14.9763489,17.3096466,11.35672,76.642334-27.4596558,83.3049011c38.699585,8.9856415,13.1682129,64.6348572,27.4596558,83.4877014c3.5209656,4.8055725,14.2848206,5.9223938,28.7571411,5.5320129v27.6424866h-24.6995544C342.3360596,349.9889526,324.5791626,339.1993103,324.6032104,304.4647217z M123.1558151,202.5466461c17.1223679-21.9187164,51.0905228-20.6435547,60.4513168,3.1316833l0.0000153-16.2186432h23.77948v85.5164795c31.0759125-3.0274353,41.235321-56.6345673,16.7730408-89.3853455c-37.5749817-51.3345795-113.2302017-34.6904297-135.6468506,14.7442169c-38.9092178,91.9951019,54.4230881,168.6609344,124.7715378,107.821579l12.1610413,19.8988037c-61.1763916,49.9404297-121.842392,12.6075134-137.8585052-6.817688c-44.876297-45.972229-42.8999062-145.7396545,33.3632278-181.7196198c78.3185043-30.8526382,137.8525696,27.0950928,137.8525696,88.4652863c-1.545929,51.9612122-33.1176758,71.5133362-75.1955414,70.4007263v-18.9846497c-9.5386353,24.1185303-41.8182831,23.5609436-58.0566177,5.9822388C105.5101395,263.6871033,107.2907486,222.8558655,123.1558151,202.5466461z M181.2126617,242.3560944c0-43.5889587-44.232666-43.5889587-44.232666,0C135.4359894,285.8718567,182.2367859,286.4326477,181.2126617,242.3560944z"/></svg>
Deleted web/public/file-icons/ocaml.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#E44F31" d="M1,7.13V3.735A1.75,1.75,0,0,1,2.764,2H13.236A1.75,1.75,0,0,1,15,3.735V14H9.189a10.268,10.268,0,0,1-.459-1.485,2.069,2.069,0,0,0-.022-0.371,0.869,0.869,0,0,0-.159-0.2,1.768,1.768,0,0,1-.279-0.775c-0.011-.242-0.1-0.463-0.115-0.7-0.005-.116.008-0.237,0-0.35a1.133,1.133,0,0,0-.036-0.266l0.03-.075A1.218,1.218,0,0,1,8.5,9.763c0.139,0,.271.008,0.41,0.016a3.941,3.941,0,0,0,.818-0.032A2.1,2.1,0,0,0,10.77,9.3a2.623,2.623,0,0,0,.823-1.057,1.847,1.847,0,0,1,.293-0.734,3.659,3.659,0,0,1,.9-0.3,0.642,0.642,0,0,1,.435-0.073c0.12,0.024.336,0.161,0.386-.03a0.34,0.34,0,0,1-.077-0.1c0.224-.022.006-0.53-0.085-0.632a1.174,1.174,0,0,0-.6-0.291,1.864,1.864,0,0,0-.818-0.11,1.332,1.332,0,0,1-.716-0.175,1.108,1.108,0,0,0-1.031.336,0.723,0.723,0,0,1-.18.269,0.873,0.873,0,0,0-.025.3c-0.036.116-.09,0.519-0.148,0.662a0.686,0.686,0,0,1-.418.538,1.592,1.592,0,0,1-.763-0.04c-0.142-.051-0.383-0.132-0.5-0.18A1.07,1.07,0,0,1,7.6,7.21c-0.06-.1-0.64-1.286-0.725-1.49A1.625,1.625,0,0,0,6.5,5.158a0.727,0.727,0,0,0-.675-0.315,0.88,0.88,0,0,0-.459.164,2.215,2.215,0,0,0-.263.465c-0.063.1-.172,0.377-0.273,0.608a0.976,0.976,0,0,1-.178.342A0.261,0.261,0,0,1,4.262,6.4,5.461,5.461,0,0,1,3.888,5.8c-0.375-.64-0.522-0.928-0.952-0.931-0.547,0-.588.3-0.829,0.74A3.068,3.068,0,0,1,1,7.13Zm0,3.93V14H3.713s0.219-.831.268-1A2.573,2.573,0,0,0,4.1,12.634a1.424,1.424,0,0,0-.014-0.361,1.209,1.209,0,0,1,.328-0.637c0.1-.223.164-0.476,0.249-0.7a1.157,1.157,0,0,1,.432-0.637,4.462,4.462,0,0,0-.574-0.056,10.654,10.654,0,0,1-1.091-.2A7.376,7.376,0,0,1,2.72,9.8,1.777,1.777,0,0,0,2.14,9.43a0.412,0.412,0,0,0-.306.175,2.578,2.578,0,0,0-.123.371,2.334,2.334,0,0,1-.205.369A5,5,0,0,0,1,11.06Zm7.544,2.491c-0.052-.108-0.118-0.317-0.161-0.412a2.612,2.612,0,0,0-.23-0.385,0.6,0.6,0,0,1-.211-0.369,6.538,6.538,0,0,0-.473-1.409A2.161,2.161,0,0,0,7,10.4a1.861,1.861,0,0,0-.547-0.371,1.708,1.708,0,0,0-.982,1.063,6.185,6.185,0,0,0-.41.681,2.227,2.227,0,0,1-.273.678,1.869,1.869,0,0,0-.394.675C4.374,13.177,4.218,14,4.218,14h4.5l-0.008-.019C8.654,13.812,8.574,13.615,8.544,13.551Z" /></svg>
Deleted web/public/file-icons/patch.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 512 512"><path fill="#3F51B5" d="M477.1991882,477.1991882l-0.0001831,0.0001831c-46.4007263,46.4007263-121.6312561,46.4006042-168.032135-0.0002441L34.800869,202.8331146c-46.400856-46.4008484-46.4009628-121.6314011-0.0002327-168.0321198l0.0001678-0.0001678c46.4007263-46.4007263,121.631279-46.4006233,168.032135,0.0002327l274.3660278,274.3660278C523.5997925,355.5679321,523.5999146,430.7984619,477.1991882,477.1991882z M221.9202271,128.3818054l-93.5394287,93.5394287l158.912384,158.912384l93.5394287-93.5394287L221.9202271,128.3818054z M86.8376694,180.3838501c-5.5687027,5.5686951-5.5687027,14.5973511,0,20.1660614c5.5687103,5.5686951,14.5973587,5.5686951,20.1660614,0c5.5687027-5.5687103,5.5687027-14.5973511,0-20.1660614C101.4350204,174.815155,92.4063721,174.8151398,86.8376694,180.3838501z M134.0694427,133.1520691c-5.5686951,5.5687103-5.5686951,14.5973511,0.0000153,20.1660614c5.5686951,5.5686951,14.5973511,5.5687103,20.1660461,0c5.5687103-5.5686951,5.5687103-14.5973511,0-20.1660614C148.6668091,127.5833664,139.6381531,127.5833664,134.0694427,133.1520691z M181.3012238,85.9202881c-5.5686951,5.5687027-5.5686951,14.5973587,0,20.1660614c5.5687103,5.5687027,14.5973663,5.5687027,20.1660614,0c5.5687103-5.5687027,5.5687103-14.5973511,0-20.1660538S186.8699341,80.3515854,181.3012238,85.9202881z M124.0505829,77.332695c-5.5687027,5.5687027-5.5686951,14.5973511,0.0000076,20.1660614c5.5687027,5.5687027,14.5973587,5.5687027,20.1660538,0c5.5687103-5.5687027,5.5687103-14.5973511,0-20.1660614C138.647934,71.7639923,129.6192932,71.7639923,124.0505829,77.332695z M78.2500763,123.1332092c-5.5687027,5.5686951-5.5687027,14.5973511,0,20.1660614c5.5687027,5.5686951,14.5973587,5.5686951,20.1660614,0c5.5687027-5.5687103,5.5686951-14.5973511-0.0000076-20.1660614C92.8474274,117.5645065,83.818779,117.5645065,78.2500763,123.1332092z M65.3686829,64.4513016c-5.5687065,5.5687027-5.5687027,14.5973511,0,20.1660538c5.5687027,5.5687103,14.5973587,5.5687103,20.1660614,0.0000076c5.5686951-5.5687027,5.5686951-14.5973511-0.0000076-20.1660614C79.9660339,58.8825989,70.9373779,58.8825989,65.3686829,64.4513016z M424.7448425,329.3638c5.5687256-5.5686951,5.5687256-14.5973511,0-20.1660461c-5.5686951-5.5687256-14.5973511-5.5687256-20.1660767-0.0000305c-5.5686951,5.5687256-5.5686951,14.5973816,0,20.1660767C410.1474915,334.9325256,419.1761475,334.9325256,424.7448425,329.3638z M377.513031,376.5956116c5.5686951-5.5686951,5.5686951-14.5973511,0-20.1660767c-5.5686951-5.5686951-14.5973511-5.5686951-20.1660767,0c-5.5686951,5.5686951-5.5686951,14.5973511,0,20.1660767C362.9156494,382.1643066,371.9443359,382.1643066,377.513031,376.5956116z M330.2812195,423.8273926c5.5686951-5.5686951,5.5686951-14.5973511,0-20.1660461c-5.5687256-5.5687256-14.5973816-5.5687256-20.1660767-0.0000305c-5.5687256,5.5687256-5.5687256,14.5973816,0,20.1660767C315.6838379,429.3961182,324.7124939,429.3961182,330.2812195,423.8273926z M387.5318909,432.4150085c5.5686951-5.5686951,5.5686951-14.5973511,0-20.1660461c-5.5687256-5.5687256-14.5973816-5.5687256-20.1660767-0.0000305c-5.5686951,5.5687256-5.5686951,14.5973816,0,20.1660767S381.9631653,437.9837341,387.5318909,432.4150085z M433.332428,386.614502c5.5687256-5.5687256,5.5687256-14.5973816,0-20.1660767c-5.5686951-5.5687256-14.5973511-5.5687256-20.1660767,0c-5.5686951,5.5686951-5.5686951,14.5973511,0,20.1660461C418.7350769,392.183197,427.7637329,392.183197,433.332428,386.614502z M446.2138367,445.2964172c5.5686951-5.5686951,5.5686951-14.5973511,0-20.1660461c-5.5687256-5.5687256-14.5973816-5.5687256-20.1660767-0.0000305c-5.5687256,5.5687256-5.5687256,14.5973816,0,20.1660767C431.6164551,450.8651428,440.6451111,450.8651428,446.2138367,445.2964172z M433.539032,246.4934998l43.6603394-43.6601868c46.4008484-46.4007263,46.4008484-121.6314011,0-168.0322571l-0.0001221-0.000248c-46.4008789-46.4008522-121.6315308-46.4008522-168.0323792-0.0001221l-43.6603394,43.6603165L433.539032,246.4934998z M78.4607697,265.5066528l-43.6600685,43.6599426c-46.4008522,46.4008484-46.4009781,121.6314087-0.0001221,168.0323792l0.000248,0.0001221c46.4007263,46.4008789,121.6313934,46.401001,168.0323792,0.0001221l43.6600647-43.6600647L78.4607697,265.5066528z"/></svg>
Deleted web/public/file-icons/pdf.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="16px" height="16px" viewBox="0 0 24 24"><path fill="#D73736" fill-rule="evenodd" d="M11.43,10.94C11.2,11.68 10.87,12.47 10.42,13.34C10.22,13.72 10,14.08 9.92,14.38L10.03,14.34V14.34C11.3,13.85 12.5,13.57 13.37,13.41C13.22,13.31 13.08,13.2 12.96,13.09C12.36,12.58 11.84,11.84 11.43,10.94M17.91,14.75C17.74,14.94 17.44,15.05 17,15.05C16.24,15.05 15,14.82 14,14.31C12.28,14.5 11,14.73 9.97,15.06C9.92,15.08 9.86,15.1 9.79,15.13C8.55,17.25 7.63,18.2 6.82,18.2C6.66,18.2 6.5,18.16 6.38,18.09L5.9,17.78L5.87,17.73C5.8,17.55 5.78,17.38 5.82,17.19C5.93,16.66 6.5,15.82 7.7,15.07C7.89,14.93 8.19,14.77 8.59,14.58C8.89,14.06 9.21,13.45 9.55,12.78C10.06,11.75 10.38,10.73 10.63,9.85V9.84C10.26,8.63 10.04,7.9 10.41,6.57C10.5,6.19 10.83,5.8 11.2,5.8H11.44C11.67,5.8 11.89,5.88 12.05,6.04C12.71,6.7 12.4,8.31 12.07,9.64C12.05,9.7 12.04,9.75 12.03,9.78C12.43,10.91 13,11.82 13.63,12.34C13.89,12.54 14.18,12.74 14.5,12.92C14.95,12.87 15.38,12.85 15.79,12.85C17.03,12.85 17.78,13.07 18.07,13.54C18.17,13.7 18.22,13.89 18.19,14.09C18.18,14.34 18.09,14.57 17.91,14.75M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M17.5,14.04C17.4,13.94 17,13.69 15.6,13.69C15.53,13.69 15.46,13.69 15.37,13.79C16.1,14.11 16.81,14.3 17.27,14.3C17.34,14.3 17.4,14.29 17.46,14.28H17.5C17.55,14.26 17.58,14.25 17.59,14.15C17.57,14.12 17.55,14.08 17.5,14.04M8.33,15.5C8.12,15.62 7.95,15.73 7.85,15.81C7.14,16.46 6.69,17.12 6.64,17.5C7.09,17.35 7.68,16.69 8.33,15.5M11.35,8.59L11.4,8.55C11.47,8.23 11.5,7.95 11.56,7.73L11.59,7.57C11.69,7 11.67,6.71 11.5,6.47L11.35,6.42C11.33,6.45 11.3,6.5 11.28,6.54C11.11,6.96 11.12,7.69 11.35,8.59Z"/></svg>
Deleted web/public/file-icons/perl.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="16px" height="16px"><path fill="#4DB6AC" d="M212.6733704,441.6739502c-2.6396484,5.3020325-13.7670746,1.604248-25.9412994-2.6282654c-12.1742096-4.2324829-53.4983063-21.1853943-77.3233719-48.1962585c-23.8250656-27.0108337-35.9992828-63.0328979-34.9525375-97.43927s10.581337-48.7196503,26.4874573-70.9745789c15.8833771-22.2321777,59.8471222-55.0684357,73.614212-63.5562592c13.7671051-8.4878235,32.3128967-22.7783203,41.3013306-34.4291534c9.0112-11.6508408,13.7670898-21.1854095,13.2437286-37.0687866c-0.5233765-15.8833771,1.0695038-30.1966286,0.5233612-35.475914c-0.5233612-5.3020401-0.5233612-15.8833771,4.2325439-13.7671127c4.7558899,2.1162643,5.3020325,7.9416885,5.3020325,13.7671127s-1.5928802,29.6504745-3.185791,52.9521713c-1.5928802,23.3016968-20.1159058,42.8942261-33.8830261,56.6613312c-13.7671204,13.7670898-53.4983063,56.6613159-68.3121643,77.8467407c-14.8366394,21.1854095-22.2094269,49.083725-20.6620483,72.544693c1.5928802,23.8251038,7.4183121,47.1267395,29.6504822,72.0213623c22.2321472,24.8946228,37.5921783,36.5454102,49.2430115,42.3708496C203.6621246,432.1280212,215.3130188,436.3719177,212.6733704,441.6739502z M232.174942,467.1260376c-3.8001862,4.6193542-10.3310242,7.4183044-11.1274872,5.5523376s4.7104034-2.9354553,8.7608948-7.44104c3.7091675-4.14151,2.7306671-11.8783875,5.3703156-11.9921875C237.3404541,453.1541138,235.9751129,462.4838867,232.174942,467.1260376z M241.7891693,428.4302368c-7.4105225,9.5406494-37.5807953-23.2903137-53.4869385-42.3594666c-15.9061432-19.0691223-31.7667542-57.730835-32.3128815-87.9047241c-0.5233765-30.1965942,16.4067383-58.2541962,28.6037292-75.2071075c12.1969757-16.9529114,36.022049-46.0800018,42.3708496-59.3237457c6.3488159-13.2437439,13.2437286-27.5342255,15.3600159-46.0799866c2.116272-18.5457687,0-84.7416916,0-84.7416916s-0.5233765-29.1271057,4.2325287-25.94133c4.778656,3.1857743,5.8254089,21.7087994,5.8254089,38.6616859s1.5928802,90.0209808-3.7091675,109.6135101c-5.3020325,19.5925446-17.4762573,41.3013458-30.7200012,60.3704987s-28.6037292,59.3237305-28.0576019,84.1955566c0.5233765,24.8945923,14.8366394,65.6725464,25.9413452,82.6254578C227.0094452,399.3372803,245.9578094,423.063324,241.7891693,428.4302368z M257.6383972,477.8894348c-2.1845093,0-1.3652954-6.5991211-1.3652954-14.7456055s-0.750946-14.7683716,1.3652954-14.7683716c1.9342041,0,1.3653259,6.5991516,1.3653259,14.7683716S259.4133301,477.8894348,257.6383972,477.8894348z M292.1813354,472.4281311c-0.7964478,1.8659668-7.327301-0.9102478-11.1274719-5.5523682c-3.8001709-4.6420898-5.1654968-13.9491577-3.0037231-13.8808899c2.6396179,0.1137695,1.6611328,7.850647,5.3703003,11.9921875C287.4709167,469.4926453,292.9777832,470.5621948,292.1813354,472.4281311z M326.0870972,441.5488281c-9.557312,3.8229065-15.610321,7.9417114-23.6885376,0.4095764c-13.0161743-12.1059265-25.3269348-13.3575134-41.5971375-17.7037964c-4.5283203-1.2060547-7.6686096-11.2640381-9.3070221-14.8821411c-5.4385834-11.9239197-9.5800781-24.4622192-12.7431183-37.15979c-15.3372345-61.3034668-3.1175079-122.6979675,12.4017792-182.613327c6.6218414-25.57724,10.8998871-50.9952087,11.1047211-77.5509415c0.113739-13.9036407-1.8204651-27.6252365-3.4588623-41.3923569c-0.7054443-5.8481827-3.4360962-20.3434715,3.6864014-9.2160072c2.8671875,4.4828453,8.1237488-1.8659515,8.5788574,3.2995567c0.3641052,3.9822235,0.8191833,32.6314621,2.4348755,42.7349396c3.8911743,24.439476,34.0195618,39.5719223,52.5881042,51.1772385c42.5301208,26.5784912,100.1580505,61.7676392,108.2481689,116.280899C444.911377,346.1967468,408.3333435,407.8333435,326.0870972,441.5488281z M512,256C512,59.6616783,297.9924927-63.7160072,127.7110519,34.4531631s-170.2813873,344.9245605,0.0000534,443.093689S512,452.3383484,512,256z"/></svg>
Deleted web/public/file-icons/php.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#BB80B3" d="M5.323,10.323a1.2,1.2,0,0,0-.782-1.039C4.345,9.212,4.176,9.08,3.982,9a4.5,4.5,0,0,0-.674-0.244,0.407,0.407,0,0,0-.442.179,0.313,0.313,0,0,0,.055.386c0.093,0.131.2,0.256,0.3,0.378,0.172,0.2.355,0.4,0.523,0.607a0.956,0.956,0,0,1,.274.679,2.142,2.142,0,0,1-.757,1.437,0.3,0.3,0,0,1-.194.066,1.252,1.252,0,0,1-.733-0.317,0.351,0.351,0,0,1,.082-0.622,1.791,1.791,0,0,0,.2-0.117,0.4,0.4,0,0,0,.135-0.518,0.8,0.8,0,0,0-.167-0.248c-0.357-.313-0.708-0.637-1.09-0.922A3.542,3.542,0,0,1,.014,7.02,5.542,5.542,0,0,1,.9,3.582,2.366,2.366,0,0,1,1.916,2.6,6.463,6.463,0,0,1,2.9,2.309,6.813,6.813,0,0,1,5.7,2.058a1.982,1.982,0,0,1,1.7,1.317,3.53,3.53,0,0,1,.168,1.572A2.713,2.713,0,0,1,5.582,7.23,0.781,0.781,0,0,1,4.6,6.916C4.4,6.651,4.236,6.363,4.058,6.084,4.032,6.043,4.013,5.995,3.991,5.95S3.946,5.864,3.923,5.82A3.088,3.088,0,0,0,4.36,7.113a0.788,0.788,0,0,0,.6.4A2.657,2.657,0,0,0,7.432,6.494,3.551,3.551,0,0,0,8.063,3.55C8.025,3.34,7.96,3.135,7.914,2.927a0.22,0.22,0,0,1,.162-0.281A1.69,1.69,0,0,1,8.27,2.6a8.31,8.31,0,0,1,2.506-.146,5.976,5.976,0,0,1,2.67.857,5.816,5.816,0,0,1,2.478,3.326A2.366,2.366,0,0,1,16,7.263c0,0.141-.09.178-0.2,0.089a3.039,3.039,0,0,1-.341-0.321c-0.1-.107-0.177-0.23-0.273-0.343-0.074-.088-0.139-0.075-0.174.035-0.022.067-.042,0.133-0.062,0.2a4.768,4.768,0,0,1-1.135,2.038,0.569,0.569,0,0,0-.158.312,5.028,5.028,0,0,0-.012.9c0.062,0.642.158,1.281,0.233,1.922a0.576,0.576,0,0,1-.432.7,2.985,2.985,0,0,1-.777.17c-0.418.028-.84,0.008-1.26,0.008A1.114,1.114,0,0,0,11,11.843c0.085-.335.184-0.653,0.243-0.978a0.858,0.858,0,0,0-.189-0.757,0.486,0.486,0,0,0-.628-0.146,2.315,2.315,0,0,1-1.978.125,0.519,0.519,0,0,0-.7.226,1.592,1.592,0,0,0-.242.956c0.026,0.42.1,0.835,0.16,1.253,0.032,0.234,0,.294-0.236.354a5.132,5.132,0,0,1-1.627.1,0.492,0.492,0,0,1-.077-0.019A1.031,1.031,0,0,0,5.2,11.834a2.42,2.42,0,0,0,.125-1.507l0,0h0ZM2.087,8.6a0.579,0.579,0,0,0,.166-0.289,0.7,0.7,0,0,0-.381-0.705,0.263,0.263,0,0,0-.322.062Q1.229,8.024.913,8.381a0.723,0.723,0,0,0-.093.136,0.191,0.191,0,0,0,.145.312C1.074,8.856,1.187,8.861,1.3,8.877a1.289,1.289,0,0,0,.788-0.271h0Zm0.024-2.6a0.354,0.354,0,0,0-.352-0.342,0.359,0.359,0,0,0-.358.354,0.355,0.355,0,0,0,.363.333,0.347,0.347,0,0,0,.349-0.344h0Z"/></svg>
Deleted web/public/file-icons/plist.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#2AA3C1" d="M10.5,3L9,4.5,12.5,8,9,11.5,10.5,13,15,8Zm-5,0L1,8l4.5,5L7,11.5,3.5,8,7,4.5Z"/></svg>
Deleted web/public/file-icons/pnpm.svg
@@ -1 +0,0 @@
1<svg version="1.1" viewBox="0 0 300 300" width="16px" height="16px" xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2"><g fill-rule="nonzero"><path fill="#f9ad00" d="M195.015 30h75v75h-75zM112.5 30h75v75h-75zM30 30h75v75H30zM195.015 112.5h75v75h-75z"/><path fill="#4e4e4e" d="M112.5 112.5h75v75h-75zM112.5 195h75v75h-75zM195.015 195h75v75h-75zM30 195h75v75H30z"/></g></svg>
Deleted web/public/file-icons/powerpoint.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#EA7130" d="M14.368,2.465h-5.1V1h-0.9L1,2.306V13.7L8.278,15H9.267V13.372H14.04a1.4,1.4,0,0,0,.806-0.158A1.955,1.955,0,0,0,15,12.229V3.1A0.577,0.577,0,0,0,14.368,2.465ZM5.665,8.54A2.3,2.3,0,0,1,4.5,8.69l0,1.91L3.55,10.518l0-5.217c0.864,0.042,1.91-.35,2.626.313A2.021,2.021,0,0,1,5.665,8.54ZM14.242,12.6H9.267V11.581h3.817V10.93H9.267V10.116h3.817V9.465H9.269s0-.638,0-0.957a2.188,2.188,0,0,0,1.91-.192,2.239,2.239,0,0,0,.98-1.778H10.063V4.412l-0.8.164V3.233h4.975V12.6ZM10.381,4.072a2.27,2.27,0,0,1,2.095,2.137H10.381V4.072ZM4.5,6.182a0.953,0.953,0,0,1,.913.228,1.206,1.206,0,0,1,.021,1.068,1.027,1.027,0,0,1-.908.342C4.488,7.274,4.491,6.729,4.5,6.182Z" /></svg>
Deleted web/public/file-icons/powershell.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#497CE3" d="M12.07,13H1.558a0.577,0.577,0,0,1-.544-0.713L3.079,2.876A0.966,0.966,0,0,1,4.009,2H14.461a0.556,0.556,0,0,1,.525.687l-2.112,9.644A0.833,0.833,0,0,1,12.07,13ZM4.554,11.529L9.389,7.9A0.662,0.662,0,0,0,9.478,6.94L6.284,3.4c-0.526-.583-1.425.3-0.9,0.883L8.1,7.287l-4.262,3.2A0.635,0.635,0,1,0,4.554,11.529Zm5.911-1.254H7.9a0.583,0.583,0,0,0,0,1.166h2.566A0.584,0.584,0,0,0,10.465,10.276Z" /></svg>
Deleted web/public/file-icons/prettier.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 512 512"><path fill="#EE6292" transform="scale(0.8) translate(64 64)" d="M304.804657,24.3809528H12.1450272c-16.5529213-0.7516956-15.8297806-24.246706,0-24.3809528H304.804657C320.7158508,0.2409473,321.2848816,23.565073,304.804657,24.3809528z M12.141367,121.9047623c-16.5479336-0.7517014-15.8250103-24.2467041,0-24.3809509h170.7621918c15.9063873,0.2409515,16.4752655,23.5650711,0,24.3809509H12.141367z M134.0461273,73.1428604c-16.5479355-0.7517014-15.8250122-24.2467079,0-24.3809547l243.9050446,0.0000038c15.9064026,0.2409477,16.4752808,23.5650711,0,24.3809509H134.0461273z M329.1869812,97.5238113c15.9063721,0.2409515,16.4775696,23.5650711,0.0022888,24.3809509h-97.619339c-16.5479279-0.7517014-15.8249969-24.2467041,0-24.3809509H329.1869812z M377.8556519,121.9047623c-16.5479431-0.7517014-15.8250122-24.2467041,0-24.3809509h24.4764709c15.9064026,0.2409515,16.4752808,23.5650711,0,24.3809509H377.8556519z M12.141367,170.6666718c-16.5479336-0.751709-15.8250103-24.2467194,0-24.3809662l48.8574371,0.0000153c15.9063797,0.2409515,16.4752655,23.5650635,0,24.3809509H12.141367z M304.7127991,170.6666718c-16.5479431-0.751709-15.8250122-24.2467194,0-24.3809662l122.0002747,0.0000153c15.9064026,0.2409515,16.4752808,23.5650635,0,24.3809509H304.7127991z M109.6651764,170.6666718c-16.5479355-0.751709-15.8250122-24.2467194,0-24.3809662l24.4764862,0.0000153c15.9063721,0.2409515,16.4752655,23.5650635,0,24.3809509H109.6651764z M12.141367,463.2380981c-16.5479336-0.751709-15.8250103-24.2467041,0-24.3809509h48.8574371c15.9063797,0.2409363,16.4752655,23.5650635,0,24.3809509H12.141367z M109.6651764,463.2380981c-16.5479355-0.751709-15.8250122-24.2467041,0-24.3809509h24.4764862c15.9063721,0.2409363,16.4752655,23.5650635,0,24.3809509H109.6651764z M304.7127991,219.4285736c-16.5479431-0.7516937-15.8250122-24.2467041,0-24.3809509h122.0002747c15.9064026,0.2409515,16.4752808,23.5650635,0,24.3809509H304.7127991z M12.141367,219.4285736c-16.5479336-0.7516937-15.8250103-24.2467041,0-24.3809509h122.0002975c15.9063721,0.2409515,16.4752655,23.5650635,0,24.3809509H12.141367z M12.141367,414.4761963c-16.5479336-0.751709-15.8250103-24.2467041,0-24.3809509h122.0002975c15.9063721,0.2409363,16.4752655,23.5650635,0,24.3809509H12.141367z M12.141367,512c-16.5479336-0.751709-15.8250103-24.2467041,0-24.3809509h122.0002975c15.9063721,0.2409363,16.4752655,23.5650635,0,24.3809509H12.141367z M12.141367,268.1904602c-16.5479336-0.7516785-15.8250103-24.2466888,0-24.3809357h24.4764843c15.9063797,0.2409515,16.4752617,23.5650787,0,24.3809662L12.141367,268.1904602z M85.2842255,268.1904602c-16.5479355-0.7516785-15.8250122-24.2466888,0-24.3809357h73.2383881c15.9063873,0.2409515,16.4752655,23.5650787,0,24.3809662L85.2842255,268.1904602z M207.1889801,268.1904602c-16.5479279-0.7516785-15.8250122-24.2466888,0-24.3809357h195.1431427c15.9064026,0.2409515,16.4752808,23.5650787,0,24.3809662L207.1889801,268.1904602z M304.7127991,316.9523926c-16.5479431-0.751709-15.8250122-24.2467041,0-24.3809509h73.2383728c15.9064026,0.2409363,16.4752808,23.5650635,0,24.3809509H304.7127991z M158.4270782,316.9523926c-16.5479279-0.751709-15.8250122-24.2467041,0-24.3809509h97.619339c15.9063721,0.2409363,16.4752808,23.5650635,0,24.3809509H158.4270782z M12.141367,316.9523926c-16.5479336-0.751709-15.8250103-24.2467041,0-24.3809509h97.6193466c15.9063797,0.2409363,16.4752579,23.5650635,0,24.3809509H12.141367z M12.141367,365.7142944c-16.5479336-0.751709-15.8250103-24.2467041,0-24.3809509h24.4764843c15.9063797,0.2409363,16.4752617,23.5650635,0,24.3809509H12.141367z M85.2842255,365.7142944c-16.5479355-0.751709-15.8250122-24.2467041,0-24.3809509h219.5240936c15.9064026,0.2409363,16.4752808,23.5650635,0,24.3809509H85.2842255z M12.141367,73.1428604c-16.5479336-0.7517014-15.8250103-24.2467079,0-24.3809547l73.1919708,0.0000038c15.967186,0.2409477,16.5382385,23.5650711,0,24.3809509H12.141367z"/></svg>
Deleted web/public/file-icons/prisma.svg
@@ -1 +0,0 @@
1<svg version="1.1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="16px" height="16px"><g transform="translate(-.5)" fill-rule="evenodd" fill="#00bfa5"><path d="m19.308 17.864-9.2184 2.6562c-.2816.08125-.55154-.15605-.49232-.4328l3.2931-15.366c.06164-.28734.46928-.33297.59794-.066875l6.0975 12.615c.11501.238-.01617.5186-.2779.5939zm1.5807-.62645-7.0601-14.607v-.000315c-.17706-.36516-.54656-.60609-.96534-.62844-.4296-.024375-.81456.1775-1.0317.5211l-7.657 12.083c-.2372.3766-.23255.84095.013472 1.213l3.7429 5.6488c.22309.3372.60959.53235 1.0113.53235.11387 0 .22837-.0156.34097-.0481l10.865-3.1306c.33282-.09595.60497-.3253.74738-.6294.14195-.3042.13969-.65235-.0064-.9545z"/></g></svg>
Deleted web/public/file-icons/psd.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#1E9CEF" d="M1,15V1H15V15H1ZM6.162,3.887C5.244,3.855,4.15,3.911,4.15,3.911v7.058H5.272V8.414C6.779,8.543,8.43,7.951,8.43,6.146A2.128,2.128,0,0,0,6.162,3.887Zm4.069,3.137c0.01-.366.778-0.591,1.882-0.09V5.848A3.219,3.219,0,0,0,10.1,5.784a1.518,1.518,0,0,0-.681,2.31c0.573,0.681,2,.935,1.765,1.6A1,1,0,0,1,10,9.988a2.944,2.944,0,0,1-.865-0.382v1.15a2.608,2.608,0,0,0,.886.275,2.567,2.567,0,0,0,1.8-.409,1.579,1.579,0,0,0-.519-2.64C10.205,7.449,10.223,7.3,10.231,7.024ZM6.454,7.3a5.4,5.4,0,0,1-1.181.1V4.929a3.856,3.856,0,0,1,1.17.053,1.1,1.1,0,0,1,.81,1.168A1.144,1.144,0,0,1,6.454,7.3Z"/></svg>
Deleted web/public/file-icons/python.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#FFCB6B" d="M7.949,1C4.395,1,4.617,2.549,4.617,2.549l0,1.605H8.013V4.636H3.274S1,4.377,1,7.982s1.985,3.477,1.985,3.477H4.169V9.786a1.89,1.89,0,0,1,1.953-2H9.487a1.81,1.81,0,0,0,1.89-1.836V2.868S11.664,1,7.949,1h0ZM6.079,2.079a0.613,0.613,0,1,1-.61.613A0.611,0.611,0,0,1,6.079,2.079ZM8.052,15c3.554,0,3.332-1.549,3.332-1.549l0-1.605H7.988V11.364h4.739S15,11.623,15,8.018s-1.985-3.477-1.985-3.477H11.832V6.214a1.89,1.89,0,0,1-1.953,2H6.514a1.81,1.81,0,0,0-1.89,1.836v3.087S4.337,15,8.052,15h0Zm1.87-1.079a0.613,0.613,0,1,1,.61-0.613A0.611,0.611,0,0,1,9.922,13.921Z" /></svg>
Deleted web/public/file-icons/r.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#497CE3" d="M15.275,9.5c2.585-4.065-2.321-6.72-5.6-7.333C3.1,0.939-3.71,6.736,2.359,11.113A8.349,8.349,0,0,0,6,12.406V14H8.685V12.512a12.832,12.832,0,0,0,2.289-.336C11.449,13.089,11.856,14,11.856,14h3.05s-0.991-1.657-1.623-2.7A5.45,5.45,0,0,0,15.275,9.5Zm-6.59,1.81V10.345h1a1.877,1.877,0,0,1,.777.9A11.533,11.533,0,0,1,8.685,11.31ZM10.69,8.593H8.685V6.823H10.69a1.118,1.118,0,0,1,.668.9A1.059,1.059,0,0,1,10.69,8.593Zm2.194,2.048c-0.041-.066-0.072-0.117-0.092-0.147a2.922,2.922,0,0,0-1.286-.6A2.542,2.542,0,0,0,13.97,7.305c-0.022-1.849-1.88-2.355-2.813-2.355H6v5.883a4.873,4.873,0,0,1-1.069-.515c-4.651-3.083.566-7.166,5.6-6.3,2.514,0.432,6.274,2.3,4.293,5.165A4.372,4.372,0,0,1,12.884,10.641Z" /></svg>
Deleted web/public/file-icons/react.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 512 512" preserveAspectRatio="xMidYMid meet"><path fill="#26A2C1" d="M300.7026672,254.8256073c0-35.0435791-37.9459534-57.064743-68.1388092-39.5429382c-30.1928406,17.5217743-30.1928406,61.5641022,0.0000153,79.0858612C262.7567139,311.8903198,300.7026672,289.8691711,300.7026672,254.8256073z M404.7128601,170.6555023c30.0607605-143.6903687-36.072876-188.7814789-148.5000916-84.7713013C150.3989563-13.3162594,75.0466537,17.9469166,107.1114731,170.0542908C-40.749897,214.6295471-30.9532928,298.539856,108.3139038,341.4005432c-31.8643951,140.0830994,27.6558838,191.1863708,147.8988647,85.3725281c116.6357117,104.6113892,178.5369263,55.6321106,146.6964417-85.9737244C554.8162231,289.2951965,541.1886597,214.7445526,404.7128601,170.6555023z M384.2715454,165.2445679c-23.8307495-5.375412-46.8896179-9.2882996-70.3421326-11.4230804c-12.3563232-18.0675659-26.7483521-36.115387-43.2874756-52.9069061C360.7797852,14.4541903,413.9045715,41.4697456,384.2715454,165.2445679z M151.6013794,282.4814758c8.918045,19.0384521,20.6417389,36.4736938,29.4595337,51.1032715c-14.3586273-1.4685364-29.8273621-3.6986389-46.2935486-6.6133423C140.0435486,312.2022095,145.6609344,297.3711853,151.6013794,282.4814758z M150.3989563,230.7770081c-6.5595093-15.7457123-12.5452271-30.9425354-17.4352417-45.0911255c16.9986267-3.5235443,33.6560059-6.7227173,48.0971985-7.8157959C170.3130951,194.7540894,160.1044769,212.4068451,150.3989563,230.7770081z M163.0244598,257.2304688c14.7019501-29.4436951,30.2539062-57.3918915,47.4959869-82.3664551c30.5939941-1.7593842,60.8627319-1.6035156,90.7834473,0.6012115c16.781189,23.3826599,32.4436035,51.329834,47.4959717,81.7652435c-11.2641907,23.2770996-27.2587585,50.103241-45.6923218,78.7591553c-30.7770996,2.3305664-62.3356171,2.0082397-94.3907471,0C192.4701996,311.0995178,177.2120972,284.8831787,163.0244598,257.2304688z M360.2229614,282.4814758c5.8381348,14.1664429,11.2332153,28.6053467,16.2327881,43.2874756c-15.4307861,3.2492371-30.652771,5.6168823-45.6923218,7.2145996C341.0576782,317.0340271,350.8233337,300.098938,360.2229614,282.4814758z M330.7634277,177.8700867c16.3929443,2.2440643,32.2282715,5.046402,47.4959717,8.4170074c-5.107666,15.1780396-10.9108887,30.0122375-17.4352112,44.4899139C351.5182495,212.422699,341.526947,194.7579803,330.7634277,177.8700867z M284.4698792,151.416626c-18.7246399-0.7345581-37.7732086-0.7109985-57.1154175,0c8.4677277-11.1366272,17.9058075-22.7698364,28.2570953-34.8704605C265.6993713,127.435112,275.2571106,139.155426,284.4698792,151.416626z M241.1824036,100.9145813c-16.5391388,16.7915192-30.9311523,34.2381287-43.2874756,52.3056946c-23.4525146,2.1347809-47.1125183,6.0476685-70.9433594,11.4230804C98.6944656,38.9894409,151.0445709,14.4541903,241.1824036,100.9145813z M113.7248383,321.5604553c-125.2531357-37.676178-119.2409821-96.3948212-1.2024307-131.0648499c7.2205963,21.9109497,15.9619217,44.1636047,25.8522415,65.5324097C128.7643127,278.1272278,120.2563705,300.8252869,113.7248383,321.5604553z M129.3564301,346.8114929c21.2429504,5.2105103,43.7547455,7.711853,67.3360748,10.2206421c14.188797,20.0240784,28.8825989,38.6404724,44.4898987,54.7105713C149.3969421,499.7204285,98.229538,467.7562256,129.3564301,346.8114929z M255.611557,396.7123108c-10.8765411-11.2629395-21.0314331-24.0250854-30.6619568-37.8765259c21.4670105,0.452301,42.2002716,0.5025024,61.9251251,0C277.3218689,372.3880005,266.8888245,385.0007324,255.611557,396.7123108z M270.6419373,411.7427063c16.7466125-18.195282,31.5098267-36.6409607,44.4898987-55.3117981c22.1543274-1.6300659,44.5992737-5.033905,67.3360901-10.2206421C413.8760376,474.9438171,356.8478088,493.9532471,270.6419373,411.7427063z M397.498291,320.3580322c-6.440094-20.7351685-14.5727844-42.2308044-24.0486145-64.3300171c9.7519226-21.3688049,18.1315613-43.0202484,25.2510376-64.9311981C513.9336548,225.9672852,523.1522217,281.6798401,397.498291,320.3580322z"/></svg>
Deleted web/public/file-icons/readme.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 12 16"><path fill-rule="evenodd" fill="#000" d="M4 9H3V8h1v1zm0-3H3v1h1V6zm0-2H3v1h1V4zm0-2H3v1h1V2zm8-1v12c0 .55-.45 1-1 1H6v2l-1.5-1.5L3 16v-2H1c-.55 0-1-.45-1-1V1c0-.55.45-1 1-1h10c.55 0 1 .45 1 1zm-1 10H1v2h2v-1h3v1h5v-2zm0-10H2v9h9V1z"/></svg>
Deleted web/public/file-icons/redis.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 512 512" preserveAspectRatio="xMidYMid meet"><path fill="#D52A29" d="M228.4079132,50.3201981l10.2600861,20.8250961l-36.3735809,13.672348l50.6557007,5.8160248l15.7830048,25.7968521l10.0138855-24.0145416l43.5810852-4.6014481l-33.3561096-13.2452621l8.2315369-21.1299706l-33.2310486,10.4125481L228.4079132,50.3201981z M298.7278137,124.8027878l69.3701172,27.8137283l77.6016846-30.7686081l-69.9095154-27.4853897L298.7278137,124.8027878z M21.8101215,197.2685242l204.147049,85.7628784c19.3867493,9.4212341,39.14534,8.86203,64.4921722-4.5027771C368.25,241.6990356,465.75,203.9490356,503.8363647,182.6815491c8.536438-6.0505524,8.1611938-4.5730743,8.1611938-34.0284271c0-14.0710297-0.0703735-25.562355-0.2345276-25.562355c-1.9319763,2.8040466-5.7601624,5.8947067-10.3421936,9.0992966C464.75,152.6990356,376.6666565,187.7823639,296.8047485,224.073822c-20.425293,9.9918518-40.8727417,19.0541382-61.7281647,10.9678497c-87.8235626-37.7087097-198.8301392-81.7327728-222.4222565-94.3990479c-4.6928158-2.3984528-8.9210491-5.8605194-12.607378-10.5632172v52.9070282C4.9661202,188.5084534,12.711916,193.1089478,21.8101215,197.2685242z M184.142807,169.1030426l81.3304901,34.1691284l35.9749146-51.7344666L184.142807,169.1030426z M213.2463074,365.0888977c37.2536926,18.6934814,52.4203491,13.3601379,81.0256042-0.9380798c77.3947449-35.5351257,172.5884094-74.6924133,207.4771423-92.5873108c3.5332947-2.58078,6.9596558-5.5829163,10.1780396-9.4041138c0,0-0.070343-52.0627594-0.2345276-52.0627594C502.9659424,228.7567902,390.5,266.6156921,298.3056335,310.8685608c-26.4677124,12.9187317-44.5664368,22.0342102-77.3671722,6.2146912c-83.531189-37.5495605-201.5835266-80.324173-217.2096405-95.4014893L0,218.1874695v52.0393066l4.1978517,3.9633484C15.7833824,287.1846313,117,321.4490356,213.2463074,365.0888977z M294.4595642,449.0694275c20.7313232-9.943512,36.0452881-16.7679749,103.7034302-45.9184265c93.9944153-40.4776306,104.1724548-45.3555908,111.3252258-53.6105652l2.4859009-2.790741c0,0-0.0703735-51.9689636-0.1641846-51.9689636c-0.1641541,0-1.2429199,1.2429199-2.4859009,2.790741c-6.1443176,7.6921387-18.4095764,13.6019897-113.8814697,54.6893921c-62.3111572,26.8756409-81.2601318,35.2713318-101.7569275,45.0506897c-30.0199585,14.5611267-43.1984406,15.8789673-67.6581421,6.2147217L18.2923527,315.605896c-7.3872862-3.7288208-14.9152842-9.0054626-16.6272583-11.7258606l-1.641623-2.4859009v26.5003967c0,30.3699646-0.5393835,28.1185913,7.621798,33.6297607c9.0054483,6.0505066,22.372921,11.9603577,111.2548676,48.849884c62.1469955,25.796875,84.5199356,35.2009888,95.5422134,40.1727295C249.6130066,469.0687866,267.5249023,461.5767822,294.4595642,449.0694275z M119.6154938,146.5650787c-42.2966461-9.3899078-42.2966614-32.9921646-0.0000153-42.3820801s95.4545288,2.4112015,95.4545288,21.19104S161.9121399,155.9550018,119.6154938,146.5650787z"/></svg>
Deleted web/public/file-icons/rollup.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 387 512" width="16px" height="16px"><path fill="#FF5370" d="M0,424.237915V0c0-0.0000221,214.4265747,0,214.4265747,0c59.985199,0.9047534,112.3703918,32.8415298,141.9558411,80.522049c-21.7140808-26.6902275-55.2804871-41.7091141-88.5754089-41.7091141c-63.3327332,0-79.2563629,22.5283279-200.8552551,246.0929565C39.1691704,335.5903015,15.8385849,382.8205566,0,424.237915z M386.2718201,512l-96.3962097-190.8722534c82.7416077-42.782074,118.753418-147.2455292,69.9448547-234.7247925c-38.0960083-38.6881065-117.3150024-47.1472778-138.0653687-0.1809616c-19.962326,46.5788422,31.1603241,98.2388611,56.3742065,97.6726685c23.5373535-1.0623322,16.0852661-35.0139008-3.0390015-76.2300034c20.5595093,38.781868,30.0801697,55.379921,27.0989075,68.6203842c-3.7574158,17.0569611-28.12146,28.4887543-76.0008392,74.330658C141.1414948,332.043457,55.8213921,503.1380615,39.9101524,512H386.2718201z"/></svg>
Deleted web/public/file-icons/rst.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 512 512"><g fill="#AB7967"><path d="M142.1862793,244.0549316c-3.2392578-2.8183594-8.0996094-4.2294922-14.5800781-4.2294922H96.6462402v36.1796875h30.9599609c6.4804688,0,11.3408203-1.4404297,14.5800781-4.3203125c3.2402344-2.8798828,4.8603516-7.5595703,4.8603516-14.0400391C147.0466309,251.4064941,145.4265137,246.8762207,142.1862793,244.0549316z"/><path d="M0,96v320h512V96H0z M150.8269043,346.3850098c-1.5615234-4.078125-2.5205078-8.9375-2.8798828-14.5800781c-0.3603516-5.6386719-0.9003906-11.0390625-1.6201172-16.1992188c-0.9619141-6.7207031-3.0009766-11.6386719-6.1201172-14.7597656c-3.1220703-3.1191406-8.2207031-4.6796875-15.2998047-4.6796875H96.6462402v50.21875H68.3864746V217.8654785h69.2998047c5.7607422,0,10.9492188,0.9306641,15.5703125,2.7890625c4.6181641,1.8623047,8.578125,4.4101562,11.8798828,7.6503906c3.2988281,3.2402344,5.8193359,6.9921875,7.5605469,11.25c1.7373047,4.2607422,2.609375,8.8505859,2.609375,13.7705078c0,7.5595703-1.5917969,14.1015625-4.7695312,19.6191406c-3.1816406,5.5214844-8.3701172,9.7207031-15.5703125,12.6005859v0.359375c3.4794922,0.9628906,6.359375,2.4316406,8.6396484,4.4101562c2.2783203,1.9804688,4.140625,4.3203125,5.5800781,7.0214844c1.4404297,2.6992188,2.4892578,5.6699219,3.1503906,8.9082031c0.6582031,3.2402344,1.1083984,6.4804688,1.3496094,9.7207031c0.1181641,2.0410156,0.2392578,4.4414062,0.3603516,7.1992188c0.1181641,2.7617188,0.3291016,5.5800781,0.6298828,8.4609375c0.2978516,2.8789062,0.7792969,5.6113281,1.4404297,8.1894531c0.6582031,2.5820312,1.6474609,4.7695312,2.9697266,6.5703125H150.8269043z M335.4460449,316.529541c-3.1269531,6.9082031-7.7714844,12.8730469-13.9414062,17.8886719c-6.1679688,5.0195312-13.8164062,8.9257812-22.9453125,11.7207031c-9.1308594,2.7949219-19.7011719,4.1933594-31.7060547,4.1933594c-9.7070312,0-19.1220703-1.1953125-28.2509766-3.578125c-9.1298828-2.3808594-17.1904297-6.125-24.1806641-11.2265625c-6.9931641-5.0957031-12.5449219-11.5957031-16.6542969-19.4921875c-4.1142578-7.8945312-6.0878906-17.2695312-5.921875-28.1269531h37.5039062c0,5.921875,1.0683594,10.9414062,3.2080078,15.0507812c2.1357422,4.1132812,4.9726562,7.4453125,8.5117188,9.9921875c3.5361328,2.5527344,7.6875,4.4414062,12.4609375,5.6757812c4.7685547,1.234375,9.703125,1.8496094,14.8037109,1.8496094c3.4550781,0,7.1552734-0.2851562,11.1025391-0.8632812c3.9472656-0.5742188,7.6484375-1.6835938,11.1035156-3.3300781c3.4550781-1.6425781,6.3300781-3.90625,8.6347656-6.7851562c2.3027344-2.8769531,3.4550781-6.5390625,3.4550781-10.9804688c0-4.7675781-1.5234375-8.6347656-4.5644531-11.5957031c-3.0449219-2.9609375-7.03125-5.4296875-11.9667969-7.4033203s-10.5292969-3.7011719-16.7773438-5.1816406c-6.2529297-1.4804688-12.5839844-3.1220703-18.9990234-4.9345703c-6.5810547-1.6425781-12.9960938-3.6582031-19.2451172-6.0449219c-6.2539062-2.3828125-11.84375-5.4667969-16.7783203-9.2529297c-4.9345703-3.7822266-8.9248047-8.5126953-11.9667969-14.1875c-3.0458984-5.6748047-4.5644531-12.5410156-4.5644531-20.6025391c0-9.0439453,1.9316406-16.9013672,5.7978516-23.5634766c3.8632812-6.6621094,8.9208984-12.2138672,15.1748047-16.6542969c6.2490234-4.4414062,13.3232422-7.7304688,21.2197266-9.8701172c7.8955078-2.1357422,15.7910156-3.2070312,23.6865234-3.2070312c9.2099609,0,18.0498047,1.0292969,26.5224609,3.0839844c8.4707031,2.0585938,15.9960938,5.3896484,22.578125,9.9931641c6.5761719,4.6064453,11.8007812,10.4863281,15.6679688,17.6416016c3.8613281,7.1552734,5.796875,15.8330078,5.796875,26.0302734h-37.5039062c-0.3320312-5.2617188-1.4414062-9.6220703-3.3300781-13.0771484c-1.8925781-3.4541016-4.4042969-6.1679688-7.5253906-8.1416016c-3.1269531-1.9746094-6.7050781-3.3701172-10.734375-4.1953125c-4.03125-0.8203125-8.4296875-1.2333984-13.1992188-1.2333984c-3.1259766,0-6.2529297,0.3320312-9.3759766,0.9873047c-3.1269531,0.6591797-5.9638672,1.8115234-8.5126953,3.4541016c-2.5517578,1.6464844-4.6494141,3.7011719-6.2910156,6.1679688c-1.6464844,2.4677734-2.4677734,5.5947266-2.4677734,9.3769531c0,3.4541016,0.6552734,6.2529297,1.9736328,8.3886719c1.3144531,2.1396484,3.9052734,4.1132812,7.7724609,5.921875c3.8632812,1.8115234,9.2099609,3.6201172,16.0380859,5.4277344c6.8242188,1.8125,15.7480469,4.1142578,26.7695312,6.9091797c3.2890625,0.6591797,7.8535156,1.8505859,13.6953125,3.5771484c5.8359375,1.7275391,11.6347656,4.484375,17.3945312,8.2666016c5.7558594,3.7851562,10.7324219,8.84375,14.9277344,15.1738281c4.1933594,6.3339844,6.2910156,14.4345703,6.2910156,24.3037109C340.1335449,302.138916,338.5690918,309.6213379,335.4460449,316.529541z M453.6940918,241.6252441h-38.5214844v104.7597656h-28.2597656V241.6252441h-38.5195312v-23.7597656h105.3007812V241.6252441z"/></g></svg>
Deleted web/public/file-icons/ruby.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#D14748" d="M13,6L8,11V4h3Zm3,0L8,14,0,6,4,2h8ZM8,12.5L14.5,6l-3-3h-7l-3,3Z" /></svg>
Deleted web/public/file-icons/rust.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#a72146" d="M14.9,7.831l-0.587-.363c0-.057-0.01-0.114-0.017-0.171l0.5-.471a0.2,0.2,0,0,0-.067-0.337l-0.645-.241c-0.016-.056-0.033-0.111-0.05-0.167l0.4-.559a0.2,0.2,0,0,0,.023-0.2A0.2,0.2,0,0,0,14.308,5.2l-0.68-.111c-0.026-.052-0.054-0.1-0.082-0.153l0.286-.628a0.2,0.2,0,0,0-.191-0.286l-0.69.024c-0.036-.044-0.072-0.089-0.109-0.133L13,3.246A0.2,0.2,0,0,0,12.758,3l-0.672.159C12.042,3.124,12,3.088,11.953,3.052l0.024-.69a0.2,0.2,0,0,0-.089-0.175,0.2,0.2,0,0,0-.2-0.016l-0.628.286-0.153-.082L10.8,1.695a0.2,0.2,0,0,0-.123-0.154,0.2,0.2,0,0,0-.2.023l-0.559.4-0.166-.05L9.517,1.27A0.2,0.2,0,0,0,9.18,1.2l-0.471.505L8.538,1.691,8.174,1.1a0.2,0.2,0,0,0-.344,0l-0.363.587L7.3,1.708,6.825,1.2a0.2,0.2,0,0,0-.337.067l-0.242.645-0.166.05-0.559-.4a0.2,0.2,0,0,0-.318.131l-0.111.68C5.042,2.4,4.99,2.429,4.94,2.457L4.312,2.171a0.2,0.2,0,0,0-.2.016,0.2,0.2,0,0,0-.089.175l0.024,0.69c-0.044.036-.089,0.072-0.133,0.109L3.245,3A0.2,0.2,0,0,0,3,3.246L3.161,3.919c-0.037.044-.073,0.087-0.109,0.133l-0.69-.024a0.2,0.2,0,0,0-.191.286L2.457,4.94c-0.028.05-.055,0.1-0.082,0.153L1.695,5.2a0.2,0.2,0,0,0-.131.318l0.4,0.559c-0.017.055-.034,0.111-0.05,0.167L1.27,6.489a0.2,0.2,0,0,0-.067.337L1.708,7.3C1.7,7.354,1.7,7.411,1.691,7.468L1.1,7.831a0.2,0.2,0,0,0,0,.344L1.691,8.538C1.7,8.6,1.7,8.653,1.708,8.709L1.2,9.18a0.2,0.2,0,0,0,.067.337L1.915,9.759c0.016,0.056.033,0.112,0.05,0.167l-0.4.559a0.2,0.2,0,0,0-.023.195,0.2,0.2,0,0,0,.154.122l0.68,0.111c0.026,0.052.054,0.1,0.082,0.153l-0.286.627a0.2,0.2,0,0,0,.191.286l0.69-.024c0.036,0.045.072,0.089,0.109,0.133L3,12.759A0.2,0.2,0,0,0,3.245,13l0.673-.158,0.133,0.109-0.024.691a0.2,0.2,0,0,0,.286.19l0.628-.286c0.05,0.028.1,0.055,0.153,0.082L5.2,14.311a0.2,0.2,0,0,0,.122.154,0.205,0.205,0,0,0,.2-0.022l0.559-.4,0.167,0.05,0.242,0.645a0.2,0.2,0,0,0,.337.067L7.3,14.3c0.057,0.007.114,0.012,0.172,0.017L7.832,14.9a0.2,0.2,0,0,0,.343,0l0.363-.587L8.709,14.3,9.18,14.8a0.2,0.2,0,0,0,.337-0.067l0.242-.645,0.167-.05,0.559,0.4a0.2,0.2,0,0,0,.2.022,0.2,0.2,0,0,0,.122-0.154l0.111-.68,0.153-.082,0.628,0.286a0.2,0.2,0,0,0,.285-0.19l-0.024-.691,0.133-.109L12.76,13a0.2,0.2,0,0,0,.189-0.054A0.2,0.2,0,0,0,13,12.759l-0.158-.673c0.037-.044.073-0.087,0.109-0.133l0.69,0.024a0.2,0.2,0,0,0,.175-0.089,0.2,0.2,0,0,0,.015-0.2l-0.286-.627c0.027-.05.055-0.1,0.082-0.153l0.68-.111a0.2,0.2,0,0,0,.131-0.317l-0.4-.559c0.017-.055.034-0.111,0.05-0.167l0.645-.241a0.2,0.2,0,0,0,.127-0.15A0.2,0.2,0,0,0,14.8,9.18l-0.5-.471c0.006-.057.011-0.114,0.017-0.171L14.9,8.175a0.2,0.2,0,0,0,0-.344h0ZM10.968,12.7a0.416,0.416,0,1,1,.493-0.319A0.417,0.417,0,0,1,10.968,12.7Zm-0.2-1.349a0.378,0.378,0,0,0-.449.291l-0.209.973A5.129,5.129,0,0,1,5.846,12.6l-0.209-.973a0.379,0.379,0,0,0-.449-0.291l-0.859.184a5.153,5.153,0,0,1-.444-0.523h4.18c0.047,0,.079-0.009.079-0.052V9.463c0-.043-0.032-0.052-0.079-0.052H6.842V8.474H8.164a0.831,0.831,0,0,1,.813.7c0.052,0.206.168,0.877,0.247,1.092a1.067,1.067,0,0,0,.739.722h2.083a0.432,0.432,0,0,0,.075-0.007,5.112,5.112,0,0,1-.473.556l-0.879-.189h0Zm-5.78,1.329a0.416,0.416,0,1,1,.319-0.494A0.415,0.415,0,0,1,4.988,12.682ZM3.4,6.253a0.415,0.415,0,1,1-.548-0.212A0.416,0.416,0,0,1,3.4,6.253ZM2.916,7.408l0.895-.4A0.379,0.379,0,0,0,4,6.51L3.819,6.093H4.543V9.36H3.081a5.107,5.107,0,0,1-.2-1.4,5.29,5.29,0,0,1,.029-0.55h0Zm3.927-.317V6.129H8.569a0.644,0.644,0,0,1,.63.507,0.642,0.642,0,0,1-.755.455h-1.6Zm6.272,0.866q0,0.192-.014.379H12.575a0.072,0.072,0,0,0-.074.086V8.664a0.619,0.619,0,0,1-.6.722,0.574,0.574,0,0,1-.6-0.275,1.8,1.8,0,0,0-.834-1.4,1.886,1.886,0,0,0,1.049-1.453,1.643,1.643,0,0,0-.8-1.349,2.282,2.282,0,0,0-1.1-.361H4.19A5.107,5.107,0,0,1,7.051,2.932L7.691,3.6a0.379,0.379,0,0,0,.535.012L8.942,2.93a5.118,5.118,0,0,1,3.5,2.494L11.953,6.53a0.38,0.38,0,0,0,.193.5L13.089,7.45C13.106,7.617,13.114,7.787,13.114,7.958Zm-5.423-5.6a0.415,0.415,0,1,1-.014.588A0.415,0.415,0,0,1,7.691,2.361Zm4.862,3.912a0.415,0.415,0,1,1,.211.549A0.416,0.416,0,0,1,12.554,6.273Z" /></svg>
Deleted web/public/file-icons/sass.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#F03D70" d="M15.984,10.5a1.63,1.63,0,0,0-.657-1.114A2.409,2.409,0,0,0,13.9,8.9a3.218,3.218,0,0,0-1.244.193c-0.114.042-.227,0.091-0.342,0.137C12.3,9.2,12.279,9.172,12.266,9.144a2.331,2.331,0,0,1-.292-0.754,1.69,1.69,0,0,1,0-.591c0.045-.188.1-0.373,0.159-0.559a0.136,0.136,0,0,0-.082-0.192,0.369,0.369,0,0,0-.1-0.033,1.545,1.545,0,0,0-.66.049,0.19,0.19,0,0,0-.149.151c-0.01.049-.03,0.1-0.043,0.144a3.613,3.613,0,0,1-.188.6c-0.229.479-.49,0.939-0.758,1.4-0.029.049-.059,0.1-0.1,0.158a2.076,2.076,0,0,1-.225-0.583,1.8,1.8,0,0,1,0-.7c0.043-.174.1-0.345,0.152-0.516a0.119,0.119,0,0,0-.06-0.167A0.354,0.354,0,0,0,9.8,7.492a1.477,1.477,0,0,0-.683.056,0.186,0.186,0,0,0-.127.139,5.424,5.424,0,0,1-.2.63c-0.354.815-.718,1.627-1.082,2.437-0.076.169-.165,0.333-0.251,0.5a1.2,1.2,0,0,1-.08.118,0.1,0.1,0,0,1-.03-0.061A1.673,1.673,0,0,1,7.348,11c0.06-.3.129-0.608,0.2-0.91,0.06-.249.13-0.5,0.2-0.745a0.233,0.233,0,0,0-.037-0.23,0.341,0.341,0,0,0-.431-0.052c-0.013.01-.029,0.016-0.055,0.031,0-.039.007-0.064,0.011-0.089a1.39,1.39,0,0,0-.02-0.566,0.448,0.448,0,0,0-.47-0.359,1.007,1.007,0,0,0-.333.064,2.52,2.52,0,0,0-1.057.842,0.327,0.327,0,0,1-.1.084c-0.369.206-.74,0.409-1.11,0.613l-0.412.227C3.624,9.8,3.523,9.691,3.409,9.6c-0.348-.285-0.7-0.563-1.052-0.845a4.163,4.163,0,0,1-.842-0.841,1.5,1.5,0,0,1-.292-0.82,1.933,1.933,0,0,1,.293-1.072,4.113,4.113,0,0,1,.972-1.068A8.95,8.95,0,0,1,3.764,4.1a9.617,9.617,0,0,1,1.959-.869,5.392,5.392,0,0,1,2.165-.247,2.213,2.213,0,0,1,.833.243,0.862,0.862,0,0,1,.438.476,1.2,1.2,0,0,1,.02.667,2.55,2.55,0,0,1-.663,1.16A4.475,4.475,0,0,1,6.684,6.7a5.006,5.006,0,0,1-1.246.267,2.334,2.334,0,0,1-1.019-.124,1.326,1.326,0,0,1-.562-0.383,0.084,0.084,0,0,0-.113-0.037,0.1,0.1,0,0,0-.042.126,1.208,1.208,0,0,0,.1.235,1.343,1.343,0,0,0,.445.423,2.17,2.17,0,0,0,1.105.3,5.535,5.535,0,0,0,1.742-.223A4.272,4.272,0,0,0,9.183,6.019a3.231,3.231,0,0,0,.883-1.849A1.794,1.794,0,0,0,9.9,3.152a1.792,1.792,0,0,0-.8-0.766,3.746,3.746,0,0,0-1.573-.391H6.978a6.533,6.533,0,0,0-1.933.4,13.23,13.23,0,0,0-1.387.593,8.956,8.956,0,0,0-2.644,1.9A3.956,3.956,0,0,0,.142,6.267C0.089,6.422.056,6.586,0.018,6.746c0,0.013.012,0.026-.021,0.039V7.011a0.638,0.638,0,0,1,.051.173,2.535,2.535,0,0,0,.541.993A6.512,6.512,0,0,0,1.8,9.252C2.185,9.528,2.572,9.8,2.955,10.07c0.066,0.047.127,0.1,0.2,0.158-0.061.033-.113,0.063-0.166,0.09a8.848,8.848,0,0,0-1.44.9,2.955,2.955,0,0,0-.716.727,1.571,1.571,0,0,0-.181,1.508,0.89,0.89,0,0,0,.3.391,0.44,0.44,0,0,0,.148.077C1.236,13.949,1.374,13.97,1.511,14H1.9a2.4,2.4,0,0,0,1.17-.43,2.579,2.579,0,0,0,1.081-1.55,2.749,2.749,0,0,0-.016-1.43C4.12,10.542,4.1,10.5,4.087,10.456s-0.036-.084-0.059-0.137l0.878-.507L4.919,9.823c-0.016.052-.033,0.1-0.047,0.158a3.584,3.584,0,0,0-.149,1.169,2.251,2.251,0,0,0,.391,1.173,0.565,0.565,0,0,0,.886.01,2.052,2.052,0,0,0,.2-0.322c0.143-.3.276-0.6,0.412-0.9,0.006-.013.011-0.026,0.024-0.055-0.01.142-.021,0.267-0.027,0.392a1.5,1.5,0,0,0,.069.574,0.394,0.394,0,0,0,.334.3,0.419,0.419,0,0,0,.313-0.115,1.326,1.326,0,0,0,.344-0.41c0.243-.448.492-0.892,0.712-1.351,0.238-.5.446-1.007,0.667-1.512,0.012-.028.023-0.056,0.037-0.09,0.052,0.206.089,0.408,0.156,0.6a5.583,5.583,0,0,0,.279.648,0.194,0.194,0,0,1-.025.247c-0.288.349-.571,0.7-0.854,1.054a2.326,2.326,0,0,0-.177.242,0.47,0.47,0,0,0-.061.175,0.223,0.223,0,0,0,.177.253,1.223,1.223,0,0,0,.346.025,2.022,2.022,0,0,0,1.072-.4,1.131,1.131,0,0,0,.444-1.218,2.848,2.848,0,0,0-.121-0.394,0.146,0.146,0,0,1,.016-0.154,12.522,12.522,0,0,0,.87-1.483,0.313,0.313,0,0,1,.039-0.062,3.859,3.859,0,0,0,.44,1.255c-0.116.113-.232,0.215-0.336,0.328a2.254,2.254,0,0,0-.533.846,1.008,1.008,0,0,0-.059.437,0.367,0.367,0,0,0,.452.334,2.056,2.056,0,0,0,1.227-.76,1,1,0,0,0,.14-0.912C12.555,9.81,12.527,9.723,12.5,9.622c0.121-.038.24-0.083,0.362-0.111a3.2,3.2,0,0,1,1.876.11,1.417,1.417,0,0,1,.817.714,0.826,0.826,0,0,1-.313,1.118,0.733,0.733,0,0,0-.113.077,0.111,0.111,0,0,0-.026.086,0.086,0.086,0,0,0,.068.035,0.621,0.621,0,0,0,.346-0.144,1.047,1.047,0,0,0,.474-0.746c0-.009-0.012-0.018.02-0.027V10.522c-0.033-.009-0.019-0.017-0.02-0.027h0ZM3.55,11.363a2.038,2.038,0,0,1-.722,1.41A1.639,1.639,0,0,1,2,13.187a0.607,0.607,0,0,1-.344-0.024,0.353,0.353,0,0,1-.2-0.361,1.31,1.31,0,0,1,.194-0.665,2.37,2.37,0,0,1,.6-0.667,9.289,9.289,0,0,1,1.183-.8,0.281,0.281,0,0,1,.031-0.013,2.094,2.094,0,0,1,.076.706h0ZM6.761,8.878a4.348,4.348,0,0,1-.12.542,16.065,16.065,0,0,1-.674,1.791,3.047,3.047,0,0,1-.175.31,0.221,0.221,0,0,1-.036.043c-0.074.073-.144,0.069-0.181-0.027a2.023,2.023,0,0,1-.093-0.348,1.826,1.826,0,0,1-.016-0.243,3.056,3.056,0,0,1,.477-1.587A1.491,1.491,0,0,1,6.4,8.84a0.825,0.825,0,0,1,.2-0.091c0.108-.031.175,0.02,0.157,0.129h0Zm2.191,2.8,0.787-.924A1.072,1.072,0,0,1,8.952,11.682Zm2.515-.621c-0.056.032-.116,0.055-0.174,0.085s-0.069,0-.071-0.045a0.31,0.31,0,0,1,.012-0.092,1.984,1.984,0,0,1,.593-0.96,0.1,0.1,0,0,1,.02-0.011,0.849,0.849,0,0,1-.381,1.023h0Z" /></svg>
Deleted web/public/file-icons/scala.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#FF5370" d="M13,1c-0.737.877-7.155,1.55-9.99,1.867V6.16c2.726-.249,9.265-0.736,9.951-1.867ZM12.99,5.439C12.253,6.316,5.835,6.989,3,7.306V10.6c2.726-.249,9.266-0.736,9.951-1.867ZM13,9.84c-0.737.877-7.155,1.551-9.99,1.867V15c2.726-.249,9.266-0.736,9.952-1.867Z" /></svg>
Deleted web/public/file-icons/settings.svg
@@ -1 +0,0 @@
1<svg version="1.1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="16px" height="16px"><path d="m12.002 15.5a3.5 3.5 0 0 1 -3.5 -3.5 3.5 3.5 0 0 1 3.5 -3.5 3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1 -3.5 3.5m7.43-2.53c.04-.32.07-.64.07-.97s-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65c-.04-.24-.25-.42-.5-.42h-4c-.25 0-.46.18-.5.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.22-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.63c-.04.34-.07.67-.07 1s.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64z" fill="#81CBC4"/></svg>
Deleted web/public/file-icons/shell.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#AA759F" d="M13.992,2H2a0.961,0.961,0,0,0-1,.916v9.165a0.961,0.961,0,0,0,1,.917h12a0.961,0.961,0,0,0,1-.917V2.912A0.961,0.961,0,0,0,13.992,2ZM3,8.411L5,6.578,3,4.745l1-.916,3,2.75L4,9.327Zm8,0.917h-4V8.411h4V9.327Z" /></svg>
Deleted web/public/file-icons/sketch.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#E99727" d="M7.544,13.971L3.677,6.784,1.062,4.8ZM14.966,4.8L12.351,6.784,8.484,13.971ZM11.7,6.861H4.3L8,14l3.7-7.14h0ZM3.791,6.236L1,4.147,2.7,2.2l2.409,1.16L3.792,6.236h0ZM5.577,3.547L4.3,6.35h7.4l-1.275-2.8H5.577Zm6.63,2.688L15,4.147,13.305,2.2l-2.41,1.16,1.312,2.879h0Zm-1.785-3.2H5.577L3.231,1.891,5.36,1h5.279l2.13,0.891Z" /></svg>
Deleted web/public/file-icons/solidity.svg
@@ -1 +0,0 @@
1<svg width="16px" height="16px" version="1.1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><g transform="translate(2.5e-7 -.0038283)" fill="#0288d1"><path d="m5.7473 14.05 6.2533 8.6104 6.252-8.6104-6.2533 3.8072z"/><path d="m11.999 1.3471-6.252 10.486 6.252 3.8072 6.2533-3.8072z"/><rect x=".8969" y=".69165" width="0" height="0"/></g></svg>
Deleted web/public/file-icons/source.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 512 512"><path fill="#85D2CB" d="M126.4771271,102.0586624L0,255.9999695l126.4771271,153.9150696l29.9060516-49.2045288L71.1548615,255.9736786l85.2283173-104.7368317L126.4771271,102.0586624z M181.8256378,406.2391663h55.5060577l88.2478333-301.3448486h-55.5323181L181.8256378,406.2391663z M385.5228882,102.0586624l-29.9060669,49.2045059l85.2283325,104.7368011l-85.2283325,104.7368469l29.9060669,49.2045288L512,256.0262756L385.5228882,102.0586624z"/></svg>
Deleted web/public/file-icons/sqlite.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 229 512"><path fill="#26A2C1" d="M119.4475555,34.3323021C74.4954529,82.0261002,32.7584534,170.3720245,19.7797623,237.8494873c6.2101078,12.2523499,12.4576721,34.8269043,15.1089478,48.285553c-2.728714-11.5440979-10.1588497-24.0269775-18.4423962-38.6402588l-8.0081415,25.0698242c10.3158979,18.8565674,16.5882797,51.1849976,16.5882797,51.1849976c-2.772768-11.7883606-10.3283825-24.2322388-19.6061401-40.6324158c-4.6352463,17.140564-6.4893451,28.6991272-4.8127823,31.5197144c10.7645073,22.0186157,18.4895954,67.8961792,19.7244797,82.1130066C18.9925976,438.7782898,22.2413311,482.511261,32.1864357,512l3.2939873-1.7948914C-2.4990261,373.5537109,83.4674683,129.1489716,196.491333,36.4625397c-63.0433502,63.1979294-139.5702057,236.28508-158.4664764,354.9420166c14.221344-43.4332886,60.1596451-62.1123962,60.1596451-62.1123962s22.5450745-27.7918091,48.8772812-67.4971313c-27.0683975,5.9363098-47.725502,11.297821-66.6095734,20.6120605c0,0,41.4608612-25.247345,77.0240936-36.6875153c48.9167023-77.0438232,102.2122345-186.514679,48.5419922-234.4057617C180.892746-12.0014248,147.942337,3.1980665,119.4475555,34.3323021z"/></svg>
Deleted web/public/file-icons/storybook.svg
@@ -1 +0,0 @@
1<svg stroke-miterlimit="1.4142" version="1.1" width="16px" height="16px" viewBox="0 0 300 300" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g transform="translate(11.5 2.2608)" fill="#ec407a"><g transform="translate(-.006806 .000978)"><path transform="translate(-11.5 -2.2608)" d="m257.53 22.271-18.676 1.377v36.545l-16.613-7.1191-16.613 7.1191v-34.096l-128.74 9.4922 11.061 234.02 169.59 8.1172zm-87.213 48.121v.002c31.663 0 49.853 17.289 48.955 52.32l-36.152.90039c.67367-18.863-7.4134-20.66-11.904-20.66-4.2666 0-11.451 1.3481-11.451 11.453 0 24.926 61.529 23.578 61.529 73.879 0 28.294-22.008 41.316-50.078 41.316-28.968 0-54.343-9.8807-51.424-53.221h37.951c-.44912 17.516 3.3698 23.807 13.025 23.807 7.4104 0 10.777-4.2674 10.777-11.453 0-25.6-60.629-26.501-60.629-73.434 0-26.947 17.738-44.91 49.4-44.91z" style="clip-rule:evenodd;fill-rule:nonzero;fill:#ec407a;paint-order:fill markers stroke"/><path d="m50.77 33.329h-19.795l8.2949 234.02h22.56z" style="clip-rule:evenodd;fill-rule:nonzero;fill:#ba68c8;paint-order:fill markers stroke"/></g></g></svg>
Deleted web/public/file-icons/stylelint.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 -1 16 16"><path fill="#7A8387" d="M8.1,14.954C9.628,9.226,9.581,9.2,10.688,5.116l0.266,0.161a0.161,0.161,0,0,0,.262-0.158V3.17c0.295-1.076.525-1.907,0.592-2.156h1.2L15,2.944,13.692,4.126l0.989,1.119L8.162,14.954c-0.032.055-.077,0.043-0.066,0h0ZM8.808,2.608a0.1,0.1,0,0,1,.107.077v1a0.1,0.1,0,0,1-.107.075H7.2A0.1,0.1,0,0,1,7.09,3.681v-1A0.1,0.1,0,0,1,7.2,2.607H8.808ZM10.8,1.5L9.024,2.574a0.208,0.208,0,0,1,.033.111v1A0.2,0.2,0,0,1,9,3.815L10.795,4.9a0.136,0.136,0,0,0,.222-0.134V1.637A0.136,0.136,0,0,0,10.795,1.5h0ZM6.947,3.683v-1a0.21,0.21,0,0,1,.033-0.111L5.209,1.5a0.136,0.136,0,0,0-.222.134V4.765A0.137,0.137,0,0,0,5.21,4.9L7,3.819A0.217,0.217,0,0,1,6.95,3.682h0ZM7.839,14.954H7.9C6.376,9.226,6.42,9.2,5.314,5.116l-0.266.161a0.161,0.161,0,0,1-.263-0.158V3.17C4.49,2.094,4.26,1.262,4.193,1.013h-1.2L1,2.944,2.308,4.126,1.319,5.247l6.519,9.707h0ZM7.951,4.831a0.507,0.507,0,1,1-.484.506A0.5,0.5,0,0,1,7.951,4.831Zm0,3.008a0.507,0.507,0,1,1-.484.506A0.5,0.5,0,0,1,7.951,7.839Zm0,3.009a0.507,0.507,0,1,1-.484.506A0.5,0.5,0,0,1,7.951,10.848Z" /></svg>
Deleted web/public/file-icons/stylus.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#6AAB20" d="M11.166,4.272l-0.276-.2C12.27,2.461,10.613,1.29,9.833,2.5,9.1,3.632,10.1,5.036,10.7,6.86c0.949,2.884.8,4.437-.42,5.94-3.086,3.771-8.862,2.329-6.868-1.577a3.579,3.579,0,0,1,3.9-2.07C7.5,9.4,7.3,9.5,7.155,9.572c-1.645.838-2.594,4.2-1.165,4.129a3.043,3.043,0,0,0,2.233-1.923c0.612-1.393-.144-3.143-0.684-4.794C6.279,3.089,9.136-.275,12.4,1.475,13.639,2.14,12.823,4.47,11.166,4.272Z" /></svg>
Deleted web/public/file-icons/svelte.svg
@@ -1 +0,0 @@
1<svg id="svelte" version="1.1" width="16px" height="16px" viewBox="0 0 300 300" xmlns="http://www.w3.org/2000/svg"><path fill="#ff5722" d="m175.94 24.328c-13.037.25172-26.009 3.8724-37.471 11.174l-58.557 37.316a67.134 67.134 0 0 0-30.355 44.906 70.794 70.794 0 0 0 6.959 45.445 67.224 67.224 0 0 0-10.035 25.102 71.535 71.535 0 0 0 12.236 54.156c23.351 33.41 69.468 43.311 102.81 22.07l58.559-37.158a67.359 67.359 0 0 0 30.355-44.906 70.771 70.771 0 0 0-6.9824-45.422 67.65 67.65 0 0 0 10.059-25.102 71.625 71.625 0 0 0-12.236-54.156v-.17969c-15.324-21.925-40.453-33.727-65.342-33.246zm5.1367 28.68a46.5 46.5 0 0 1 36.09 19.969 42.975 42.975 0 0 1 7.3652 32.557 45.242 45.242 0 0 1-1.3926 5.4551l-1.123 3.3691-2.9863-2.2461a75.846 75.846 0 0 0-22.902-11.451l-2.2441-.65039.20117-2.2461a13.157 13.157 0 0 0-2.3789-8.7109 13.988 13.988 0 0 0-14.953-5.4121 12.843 12.843 0 0 0-3.5938 1.5723l-58.578 37.25a12.237 12.237 0 0 0-5.502 8.1504 13.112 13.112 0 0 0 2.2461 9.834 14.033 14.033 0 0 0 14.93 5.5684 13.472 13.472 0 0 0 3.5937-1.5723l22.453-14.234a41.785 41.785 0 0 1 11.898-5.2324 46.477 46.477 0 0 1 49.914 18.502 43.02 43.02 0 0 1 7.3633 32.557 40.415 40.415 0 0 1-18.254 27.078l-58.58 37.316a43.065 43.065 0 0 1-11.898 5.2305 46.545 46.545 0 0 1-49.936-18.523 42.975 42.975 0 0 1-7.3418-32.557 38.17 38.17 0 0 1 1.3906-5.4102l1.1016-3.3691 3.0078 2.2461a75.846 75.846 0 0 0 22.836 11.361l2.2442.65039-.20118 2.2461a13.247 13.247 0 0 0 2.4473 8.6445 14.033 14.033 0 0 0 15.043 5.5684 13.135 13.135 0 0 0 3.5918-1.5723l58.467-37.316a12.169 12.169 0 0 0 5.502-8.1738 12.955 12.955 0 0 0-2.2461-9.8106 14.033 14.033 0 0 0-15.043-5.5684 12.843 12.843 0 0 0-3.5918 1.5703l-22.453 14.258a42.885 42.885 0 0 1-11.877 5.209 46.522 46.522 0 0 1-49.846-18.5 43.02 43.02 0 0 1-7.2969-32.557 40.415 40.415 0 0 1 18.254-27.078l58.646-37.316a42.773 42.773 0 0 1 11.811-5.209 46.5 46.5 0 0 1 13.822-1.4453z" /></svg>
Deleted web/public/file-icons/svg.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#FFB62C" d="M15,15H1V8.012H1S1,8,1,8a2.05,2.05,0,0,1,2.05-2.05,2.05,2.05,0,0,1,2.9-2.9,2.05,2.05,0,0,1,4.1,0,2.05,2.05,0,1,1,2.9,2.9A2.05,2.05,0,0,1,15,8s0,0.01,0,.014V15Zm-0.94-6.986s0-.008,0-0.012a1.109,1.109,0,0,0-1.894-.784H9.894L11.5,5.611A1.11,1.11,0,1,0,10.39,4.5L8.785,6.106V3.836a1.11,1.11,0,1,0-1.569,0v2.27L5.611,4.5A1.11,1.11,0,1,0,4.5,5.611L6.106,7.216H3.836A1.109,1.109,0,0,0,1.942,8s0,0.008,0,.012H14.058Zm-5.313.459L7.988,12.126,7.231,8.471H6.16l1.293,6.24H8.524l1.292-6.24H8.745Zm2.9,2.585v1.071H12.4v0.757h0a0.757,0.757,0,0,1-1.514,0h0V10.3a0.757,0.757,0,0,1,1.514,0h1.071a1.828,1.828,0,0,0-3.656,0h0v2.585h0a1.828,1.828,0,1,0,3.656,0h0V11.056H11.644Zm-8.6.535h0a1.824,1.824,0,0,0,1.293.535h0a0.757,0.757,0,0,1,.535.222h0a0.757,0.757,0,1,1-1.293.535H2.5a1.828,1.828,0,1,0,3.12-1.292h0a1.819,1.819,0,0,0-1.292-.535h0A0.755,0.755,0,0,1,3.8,10.834h0A0.757,0.757,0,1,1,5.09,10.3H6.16a1.828,1.828,0,1,0-3.12,1.293h0Z" /></svg>
Deleted web/public/file-icons/swagger.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 512 512" preserveAspectRatio="xMidYMid meet"><path fill="#7963E6" d="M127.7110825,34.4531364c-170.2814789,98.1692047-170.2814026,344.9245911,0,443.0937195S512,452.3383789,512,256.0000305S297.9924927-63.7160034,127.7110825,34.4531364z M176.3741302,408.5532532c-122.139801,0-19.929306-137.3335266-104.7761612-134.9657288v-36.7011261c80.0386734,9.9306183-15.3906517-146.2125549,104.1839828-132.5976257v28.4137802c-66.6934204,1.5785828-0.3946075,86.8199158-60.3792725,122.5344086c60.3792725,39.6608887-3.1571045,130.0325317,60.9714508,120.1665955V408.5532532z M172.0883179,275.1309204c-13.7811279-7.9449768-13.7811279-27.9151764,0-35.8600769c13.7810516-7.9449921,31.1009064,2.0401154,31.1009064,17.9299927S185.8693695,283.0758362,172.0883179,275.1309204z M245.6174622,275.1309204c-13.7810669-7.9449768-13.7810669-27.9151764,0-35.8600769c13.7810669-7.9449921,31.1008911,2.0401154,31.1008911,17.9299927S259.3985291,283.0758362,245.6174622,275.1309204z M319.1466064,275.1309204c-13.7810669-7.9449768-13.7810669-27.9151764,0-35.8600769c13.7810669-7.9449921,31.1009827,2.0401154,31.1009827,17.9299927S332.9276733,283.0758362,319.1466064,275.1309204z M335.6259155,408.5532532v-33.1496887c64.1285706,9.8659363,0.5921631-80.5057068,60.9714355-120.1665955c-59.9846497-35.7144928,6.3141479-120.9558258-60.3792725-122.5344086v-28.4137802c119.574646-13.6149292,24.1453247,142.5282288,104.1839905,132.5976257v36.7011261C355.5552063,271.2197266,457.7657166,408.5532532,335.6259155,408.5532532z"/></svg>
Deleted web/public/file-icons/swift.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#FF5370" d="M9.72,1.994S12.832,3.773,13.646,6.6c0.734,2.553.146,3.771,0.146,3.771a4.462,4.462,0,0,1,.872,1.4A2.415,2.415,0,0,1,14.809,14s-0.048-1.041-1.6-1.257c-1.422-.2-1.939,1.184-4.653,1.117A9.227,9.227,0,0,1,1,9.676C2.327,10.4,4.065,11.523,6.23,11.352s2.617-.838,2.617-0.838a33.484,33.484,0,0,1-6.4-6.7C4.148,5.238,8.364,8.072,8.266,8A37.953,37.953,0,0,1,4.049,3.112s6.325,5.2,6.834,5.167c0.209-.431,1.291-2.662-1.163-6.285h0Z" /></svg>
Deleted web/public/file-icons/tailwindcss.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="16px" height="16px"><path fill="#44a8b3" d="M9,13.7q1.4-5.6,7-5.6c5.6,0,6.3,4.2,9.1,4.9q2.8.7,4.9-2.1-1.4,5.6-7,5.6c-5.6,0-6.3-4.2-9.1-4.9Q11.1,10.9,9,13.7ZM2,22.1q1.4-5.6,7-5.6c5.6,0,6.3,4.2,9.1,4.9q2.8.7,4.9-2.1-1.4,5.6-7,5.6c-5.6,0-6.3-4.2-9.1-4.9Q4.1,19.3,2,22.1Z"/></svg>
Deleted web/public/file-icons/terraform.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 458 512" width="16px" height="16px"><g fill="#26A69A" transform="scale(0.9) translate(30 30)"><path d="M158.6916809,271.2892761l141.528717,80.8660583V512l-141.528717-80.8667297V271.2892761z M158.6916809,253.9532318l141.528717,80.866745V171.5395813l-141.528717-80.8664246V253.9532318z M314.2690735,171.2910461v164.8621521l142.9368591-82.1428833V89.7128601L314.2690735,171.2910461z M144.6430206,82.6460876L0,0v165.3662872l144.6430206,82.6460876V82.6460876z"/></g></svg>
Deleted web/public/file-icons/tex.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#4E85F4" d="M15.866,9.727a1.379,1.379,0,0,1-.524-0.07,0.885,0.885,0,0,1-.311-0.273L13.084,6.617l1.245-1.789a1.581,1.581,0,0,1,.429-0.383,1.746,1.746,0,0,1,.895-0.172V4H13.5V4.273a0.592,0.592,0,0,1,.406.129,0.377,0.377,0,0,1,.146.285,0.681,0.681,0,0,1-.008.105,0.2,0.2,0,0,1-.063.105l-1.04,1.516L11.76,4.687l-0.035-.043-0.035-.09a0.24,0.24,0,0,1,.15-0.2,0.825,0.825,0,0,1,.41-0.086V4H9.742V4.273H9.884a1.775,1.775,0,0,1,.544.063,0.54,0.54,0,0,1,.284.219l1.671,2.477L10.924,9.172a1.537,1.537,0,0,1-.374.355,1.672,1.672,0,0,1-.95.2V10h0.513a3.433,3.433,0,0,1-.434,1.242,1.691,1.691,0,0,1-1.466.484H7.031a0.541,0.541,0,0,1-.367-0.07,0.5,0.5,0,0,1-.051-0.273V9.031H7.377a0.98,0.98,0,0,1,.8.234,1.4,1.4,0,0,1,.173.8H8.56V7.727H8.347a1.59,1.59,0,0,1-.146.8,0.984,0.984,0,0,1-.824.23H6.613V6.617a0.5,0.5,0,0,1,.051-0.273,0.541,0.541,0,0,1,.367-0.07H8.142a1.639,1.639,0,0,1,1.336.4A2.868,2.868,0,0,1,9.884,8h0.2L9.813,6H5.772L5.635,4.07H0.142L0,6.07H0.2A3.067,3.067,0,0,1,.567,4.664a1.638,1.638,0,0,1,1.245-.32H2.294a0.165,0.165,0,0,1,.185.094,0.914,0.914,0,0,1,.028.25V9.313a0.4,0.4,0,0,1-.114.309,1.605,1.605,0,0,1-.792.105H1.182V10H4.524V9.727H4.24a1.578,1.578,0,0,1-.788-0.105,0.407,0.407,0,0,1-.11-0.309V4.688a1.088,1.088,0,0,1,.016-0.223,0.208,0.208,0,0,1,.118-0.121H3.965a1.638,1.638,0,0,1,1.245.32A2.891,2.891,0,0,1,5.558,6H4.871V6.273h0.2a1.292,1.292,0,0,1,.615.078,0.462,0.462,0,0,1,.087.336v4.625a0.463,0.463,0,0,1-.087.336,1.291,1.291,0,0,1-.615.078h-0.2V12h5.147l0.305-2h1.508V9.727a0.526,0.526,0,0,1-.437-0.156,0.465,0.465,0,0,1-.122-0.258,0.681,0.681,0,0,1,.008-0.105A0.2,0.2,0,0,1,11.342,9.1L12.6,7.242l1.387,2.141a0.048,0.048,0,0,0,.035.055l0.035,0.008a0.239,0.239,0,0,1-.146.2,0.8,0.8,0,0,1-.406.086V10H16V9.727H15.866Z" /></svg>
Deleted web/public/file-icons/text.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#E79627" d="M8,5H4V4H8V5ZM4,8h7V7H4V8Zm0,2h7V9H4v1Zm0,2h7V11H4v1ZM14,4.5V14a1,1,0,0,1-1,1H3a1,1,0,0,1-1-1V2A1,1,0,0,1,3,1h7.5ZM13,5L10,2H3V14H13V5Z" /></svg>
Deleted web/public/file-icons/todo.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#6AAB20" d="M16,7.766l-6,6.207-3-3.1L8.5,9.318,10,10.87l4.5-4.655ZM5.7,11.594l0.8,0.828H2a1.021,1.021,0,0,1-1-1.034V2.077A1.021,1.021,0,0,1,2,1.042H9a1.021,1.021,0,0,1,1,1.034V8.8L9.2,7.973a0.984,0.984,0,0,0-1.42,0L5.7,10.146a1.056,1.056,0,0,0,0,1.457V11.594ZM4,3.111H9V2.077H4V3.111ZM4,5.18H9V4.146H4V5.18ZM4,7.249H7V6.215H4V7.249ZM3,8.284H2V9.318H3V8.284ZM3,6.215H2V7.249H3V6.215ZM3,4.146H2V5.18H3V4.146ZM3,2.077H2V3.111H3V2.077Z" /></svg>
Deleted web/public/file-icons/toml.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="16px" height="16px"><path transform="scale(0.9) translate(30 30)" fill="#8BC34A" d="m512 511.99955h-114.53076v-56.58646h52.66809v-398.55357h-52.66809v-56.859506h114.53076zm-136.11498-404.25698v59.34432h-91.16149v275.87982h-64.10614v-275.87982h-91.58626v-59.34432zm-375.88502-107.74212h113.96325v56.586452h-52.668085v398.55358h52.668085v56.85952h-113.96325z"/></svg>
Deleted web/public/file-icons/travis.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16px" height="16px" viewBox="0 0 32 32"><path fill="#F07178" d="M11.392 9.374h4.096v13.12h-1.536v2.24h6.080v-2.496h-1.92v-13.056h4.352v1.92h1.984v-3.904h-15.296v3.904h2.24zM29.263 2.718h-24.848c-0.433 0-0.832 0.321-0.832 0.749v24.845c0 0.428 0.398 0.774 0.832 0.774h24.848c0.433 0 0.753-0.347 0.753-0.774v-24.845c0-0.428-0.319-0.749-0.753-0.749zM25.728 12.382h-4.544v-1.92h-1.792v10.496h1.92v5.056h-8.64v-4.8h1.536v-10.56h-1.536v1.728h-4.8v-6.464h17.856v6.464z"></path></svg>
Deleted web/public/file-icons/tsconfig.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" version="1.0"><path d="M 10.001953 2 C 9.7519531 2 9.5419531 2.1799219 9.5019531 2.4199219 L 9.1328125 5.0703125 C 8.5028125 5.3203125 7.9614062 5.6607812 7.4414062 6.0507812 L 4.9511719 5.0507812 C 4.7311719 4.9607813 4.4617969 5.0495313 4.3417969 5.2695312 L 2.3417969 8.7304688 C 2.2117969 8.9504688 2.2728906 9.2191406 2.4628906 9.3691406 L 4.5722656 11 C 4.5322656 11.34 4.5019531 11.67 4.5019531 12 C 4.5019531 12.33 4.5322656 12.650703 4.5722656 12.970703 L 2.4628906 14.630859 C 2.2728906 14.780859 2.2117969 15.049531 2.3417969 15.269531 L 4.3417969 18.730469 C 4.4617969 18.950469 4.7311719 19.029219 4.9511719 18.949219 L 7.4414062 17.939453 C 7.9614063 18.339453 8.5028125 18.679687 9.1328125 18.929688 L 9.5019531 21.580078 C 9.5419531 21.820078 9.7519531 22 10.001953 22 L 14.001953 22 C 14.251953 22 14.461953 21.820078 14.501953 21.580078 L 14.871094 18.929688 C 15.501094 18.669687 16.0425 18.339453 16.5625 17.939453 L 19.052734 18.949219 C 19.272734 19.029219 19.542109 18.950469 19.662109 18.730469 L 21.662109 15.269531 C 21.782109 15.049531 21.732969 14.780859 21.542969 14.630859 L 19.431641 12.970703 C 19.471641 12.650703 19.501953 12.33 19.501953 12 C 19.501953 11.67 19.471641 11.34 19.431641 11 L 21.542969 9.3691406 C 21.732969 9.2191406 21.782109 8.9504688 21.662109 8.7304688 L 19.662109 5.2695312 C 19.542109 5.0495313 19.272734 4.9607812 19.052734 5.0507812 L 16.5625 6.0507812 C 16.0425 5.6607812 15.501094 5.3203125 14.871094 5.0703125 L 14.501953 2.4199219 C 14.461953 2.1799219 14.251953 2 14.001953 2 L 10.001953 2 z M 15.173828 8.0488281 A 4.3303023 4.3302765 0 0 1 16.037109 8.1191406 A 2.1285842 2.1285715 0 0 1 17.107422 8.734375 A 2.8161398 2.8161233 0 0 1 17.517578 9.2851562 C 17.522878 9.3068062 16.778958 9.8054026 16.328125 10.083984 C 16.311765 10.095044 16.247446 10.024756 16.173828 9.9160156 A 0.96902537 0.96901963 0 0 0 15.369141 9.4355469 C 14.850948 9.3999369 14.517125 9.6712829 14.519531 10.125 A 0.6254881 0.62548439 0 0 0 14.59375 10.445312 C 14.707781 10.681073 14.919042 10.823124 15.583984 11.111328 C 16.808016 11.640583 17.333911 11.984864 17.658203 12.478516 A 2.4817443 2.4817296 0 0 1 17.857422 14.5625 A 2.2921733 2.2921596 0 0 1 15.966797 15.917969 A 5.2444771 5.244446 0 0 1 14.664062 15.904297 A 3.142356 3.1423373 0 0 1 12.923828 14.998047 A 3.0206263 3.0206084 0 0 1 12.478516 14.337891 A 1.2774391 1.2774316 0 0 1 12.634766 14.238281 C 12.710786 14.194971 12.998165 14.028427 13.269531 13.871094 L 13.763672 13.583984 L 13.865234 13.734375 A 2.2955412 2.2955277 0 0 0 14.515625 14.355469 A 1.5877775 1.5877681 0 0 0 16.179688 14.271484 A 0.74336856 0.74336413 0 0 0 16.275391 13.320312 C 16.142594 13.127859 15.870884 12.971113 15.099609 12.636719 A 4.2340733 4.2340482 0 0 1 13.488281 11.648438 A 2.2551252 2.2551118 0 0 1 13.019531 10.792969 A 3.4238257 3.4238053 0 0 1 12.990234 9.7011719 A 2.0843188 2.0843065 0 0 1 14.742188 8.078125 A 4.3303023 4.3302765 0 0 1 15.173828 8.0488281 z M 9.0253906 8.1269531 L 12.013672 8.1367188 L 12.023438 8.8320312 L 12.029297 9.53125 L 9.8007812 9.53125 L 9.8007812 15.857422 L 8.2304688 15.857422 L 8.2304688 9.5332031 L 6.0019531 9.5332031 L 6.0019531 8.8457031 A 6.7292895 6.7292497 0 0 1 6.0214844 8.140625 C 6.0296644 8.129555 7.3832438 8.1240631 9.0253906 8.1269531 z" fill="#0288d1"/></svg>
Deleted web/public/file-icons/typeScript.svg
@@ -1 +0,0 @@
1<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" version="1.1" viewBox="0 0 500 500" width="16px" height="16px" xmlns="http://www.w3.org/2000/svg"><path d="m46 46v408h408v-408h-408zm310.02 198.17v.00586c3.9124.01226 8.3586.21357 11.703.57617 13.619 1.4734 24.225 7.349 33.248 18.416 4.4934 5.5129 6.0307 7.9253 5.7031 8.957-.21108.66564-3.294 2.874-13.096 9.3809-9.6287 6.3921-12.731 8.3066-13.451 8.3066-.73045 0-2.2517-1.5657-4.4453-4.5723-4.2244-5.7892-8.5373-8.4314-15.205-9.3125-7.1704-.9496-13.602 1.3105-16.752 5.8887-2.6923 3.9112-3.099 10.206-.95899 14.779 2.4792 5.2976 6.9671 8.2262 24.166 15.768 19.836 8.6975 29.888 14.651 37.209 22.039 7.8838 7.9563 11.878 17.143 13.105 30.137.59907 6.3332-.13284 13.84-1.9453 19.943-4.4447 14.961-16.439 25.916-34.02 31.072-4.8597 1.4248-9.3818 2.276-13.855 2.6035-6.8282.50274-16.603.22572-22.486-.63086-14.884-2.1677-31.686-10.829-40.064-20.65-4.1128-4.8205-9.3633-12.754-9.3633-14.15 0-.67351.33372-1.0557 1.6562-1.8965 3.9213-2.4911 26.394-15.338 26.83-15.338.26345 0 1.4373 1.3838 2.6074 3.0742 2.6512 3.8278 9.17 10.407 12.484 12.602 2.7074 1.7928 6.1691 3.2327 10.279 4.2715 2.3539.5868 3.6002.69141 8.7363.69141 5.2475-.00176 6.3243-.0901 8.6719-.72071 6.2096-1.6713 11.057-5.1293 13.111-9.3535.90039-1.8254.91797-2.0533.91797-6.4805v-4.5898l-1.1035-2.1894c-2.6734-5.3067-8.4338-8.9474-26.645-16.836-8.3655-3.6237-18.611-8.733-22.611-11.275-9.1282-5.801-15.455-12.433-19.607-20.551-4.13-8.073-4.7459-11.078-4.7559-23.217-.01017-9.5024-.02592-9.3858 1.9414-15.451 1.785-5.5038 5.439-11.652 9.4727-15.939 8.0511-8.5582 19.813-14.058 32.406-15.152 1.6108-.15244 3.768-.21208 6.1152-.20508zm-108.36 1.877h.00391c24.253.01227 38.156.0962 38.379.23633.42036.2603.47265 2.3708.47265 15.842v15.541l-24.201.08789-24.201.08789v68.713c0 37.793-.07654 68.938-.18164 69.213-.17115.46249-2.0325.49805-17.779.49805h-17.588l-.18164-.71094c-.11745-.39011-.20308-31.536-.20508-69.213l-.00586-68.504-24.201-.08594-24.201-.08789v-15.357c0-12.18.08414-15.442.4082-15.766.333-.34334 12.684-.43187 65.902-.48437 10.261-.01034 19.495-.01477 27.58-.00977z" fill="#0288d1"/></svg>
Deleted web/public/file-icons/version.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="16px" height="16px" viewBox="0 0 24 24"><path fill="#80CBC4" d="M5.5,7A1.5,1.5 0 0,1 4,5.5A1.5,1.5 0 0,1 5.5,4A1.5,1.5 0 0,1 7,5.5A1.5,1.5 0 0,1 5.5,7M21.41,11.58L12.41,2.58C12.05,2.22 11.55,2 11,2H4C2.89,2 2,2.89 2,4V11C2,11.55 2.22,12.05 2.59,12.41L11.58,21.41C11.95,21.77 12.45,22 13,22C13.55,22 14.05,21.77 14.41,21.41L21.41,14.41C21.78,14.05 22,13.55 22,13C22,12.44 21.77,11.94 21.41,11.58Z"/></svg>
Deleted web/public/file-icons/video.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#7963E6" d="M14.3,3.072L9.75,6.038V3.815A0.85,0.85,0,0,0,8.875,3h-7A0.85,0.85,0,0,0,1,3.815V11.17a0.85,0.85,0,0,0,.875.817h7A0.85,0.85,0,0,0,9.75,11.17V8.947l4.55,2.966a0.448,0.448,0,0,0,.7-0.335V3.407A0.448,0.448,0,0,0,14.3,3.072Z" /></svg>
Deleted web/public/file-icons/vue.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#41B883" d="M15,2L8,14,1,2H2.138L8,12.05,13.863,2H15ZM8,4.729L6.382,2H3.927L8,8.982,12.073,2H9.618Z" /></svg>
Deleted web/public/file-icons/webassembly.svg
@@ -1 +0,0 @@
1<svg version="1.1" viewBox="0 0 300 300" width="16px" height="16px" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0,142.97)" fill="#7c4dff"><g id="icon" transform="matrix(2.2365 0 0 2.2365 -188.25 -113.32)"><polygon points="177.3 67.39 174.19 81.38 183.87 81.38 180.3 67.39"/><path transform="translate(-.21)" d="m163.77 0a5.62 5.62 0 0 1 0 .58 12.34 12.34 0 0 1 -12.34 12.34 12.33 12.33 0 0 1 -12.32 -12.34 5.62 5.62 0 0 1 0 -.58h-41.47v107.62h107.62v-107.62zm-14.77 96.1-5.25-25.93h-.09l-5.66 25.93h-7.22l-8.18-38.1h7.13l4.88 25.94h.09l5.88-25.94h6.67l5.28 26.26h.1l5.56-26.26h7l-9.09 38.1zm39.26 0-2.43-8.48h-12.83l-1.87 8.48h-7.13l9.22-38.1h11.25l11.21 38.1z" /></g></g></svg>
Deleted web/public/file-icons/webpack.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 462 512"><path fill="#497CE3" d="M355.0114136,174.6519165l-124.1871338,71.7220459L106.630249,174.6556396l124.1939697-68.3209229L355.0114136,174.6519165z M241.8120117,265.434021v131.1519165l123.7680664-68.0667725V193.9533691L241.8120117,265.434021z M16.1795044,395.7438965l203.6325073,115.1856079v-89.2358398L92.489502,351.6723633L16.1795044,395.7438965z M219.8120117,396.5859375v-131.166626l-123.7680664-71.472168v134.5722656L219.8120117,396.5859375z M241.8120117,1.0708008v86.1991577l135.4661865,74.5221558l73.5410767-42.4723511L241.8120117,1.0708008z M369.1555786,351.6605225l-127.3435669,70.0331421v89.2357788l203.6576538-115.1998291L369.1555786,351.6605225z M387.5800781,181.2475586v155.6484985l74.0437622,42.7579956V138.4845581L387.5800781,181.2475586z M84.3620605,161.7963867l135.4499512-74.5130005V1.0707397L10.8051758,119.319458L84.3620605,161.7963867z M74.0439453,336.9199219v-155.677063L0,138.4848022v241.197937L74.0439453,336.9199219z"/></svg>
Deleted web/public/file-icons/word.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#497CE3" d="M14.34,2.3H9.249V1H8.3L1,2.306V13.7L8.257,15H9.249V13.7h4.764a1.417,1.417,0,0,0,.8-0.161,1.942,1.942,0,0,0,.154-0.985V2.934A0.572,0.572,0,0,0,14.34,2.3Zm-8.046,7.91a2.672,2.672,0,0,1-.976.016C5.107,9.151,4.861,8.08,4.672,7c-0.186,1.052-.427,2.093-0.64,3.139q-0.457-.024-0.917-0.057C2.852,8.646,2.543,7.223,2.3,5.788c0.271-.013.544-0.024,0.816-0.034C3.276,6.79,3.461,7.823,3.6,8.861,3.828,7.8,4.056,6.732,4.279,5.667c0.3-.018.6-0.031,0.9-0.047,0.211,1.1.427,2.2,0.655,3.29,0.179-1.13.378-2.256,0.57-3.384,0.317-.011.635-0.029,0.951-0.049C7,7.054,6.686,8.645,6.294,10.212Zm7.919,2.717H9.249V11.907h4.126V11.256H9.249V10.442h4.126V9.791H9.249V8.977h4.126V8.326H9.249V7.512h4.126V6.86H9.249V6.047h4.126V5.4H9.249V4.581h4.126V3.93H9.249V3.071h4.964v9.859Z" /></svg>
Deleted web/public/file-icons/xml.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 512 512" width="16px" height="16px"><path fill="#D14748" transform="scale(0.8) translate(64 64)" d="M0.0000219,100.4676361h304.585144V3.2513001H0.0000219V100.4676361z M414.7606201,100.4676361h97.2163391V3.2513001h-97.2163391V100.4676361z M0.0000219,307.859436h511.9769287v-97.2163391H0L0.0000219,307.859436z M0.0000219,508.7486877h200.912262v-90.7367554H0.0000219V508.7486877z M311.087738,508.7486877H512v-90.7367554H311.0877686L311.087738,508.7486877z"/></svg>
Deleted web/public/file-icons/yaml.svg
@@ -1 +0,0 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 512 512"><path fill="#F4c179" transform="scale(0.8) translate(64 64)" d="M235.793457,20.9389935l-91.8156738,137.6740112v87.2750244H87.7020035v-87.2751465L0,20.9389935h63.2502899l55.7678528,88.6458817l56.2244568-88.6458817H235.793457z M330.2944641,195.8635406H228.4328003l-20.7168121,50.0244904h-45.1060944l95.3815918-224.9490356h46.1360474l91.5108948,224.9490356h-48.1963501L330.2944641,195.8635406z M313.3734131,151.1300201l-31.2258606-82.5503616l-34.8368378,82.5503616H313.3734131z M87.7023544,270.5895996v220.471405h47.3023605V338.9823914l49.5055237,102.2188416h37.2337494l51.1964111-105.8118896v155.6254272h45.3786011V270.5895996h-61.959259l-54.977951,99.7063904l-52.3599548-99.7063904H87.7023544z M512,443.201355H395.6384277V270.5895996h-48.1963501v219.5221863L512,490.111969V443.201355z"/></svg>
Deleted web/public/file-icons/yarn.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#1E9CEF" d="M7.511,2.479S8.1,0.7,8.637,1.045A9.179,9.179,0,0,1,9.4,2.488a1.232,1.232,0,0,1,.711-0.234A5.8,5.8,0,0,1,9.078,8.025a4.574,4.574,0,0,1,1.436,4.55c1.378,0,2.087-1.342,3.845-1.425a0.541,0.541,0,0,1,.234,1.055c-1.222.173-2.427,1.538-4.89,2.232-0.318.484-2.216,0.43-3.71,0.564-1.581.017-1.173-1.216-.6-1.425a4.841,4.841,0,0,1-.459-0.383C4.536,15.3,3.457,14.658,2.8,14.477c-0.509-.271.036-0.906,0.036-0.906-0.5.213-.886-0.725-0.828-1.646a2.688,2.688,0,0,1,.873-1.447A4.247,4.247,0,0,1,4.787,6.446a2.02,2.02,0,0,1-.314-2.629C5.458,3.543,5.649,2.374,7.511,2.479Z" /></svg>
Deleted web/public/file-icons/zeit.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 512 512" width="16px" height="16px"><polygon fill="#212121" points="256,30 512,482 0,482"/></svg>
Deleted web/public/file-icons/zig.svg
@@ -1 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 512 512" width="16px" height="16px"><path fill="#f7a41e" d="M270.2759705,194.8598022H146.4011536v-61.9049225h179.1702271L447.700592,88L242.7554169,318.6696472h124.16362v61.90979H187.7487946L65.6152878,424L270.2759705,194.8598022z M0,132.9573059l122.5645294-0.004837v61.9049225H55.1273193v123.8146973h82.6909637L82.690979,380.5769958H0.0000041L0,132.9573059z M456.8683777,194.8607635h-82.690979l55.1316223-61.9049225H512V380.575531l-122.5645447,0.0048523v-61.9049377h67.4329224V194.8607635z"/></svg>
Deleted web/public/favicon.png

Binary file not shown.

Deleted web/public/manifest.webmanifest
@@ -1,18 +0,0 @@
1{
2 "name": "GitDataAI",
3 "short_name": "GitData",
4 "description": "Repositories, issues, pull requests and team communication in one engineering workspace.",
5 "start_url": "/app/@me",
6 "scope": "/",
7 "display": "standalone",
8 "background_color": "#090b0d",
9 "theme_color": "#090b0d",
10 "icons": [
11 {
12 "src": "/favicon.png",
13 "sizes": "400x400",
14 "type": "image/png",
15 "purpose": "any"
16 }
17 ]
18}
Deleted web/public/sw.js
@@ -1,94 +0,0 @@
1/*
2 * GitData Service Worker — Web Push 接收端。
3 *
4 * 只处理推送与通知点击,不做离线缓存:这个站点是实时协作工具,缓存旧版本
5 * 的界面比直接报网络错误更糟。
6 */
7
8self.addEventListener('install', () => {
9 // 新版本立刻接管,推送处理逻辑没有需要等待的迁移步骤。
10 self.skipWaiting()
11})
12
13self.addEventListener('activate', event => {
14 event.waitUntil(self.clients.claim())
15})
16
17self.addEventListener('push', event => {
18 let payload = {}
19 try {
20 payload = event.data ? event.data.json() : {}
21 } catch {
22 // 推送服务只保证把字节送到;解析失败时退化成一条通用提醒,而不是静默丢弃。
23 payload = {}
24 }
25 const title = typeof payload.title === 'string' && payload.title ? payload.title : 'GitDataAI'
26 const url = typeof payload.url === 'string' && payload.url ? payload.url : '/app/@me/notify'
27
28 event.waitUntil(
29 self.registration.showNotification(title, {
30 body: typeof payload.body === 'string' ? payload.body : undefined,
31 icon: typeof payload.icon === 'string' ? payload.icon : '/favicon.png',
32 tag: typeof payload.tag === 'string' ? payload.tag : undefined,
33 // 同一 tag 的新通知要能再次打断用户:聊天场景里"上一条已经被顶掉"比
34 // 静默替换更容易错过。
35 renotify: true,
36 data: { url },
37 }),
38 )
39})
40
41self.addEventListener('notificationclick', event => {
42 event.notification.close()
43 const target = new URL(event.notification.data?.url || '/app/@me/notify', self.location.origin)
44
45 event.waitUntil(
46 (async () => {
47 const clientList = await self.clients.matchAll({
48 type: 'window',
49 includeUncontrolled: true,
50 })
51 // 优先复用已经打开的窗口:直接 openWindow 会让同一个应用出现两个标签页,
52 // 而用户期望的是"通知把我带回刚才那个界面"。
53 for (const client of clientList) {
54 if (new URL(client.url).origin !== target.origin) continue
55 await client.focus()
56 if ('navigate' in client && client.url !== target.href) {
57 await client.navigate(target.href).catch(() => {})
58 }
59 return
60 }
61 await self.clients.openWindow(target.href)
62 })(),
63 )
64})
65
66self.addEventListener('pushsubscriptionchange', event => {
67 // 浏览器会不定期轮换订阅(尤其是长期未使用的设备)。不重新注册的话,服务端
68 // 存着的 endpoint 会失效,推送从此静默丢失。
69 event.waitUntil(
70 (async () => {
71 const applicationServerKey =
72 event.oldSubscription?.options?.applicationServerKey ??
73 (await self.registration.pushManager.getSubscription())?.options?.applicationServerKey
74 if (!applicationServerKey) return
75 const subscription = await self.registration.pushManager.subscribe({
76 userVisibleOnly: true,
77 applicationServerKey,
78 })
79 const json = subscription.toJSON()
80 await fetch('/api/v1/me/push-subscriptions', {
81 method: 'POST',
82 credentials: 'include',
83 headers: { 'content-type': 'application/json' },
84 body: JSON.stringify({
85 endpoint: subscription.endpoint,
86 p256dh_key: json.keys?.p256dh ?? '',
87 auth_key: json.keys?.auth ?? '',
88 platform: 'web_push',
89 user_agent: self.navigator.userAgent,
90 }),
91 })
92 })(),
93 )
94})
Deleted web/public/wronged.png

Diff too large to display.

Deleted web/scripts/flatten-openapi.mjs
@@ -1,40 +0,0 @@
1#!/usr/bin/env bun
2// Flattens `allOf` composition in the OpenAPI document so the Alova
3// generator (which cannot resolve composed schemas) can process it.
4// Usage: bun scripts/flatten-openapi.mjs <input> <output>
5import { readFileSync, writeFileSync } from 'node:fs'
6
7function mergeInto(target, source) {
8 if (!source || typeof source !== 'object') return
9 for (const [key, value] of Object.entries(source)) {
10 if (key === 'allOf') continue
11 if (key === 'required' && Array.isArray(value)) {
12 target.required = [...new Set([...(target.required ?? []), ...value])]
13 continue
14 }
15 if (key === 'properties' && value && typeof value === 'object') {
16 target.properties = { ...(target.properties ?? {}), ...value }
17 continue
18 }
19 if (target[key] === undefined) target[key] = value
20 }
21}
22
23function flatten(node) {
24 if (Array.isArray(node)) return node.map(flatten)
25 if (!node || typeof node !== 'object') return node
26 const out = {}
27 for (const [key, value] of Object.entries(node)) {
28 if (key === 'allOf' && Array.isArray(value)) {
29 for (const part of value) mergeInto(out, flatten(part))
30 continue
31 }
32 out[key] = flatten(value)
33 }
34 return out
35}
36
37const [input, output] = process.argv.slice(2)
38const doc = JSON.parse(readFileSync(input, 'utf8'))
39writeFileSync(output, JSON.stringify(flatten(doc), null, 2))
40console.log(`flattened ${input} -> ${output}`)
Deleted web/scripts/gen-tree-icons.mjs
@@ -1,144 +0,0 @@
1#!/usr/bin/env bun
2/**
3 * Generates the repo tree's file-type icons from the MIT-licensed
4 * `atom-material-icons` npm package (the web port of Atom File Icons):
5 *
6 * - `public/file-icons/*.svg` — the curated icon files (referenced rules only).
7 * `folder.svg` is the package's default folder recolored to the set's own
8 * gold folder base, matching the product mock.
9 * - `src/features/repo-browse/model/tree-icons.ts` — the upstream name-pattern
10 * rules filtered to those icons, plus the resolver used by the tree.
11 *
12 * Usage: node scripts/gen-tree-icons.mjs
13 * (downloads the package once into the OS temp dir, then works offline)
14 */
15import { execFileSync } from 'node:child_process'
16import {
17 existsSync,
18 mkdirSync,
19 mkdtempSync,
20 readFileSync,
21 readdirSync,
22 rmSync,
23 writeFileSync,
24} from 'node:fs'
25import { tmpdir } from 'node:os'
26import { dirname, join, resolve } from 'node:path'
27import { fileURLToPath } from 'node:url'
28
29const PKG = 'atom-material-icons'
30const VERSION = '3.0.0'
31const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
32const iconDir = join(repoRoot, 'public/file-icons')
33const modulePath = join(repoRoot, 'src/features/repo-browse/model/tree-icons.ts')
34
35/** Curated file icons; rules referencing anything else are dropped. */
36const FILE_ICONS = [
37 // languages
38 'go', 'rust', 'python', 'java', 'kotlin', 'scala', 'ruby', 'php', 'js', 'jsx', 'typeScript',
39 'react', 'vue', 'svelte', 'angular', 'c', 'cpp', 'csharp', 'objc', 'swift', 'dart', 'lua',
40 'shell', 'powershell', 'ex', 'erlang', 'haskell', 'clojure', 'elm', 'fsharp', 'r', 'perl',
41 'julia', 'nim', 'zig', 'ocaml', 'solidity', 'graphql', 'asm', 'webassembly', 'tex',
42 // web & styles
43 'html', 'css', 'scss', 'sass', 'less', 'stylus', 'mdx', 'tailwindcss',
44 // config & tooling
45 'json', 'json5', 'yaml', 'toml', 'xml', 'csv', 'hcl', 'terraform', 'plist', 'lock',
46 'editorconfig', 'git', 'gitlab', 'github', 'docker', 'makefile', 'cmake', 'gradle',
47 'maven', 'bazel', 'npm', 'yarn', 'pnpm', 'nodejs', 'tsconfig', 'eslint', 'prettier',
48 'stylelint', 'babel', 'webpack', 'rollup', 'gulpfile', 'nginx', 'kubernetes', 'helm',
49 'ansible', 'aws', 'azure', 'firebase', 'netlify', 'zeit', 'travis', 'circleci',
50 'jenkins', 'husky', 'lint-staged', 'commitlint', 'jest', 'cypress', 'storybook',
51 'prisma', 'swagger',
52 // docs, media & data
53 'markdown', 'readme', 'text', 'rst', 'asciidoc', 'pdf', 'word', 'powerpoint', 'excel',
54 'license', 'history', 'version', 'image', 'svg', 'font', 'audio', 'video', 'psd',
55 'sketch', 'blender', 'archive', 'binary', 'db', 'sqlite', 'mongodb', 'redis', 'key',
56 'certificate', 'diff', 'patch', 'backup', 'log', 'config', 'settings', 'manifest',
57 'source', 'todo', 'authors', 'blank',
58]
59
60const cache = join(tmpdir(), `ami-icons-${VERSION}`)
61const pkgRoot = join(cache, 'package')
62if (!existsSync(join(pkgRoot, 'public', 'icon_associations.json'))) {
63 console.log(`fetching ${PKG}@${VERSION} ...`)
64 const res = await fetch(`https://registry.npmjs.org/${PKG}/-/${PKG}-${VERSION}.tgz`)
65 if (!res.ok) throw new Error(`download failed: ${res.status} ${res.statusText}`)
66 mkdirSync(cache, { recursive: true })
67 const tgz = join(mkdtempSync(join(tmpdir(), 'ami-')), 'icons.tgz')
68 writeFileSync(tgz, Buffer.from(await res.arrayBuffer()))
69 execFileSync('tar', ['-xzf', tgz, '-C', cache], { stdio: 'inherit' })
70}
71
72const read = rel => readFileSync(join(pkgRoot, rel), 'utf8')
73const iconName = path => path.split('/').pop().replace(/\.svg$/, '')
74const minify = s =>
75 s.replace(/<!--[\s\S]*?-->/g, '').replace(/\s*\n\s*/g, ' ').replace(/>\s+</g, '><').trim()
76
77const available = new Set(readdirSync(join(pkgRoot, 'public/icons/files')).map(f => f.replace(/\.svg$/, '')))
78const missing = FILE_ICONS.filter(n => !available.has(n))
79if (missing.length > 0) throw new Error(`unknown icon names: ${missing.join(', ')}`)
80
81const allow = new Set(FILE_ICONS)
82const allRules = JSON.parse(read('public/icon_associations.json')).associations.associations.regex
83const rules = allRules.filter(rule => allow.has(iconName(rule.icon)))
84const used = [...new Set(rules.map(rule => iconName(rule.icon)))]
85
86rmSync(iconDir, { recursive: true, force: true })
87mkdirSync(iconDir, { recursive: true })
88for (const name of [...used, 'default']) {
89 writeFileSync(join(iconDir, `${name}.svg`), minify(read(`public/icons/files/${name}.svg`)) + '\n')
90}
91
92// Plain gold folder: the package default with its base path recolored to the
93// amber used by the set's own gold folders (e.g. folders/svg.svg).
94const folderSvg = minify(read('public/icons/folders/default.svg')).replaceAll('fill="#78909C"', 'fill="#FFB300"')
95if (!folderSvg.includes('#FFB300')) throw new Error('default folder fill changed upstream')
96writeFileSync(join(iconDir, 'folder.svg'), folderSvg + '\n')
97
98const ruleLines = rules
99 .map(rule => ` [${JSON.stringify(rule.pattern)}, ${JSON.stringify(iconName(rule.icon))}],`)
100 .join('\n')
101
102const source = `/**
103 * Generated by scripts/gen-tree-icons.mjs - do not edit by hand.
104 *
105 * Atom Material Icons (MIT): https://www.npmjs.com/package/atom-material-icons
106 * The patterns keep the upstream order: the first one matching a file name
107 * wins, and anything unmatched falls back to \`default.svg\`. The icon files
108 * live in \`public/file-icons\`.
109 */
110export type TreeIconRule = readonly [pattern: RegExp, icon: string]
111
112const RAW_RULES: ReadonlyArray<readonly [string, string]> = [
113${ruleLines}
114]
115
116export const TREE_FILE_ICON_RULES: readonly TreeIconRule[] = RAW_RULES.map(
117 ([pattern, icon]): TreeIconRule => [new RegExp(pattern), icon],
118)
119
120/** Icon file for a tree row; directories share one folder icon. */
121export function treeIconUrl(name: string, isDir: boolean): string {
122 if (isDir) return '/file-icons/folder.svg'
123 for (const [pattern, icon] of TREE_FILE_ICON_RULES) {
124 if (pattern.test(name)) return \`/file-icons/\${icon}.svg\`
125 }
126 return '/file-icons/default.svg'
127}
128`
129writeFileSync(modulePath, source)
130
131const sample = [
132 '.gitignore', '.golangci.yml', '.goreleaser.yml', '.release-please-manifest.json',
133 'CHANGELOG.md', 'CODE_OF_CONDUCT.md', 'CONTRIBUTING.md', 'LICENSE', 'Makefile',
134 'README.md', 'README.zh-CN.md', 'SECURITY.md', 'den.example.yaml', 'go.mod', 'go.sum',
135 'release-please-config.json', 'main.go', 'index.ts',
136]
137console.log(`rules kept: ${rules.length}/${allRules.length}, icons: ${used.length}`)
138for (const name of sample) {
139 const hit = rules.find(rule => new RegExp(rule.pattern).test(name))
140 console.log(` ${name} -> ${hit ? iconName(hit.icon) : 'default'}`)
141}
142const bytes = readdirSync(iconDir).reduce((sum, f) => sum + readFileSync(join(iconDir, f)).length, 0)
143console.log(`wrote ${readdirSync(iconDir).length} icons (${(bytes / 1024).toFixed(0)} KB) -> public/file-icons`)
144console.log(`wrote ${modulePath}`)
No newline at end of file
Deleted web/scripts/lint-i18n.mjs
@@ -1,148 +0,0 @@
1#!/usr/bin/env bun
2/**
3 * i18n guard for the user-facing app.
4 *
5 * Checks, in order of how badly they break the product:
6 *
7 * 1. Dictionary parity — zh-CN is the reference key set; en must contain every
8 * key with an identical `{placeholder}` set, and neither may be empty.
9 * These are hard failures: a missing translation is a shipped bug.
10 * 2. Untranslated copy — a CJK literal in `src` means a user-visible string was
11 * missed. Comments and the generated API client are exempt.
12 *
13 * The second check is a **ratchet**. The whole app is being migrated in waves,
14 * so the remaining count is compared against `CJK_BASELINE`: the build fails if
15 * it grows (new hardcoded copy cannot land) and reports the number otherwise.
16 * When a wave finishes, lower the baseline; when the last wave lands, set it to
17 * 0 and the check becomes absolute.
18 *
19 * Usage: bun scripts/lint-i18n.mjs [--strict]
20 * --strict ignores the baseline and fails on any untranslated copy.
21 */
22import { readdirSync, readFileSync, statSync } from 'node:fs'
23import { dirname, join, relative, resolve } from 'node:path'
24import { fileURLToPath } from 'node:url'
25
26const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
27const srcRoot = join(repoRoot, 'src')
28const messagesRoot = join(srcRoot, 'shared', 'i18n', 'messages')
29const strict = process.argv.includes('--strict')
30
31/** Maximum tolerated untranslated lines; lowered wave by wave, 0 at the end. */
32const CJK_BASELINE = Number(process.env.I18N_CJK_BASELINE ?? 0)
33
34/** Files whose CJK text is authored content, not translatable copy. */
35const CJK_EXEMPT = [
36 'src/shared/api/', // generated client; descriptions come from the OpenAPI doc
37 'src/shared/i18n/messages/', // the dictionaries themselves
38 'src/shared/i18n/locale.ts', // `localeLabel` is an endonym table: 简体中文 is
39 // the correct label for Chinese even while the UI is in English
40]
41
42const CJK = /[\u3000-\u303f\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff00-\uffef]/
43
44const failures = []
45const warnings = []
46
47function walk(dir) {
48 const out = []
49 for (const entry of readdirSync(dir)) {
50 const path = join(dir, entry)
51 if (statSync(path).isDirectory()) out.push(...walk(path))
52 else out.push(path)
53 }
54 return out
55}
56
57/** Collect `'key': 'value'` pairs from a dictionary module. */
58function readDictionary(directory) {
59 const entries = new Map()
60 for (const file of walk(directory)) {
61 if (!file.endsWith('.ts') || file.endsWith('index.ts')) continue
62 const source = readFileSync(file, 'utf8')
63 const pair = /^\s*'([^']+)':\s*(?:\r?\n\s*)?((?:'(?:[^'\\]|\\.)*')|(?:"[^"]*"))/gm
64 for (const match of source.matchAll(pair)) {
65 const [, key, rawValue] = match
66 const value = rawValue.slice(1, -1)
67 if (entries.has(key)) failures.push(`${relative(repoRoot, file)}: duplicate key ${key}`)
68 entries.set(key, value)
69 }
70 }
71 return entries
72}
73
74function placeholders(value) {
75 return [...value.matchAll(/\{(\w+)\}/g)].map(m => m[1]).sort()
76}
77
78const zh = readDictionary(join(messagesRoot, 'zh-CN'))
79const en = readDictionary(join(messagesRoot, 'en'))
80
81for (const [key, value] of zh) {
82 const other = en.get(key)
83 if (other === undefined) {
84 failures.push(`en is missing key: ${key}`)
85 continue
86 }
87 if (!other.trim()) failures.push(`en has an empty value: ${key}`)
88 if (jsonish(value) === jsonish(other) && value !== other) {
89 failures.push(`en value is not a real translation for ${key}`)
90 }
91 const a = placeholders(value).join(',')
92 const b = placeholders(other).join(',')
93 if (a !== b) failures.push(`placeholder mismatch for ${key}: zh-CN has [${a}] but en has [${b}]`)
94}
95for (const key of en.keys()) {
96 if (!zh.has(key)) failures.push(`zh-CN is missing key: ${key}`)
97}
98for (const [key, value] of zh) if (!value.trim()) failures.push(`empty zh-CN value: ${key}`)
99
100function jsonish(value) {
101 return value.replace(/\s+/g, ' ').trim()
102}
103
104/**
105 * Strip comments only. CJK inside a comment documents Rust-side behaviour and
106 * never reaches a user; CJK inside a string or JSX always does.
107 */
108function stripComments(source) {
109 return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1')
110}
111
112const scanned = walk(srcRoot).filter(file => /\.(ts|tsx)$/.test(file))
113let cjkLines = 0
114for (const file of scanned) {
115 const rel = relative(repoRoot, file).replace(/\\/g, '/')
116 if (CJK_EXEMPT.some(exempt => rel.startsWith(exempt))) continue
117 const source = stripComments(readFileSync(file, 'utf8'))
118 for (const [index, line] of source.split('\n').entries()) {
119 if (!CJK.test(line)) continue
120 cjkLines += 1
121 if (strict) {
122 failures.push(`untranslated CJK copy at ${rel}:${index + 1}: ${line.trim().slice(0, 120)}`)
123 } else if (cjkLines <= 12) {
124 warnings.push(`untranslated CJK copy at ${rel}:${index + 1}: ${line.trim().slice(0, 100)}`)
125 }
126 }
127}
128
129if (!strict && cjkLines > CJK_BASELINE) {
130 failures.push(
131 `untranslated CJK lines grew to ${cjkLines}, above the baseline of ${CJK_BASELINE} — ` +
132 'add dictionary entries instead of new hardcoded copy',
133 )
134}
135
136if (failures.length) {
137 console.error(`i18n lint failed with ${failures.length} problem(s):`)
138 for (const failure of failures.slice(0, 60)) console.error(` - ${failure}`)
139 if (failures.length > 60) console.error(` … and ${failures.length - 60} more`)
140 process.exit(1)
141}
142if (warnings.length) {
143 console.warn(`i18n: ${cjkLines} line(s) still unmigrated (baseline ${CJK_BASELINE}); first few:`)
144 for (const warning of warnings) console.warn(` - ${warning}`)
145}
146console.log(
147 `i18n lint passed: ${zh.size} keys, ${cjkLines}/${CJK_BASELINE} CJK lines remaining across ${scanned.length} files`,
148)
Deleted web/scripts/lint-nav.mjs
@@ -1,65 +0,0 @@
1#!/usr/bin/env bun
2/**
3 * Navigation guard: outside the auth flow every route change must go through
4 * react-router (`Link` / `useNavigate`) so the SPA never pays a full document
5 * reload. Raw anchors, `href=` attributes and `window.location` writes are
6 * reported as errors. Auth pages are exempt (e.g. the `#terms` anchors).
7 *
8 * Usage: bun scripts/lint-nav.mjs [dir] (default: src)
9 */
10import { readdirSync, readFileSync, statSync } from 'node:fs'
11import { dirname, join, relative, resolve } from 'node:path'
12import { fileURLToPath } from 'node:url'
13
14const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
15const scanRoot = resolve(process.argv[2] ?? join(repoRoot, 'src'))
16
17const AUTH_EXEMPT = [
18 'src/features/auth/',
19 'src/app/routes/deferred-routes',
20]
21const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'livekit'])
22
23const PATTERNS = [
24 ['raw <a> anchor', /<a[\s>]/],
25 ['href attribute', /\bhref=["'{]/],
26 ['full-page navigation', /(?:window\.)?location\s*\.\s*(?:href\s*=|assign\s*\(|replace\s*\()/],
27 // A reload is a browser-level refresh: route state must be changed through
28 // react-router instead (see pages/workspace/model/repo-location).
29 ['browser-level refresh', /(?:window\.|document\.)?location\s*\.\s*reload\s*\(/],
30]
31
32function* walk(dir) {
33 for (const entry of readdirSync(dir)) {
34 const path = join(dir, entry)
35 if (statSync(path).isDirectory()) {
36 if (!SKIP_DIRS.has(entry)) yield* walk(path)
37 } else if (/\.tsx?$/.test(entry) && !entry.endsWith('.d.ts')) {
38 yield path
39 }
40 }
41}
42
43const violations = []
44for (const file of walk(scanRoot)) {
45 const rel = relative(repoRoot, file).replaceAll('\\', '/')
46 if (rel.startsWith('src/shared/api/') || AUTH_EXEMPT.some(prefix => rel.startsWith(prefix))) continue
47 readFileSync(file, 'utf8')
48 .split('\n')
49 .forEach((line, i) => {
50 // Fragment-only anchors (`href="#L12"`) never trigger a document reload,
51 // so they are allowed outside the auth flow as in-page permalinks.
52 const fragmentOnly = line.includes('href={`#') || /href=["'{]#/.test(line)
53 for (const [label, re] of PATTERNS) {
54 if (fragmentOnly && label !== 'full-page navigation' && label !== 'browser-level refresh') continue
55 if (re.test(line)) violations.push(`${rel}:${i + 1}: ${label} — ${line.trim()}`)
56 }
57 })
58}
59
60if (violations.length > 0) {
61 console.error('navigation guard: use <Link> / useNavigate() outside auth pages')
62 for (const v of violations) console.error(' ' + v)
63 process.exit(1)
64}
65console.log('navigation guard: OK — no href/location navigation outside auth pages')
Deleted web/src/app/routes/deferred-routes.tsx
@@ -1,63 +0,0 @@
1import { lazy, Suspense, useEffect, type ReactNode } from 'react'
2import { Loader2 } from 'lucide-react'
3import { useT } from '@/shared/i18n'
4
5const WorkspaceRouteContent = lazy(() => import('./workspace-route').then(route => ({ default: route.WorkspaceRouteContent })))
6const AgreementPage = lazy(() => import('@/pages/agreement').then(page => ({ default: page.AgreementPage })))
7const NotFoundPage = lazy(() => import('@/pages/not-found').then(page => ({ default: page.NotFoundPage })))
8const SpaceInvitePage = lazy(() => import('@/pages/space-invite').then(page => ({ default: page.SpaceInvitePage })))
9
10function RoutePending() {
11 const { t } = useT()
12 return (
13 <main className="grid min-h-dvh place-items-center bg-background text-foreground" role="status">
14 <Loader2 size={22} className="animate-spin text-primary" aria-hidden />
15 <span className="sr-only">{t('common.action.loading')}</span>
16 </main>
17 )
18}
19
20function DeferredRoute({ children }: { children: ReactNode }) {
21 return <Suspense fallback={<RoutePending />}>{children}</Suspense>
22}
23
24export function WorkspaceRoute() {
25 return <DeferredRoute><WorkspaceRouteContent /></DeferredRoute>
26}
27
28export function LoginRedirectRoute() {
29 useEffect(() => {
30 window.location.replace('/api/v1/auth/login')
31 }, [])
32 return <RoutePending />
33}
34
35/**
36 * 注册表单也在 KenOAuth:把 `redirect` 原样转给后端,后端拼好跳转地址。
37 * 全页跳转是必须的(目标在另一个源),所以和 `LoginRedirectRoute` 一样放在
38 * 免检的路由层里。
39 */
40export function SignupRedirectRoute() {
41 useEffect(() => {
42 const redirect = new URLSearchParams(window.location.search).get('redirect')
43 const query = redirect ? `?redirect=${encodeURIComponent(sanitizeRedirect(redirect))}` : ''
44 window.location.replace(`/api/v1/auth/signup${query}`)
45 }, [])
46 return <RoutePending />
47}
48
49function sanitizeRedirect(raw: string): string {
50 return raw.startsWith('/') && !raw.startsWith('//') ? raw : '/app'
51}
52
53export function SpaceInviteRoute() {
54 return <DeferredRoute><SpaceInvitePage /></DeferredRoute>
55}
56
57export function AgreementRoute() {
58 return <DeferredRoute><AgreementPage /></DeferredRoute>
59}
60
61export function NotFoundRoute() {
62 return <DeferredRoute><NotFoundPage /></DeferredRoute>
63}
No newline at end of file
Deleted web/src/app/routes/router.tsx
@@ -1,52 +0,0 @@
1import { Navigate, createBrowserRouter } from 'react-router-dom'
2import { LandingPage } from '@/pages/landing'
3import {
4 AgreementRoute,
5 LoginRedirectRoute,
6 NotFoundRoute,
7 SignupRedirectRoute,
8 SpaceInviteRoute,
9 WorkspaceRoute,
10} from './deferred-routes'
11
12/**
13 * Route configuration lives in the app layer: it is the only place allowed to
14 * reach into `pages`. Every route points at a page slice's public API.
15 *
16 * The workspace is ONE layout route with path-only children, not a list of
17 * sibling routes that each render `WorkspaceShell`. Siblings remount: moving
18 * between `/app/:space/chat/:id` and `/app/:space/repo/:name` matches a
19 * different route object, so React tore the shell down and built it again —
20 * rail, channel list and community banner all refetched, and the banner
21 * visibly flashed its gradient while the image came back. Under a layout route
22 * the shell element stays mounted and only the match changes. The shell picks
23 * its own content column from the parsed pathname, so the children carry no
24 * element: they exist purely to keep the URL set (and therefore 404s) exact.
25 */
26export const router = createBrowserRouter([
27 { path: '/', element: <LandingPage /> },
28 { path: '/app', element: <Navigate to="/app/@me" replace /> },
29 {
30 path: '/app',
31 element: <WorkspaceRoute />,
32 children: [
33 { path: 'explore' },
34 { path: '@me' },
35 { path: '@me/notify' },
36 { path: '@me/chat/:roomId?' },
37 { path: '@me/board/:boardId?' },
38 { path: '@me/repo/:repoName?' },
39 { path: ':spaceName' },
40 { path: ':spaceName/chat/:roomId?' },
41 { path: ':spaceName/board/:boardId?' },
42 { path: ':spaceName/repo/:repoName?' },
43 { path: ':spaceName/members' },
44 ],
45 },
46 { path: '/auth', element: <Navigate to="/auth/login" replace /> },
47 { path: '/auth/login', element: <LoginRedirectRoute /> },
48 { path: '/auth/signup', element: <SignupRedirectRoute /> },
49 { path: '/spaces/invites/accept', element: <SpaceInviteRoute /> },
50 { path: '/agreements/:key', element: <AgreementRoute /> },
51 { path: '*', element: <NotFoundRoute /> },
52])
Deleted web/src/app/routes/workspace-route.tsx
@@ -1,15 +0,0 @@
1import { installVoiceSession } from '@/features/chat'
2import { WorkspaceShell, prefetchWorkspaceBootstrap } from '@/pages/workspace'
3import { VoiceHost } from '@/shared/voice'
4
5installVoiceSession()
6prefetchWorkspaceBootstrap()
7
8export function WorkspaceRouteContent() {
9 return (
10 <>
11 <VoiceHost />
12 <WorkspaceShell />
13 </>
14 )
15}
Deleted web/src/app/styles/index.css
@@ -1,131 +0,0 @@
1@import "tailwindcss";
2@import "./workspace.css";
3
4@custom-variant dark (&:where(.dark, .dark *));
5
6:root {
7 --background: #f2f3f0;
8 --foreground: #111111;
9 --card: #ffffff;
10 --muted: #f2f3f0;
11 --muted-foreground: #666666;
12 --primary: #ff8400;
13 --primary-foreground: #111111;
14 --secondary: #e7e8e5;
15 --secondary-foreground: #111111;
16 --border: #cbccc9;
17 --input: #cbccc9;
18 --ring: #666666;
19 --destructive: #d93c15;
20 --success: #dfe6e1;
21 --success-foreground: #004d1a;
22 --warning: #e9e3d8;
23 --warning-foreground: #804200;
24 --error: #e5dcda;
25 --error-foreground: #8c1c00;
26 --info: #dfdfd6;
27 --info-foreground: #000066;
28
29 /* Workspace surfaces and text are paired for light and dark appearance. */
30 --shell-rail: #f1f2f2;
31 --shell-sidebar: #fafafa;
32 --shell-surface: #ffffff;
33 --shell-field: #f5f5f4;
34 --shell-active: #eceeed;
35 --shell-tile: #e3e5e5;
36 --shell-board: #f5f5f4;
37 --shell-action: #2f3438;
38 --shell-focus: #8d949c;
39 --shell-ink: #23272b;
40 --shell-ink-muted: #687078;
41 --shell-hairline: #e1e3e3;
42 --shell-shadow: 0 6px 24px rgb(24 28 31 / 6%);
43
44}
45
46.dark {
47 color-scheme: dark;
48 --shell-rail: #151718;
49 --shell-sidebar: #1a1c1e;
50 --shell-surface: #202224;
51 --shell-field: #282a2d;
52 --shell-active: #34373a;
53 --shell-tile: #3c4044;
54 --shell-board: #1b1d1f;
55 --shell-action: #565e67;
56 --shell-focus: #7f8791;
57 --shell-ink: #eceeef;
58 --shell-ink-muted: #b1b5ba;
59 --shell-hairline: #3a3d40;
60 --shell-shadow: 0 6px 24px rgb(0 0 0 / 16%);
61 --background: #111111;
62 --foreground: #ffffff;
63 --card: #1a1a1a;
64 --muted: #2e2e2e;
65 --muted-foreground: #b8b9b6;
66 --secondary: #2e2e2e;
67 --secondary-foreground: #ffffff;
68 --border: #2e2e2e;
69 --input: #2e2e2e;
70 --destructive: #ff5c33;
71 --success: #222924;
72 --success-foreground: #b6ffce;
73 --warning: #291c0f;
74 --warning-foreground: #ff8400;
75 --error: #24100b;
76 --error-foreground: #ff5c33;
77 --info: #222229;
78 --info-foreground: #b2b2ff;
79}
80
81@theme inline {
82 --color-background: var(--background);
83 --color-foreground: var(--foreground);
84 --color-card: var(--card);
85 --color-muted: var(--muted);
86 --color-muted-foreground: var(--muted-foreground);
87 --color-primary: var(--primary);
88 --color-primary-foreground: var(--primary-foreground);
89 --color-secondary: var(--secondary);
90 --color-secondary-foreground: var(--secondary-foreground);
91 --color-border: var(--border);
92 --color-input: var(--input);
93 --color-ring: var(--ring);
94 --color-destructive: var(--destructive);
95 --color-success: var(--success);
96 --color-success-foreground: var(--success-foreground);
97 --color-warning: var(--warning);
98 --color-warning-foreground: var(--warning-foreground);
99 --color-error: var(--error);
100 --color-error-foreground: var(--error-foreground);
101 --color-info: var(--info);
102 --color-info-foreground: var(--info-foreground);
103
104 --color-shell-rail: var(--shell-rail);
105 --color-shell-sidebar: var(--shell-sidebar);
106 --color-shell-surface: var(--shell-surface);
107 --color-shell-field: var(--shell-field);
108 --color-shell-active: var(--shell-active);
109 --color-shell-tile: var(--shell-tile);
110 --color-shell-board: var(--shell-board);
111 --color-shell-action: var(--shell-action);
112 --color-shell-ink: var(--shell-ink);
113 --color-shell-ink-muted: var(--shell-ink-muted);
114 --color-shell-hairline: var(--shell-hairline);
115
116 --font-mono: "JetBrains Mono", ui-monospace, monospace;
117 --font-sans: BlinkMacSystemFont, "Segoe UI", system-ui, Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", Arial, "PingFang SC", "Source Han Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
118
119 --radius-card: 16px;
120 --radius-field: 10px;
121}
122
123@layer base {
124 html {
125 font-family: var(--font-sans);
126 -webkit-font-smoothing: antialiased;
127 }
128 body {
129 @apply bg-background text-foreground;
130 }
131}
Deleted web/src/app/styles/workspace.css
@@ -1,391 +0,0 @@
1/* The workspace uses one visual language across every feature and overlay. */
2.workspace-shell, .workspace-overlay {
3 --primary: var(--shell-action);
4 --primary-foreground: #fff;
5 --foreground: var(--shell-ink);
6 --card: var(--shell-surface);
7 --muted: var(--shell-field);
8 --muted-foreground: var(--shell-ink-muted);
9 --border: var(--shell-hairline);
10 --ring: var(--shell-focus);
11 color: var(--shell-ink);
12 font-size: 14px;
13 line-height: 1.6;
14}
15.workspace-shell { --nav-rail: 76px; --nav-side: 280px; --nav-motion: 230ms; --nav-ease: cubic-bezier(.22,.68,.28,1); display: flex; width: 100%; height: 100dvh; overflow: hidden; background: var(--shell-sidebar); }
16/*
17 * Collapsing the sidebar animates the width of the navigation column and
18 * clips it, instead of toggling `display: none` on the sidebar. The sidebar
19 * keeps its own width inside that clip, so its contents never reflow while
20 * the column closes — only the main column relayouts, which it must anyway.
21 */
22.workspace-navigation { display: flex; flex-direction: column; flex-shrink: 0; overflow: hidden; width: calc(var(--nav-rail) + var(--nav-side)); background: linear-gradient(to right, var(--shell-rail) 0 var(--nav-rail), var(--shell-sidebar) var(--nav-rail) 100%); transition: width var(--nav-motion) var(--nav-ease); }
23.workspace-navigation-columns { display: flex; flex: 1; min-height: 0; }
24.workspace-rail { display: flex; width: var(--nav-rail); flex-shrink: 0; flex-direction: column; align-items: center; gap: 14px; overflow-y: auto; padding: 20px 0; background: var(--shell-rail); }
25.workspace-sidebar { display: flex; flex-direction: column; width: var(--nav-side); border-right: 1px solid var(--shell-hairline); background: var(--shell-sidebar); transition: opacity calc(var(--nav-motion) * .45) ease calc(var(--nav-motion) * .4), visibility 0s; }
26.workspace-sidebar-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 76px; padding: 14px 20px; border-bottom: 1px solid var(--shell-hairline); }
27.workspace-sidebar-header > div { min-width: 0; }
28/* The community header borrows Discord's compact-cover treatment: a real
29 * uploaded banner when available, a stable colour cover otherwise, and the
30 * community identity kept legible over both. */
31.workspace-sidebar-header:has(.space-community-header) { position: relative; align-items: stretch; min-height: 132px; padding: 0; gap: 0; }
32.workspace-sidebar-header:has(.space-community-header) > .space-community-header { width: 100%; }
33.workspace-sidebar-header:has(.space-community-header) .workspace-drawer-close { position: absolute; z-index: 42; top: 10px; right: 10px; color: #fff; background: rgb(10 14 24 / 28%); backdrop-filter: blur(8px); }
34.workspace-sidebar-header:has(.space-community-header) .workspace-drawer-close:hover { color: #fff; background: rgb(10 14 24 / 50%); }
35.space-community-header { position: relative; z-index: 1; min-width: 0; min-height: 132px; isolation: isolate; }
36.space-community-banner { position: absolute; inset: 0; z-index: -1; overflow: hidden; background: radial-gradient(132px 88px at 8% -14%, hsl(var(--space-banner-hue) 94% 82% / 88%), transparent 72%), radial-gradient(142px 112px at 104% 2%, hsl(calc(var(--space-banner-hue) + 46) 78% 58% / 90%), transparent 72%), linear-gradient(145deg, hsl(var(--space-banner-hue) 62% 42%), hsl(calc(var(--space-banner-hue) + 26) 70% 27%)); }
37.space-community-banner::before, .space-community-banner::after { position: absolute; inset: 0; content: ''; pointer-events: none; }
38.space-community-banner::before { background: linear-gradient(116deg, rgb(255 255 255 / 14%) 0 12%, transparent 12% 42%, rgb(255 255 255 / 9%) 42% 52%, transparent 52%); mix-blend-mode: soft-light; }
39.space-community-banner::after { background: linear-gradient(180deg, rgb(8 12 20 / 4%) 0%, rgb(8 12 20 / 15%) 44%, rgb(8 12 20 / 74%) 100%); }
40.space-community-banner > img { width: 100%; height: 100%; object-fit: cover; transform: scale(1.02); }
41.space-community-menu-trigger { position: relative; z-index: 1; display: flex; width: 100%; min-height: 132px; align-items: flex-end; gap: 10px; padding: 15px 16px; color: #fff; text-align: left; text-shadow: 0 1px 2px rgb(0 0 0 / 40%); transition: background 160ms; }
42.space-community-menu-trigger:hover, .space-community-menu-trigger[aria-expanded="true"] { background: rgb(7 10 16 / 15%); }
43.space-community-avatar { display: grid; width: 42px; height: 42px; flex: 0 0 auto; place-items: center; overflow: hidden; border: 2px solid rgb(255 255 255 / 74%); border-radius: 13px; background: rgb(255 255 255 / 18%); box-shadow: 0 3px 12px rgb(0 0 0 / 28%); font-size: 17px; font-weight: 700; text-shadow: none; }
44.space-community-avatar > img { width: 100%; height: 100%; object-fit: cover; }
45.space-community-name { display: block; overflow: hidden; color: #fff; font-size: 15px; font-weight: 700; line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; }
46.space-community-members { display: inline-flex; align-items: center; min-height: 18px; margin-top: 4px; padding: 1px 6px; border: 1px solid rgb(255 255 255 / 20%); border-radius: 999px; background: rgb(255 255 255 / 16%); color: rgb(255 255 255 / 92%); font-size: 10px; font-weight: 700; line-height: 1; text-shadow: none; }
47.space-community-members:hover { background: rgb(255 255 255 / 28%); }
48.space-community-menu-trigger svg { color: rgb(255 255 255 / 88%); }
49.workspace-nav-caption { display: flex; align-items: center; justify-content: space-between; padding: 18px 16px 8px 20px; color: var(--shell-ink-muted); font-size: 12px; font-weight: 600; letter-spacing: .06em; }
50.workspace-nav-pinned { flex-shrink: 0; padding: 10px 12px 2px; }
51.workspace-nav { flex: 1; overflow-y: auto; padding: 0 12px 20px; }
52/* User bar at the foot of the navigation column: docked into the bottom-left
53 corner, floating clear of the rail above and of the channel list on its
54 right — the space it leaves shows the rail and sidebar surfaces as-is. */
55.workspace-navigation-footer { display: flex; min-height: 60px; flex-shrink: 0; align-items: center; gap: 10px; margin: 8px 8px 0 0; padding: 12px 16px; border-top-right-radius: 12px; background: var(--shell-surface); box-shadow: var(--shell-shadow); }
56.workspace-main { position: relative; display: flex; flex: 1; min-width: 0; min-height: 0; flex-direction: column; background: var(--shell-surface); }
57.workspace-main:focus { outline: none; }
58.workspace-icon-button { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; flex-shrink: 0; border-radius: 10px; color: var(--shell-ink-muted); transition: background 160ms, color 160ms; }
59.workspace-icon-button:hover { background: var(--shell-active); color: var(--shell-ink); }
60/* Three footer controls instead of two: the row tightens to keep the name legible. */
61.workspace-icon-button.is-compact { width: 32px; height: 32px; border-radius: 9px; }
62/* A toggle that is "off" (muted microphone, deafened headphones) reads red. */
63.workspace-icon-button.is-off { color: var(--error-foreground); background: color-mix(in srgb, var(--destructive) 14%, transparent); }
64.workspace-icon-button.is-off:hover {
65 background: color-mix(in srgb, var(--destructive) 22%, transparent);
66 color: var(--error-foreground);
67}
68.workspace-collapse { width: 28px; height: 28px; }
69.workspace-expand { position: absolute; z-index: 15; top: 20px; left: 14px; background: var(--shell-field); opacity: 0; visibility: hidden; transform: translateX(-8px) scale(.94); transition: opacity 120ms ease, transform 180ms ease, background 160ms, color 160ms, visibility 0s linear 120ms; }
70.navigation-collapsed .workspace-expand { opacity: 1; visibility: visible; transform: none; transition: opacity 180ms ease calc(var(--nav-motion) * .5), transform 300ms var(--nav-ease) calc(var(--nav-motion) * .45), background 160ms, color 160ms, visibility 0s; }
71/*
72 * Directory column (explore): the rail is the whole navigation, so the space
73 * sidebar is dropped and the navigation column narrows to the rail width. The
74 * mobile drawer keeps the same rule; there is nothing behind it to open.
75 */
76.rail-only .workspace-navigation { width: var(--nav-rail); }
77.rail-only .workspace-sidebar { display: none; }
78.navigation-collapsed .workspace-navigation { width: var(--nav-rail); }
79.navigation-collapsed .workspace-sidebar { opacity: 0; visibility: hidden; transition: opacity calc(var(--nav-motion) * .45) ease, visibility 0s linear calc(var(--nav-motion) * .45); }
80.navigation-collapsed .workspace-navigation-footer, .rail-only .workspace-navigation-footer { display: none; }
81/*
82 * The collapsed-nav expand button floats over the top-left of the content
83 * column, so the bar underneath it reserves that gutter. The button is
84 * `main`'s own first child, which rules out a structural `first-child`
85 * match, and the chat header sits four levels deep — so the two bars that
86 * can lead the column are named here.
87 */
88.workspace-main :is(.shell-top-bar, .chat-header) { transition: padding-left var(--nav-motion) var(--nav-ease); }
89.navigation-collapsed .workspace-main :is(.shell-top-bar, .chat-header) { padding-left: 64px; }
90/*
91 * The global search box is a fixed overlay pinned to the top-right of the
92 * window, so every bar that can lead the content column reserves a gutter for
93 * it — otherwise it sits on top of the bar's own actions. Below `lg` the
94 * overlay collapses to its icon, so the gutter stays small enough that the bar
95 * keeps a usable title. The overlay also centres on the bar's own height
96 * (76px, 36px box), so it never drifts off the bar's optical centre line.
97 */
98@media (min-width: 768px) {
99 .workspace-main :is(.shell-top-bar, .chat-header) { padding-right: 64px; }
100}
101@media (min-width: 1024px) {
102 .workspace-main :is(.shell-top-bar, .chat-header) { padding-right: calc(clamp(200px, 22vw, 300px) + 28px); }
103}
104/*
105 * An open chat panel owns the same top-right corner, so the overlay steps left
106 * by the panel's live width — otherwise it lands inside a resized detail pane.
107 * Below 1201px the panel is a modal drawer behind a scrim, and the trigger
108 * hides with the page it covers.
109 */
110@media (max-width: 1200px) {
111 body:has(.chat-side-panel) .global-search-trigger { display: none; }
112}
113@media (min-width: 1201px) {
114 body:has(.chat-side-panel) .global-search-trigger { right: calc(var(--global-chat-panel-width, 380px) + 16px); }
115}
116/*
117 * A docked detail pane pulls the search box off the window corner and into the
118 * chat column, so the header has to reserve the field's gutter. Below the width
119 * that can host both, that reservation starved the very header it floats over —
120 * at a 1243px window the column is 539px, the gutter took 302px of it and the
121 * title collapsed to zero width, stacking the topic one character per line in a
122 * 300px-tall bar. Under that width the trigger renders as its icon only (same
123 * affordance as the bar's other actions, still opens the palette on click) and
124 * the header reserves just the icon.
125 */
126@media (min-width: 1201px) and (max-width: 1599px) {
127 body:has(.chat-side-panel) .global-search-trigger { width: 36px; justify-content: center; padding-inline: 0; }
128 body:has(.chat-side-panel) .global-search-trigger > :is(span, kbd) { display: none; }
129 body:has(.chat-side-panel) .workspace-main .chat-header { padding-right: 64px; }
130}
131@media (min-width: 1201px) {
132 /* A custom-width detail pane becomes the reading focus. Hide the global
133 * command until that pane closes, and release its former header gutter.
134 * Declared after the width ladder above so it wins at every width. */
135 body:has(.chat-panel-resized) .global-search-trigger { display: none; }
136 body:has(.chat-panel-resized) .workspace-main .chat-header { padding-right: 24px; }
137}
138.global-search-overlay { animation: global-search-fade 140ms ease-out both; }
139.global-search-dialog { animation: global-search-pop 180ms cubic-bezier(.2, .8, .2, 1) both; transform-origin: 50% 0; }
140@keyframes global-search-fade { from { opacity: 0; } to { opacity: 1; } }
141@keyframes global-search-pop { from { opacity: 0; transform: translateY(-10px) scale(.985); } to { opacity: 1; transform: translateY(0) scale(1); } }
142@media (prefers-reduced-motion: reduce) { .global-search-overlay, .global-search-dialog { animation: none; } }
143.workspace-mobile-bar, .workspace-drawer-close, .workspace-scrim { display: none; }
144.workspace-skip { position: fixed; left: 12px; top: -80px; z-index: 100; padding: 10px 16px; border-radius: 10px; color: white; background: var(--shell-action); }
145.workspace-skip:focus { top: 12px; }
146.shell-top-bar { display: flex; align-items: center; gap: 12px; min-height: 76px; flex-shrink: 0; padding: 14px 24px; border-bottom: 1px solid var(--shell-hairline); }
147.shell-top-icon { display: grid; place-items: center; width: 38px; height: 38px; flex-shrink: 0; border-radius: 12px; color: var(--shell-ink); background: transparent; }
148.shell-top-icon svg { color: inherit; }
149.shell-top-actions { display: flex; flex-shrink: 0; align-items: center; gap: 10px; color: var(--shell-ink-muted); }
150.workspace-nav-row { position: relative; min-height: 32px; margin-bottom: 2px; gap: 10px; border-radius: 8px; transition: background 160ms, color 160ms; }
151.workspace-nav-row:is(a, button, [data-kind="folder"]):hover { background: var(--shell-active); }
152/* Selected rows sit two shades below the hover gray instead of flipping to white. */
153.workspace-nav-row[aria-current="page"] { color: var(--shell-ink); background: color-mix(in srgb, var(--shell-ink) 4%, var(--shell-tile)); font-weight: 600; }
154.workspace-nav-row[aria-current="page"] > span:first-child { color: var(--shell-ink-muted); }
155.workspace-nav-row[data-kind="folder"] { margin-top: 12px; }
156/* Rail tiles rest as circles and square off on hover or when they own the route. */
157.workspace-rail a, .workspace-rail button { border-radius: 9999px; transition: background 160ms, color 160ms, border-color 160ms, border-radius 200ms, box-shadow 160ms; }
158.workspace-rail a:hover, .workspace-rail button:hover, .workspace-rail a[aria-current="page"] { border-radius: 16px; }
159.workspace-rail a[aria-current="page"] { color: var(--shell-ink); }
160/* The dashed create / discover ring follows the same morph, without changing its fill. */
161.rail-dashed-ring-shape { rx: 22px; transition: rx 200ms; }
162.workspace-rail a:hover .rail-dashed-ring-shape, .workspace-rail button:hover .rail-dashed-ring-shape, .workspace-rail a[aria-current="page"] .rail-dashed-ring-shape { rx: 14px; }
163/* Keyboard focus for controls. */
164.workspace-shell :is(button, a, summary, [role="button"], [role="option"]):focus-visible, .workspace-overlay :is(button, a, summary, [role="button"], [role="option"]):focus-visible { outline: 2px solid color-mix(in srgb, var(--shell-focus) 70%, transparent); outline-offset: 2px; }
165
166/*
167 * Fields: one hairline, real corners, and a soft ring on focus. The 2px
168 * near-black frame these used to get read as an error state rather than a
169 * focused input, and the bare `<input>` inside an icon row has no radius of
170 * its own, so the browser outline drew square corners.
171 */
172.workspace-shell :is(input, select, textarea):not([type="checkbox"]):not([type="radio"]):not(.bg-transparent),
173.workspace-overlay :is(input, select, textarea):not([type="checkbox"]):not([type="radio"]):not(.bg-transparent) { border-width: 1px; border-radius: 9px; }
174.workspace-shell :is(input, select, textarea):not(.bg-transparent):focus, .workspace-overlay :is(input, select, textarea):not(.bg-transparent):focus { border-color: var(--shell-tile); outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--shell-focus) 20%, transparent); }
175.workspace-shell :is(input, select, textarea):not(.bg-transparent):hover:not(:focus), .workspace-overlay :is(input, select, textarea):not(.bg-transparent):hover:not(:focus) { border-color: color-mix(in srgb, var(--shell-tile) 70%, var(--shell-hairline)); }
176/* A transparent field is bare: its rounded wrapper owns the border and ring. */
177.workspace-shell .bg-transparent:focus-visible, .workspace-overlay .bg-transparent:focus-visible { outline: none; }
178.workspace-shell :is(button, [role="button"]):not(:disabled), .workspace-overlay :is(button, [role="button"]):not(:disabled) { cursor: pointer; touch-action: manipulation; }
179.workspace-shell :is(button, input, select, textarea), .workspace-overlay :is(button, input, select, textarea) { font-family: inherit; }
180.workspace-shell .bg-primary.font-mono, .workspace-overlay .bg-primary.font-mono { font-family: var(--font-sans); letter-spacing: normal; }
181.workspace-shell ::selection, .workspace-overlay ::selection { background: var(--shell-active); color: var(--shell-ink); }
182.workspace-shell *, .workspace-overlay * { scrollbar-width: thin; scrollbar-color: var(--shell-tile) transparent; }
183.workspace-overlay { background: rgb(18 20 22 / 46%); backdrop-filter: blur(5px); }
184.workspace-dialog { max-height: calc(100dvh - 32px); overflow-y: auto; border: 1px solid var(--shell-hairline); border-radius: 20px; padding: 28px; box-shadow: 0 24px 80px rgb(15 17 19 / 20%); }
185.workspace-dialog h2 { font-size: 20px; line-height: 1.4; }
186.workspace-dialog :is(input, select, textarea) { border-width: 1px; border-radius: 10px; font-size: 14px; }
187.workspace-dialog :is(input:not([type="checkbox"]), select) { min-height: 42px; }
188.workspace-dialog .dialog-close { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 10px; background: var(--shell-field); }
189.settings-dialog { display: flex; position: relative; width: min(1320px, calc(100vw - 3rem)); height: min(860px, calc(100dvh - 3rem)); overflow: hidden; border: 1px solid var(--shell-hairline); border-radius: 18px; background: var(--shell-surface); box-shadow: 0 24px 80px rgb(15 17 19 / 22%); }
190.settings-nav { display: flex; width: 264px; flex-shrink: 0; flex-direction: column; gap: 2px; overflow-y: auto; padding: 16px 12px 20px; background: var(--shell-sidebar); border-right: 1px solid var(--shell-hairline); }
191.settings-nav-body { display: flex; min-height: 0; flex: 1; flex-direction: column; gap: 2px; }
192.settings-nav-title { padding: 8px 10px 12px; font-size: 16px; font-weight: 700; color: var(--shell-ink); }
193.settings-nav-caption { padding: 14px 10px 4px; font-size: 11px; font-weight: 700; letter-spacing: .06em; color: var(--shell-ink-muted); }
194.settings-nav-item { display: flex; width: 100%; min-height: 36px; align-items: center; gap: 10px; padding: 7px 10px; border-radius: 8px; color: var(--shell-ink-muted); font-size: 14px; text-align: left; transition: background 160ms, color 160ms; }
195.settings-nav-item:hover { background: var(--shell-active); color: var(--shell-ink); }
196.settings-nav-item[aria-current="page"] { background: var(--shell-active); color: var(--shell-ink); font-weight: 600; }
197/* Destructive sections read red wherever they sit in a settings nav. */
198.settings-nav-item.settings-nav-danger, .settings-nav-item.settings-nav-danger[aria-current="page"] { color: #f23f43; }
199.settings-nav-item.settings-nav-danger:hover { background: color-mix(in srgb, #f23f43 10%, transparent); color: #f23f43; }
200.settings-pane { display: flex; flex: 1; min-width: 0; flex-direction: column; }
201.settings-pane-header { display: flex; min-height: 60px; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px 10px 28px; border-bottom: 1px solid var(--shell-hairline); }
202.settings-pane-header h2 { min-width: 0; overflow: hidden; font-size: 17px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
203.settings-content { flex: 1; min-width: 0; min-height: 0; overflow-y: auto; scrollbar-gutter: stable; padding: 32px 40px 56px; }
204.settings-content > section { max-width: 720px; }
205/* Swapped panes cross-fade in place: no movement, no empty flash. */
206.settings-swap { position: relative; }
207.settings-swap-out { position: absolute; top: 0; left: 0; right: 0; pointer-events: none; animation: settings-swap-out 200ms ease both; }
208.settings-swap-in { animation: settings-swap-in 200ms ease both; }
209@keyframes settings-swap-in { from { opacity: 0; } to { opacity: 1; } }
210@keyframes settings-swap-out { from { opacity: 1; } to { opacity: 0; } }
211/* The pane header already names the section, so the body keeps only its note. */
212.settings-content > section > .settings-section-head > h2 { display: none; }
213.settings-content :is(input, select, textarea) { border-width: 1px; border-radius: 10px; min-height: 40px; font-size: 14px; }
214.settings-content input[type="checkbox"] { min-height: 0; }
215.settings-close { flex-shrink: 0; background: var(--shell-field); }
216/*
217 * Settings lists (user / space / repo): one card per entry, its inline editor
218 * revealed below. Surfaces stay neutral — selection is shown by weight and a
219 * hairline, never by a tint, so a page of rows does not turn into a colour chart.
220 */
221.settings-row { display: flex; min-width: 0; align-items: center; gap: 12px; }
222/*
223 * A card is filled rather than outlined onto the same colour as the dialog, so
224 * its padding reads as part of the container. Everything inside steps back up
225 * to the dialog surface, and `--muted` is remapped so a secondary button keeps
226 * a hover state darker than the card instead of the same colour as it.
227 */
228.settings-card { --card: var(--shell-surface); --muted: var(--shell-active); --input: var(--shell-tile); border: 1px solid var(--shell-hairline); border-radius: 14px; background: var(--shell-field); }
229.settings-card > .settings-row { padding: 12px 14px; }
230.settings-card :is(input, select, textarea):not([type="color"]) { background: var(--shell-surface); }
231.settings-card .settings-chip { background: var(--shell-surface); }
232.settings-card .settings-segment-group { background: var(--shell-active); }
233.settings-card .settings-segment[aria-pressed="true"] { background: var(--shell-surface); }
234.settings-card .settings-facts code { background: var(--shell-surface); }
235.settings-card.is-danger { border-color: color-mix(in srgb, #f23f43 45%, var(--shell-hairline)); background: color-mix(in srgb, #f23f43 7%, var(--shell-field)); padding: 16px; }
236.settings-subpanel { padding: 14px 14px 16px; border-top: 1px solid var(--shell-hairline); }
237.settings-subpanel.is-flat { border-top: 0; }
238.settings-disclose { display: inline-flex; align-items: center; gap: 4px; padding: 4px 9px; border-radius: 8px; color: var(--shell-ink-muted); font-size: 12.5px; transition: background 160ms, color 160ms; }
239.settings-disclose:hover { color: var(--shell-ink); background: color-mix(in srgb, var(--shell-ink) 8%, transparent); }
240.settings-disclose svg { transition: transform 180ms; }
241.settings-segment-group { display: inline-flex; gap: 2px; padding: 2px; border-radius: 10px; background: var(--shell-field); }
242.settings-segment { min-height: 28px; padding: 3px 10px; border-radius: 8px; color: var(--shell-ink-muted); font-size: 12.5px; transition: background 160ms, color 160ms, box-shadow 160ms; }
243.settings-segment:hover:not(:disabled) { color: var(--shell-ink); }
244.settings-segment[aria-pressed="true"] { color: var(--shell-ink); font-weight: 600; background: var(--shell-surface); box-shadow: inset 0 0 0 1px var(--shell-hairline); }
245.settings-segment:disabled { opacity: .55; }
246.settings-chip { padding: 4px 10px; border: 1px solid var(--shell-hairline); border-radius: 999px; background: var(--shell-field); color: var(--shell-ink-muted); font-size: 12px; transition: border-color 160ms, background 160ms, color 160ms; }
247.settings-chip:hover { color: var(--shell-ink); }
248.settings-chip.is-on { color: var(--shell-ink); font-weight: 600; background: var(--shell-surface); border-color: var(--shell-ink-muted); }
249.settings-facts { display: grid; gap: 9px; margin: 0; }
250.settings-facts > div { display: flex; flex-wrap: wrap; align-items: baseline; gap: 10px; }
251.settings-facts dt { width: 96px; flex-shrink: 0; color: var(--shell-ink-muted); font-size: 12px; }
252.settings-facts dd { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 13px; }
253.settings-facts code { padding: 2px 6px; border-radius: 5px; background: var(--shell-field); font-family: var(--font-mono); font-size: 11.5px; }
254.settings-danger-btn { background: #f23f43; color: #fff; }
255.settings-danger-btn:hover { background: color-mix(in srgb, #f23f43 88%, #000); }
256.board-toolbar { padding: 16px 24px; gap: 12px; border-bottom: 1px solid var(--shell-hairline); }
257.board-canvas { min-height: 0; padding: 20px 24px; background: var(--shell-field); }
258.board-track { gap: 18px; }
259.repo-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding: 12px 24px 0; border-bottom: 1px solid var(--shell-hairline); }
260.repo-toolbar-refs { display: flex; min-width: 0; flex-shrink: 0; flex-wrap: wrap; gap: 8px; }
261.repo-toolbar-clone { display: flex; flex-shrink: 0; align-items: center; gap: 8px; }
262.repo-toolbar-clone.empty .repo-overview-toggle { margin-left: auto; }
263.repo-toolbar-tabs { display: flex; min-width: 0; max-width: 100%; align-items: center; overflow-x: auto; padding-left: 12px; border-left: 1px solid var(--shell-hairline); overscroll-behavior-x: contain; scrollbar-width: none; }
264.repo-toolbar-tabs::-webkit-scrollbar { display: none; }
265.repo-toolbar-tabs > nav { flex: 0 0 auto; }
266.repo-content-layout { display: flex; flex: 1; min-height: 0; gap: 24px; padding: 16px 24px 24px; background: var(--shell-field); }
267.repo-content-layout > aside { width: 260px; }
268.repo-overview-toggle { display: none; }
269@media (max-width: 1200px) {
270 .workspace-shell { --nav-rail: 68px; --nav-side: 224px; }
271
272 .repo-content-layout > aside { display: none; }
273 .repo-toolbar-tabs { padding-left: 0; border-left: 0; }
274 .repo-content-layout.overview-open > aside { display: block; width: 260px; }
275 .repo-overview-toggle { display: flex; }
276}
277/* Appearance picker: swatch cards for the base64-persisted palettes. */
278.palette-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(128px, 1fr)); gap: 8px; }
279.palette-card { display: flex; flex-direction: column; gap: 5px; padding: 9px 10px; border: 1px solid var(--shell-hairline); border-radius: 12px; background: var(--shell-surface); text-align: left; transition: border-color 160ms, box-shadow 160ms; }
280.palette-card:hover { border-color: var(--shell-tile); }
281.palette-card.is-active { border-color: var(--shell-ink-muted); box-shadow: inset 0 0 0 1px var(--shell-ink-muted); }
282.palette-swatches { display: flex; height: 22px; gap: 3px; }
283.palette-swatches > i { flex: 1; border: 1px solid rgb(0 0 0 / 8%); border-radius: 4px; }
284.palette-swatches > i:first-child { flex: 2.4; }
285.palette-name { font-size: 13px; font-weight: 600; }
286.palette-origin { font-size: 11px; color: var(--shell-ink-muted); }
287.palette-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 10px; font-size: 12px; color: var(--shell-ink-muted); }
288.palette-meta code { max-width: 100%; overflow: hidden; padding: 2px 6px; border-radius: 5px; background: var(--shell-field); font-family: var(--font-mono); font-size: 11px; }
289.palette-restore { color: var(--shell-ink); text-decoration: underline; text-underline-offset: 2px; }
290
291@media (max-width: 760px) {
292 .workspace-shell { --nav-rail: 64px; flex-direction: column; }
293 .workspace-mobile-bar { display: flex; min-height: 52px; flex-shrink: 0; align-items: center; gap: 10px; padding: 0 12px; border-bottom: 1px solid var(--shell-hairline); }
294 .workspace-navigation, .navigation-collapsed .workspace-navigation { position: fixed; inset: 0 auto 0 0; z-index: 45; width: min(338px, calc(100vw - 44px)); visibility: hidden; transform: translateX(-100%); transition: transform var(--nav-motion) var(--nav-ease), visibility 0s linear var(--nav-motion); }
295 .navigation-open .workspace-navigation { visibility: visible; transform: translateX(0); box-shadow: 20px 0 60px rgb(15 17 19 / 18%); transition-delay: 0s, 0s; }
296 .workspace-sidebar, .navigation-collapsed .workspace-sidebar { display: flex; flex: 1; min-width: 0; width: auto; opacity: 1; visibility: visible; }
297 .rail-only .workspace-sidebar, .rail-only .navigation-collapsed .workspace-sidebar { display: none; }
298 .rail-only .workspace-navigation, .rail-only.navigation-open .workspace-navigation { width: 64px; }
299 .workspace-sidebar-header { padding: 14px; }
300 .workspace-sidebar-header:has(.space-community-header) { min-height: 124px; padding: 0; }
301 .space-community-header, .space-community-menu-trigger { min-height: 124px; }
302 .workspace-rail { width: 64px; }
303 .workspace-drawer-close, .workspace-scrim { display: flex; }
304 .workspace-collapse, .workspace-expand { display: none; }
305 .workspace-scrim { position: fixed; inset: 0; z-index: 40; background: rgb(15 17 19 / 42%); backdrop-filter: blur(3px); }
306 .shell-top-bar, .navigation-collapsed .workspace-main :is(.shell-top-bar, .chat-header) { min-height: 68px; padding: 12px 16px; }
307 .shell-top-actions { gap: 6px; }
308 .shell-top-icon { width: 32px; height: 32px; border-radius: 10px; }
309 .workspace-overlay { padding: 12px; }
310 .workspace-dialog { padding: 22px; max-height: calc(100dvh - 24px); }
311 .settings-dialog { width: 100%; height: calc(100dvh - 24px); border-radius: 16px; flex-direction: column; }
312 .settings-nav { width: 100%; flex-direction: row; align-items: center; gap: 4px; padding: 12px 14px; border-right: 0; border-bottom: 1px solid var(--shell-hairline); overflow-x: auto; flex-shrink: 0; }
313 .settings-nav-title { padding: 0 10px 0 2px; font-size: 15px; white-space: nowrap; }
314 .settings-nav-caption { display: none; }
315 .settings-nav-item { width: auto; flex-shrink: 0; white-space: nowrap; }
316 .settings-pane-header { min-height: 56px; padding: 10px 12px 10px 18px; }
317 .settings-content { padding: 20px 18px 32px; }
318 .settings-row { flex-wrap: wrap; align-items: flex-start; }
319 .settings-card > .settings-row { padding: 12px; }
320 .settings-subpanel { padding: 12px; }
321 .settings-facts dt { width: 100%; }
322 .board-toolbar { padding: 12px 16px; }
323 .board-canvas { padding: 16px; }
324 .repo-toolbar { padding: 10px 16px 0; }
325 .repo-toolbar-refs { flex: 1 1 100%; min-width: 0; }
326 .repo-toolbar-clone { max-width: 100%; flex-wrap: wrap; margin-left: 0; }
327 .repo-content-layout { padding: 12px 16px 16px; }
328 .repo-content-layout.overview-open { flex-direction: column; overflow-y: auto; }
329 .repo-content-layout.overview-open > section { flex: none; overflow: visible; }
330 .repo-content-layout.overview-open > aside { width: 100%; overflow: visible; }
331 .workspace-shell input, .workspace-shell textarea, .workspace-overlay input, .workspace-overlay textarea, .workspace-overlay select { font-size: 16px; }
332}
333@media (prefers-reduced-motion: reduce) {
334 .workspace-shell *, .workspace-overlay * { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; transition-delay: 0s !important; scroll-behavior: auto !important; }
335}
336
337/* 用户与流水线产出的 Markdown(描述、评论、release 正文)。语义与 README 的
338 * markdown-body 一致,但作用域是应用内部,不依赖服务端片段样式。 */
339.gitdata-markdown { font-size: 13px; line-height: 1.65; color: var(--shell-ink); word-break: break-word; }
340.gitdata-markdown-compact { font-size: 12.5px; }
341.gitdata-markdown > :first-child { margin-top: 0; }
342.gitdata-markdown > :last-child { margin-bottom: 0; }
343.gitdata-markdown h1,
344.gitdata-markdown h2 {
345 border-bottom: 1px solid var(--shell-hairline);
346 padding-bottom: .25em;
347 margin: .9em 0 .4em;
348 font-weight: 700;
349}
350.gitdata-markdown h1 { font-size: 1.35em; }
351.gitdata-markdown h2 { font-size: 1.2em; }
352.gitdata-markdown h3 { font-size: 1.08em; font-weight: 700; margin: .9em 0 .35em; }
353.gitdata-markdown h4,
354.gitdata-markdown h5,
355.gitdata-markdown h6 { font-weight: 700; margin: .8em 0 .3em; }
356.gitdata-markdown p { margin: .5em 0; }
357.gitdata-markdown ul,
358.gitdata-markdown ol { padding-left: 1.5em; margin: .5em 0; }
359.gitdata-markdown ul { list-style: disc; }
360.gitdata-markdown ol { list-style: decimal; }
361.gitdata-markdown li { margin: .15em 0; }
362.gitdata-markdown blockquote {
363 margin: .6em 0;
364 padding: .15em .9em;
365 border-left: 3px solid var(--shell-active);
366 color: var(--shell-ink-muted);
367}
368.gitdata-markdown a { color: inherit; text-decoration: underline; text-underline-offset: 3px; }
369.gitdata-markdown code {
370 background: var(--shell-field);
371 border: 1px solid var(--shell-hairline);
372 border-radius: 4px;
373 padding: .05em .35em;
374 font-family: ui-monospace, monospace;
375 font-size: .92em;
376}
377.gitdata-markdown pre {
378 background: var(--shell-field);
379 border: 1px solid var(--shell-hairline);
380 border-radius: 8px;
381 padding: 10px 12px;
382 overflow-x: auto;
383 margin: .6em 0;
384}
385.gitdata-markdown pre code { background: none; border: none; padding: 0; font-size: 12px; }
386.gitdata-markdown table { border-collapse: collapse; margin: .6em 0; display: block; overflow-x: auto; }
387.gitdata-markdown th,
388.gitdata-markdown td { border: 1px solid var(--shell-hairline); padding: 5px 9px; }
389.gitdata-markdown th { background: var(--shell-field); font-weight: 600; }
390.gitdata-markdown img { max-width: 100%; border-radius: 6px; }
391.gitdata-markdown hr { border: none; border-top: 1px solid var(--shell-hairline); margin: 1em 0; }
Deleted web/src/app/api-error-gate.tsx
@@ -1,95 +0,0 @@
1import type { ReactNode } from 'react'
2import { Loader2 } from 'lucide-react'
3import { API_PROBE_INTERVAL_MS, retryApiHealth, useApiHealth, useApiUnavailable } from '@/shared/api'
4import { useT } from '@/shared/i18n'
5
6/**
7 * App-wide「api error」intermediate state.
8 *
9 * `ApiErrorGate` sits above the router: while the rolling failure rate stays
10 * above the threshold in `@/shared/api/health` it replaces the whole tree with
11 * the outage screen, so a dead backend reads as one clear page instead of a
12 * grid of half-rendered panels each showing its own error.
13 *
14 * Unmounting is also the recovery mechanism: the health store probes the
15 * service on its own, and as soon as one request answers, the tree remounts and
16 * every view refetches through `useAsync` — no document reload involved.
17 */
18export function ApiErrorGate({ children }: { children: ReactNode }) {
19 const unavailable = useApiUnavailable()
20 if (unavailable) return <ServiceUnavailableScreen />
21 return <>{children}</>
22}
23
24function ServiceUnavailableScreen() {
25 const { t } = useT()
26 const {
27 failures,
28 samples,
29 errorRate,
30 burstFailures,
31 lastFailureStatus,
32 lastFailureUrl,
33 probing,
34 lastProbeAt,
35 } = useApiHealth()
36 const percent = Math.round(errorRate * 100)
37 const path = lastFailureUrl ? lastFailureUrl.replace(/^https?:\/\/[^/]+/, '') : null
38
39 /** Failure reason in words: status 0 / transport error means "no answer". */
40 const failureLabel =
41 lastFailureStatus === null
42 ? t('error.gate.noResponse')
43 : lastFailureStatus === 0
44 ? t('error.gate.networkError')
45 : lastFailureStatus >= 500
46 ? t('error.gate.serverError', { status: lastFailureStatus })
47 : `HTTP ${lastFailureStatus}`
48
49 return (
50 <main
51 role="alert"
52 aria-live="assertive"
53 className="flex h-dvh w-full flex-col items-center justify-center gap-5 overflow-y-auto bg-shell-surface px-6 py-10 text-center text-shell-ink"
54 >
55 <img
56 src="/wronged.png"
57 alt={t('error.gate.imageAlt')}
58 draggable={false}
59 className="max-h-[38vh] w-auto max-w-[min(420px,72vw)] shrink-0 select-none object-contain"
60 />
61 <div className="flex max-w-md flex-col gap-1.5">
62 <h1 className="text-[17px] font-semibold tracking-tight">{t('error.gate.title')}</h1>
63 <p className="text-[13px] leading-relaxed text-shell-ink-muted">
64 {t('error.gate.body', { count: burstFailures })}
65 </p>
66 </div>
67 <p className="max-w-[min(560px,92vw)] truncate font-mono text-[11px] text-shell-ink-muted">
68 {t('error.gate.lastFailure', {
69 reason: failureLabel,
70 path: path ? ` · ${path}` : '',
71 percent,
72 failures,
73 samples,
74 })}
75 </p>
76 <div className="justify-self-center">
77 <button
78 type="button"
79 onClick={retryApiHealth}
80 disabled={probing}
81 className="inline-flex h-9 min-w-32 items-center justify-center gap-2 rounded-field bg-primary px-4 font-mono text-sm font-bold tracking-[0.4px] text-primary-foreground transition-[filter] hover:brightness-95 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50"
82 >
83 {probing ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}
84 {probing ? t('error.gate.connecting') : t('error.gate.retry')}
85 </button>
86 </div>
87 <p className="text-[11px] text-shell-ink-muted">
88 {t('error.gate.probeInterval', { seconds: Math.round(API_PROBE_INTERVAL_MS / 1000) })}
89 {lastProbeAt
90 ? ` · ${t('error.gate.lastProbe', { time: new Date(lastProbeAt).toLocaleTimeString([], { hour12: false }) })}`
91 : ''}
92 </p>
93 </main>
94 )
95}
Deleted web/src/app/bootstrap.tsx
@@ -1,34 +0,0 @@
1import type { ReactNode } from 'react'
2import { alovaInstance } from '@/shared/api'
3import { ThemeProvider } from '@/shared/theme'
4import { ApiErrorGate } from './api-error-gate'
5
6/**
7 * App-wide providers (Redux / QueryClient / theme / auth context, ...).
8 *
9 * Alova is provider-less, so the only wiring here is instance configuration.
10 * Keeping provider composition here lets `app/index.tsx` stay pure bootstrap.
11 */
12
13/**
14 * Alova caches GET responses in memory for 5 minutes by default and this app
15 * has no cache-invalidation layer (useAsync refetches on mount/route change).
16 * Without this, a mutation such as creating a space keeps rendering the list
17 * cached before the POST, so the new row never appears until a tab reload.
18 * Disabling GET response caching fixes every list; concurrent identical
19 * requests are still deduplicated by shareRequest.
20 */
21alovaInstance.options.cacheFor = { GET: 0 }
22
23export function AppProviders({ children }: { children: ReactNode }) {
24 // The outage gate is deliberately above the router: it is a property of the
25 // transport, not of any single route, and it must be able to swallow the
26 // whole tree (auth screens included) without touching the URL.
27 return (
28 <ApiErrorGate>
29 <ThemeProvider>
30 {children}
31 </ThemeProvider>
32 </ApiErrorGate>
33 )
34}
Deleted web/src/app/index.tsx
@@ -1,22 +0,0 @@
1import { StrictMode } from 'react'
2import { createRoot } from 'react-dom/client'
3import { RouterProvider } from 'react-router-dom'
4import { bootLocale } from '@/shared/i18n'
5import { initPalette, initTheme } from '@/shared/lib'
6import { AppProviders } from './bootstrap'
7import { router } from './routes/router'
8import '@/shared/livekit/styles/dist/general/index.css'
9import './styles/index.css'
10
11initTheme()
12initPalette()
13// Resolve the interface language before anything paints: the store is a
14// module-level singleton, so this also sets `<html lang>` for a11y and fonts.
15bootLocale()
16createRoot(document.getElementById('root')!).render(
17 <StrictMode>
18 <AppProviders>
19 <RouterProvider router={router} />
20 </AppProviders>
21 </StrictMode>,
22)
Deleted web/src/entities/chat/model/api.ts
@@ -1,455 +0,0 @@
1/**
2 * Chat transport: every call the 聊天室 pages make, wrapped in the shared
3 * envelope contract so the UI only ever sees unwrapped rows or an `ApiFailure`.
4 *
5 * Two schema quirks are handled here and nowhere else:
6 * - `list_threads` is typed against the generic `Page` schema (items untyped),
7 * so the thread row is asserted once, in {@link listRoomThreads}.
8 * - `POST /chat/dms` carries the *list* query parameters in its generated
9 * `pathParams`, because the spec shares one path with `GET /chat/dms`; they
10 * are never substituted into the URL, so {@link openDm} passes nulls.
11 *
12 * Empty strings are dropped (`nz`), not sent: Alova omits `undefined` query
13 * values but forwards `''`, and the backend's `Option<Uuid>` deserialization
14 * rejects an empty `thread_id=` with a 400.
15 */
16import Apis from '@/shared/api'
17import { getLocale, translate } from '@/shared/i18n'
18import { unwrapApi, type ApiEnvelope } from '@/shared/lib'
19import type {
20 Attachment,
21 ChatMessage,
22 CustomEmoji,
23 DmBrief,
24 Draft,
25 MessageEdit,
26 MessagePage,
27 PinnedPage,
28 Presence,
29 ReadState,
30 ReactionAgg,
31 ReactionUser,
32 RoomBrief,
33 RoomSetting,
34 SendMessageInput,
35 SpaceReadStates,
36 ThreadBrief,
37 UploadIntent,
38} from './chat'
39
40const nz = <T,>(value: T | '' | null | undefined): T | undefined => (value ? value : undefined)
41
42const listOf = <T>(method: PromiseLike<ApiEnvelope<unknown>>): Promise<T[]> =>
43 unwrapApi<T[] | null>(method as PromiseLike<ApiEnvelope<T[] | null>>).then(rows => rows ?? [])
44
45const nothing = (method: PromiseLike<ApiEnvelope<unknown>>) =>
46 unwrapApi<null>(method as PromiseLike<ApiEnvelope<null>>)
47
48/* ------------------------------------------------------------------ rooms */
49
50export const getRoom = (roomId: string) =>
51 unwrapApi<RoomBrief>(Apis.chat.get_room({ pathParams: { room_id: roomId } }))
52
53/** Three-state fields: `null` clears, `undefined` leaves untouched. */
54export const updateRoom = (
55 roomId: string,
56 cmd: {
57 name?: string
58 topic?: string | null
59 is_private?: boolean
60 nsfw?: boolean
61 rate_limit_per_user?: number | null
62 position?: number
63 },
64) => unwrapApi<RoomBrief>(Apis.chat.update_room({ pathParams: { room_id: roomId }, data: cmd }))
65
66export const deleteRoom = (roomId: string) =>
67 nothing(Apis.chat.delete_room({ pathParams: { room_id: roomId } }))
68
69export const joinRoom = (roomId: string) =>
70 nothing(Apis.chat.join_room({ pathParams: { room_id: roomId } }))
71
72export const leaveRoom = (roomId: string) =>
73 nothing(Apis.chat.leave_room({ pathParams: { room_id: roomId } }))
74
75export const listRoomMembers = (roomId: string) =>
76 listOf<import('./chat').RoomMember>(
77 Apis.chat.list_room_members({ pathParams: { room_id: roomId }, params: { limit: 200 } }),
78 )
79
80export const addRoomMembers = (roomId: string, userIds: number[]) =>
81 nothing(Apis.chat.add_room_members({ pathParams: { room_id: roomId }, data: { user_ids: userIds } }))
82
83export const kickRoomMember = (roomId: string, userId: number) =>
84 nothing(Apis.chat.kick_room_member({ pathParams: { room_id: roomId, user_id: userId } }))
85
86/** Per-viewer channel preferences (mute / favorite / hidden / notification level). */
87export const getRoomSetting = (roomId: string) =>
88 unwrapApi<RoomSetting>(Apis.chat.get_room_setting({ pathParams: { room_id: roomId } }))
89
90export const updateRoomSetting = (
91 roomId: string,
92 cmd: {
93 muted?: boolean
94 favorite?: boolean
95 hidden?: boolean
96 mute_minutes?: number | null
97 notification_level?: 'Default' | 'All' | 'Mentions' | 'Nothing' | null
98 },
99) => unwrapApi<RoomSetting>(Apis.chat.update_room_setting({ pathParams: { room_id: roomId }, data: cmd }))
100
101/* -------------------------------------------------------------------- dms */
102
103export const openDm = (peerUserId: number) =>
104 unwrapApi<DmBrief>(
105 Apis.chat.open_dm({
106 pathParams: { limit: null, offset: null, sort: null },
107 data: { peer_user_id: peerUserId },
108 }),
109 )
110
111export const closeDm = (roomId: string) =>
112 nothing(Apis.chat.close_dm({ pathParams: { room_id: roomId } }))
113
114/* -------------------------------------------------------------- transcript */
115
116export interface MessageQuery {
117 beforeSeq?: number
118 afterSeq?: number
119 limit?: number
120 threadId?: string
121 authorId?: number
122}
123
124/** Ascending by `seq`; `has_more` means *older* rows exist (before_seq path). */
125export const listMessages = (roomId: string, query: MessageQuery = {}): Promise<MessagePage> =>
126 unwrapApi<MessagePage | null>(
127 Apis.chat.list_messages({
128 pathParams: { room_id: roomId },
129 params: {
130 before_seq: query.beforeSeq,
131 after_seq: query.afterSeq,
132 limit: query.limit,
133 thread_id: nz(query.threadId),
134 author_id: nz(query.authorId),
135 },
136 }),
137 ).then(page => page ?? { items: [], has_more: false, oldest_seq: null, newest_seq: null })
138
139export const getMessage = (roomId: string, messageId: string) =>
140 unwrapApi<ChatMessage>(
141 Apis.chat.get_message({ pathParams: { room_id: roomId, message_id: messageId } }),
142 )
143
144export const sendMessage = (roomId: string, input: SendMessageInput) =>
145 unwrapApi<ChatMessage>(
146 Apis.chat.send_message({
147 pathParams: { room_id: roomId },
148 data: {
149 content: input.content,
150 nonce: nz(input.nonce),
151 reply_to_id: input.reply_to_id ?? null,
152 thread_id: input.thread_id ?? null,
153 thread_broadcast: input.thread_broadcast ?? false,
154 attachments: input.attachments ?? [],
155 urgent: input.urgent ?? false,
156 },
157 }),
158 )
159
160export const editMessage = (roomId: string, messageId: string, content: string) =>
161 unwrapApi<ChatMessage>(
162 Apis.chat.update_message({
163 pathParams: { room_id: roomId, message_id: messageId },
164 data: { content },
165 }),
166 )
167
168export const deleteMessage = (roomId: string, messageId: string) =>
169 nothing(
170 Apis.chat.delete_message({ pathParams: { room_id: roomId, message_id: messageId } }),
171 )
172
173export const bulkDeleteMessages = (roomId: string, messageIds: string[]) =>
174 nothing(Apis.chat.bulk_delete_messages({ pathParams: { room_id: roomId }, data: { message_ids: messageIds } }))
175
176export const listMessageEdits = (roomId: string, messageId: string) =>
177 listOf<MessageEdit>(
178 Apis.chat.list_message_history({ pathParams: { room_id: roomId, message_id: messageId } }),
179 )
180
181/** Forward to another room: the backend copies the payload, no re-upload. */
182export const forwardMessage = (roomId: string, messageId: string, targetRoomId: string, comment?: string) =>
183 unwrapApi<ChatMessage>(
184 Apis.chat.forward_message({
185 pathParams: { room_id: roomId, message_id: messageId },
186 data: { target_room_id: targetRoomId, comment: nz(comment) ?? null },
187 }),
188 )
189
190/* --------------------------------------------------------------- reactions */
191
192export const addReaction = (roomId: string, messageId: string, emoji: string) =>
193 listOf<ReactionAgg>(
194 Apis.chat.add_reaction({
195 pathParams: { room_id: roomId, message_id: messageId },
196 data: { emoji },
197 }),
198 )
199
200export const removeReaction = (roomId: string, messageId: string, emoji: string) =>
201 listOf<ReactionAgg>(
202 Apis.chat.remove_reaction({
203 pathParams: { room_id: roomId, message_id: messageId },
204 data: { emoji },
205 }),
206 )
207
208/** Who reacted, per emoji — the popover's detail list. */
209export const listReactionUsers = (roomId: string, messageId: string) =>
210 listOf<ReactionUser>(
211 Apis.chat.list_reactions({ pathParams: { room_id: roomId, message_id: messageId } }),
212 )
213
214/** Add or withdraw the viewer's reaction; returns the authoritative aggregate. */
215export const setReaction = (roomId: string, messageId: string, emoji: string, on: boolean) =>
216 on ? addReaction(roomId, messageId, emoji) : removeReaction(roomId, messageId, emoji)
217
218/* -------------------------------------------------------------------- pins */
219
220export const setMessagePin = (roomId: string, messageId: string, pinned: boolean) =>
221 unwrapApi<ChatMessage>(
222 Apis.chat.set_message_pin({
223 pathParams: { room_id: roomId, message_id: messageId },
224 data: { pinned },
225 }),
226 )
227
228/** Offset-paged: `items` only, room pin lists are small by design. */
229export const listPins = (roomId: string) =>
230 unwrapApi<PinnedPage | null>(Apis.chat.list_pins({ pathParams: { room_id: roomId } })).then(
231 page => page?.items ?? [],
232 )
233
234/* -------------------------------------------------------------- read state */
235
236export const getReadState = (roomId: string) =>
237 unwrapApi<ReadState>(Apis.chat.get_read_state({ pathParams: { room_id: roomId } }))
238
239/** No cursor at all means "read everything" — the backend clamps to the tip. */
240export const markRead = (roomId: string, cursor?: { messageId?: string; seq?: number }) =>
241 unwrapApi<ReadState>(
242 Apis.chat.mark_read({
243 pathParams: { room_id: roomId },
244 data: { message_id: nz(cursor?.messageId) ?? null, seq: cursor?.seq ?? null },
245 }),
246 )
247
248/** Unread rows for every room of a space — one call for the whole sidebar. */
249export const listSpaceReadStates = (spaceName: string) =>
250 unwrapApi<SpaceReadStates>(
251 Apis.chat.list_space_read_states({ pathParams: { space_name: spaceName } }),
252 ).then(state => state?.items ?? [])
253
254/* ------------------------------------------------------------------- drafts */
255
256export const getDraft = (roomId: string, threadId?: string) =>
257 unwrapApi<Draft | null>(
258 Apis.chat.get_draft({
259 pathParams: { room_id: roomId },
260 params: { thread_id: nz(threadId) },
261 }),
262 )
263
264/** Empty content clears the draft server-side, so `''` is a meaningful write. */
265export const saveDraft = (roomId: string, content: string, threadId?: string) =>
266 unwrapApi<Draft>(
267 Apis.chat.upsert_draft({
268 pathParams: { room_id: roomId },
269 data: { content, thread_id: nz(threadId) ?? null },
270 }),
271 )
272
273export const clearDraft = (roomId: string, threadId?: string) =>
274 nothing(
275 Apis.chat.delete_draft({
276 pathParams: { room_id: roomId },
277 params: { thread_id: nz(threadId) },
278 }),
279 )
280
281/* ------------------------------------------------------------------ threads */
282
283/** Idempotent: a root that already has a thread returns the existing one. */
284export const createThread = (roomId: string, rootMessageId: string, name?: string) =>
285 unwrapApi<ThreadBrief>(
286 Apis.chat.create_thread({
287 pathParams: { room_id: roomId, message_id: rootMessageId },
288 data: { name: nz(name) ?? null },
289 }),
290 )
291
292export const listRoomThreads = (roomId: string) =>
293 unwrapApi<{ items?: ThreadBrief[] } | null>(
294 Apis.chat.list_threads({
295 pathParams: { room_id: roomId },
296 params: { limit: 100 },
297 }) as unknown as PromiseLike<ApiEnvelope<{ items?: ThreadBrief[] } | null>>
298 ).then(page => page?.items ?? [])
299
300export const listMyThreads = () =>
301 unwrapApi<{ items?: ThreadBrief[] } | null>(
302 Apis.chat.list_my_threads({ params: { limit: 50 } }),
303 ).then(page => page?.items ?? [])
304
305export const getThread = (threadId: string) =>
306 unwrapApi<ThreadBrief>(Apis.chat.get_thread({ pathParams: { thread_id: threadId } }))
307
308export const updateThread = (
309 threadId: string,
310 cmd: { name?: string | null; archived?: boolean | null; locked?: boolean | null },
311) => unwrapApi<ThreadBrief>(Apis.chat.update_thread({ pathParams: { thread_id: threadId }, data: cmd }))
312
313// `notify_level` is a three-state field, so both follow calls carry a body.
314export const followThread = (threadId: string, notifyLevel: 'All' | 'None' | null = null) =>
315 nothing(
316 Apis.chat.follow_thread({
317 pathParams: { thread_id: threadId },
318 data: { notify_level: notifyLevel },
319 }),
320 )
321
322export const unfollowThread = (threadId: string) =>
323 nothing(
324 Apis.chat.unfollow_thread({ pathParams: { thread_id: threadId }, data: { notify_level: null } }),
325 )
326
327
328
329export const markThreadRead = (threadId: string, messageId?: string) =>
330 nothing(
331 Apis.chat.mark_thread_read({
332 pathParams: { thread_id: threadId },
333 data: { message_id: nz(messageId) ?? null },
334 }),
335 )
336
337/* ------------------------------------------------------------ space people */
338
339/**
340 * A space member row, as the generic `Page` schema leaves it. `MemberDto` nests
341 * the identity under `user` and ships no `user_id`/`role`/`group_ids`, so the id
342 * is flattened in `listSpaceMembers` below — every caller reads `user_id`.
343 */
344export interface SpaceMemberRow {
345 user?: import('@/shared/api').UserDto | null
346 alias_name?: string | null
347 status?: string | null
348}
349
350export interface SpaceMemberOption extends SpaceMemberRow {
351 user_id: number
352}
353
354/**
355 * People who can be pulled into a room. Only space members may join one, so this
356 * is the source for the add-member picker (the same list the 问题集 uses for
357 * assignees — both are "who is in this space", read from the space endpoint).
358 */
359export const listSpaceMembers = (spaceName: string) =>
360 unwrapApi<{ items?: SpaceMemberRow[] } | null>(
361 Apis.space.list_space_members({
362 pathParams: { space_name: spaceName },
363 params: { limit: 100 },
364 }) as unknown as PromiseLike<ApiEnvelope<{ items?: SpaceMemberRow[] } | null>>,
365 ).then(page =>
366 (page?.items ?? [])
367 .map(row => ({ ...row, user_id: row.user?.user_id ?? '' }))
368 .filter((row): row is SpaceMemberOption => Boolean(row.user_id)),
369 )
370
371/* ---------------------------------------------------------------- presence */
372
373export const listSpacePresences = (spaceName: string) =>
374 listOf<import('./chat').Presence>(
375 Apis.chat.list_space_presences({ pathParams: { space_name: spaceName } }),
376 )
377
378export const getUserPresence = (userId: number) =>
379 unwrapApi<Presence>(Apis.chat.get_presence({ pathParams: { user_id: userId } }))
380
381/* ------------------------------------------------------------------ emojis */
382
383export const listSpaceEmojis = (spaceName: string) =>
384 // The endpoint answers with a bare array, not the paged { items } envelope
385 // the generated type advertises: reading .items silently returned nothing,
386 // so a space's custom emojis never reached the picker.
387 listOf<CustomEmoji>(
388 Apis.chat.list_emojis({ pathParams: { space_name: spaceName }, params: { limit: 100 } }),
389 )
390
391/* -------------------------------------------------------------- attachments */
392
393/**
394 * Three-step upload: reserve a row + presigned PUT, stream the bytes, then
395 * `finalize` (which `head`s the object and refuses an empty/oversized blob).
396 * Only a `Ready`/`Uploaded` attachment the caller owns may be referenced by
397 * `send_message`, so a message never sends half-uploaded ids.
398 */
399export const createAttachment = (cmd: { filename: string; content_type: string; size: number }) =>
400 unwrapApi<UploadIntent>(Apis.chat.create_attachment({ data: cmd }))
401
402export const finalizeAttachment = (
403 attachmentId: string,
404 meta: { width?: number | null; height?: number | null; duration_secs?: number | null } = {},
405) =>
406 unwrapApi<Attachment>(
407 Apis.chat.finalize_attachment({
408 pathParams: { attachment_id: attachmentId },
409 data: {
410 width: meta.width ?? null,
411 height: meta.height ?? null,
412 duration_secs: meta.duration_secs ?? null,
413 },
414 }),
415 )
416
417/** Stream endpoint used by every `src`/download link: cookie-authed, no expiry. */
418export { attachmentUrl } from './meta'
419
420/**
421 * PUT the raw bytes to the presigned URL. The object store allows `*` origins,
422 * so the browser talks to it directly; `trackFetch` deliberately does not wrap
423 * this call — a dead bucket must not look like a dead API to the outage gate.
424 */
425export async function putUpload(uploadUrl: string, file: File): Promise<void> {
426 const response = await fetch(uploadUrl, {
427 method: 'PUT',
428 headers: { 'content-type': file.type || 'application/octet-stream' },
429 body: file,
430 })
431 if (!response.ok) {
432 throw new Error(translate(getLocale(), 'chat.upload.failedStatus', { status: response.status }))
433 }
434}
435
436/** Probe an image blob's intrinsic size for `finalize` (best effort). */
437export function readImageSize(file: File): Promise<{ width: number; height: number } | null> {
438 return new Promise(resolve => {
439 if (!file.type.startsWith('image/')) return resolve(null)
440 const url = URL.createObjectURL(file)
441 const image = new Image()
442 const done = (value: { width: number; height: number } | null) => {
443 URL.revokeObjectURL(url)
444 resolve(value)
445 }
446 image.onload = () =>
447 done(
448 image.naturalWidth > 0 && image.naturalHeight > 0
449 ? { width: image.naturalWidth, height: image.naturalHeight }
450 : null,
451 )
452 image.onerror = () => done(null)
453 image.src = url
454 })
455}
Deleted web/src/entities/chat/model/chat.ts
@@ -1,145 +0,0 @@
1/**
2 * Chat domain types, all derived from the generated response envelopes so they
3 * follow the API instead of a hand-written copy. Anything the transcript needs
4 * beyond the wire shape (optimistic rows, upload progress) is defined here too,
5 * because those are domain states, not component details.
6 */
7import type {
8 AppResponseData_AttachmentDto,
9 AppResponseData_DmDto,
10 AppResponseData_DraftDto,
11 AppResponseData_MessageDto,
12 AppResponseData_MessagePage,
13 AppResponseData_Page_EmojiDto,
14 AppResponseData_Page_MessageDto,
15 AppResponseData_ReadStateDto,
16 AppResponseData_RoomBriefDto,
17 AppResponseData_RoomSettingDto,
18 AppResponseData_SpaceReadStatePage,
19 AppResponseData_ThreadDto,
20 AppResponseData_UploadIntentDto,
21 AppResponseData_Vec_MessageEditDto,
22 AppResponseData_Vec_PresenceDto,
23 AppResponseData_Vec_ReactionUserDto,
24 AppResponseData_Vec_RoomMemberDto,
25 ChatMessageType,
26 PresenceStatus,
27 RoomKind,
28 UserDto,
29} from '@/shared/api'
30
31/** One row of `GET /chat/rooms/{id}/messages` — also the WS payload. */
32export type ChatMessage = NonNullable<AppResponseData_MessageDto['data']>
33export type MessagePage = NonNullable<AppResponseData_MessagePage['data']>
34export type Attachment = NonNullable<AppResponseData_AttachmentDto['data']>
35export type ReactionAgg = NonNullable<ChatMessage['reactions']>[number]
36export type ReactionUser = NonNullable<AppResponseData_Vec_ReactionUserDto['data']>[number]
37export type MessageEdit = NonNullable<AppResponseData_Vec_MessageEditDto['data']>[number]
38
39/** Room as the sidebar lists it (`GET /chat/spaces/{space}/rooms`). */
40export type RoomBrief = NonNullable<AppResponseData_RoomBriefDto['data']>
41export type RoomKindName = RoomKind
42export type DmBrief = NonNullable<AppResponseData_DmDto['data']>
43export type RoomSetting = NonNullable<AppResponseData_RoomSettingDto['data']>
44export type RoomMember = NonNullable<AppResponseData_Vec_RoomMemberDto['data']>[number]
45export type Presence = NonNullable<AppResponseData_Vec_PresenceDto['data']>[number]
46export type PresenceState = PresenceStatus
47
48export type ThreadBrief = NonNullable<AppResponseData_ThreadDto['data']>
49export type ReadState = NonNullable<AppResponseData_ReadStateDto['data']>
50export type Draft = NonNullable<AppResponseData_DraftDto['data']>
51export type SpaceReadStates = NonNullable<AppResponseData_SpaceReadStatePage['data']>
52export type CustomEmoji = NonNullable<AppResponseData_Page_EmojiDto['data']>['items'][number]
53export type UploadIntent = NonNullable<AppResponseData_UploadIntentDto['data']>
54export type PinnedPage = NonNullable<AppResponseData_Page_MessageDto['data']>
55export type UserInfo = UserDto
56
57/** What `POST /chat/rooms/{id}/messages` accepts; re-declared so the composer
58 * can build a command without importing the whole generated namespace. */
59export interface SendMessageInput {
60 content: string
61 nonce?: string
62 reply_to_id?: string | null
63 thread_id?: string | null
64 thread_broadcast?: boolean
65 attachments?: string[]
66 urgent?: boolean
67}
68
69/** Optimistic state of a message the local user pressed send on. */
70export type PendingState = 'sending' | 'failed'
71
72/** A local-only row: the server has not confirmed it (or confirmed and the echo
73 * has not arrived yet). It renders in the transcript with a state chip. */
74export interface PendingMessage {
75 /** Client-side key; never a uuid the server could confuse with a real id. */
76 localId: string
77 /** Idempotency key sent as `nonce`: a retry can never duplicate the post. */
78 nonce: string
79 room_id: string
80 author_id: number
81 content: string
82 created_at: string
83 reply_to_id?: string | null
84 thread_id?: string | null
85 thread_broadcast?: boolean
86 attachments: Attachment[]
87 state: PendingState
88 /** Server reason (`ApiFailure.message`) when state is failed. */
89 error?: string
90 /** True while the request is in flight; used to hide the retry chip. */
91 sending: boolean
92}
93
94/** One upload in the composer's staging strip. */
95export interface UploadItem {
96 localId: string
97 file: File
98 /** Object URL preview for images; revoked when the item is dropped. */
99 previewUrl?: string
100 /** Set once `POST /chat/attachments` answered. */
101 attachmentId?: string
102 /** Dimensions probed from an image, sent to `finalize`. */
103 width?: number
104 height?: number
105 /** Finalized metadata, present once `state` is ready. */
106 attachment?: Attachment
107 progress: number
108 state: 'checking' | 'uploading' | 'finalizing' | 'ready' | 'failed' | 'too_big'
109 error?: string
110}
111
112/** Message row the transcript renders: either a server row or a pending one. */
113export type TranscriptRow =
114 | { kind: 'message'; message: ChatMessage }
115 | { kind: 'pending'; message: PendingMessage }
116
117/**
118 * System-post kinds get their own compact rendering. A quoted reply is *not* one:
119 * it carries the author's own text plus a `reply_to_id` quote and renders as a
120 * full message row.
121 */
122export const SYSTEM_MESSAGE_TYPES: readonly ChatMessageType[] = [
123 'ThreadCreated',
124 'ChannelJoin',
125 'ChannelLeave',
126 'ChannelTopic',
127 'ChannelPinnedItem',
128 'Reminder',
129 'System',
130]
131
132export function isSystemMessage(message: { message_type: ChatMessageType }): boolean {
133 return message.message_type !== 'Default' && SYSTEM_MESSAGE_TYPES.includes(message.message_type)
134}
135
136/** Room kinds that carry no text transcript. */
137const VOICE_KINDS: readonly RoomKindName[] = ['Voice', 'Stage']
138export function isVoiceRoom(kind: RoomKindName | undefined): boolean {
139 return kind !== undefined && VOICE_KINDS.includes(kind)
140}
141
142const DM_KINDS: readonly RoomKindName[] = ['Dm', 'DirectMessage', 'GroupDm']
143export function isDmKind(kind: RoomKindName | undefined): boolean {
144 return kind !== undefined && DM_KINDS.includes(kind)
145}
Deleted web/src/entities/chat/model/meta.ts
@@ -1,364 +0,0 @@
1/**
2 * Pure chat-domain helpers: transcript maths (merge / dedupe / grouping),
3 * stamps, attachment classification and the limits the backend enforces.
4 *
5 * Every limit mirrors `gitdataai/lib/config/src/chat.rs` (defaults) — the server
6 * is still the authority, these only stop the composer from sending a post that
7 * is guaranteed to be rejected.
8 */
9import { isSystemMessage } from './chat'
10import { getLocale, t } from '@/shared/i18n'
11import type { Attachment, ChatMessage, ReactionAgg } from './chat'
12
13/** `chat.max_message_len` — counted in code points, like Rust's `chars().count()`. */
14export const MAX_MESSAGE_LEN = 4000
15/** `chat.max_attachments_per_message`. */
16export const MAX_ATTACHMENTS = 10
17/** `chat.upload.max_size` (100 MiB). */
18export const MAX_UPLOAD_BYTES = 104_857_600
19/** `chat.max_mentions_per_message`. */
20export const MAX_MENTIONS = 20
21/** `chat.ws.history_limit`: the transcript is capped so a huge room stays cheap. */
22export const TRANSCRIPT_CAP = 600
23/** Messages fetched per HTTP page (also the "load older" step). */
24export const MESSAGE_PAGE_SIZE = 50
25
26/** Emojis offered by the hover toolbar before the full picker opens. */
27export const QUICK_EMOJIS = ['👍', '❤️', '😂', '🎉', '😮', '👀', '🚀', '✅']
28
29/** Reaction suggestions must stay below `chat.max_reaction_emoji_len`. */
30export const MAX_EMOJI_LEN = 64
31
32/** Tokens the backend resolves to an @everyone ping. */
33export const EVERYONE_TOKENS = ['@everyone', '@here', '@channel']
34
35/** Code-point length, the same unit the backend validates against. */
36export function textLength(value: string): number {
37 return [...value].length
38}
39
40/** `crypto.randomUUID` with a fallback for non-secure contexts (http://LAN IP). */
41export function clientNonce(): string {
42 const random = globalThis.crypto
43 if (random && typeof random.randomUUID === 'function') return random.randomUUID()
44 const bytes = new Uint8Array(16)
45 if (random?.getRandomValues) random.getRandomValues(bytes)
46 else for (let i = 0; i < 16; i += 1) bytes[i] = Math.floor(Math.random() * 256)
47 bytes[6] = (bytes[6] ?? 0) & 0x0f
48 bytes[8] = (bytes[8] ?? 0) & 0x3f
49 const hex = [...bytes].map(b => b.toString(16).padStart(2, '0'))
50 return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`
51}
52
53/**
54 * Parse a backend timestamp in *either* shape this API produces:
55 * - `2026-09-05T10:00:00.000000+00:00` (serde RFC 3339, chat/WS payloads);
56 * - `2026-09-03 10:55:00.863466 +00:00:00` (`time::OffsetDateTime`'s Display,
57 * which some rows still carry: a space instead of `T` and seconds inside the
58 * offset — both rejected by `Date`).
59 * Returns `NaN` for anything unparseable, so a caller shows the raw string
60 * instead of a confident-looking 1970.
61 */
62export function parseStamp(value: string | null | undefined): number {
63 if (!value) return Number.NaN
64 const iso = value
65 .trim()
66 .replace(/^(\d{4}-\d{2}-\d{2})[ T]/, '$1T')
67 .replace(/\s+(?=[+-]\d{2})/, '')
68 .replace(/([+-]\d{2}):(\d{2}):(\d{2})$/, '$1:$2')
69 const stamp = Date.parse(iso)
70 return Number.isFinite(stamp) ? stamp : Number.NaN
71}
72
73const pad = (value: number) => String(value).padStart(2, '0')
74
75/** `HH:MM` in the viewer's zone; the raw value when it cannot be parsed. */
76export function clockStamp(value: string): string {
77 const stamp = parseStamp(value)
78 if (!Number.isFinite(stamp)) return value
79 const date = new Date(stamp)
80 return `${pad(date.getHours())}:${pad(date.getMinutes())}`
81}
82
83/** Local calendar key, so grouping never splits at a UTC midnight. */
84export function dayKey(value: string): string {
85 const stamp = parseStamp(value)
86 if (!Number.isFinite(stamp)) return value
87 const date = new Date(stamp)
88 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
89}
90
91/** Divider label: today / yesterday / weekday / full date for anything older. */
92export function dayLabel(value: string): string {
93 const stamp = parseStamp(value)
94 if (!Number.isFinite(stamp)) return value
95 const date = new Date(stamp)
96 const now = new Date()
97 const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
98 const startOfThat = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
99 const days = Math.round((startOfToday - startOfThat) / 86_400_000)
100 if (days === 0) return t('chat.day.today')
101 if (days === 1) return t('chat.day.yesterday')
102 // Within the last week the weekday alone is unambiguous; `weekday: 'short'`
103 // yields 周一 in Chinese and Mon in English without a hand-written table.
104 if (days > 1 && days < 7) {
105 return new Intl.DateTimeFormat(getLocale(), { weekday: 'short' }).format(date)
106 }
107 return new Intl.DateTimeFormat(getLocale(), {
108 year: date.getFullYear() === now.getFullYear() ? undefined : 'numeric',
109 month: 'long',
110 day: 'numeric',
111 }).format(date)
112}
113
114/** Full stamp for tooltips and system rows. */
115export function fullStamp(value: string): string {
116 const stamp = parseStamp(value)
117 if (!Number.isFinite(stamp)) return value
118 const date = new Date(stamp)
119 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
120}
121
122/**
123 * `true` when a message may join the one above it: same author, same thread,
124 * plain post, and within ten minutes.
125 */
126export function isGrouped(previous: ChatMessage | undefined, message: ChatMessage): boolean {
127 if (!previous) return false
128 if (previous.author_id !== message.author_id) return false
129 if (isSystemMessage(previous) || isSystemMessage(message)) return false
130 if ((previous.thread_id ?? null) !== (message.thread_id ?? null)) return false
131 const gap = parseStamp(message.created_at) - parseStamp(previous.created_at)
132 return Number.isFinite(gap) && gap >= 0 && gap < 10 * 60_000
133}
134
135/**
136 * Embeds are replaced wholesale by the server's async unfurl pass, so rows whose
137 * cards changed must not compare equal: a cached row would otherwise keep
138 * rendering the stale card (or a broken iframe) after a reload.
139 */
140function embedsKey(embeds: ChatMessage['embeds']): string {
141 if (!embeds || embeds.length === 0) return ''
142 return embeds
143 .map(embed =>
144 [embed.kind, embed.src, embed.url, embed.title, embed.image_url, embed.description].join('\u0001'),
145 )
146 .join('\u0002')
147}
148
149/**
150 * Field-level equality for the parts a row renders. The socket re-delivers rows
151 * (a `history` snapshot repeats what the channel already delivered), and treating
152 * an identical copy as a change would re-render the whole transcript on every
153 * reconnect.
154 */
155export function sameRow(a: ChatMessage, b: ChatMessage): boolean {
156 if (a === b) return true
157 if (a.content !== b.content) return false
158 if (embedsKey(a.embeds) !== embedsKey(b.embeds)) return false
159 if (a.edited_at !== b.edited_at || a.deleted_at !== b.deleted_at) return false
160 if (a.pinned !== b.pinned || a.pinned_at !== b.pinned_at) return false
161 if ((a.thread_id ?? null) !== (b.thread_id ?? null)) return false
162 if (a.has_thread !== b.has_thread) return false
163 if (a.attachments.length !== b.attachments.length) return false
164 if (authorKey(a) !== authorKey(b)) return false
165 const left = a.reactions ?? []
166 const right = b.reactions ?? []
167 if (left.length !== right.length) return false
168 return left.every((reaction, index) => {
169 const other = right[index]
170 return (
171 other !== undefined &&
172 reaction.emoji === other.emoji &&
173 reaction.count === other.count &&
174 reaction.me === other.me
175 )
176 })
177}
178
179function authorKey(message: ChatMessage): string {
180 const author = message.author
181 return author
182 ? `${author.user_id}|${author.username}|${author.display_name}|${author.avatar_url}`
183 : `id:${message.author_id}`
184}
185
186/** Highest seq in a list, ignoring pending rows (which have no seq). */
187export function tipSeq(messages: readonly { seq?: number }[]): number {
188 let tip = 0
189 for (const message of messages) {
190 const seq = typeof message.seq === 'number' && Number.isFinite(message.seq) ? message.seq : 0
191 if (seq > tip) tip = seq
192 }
193 return tip
194}
195
196/**
197 * Merge a batch into an ascending transcript: dedupe by id (a `history` snapshot
198 * repeats rows the room channel already delivered), keep the newest copy of an
199 * edited row, and drop the oldest rows past {@link TRANSCRIPT_CAP}.
200 * Returns the same array reference when nothing changed, so a React state update
201 * can bail out.
202 */
203export function mergeMessages(
204 current: readonly ChatMessage[],
205 incoming: readonly ChatMessage[],
206): { items: ChatMessage[]; changed: boolean; dropped: number } {
207 if (incoming.length === 0) return { items: current as ChatMessage[], changed: false, dropped: 0 }
208 const byId = new Map(current.map(message => [message.id, message]))
209 let changed = false
210 for (const message of incoming) {
211 const known = byId.get(message.id)
212 if (!known) {
213 byId.set(message.id, message)
214 changed = true
215 continue
216 }
217 // A WS row is built for the *actor*, so it can lose the viewer's `me` flags:
218 // never let a fresher-looking payload erase what the local user reacted with.
219 const next = keepViewerReactions(known, message)
220 if (next !== known && !sameRow(known, next)) {
221 byId.set(message.id, next)
222 changed = true
223 }
224 }
225 if (!changed) return { items: current as ChatMessage[], changed: false, dropped: 0 }
226 const items = [...byId.values()].sort((a, b) => a.seq - b.seq)
227 let dropped = 0
228 if (items.length > TRANSCRIPT_CAP) {
229 dropped = items.length - TRANSCRIPT_CAP
230 items.splice(0, dropped)
231 }
232 return { items, changed: true, dropped }
233}
234
235/**
236 * Merge a reaction aggregate (`reaction_updated` carries no `me`), keeping the
237 * local user's own reaction flags.
238 */
239export function mergeReactions(
240 previous: readonly ReactionAgg[] | undefined,
241 next: readonly { emoji: string; count: number }[],
242): ReactionAgg[] {
243 const mine = new Map((previous ?? []).map(reaction => [reaction.emoji, reaction.me]))
244 return next.map(reaction => ({
245 emoji: reaction.emoji,
246 count: reaction.count,
247 me: mine.get(reaction.emoji) ?? false,
248 }))
249}
250
251/** Re-apply the local viewer flags to a row that arrived from the socket. */
252function keepViewerReactions(known: ChatMessage, incoming: ChatMessage): ChatMessage {
253 if (incoming.author_id !== known.author_id) return incoming
254 const knownReactions = known.reactions ?? []
255 if (knownReactions.some(reaction => reaction.me) === false) return incoming
256 const mine = new Set(knownReactions.filter(reaction => reaction.me).map(reaction => reaction.emoji))
257 return {
258 ...incoming,
259 reactions: (incoming.reactions ?? []).map(reaction =>
260 mine.has(reaction.emoji) ? { ...reaction, me: true } : reaction,
261 ),
262 }
263}
264
265/* ------------------------------------------------------------- attachments */
266
267export type AttachmentKind = 'image' | 'video' | 'audio' | 'pdf' | 'text' | 'file'
268
269export function attachmentKind(attachment: Pick<Attachment, 'content_type' | 'filename'>): AttachmentKind {
270 const type = (attachment.content_type ?? '').toLowerCase()
271 if (type.startsWith('image/')) return 'image'
272 if (type.startsWith('video/')) return 'video'
273 if (type.startsWith('audio/')) return 'audio'
274 if (type === 'application/pdf') return 'pdf'
275 if (type.startsWith('text/') || type === 'application/json' || type.endsWith('+json')) return 'text'
276 // Some uploaders send application/octet-stream; fall back to the extension.
277 const name = attachment.filename.toLowerCase()
278 if (/\.(png|jpe?g|gif|webp|avif|bmp|svg)$/.test(name)) return 'image'
279 if (/\.(mp4|webm|mov|m4v)$/.test(name)) return 'video'
280 if (/\.(mp3|wav|ogg|m4a|flac)$/.test(name)) return 'audio'
281 if (name.endsWith('.pdf')) return 'pdf'
282 if (/\.(txt|md|markdown|json|ya?ml|toml|csv|log|rs|ts|tsx|js|jsx|py|go|java|c|cc|h|css|html|sh)$/.test(name)) return 'text'
283 return 'file'
284}
285
286/**
287 * Same-origin stream endpoint (session cookie, no presigned expiry).
288 *
289 * `variant` selects which representation the server streams: `preview` is the
290 * compressed derivative the transcript grid loads, `original` (the default) is
291 * the uploaded bytes — which is what the lightbox and the download link need.
292 * A missing preview degrades to the original server-side, so this is always a
293 * valid URL.
294 */
295export function attachmentUrl(attachmentId: string, variant: 'preview' | 'original' = 'original'): string {
296 const base = `/api/v1/chat/attachments/${encodeURIComponent(attachmentId)}/file`
297 return variant === 'preview' ? `${base}?variant=preview` : base
298}
299
300/** Custom space emoji image, same-origin for the same reason. */
301export function emojiImageUrl(spaceName: string, emojiId: string): string {
302 return `/api/v1/chat/spaces/${encodeURIComponent(spaceName)}/emojis/${encodeURIComponent(emojiId)}/image`
303}
304
305export { formatBytes as formatSize } from '@/shared/i18n'
306
307/** First glyph of a person's name, for the coloured avatar tile. */
308export function initialsOf(user?: { display_name?: string; username?: string } | null): string {
309 const name = user?.display_name || user?.username || '?'
310 return [...name][0]?.toUpperCase() ?? '?'
311}
312
313/* ---------------------------------------------------------------- failures */
314
315/**
316 * Why a message could not be sent, in one line the composer can show verbatim.
317 * The backend already answers with a human-readable `error`/`message`, so this
318 * only fills in the transport-level cases and translates the two status codes
319 * whose raw text would be meaningless in a chat window.
320 */
321export function describeFailure(error: unknown): string {
322 const code = failureCode(error)
323 if (code === 401) return t('chat.composer.unauthorized')
324 if (code === 403) return t('chat.composer.forbidden')
325 if (code === 404) return t('chat.composer.notFound')
326 if (code === 429) return t('chat.composer.rateLimited')
327 if (code === 0 || code === 502 || code === 503 || code === 504) {
328 return t('chat.composer.unavailable')
329 }
330 if (error instanceof Error && error.message) return error.message
331 return t('chat.composer.failed')
332}
333
334/** HTTP status mirrored by the envelope, or undefined when it is not one. */
335export function failureCode(error: unknown): number | undefined {
336 const candidate = error as { code?: unknown } | null | undefined
337 return typeof candidate?.code === 'number' ? candidate.code : undefined
338}
339
340/**
341 * Seconds the backend asked us to wait. Two producers: the room's slow mode
342 * ("slow mode active, retry in 12s") and the generic quota limiter. Reading the
343 * number beats showing a countdown of zero while the server refuses every post.
344 */
345export function findRetryAfterSeconds(error: unknown): number {
346 const message = error instanceof Error ? error.message : ''
347 const match = /retry in (\d+)s/i.exec(message)
348 const seconds = match ? Number.parseInt(match[1], 10) : 0
349 return Number.isFinite(seconds) && seconds > 0 && seconds < 3600 ? seconds : 0
350}
351
352/**
353 * The plain reason a request failed, with the retry countdown taken out: the
354 * composer renders that number as a live timer, so repeating it inside the
355 * sentence would read as two different clocks.
356 */
357export function failureReason(error: unknown): string {
358 const message = error instanceof Error ? error.message : ''
359 const cleaned = message
360 .replace(/\s*\(retry in \d+s\)/i, '')
361 .replace(/,?\s*retry in \d+s/i, '')
362 .trim()
363 return cleaned || describeFailure(error)
364}
Deleted web/src/entities/chat/model/voice-api.ts
@@ -1,144 +0,0 @@
1/**
2 * Voice REST surface (LiveKit). Wraps the generated Alova definitions so
3 * consumers deal in domain types instead of the envelope namespace; every
4 * call resolves through \`unwrapApi\`, so failures surface as \`ApiFailure\`.
5 */
6import Apis from '@/shared/api'
7import { unwrapApi } from '@/shared/lib'
8import type {
9 AppResponseData_HandDto,
10 AppResponseData_Vec_VoiceParticipantDto,
11 AppResponseData_VoiceConfigDto,
12 AppResponseData_VoiceHandsDto,
13 AppResponseData_VoiceModerationDto,
14 AppResponseData_VoiceSpaceStatesDto,
15 AppResponseData_VoiceTokenDto,
16 HandState,
17 VoiceTileDto,
18} from '@/shared/api'
19
20export type VoiceConfigDto = NonNullable<AppResponseData_VoiceConfigDto['data']>
21export type VoiceToken = NonNullable<AppResponseData_VoiceTokenDto['data']>
22export type VoiceHand = NonNullable<AppResponseData_HandDto['data']>
23export type VoiceParticipant = NonNullable<AppResponseData_Vec_VoiceParticipantDto['data']>[number]
24export type VoiceHands = NonNullable<AppResponseData_VoiceHandsDto['data']>
25export type VoiceModeration = NonNullable<AppResponseData_VoiceModerationDto['data']>
26export type VoiceSpaceStates = NonNullable<AppResponseData_VoiceSpaceStatesDto['data']>
27export type VoiceTile = VoiceTileDto
28/** Shared layout as stored on the server: every tile carries its write stamp. */
29export type VoiceLayoutTiles = Record<string, VoiceTileDto>
30/**
31 * What a client may write: geometry only, the stamp is server-owned. `h` is
32 * optional on the wire so an older client's write stays valid — the server then
33 * leaves the height unset and readers fall back to the classic 3:4 shape.
34 */
35export interface VoiceTileWrite {
36 x: number
37 y: number
38 w: number
39 h?: number
40}
41export type VoiceHandState = HandState
42
43export function getVoiceConfig(): Promise<VoiceConfigDto> {
44 return unwrapApi(Apis.chat.voice_config({}))
45}
46
47export function requestVoiceToken(roomId: string): Promise<VoiceToken> {
48 return unwrapApi(Apis.chat.voice_token({ pathParams: { room_id: roomId } }))
49}
50
51export function listVoiceParticipants(roomId: string): Promise<VoiceParticipant[]> {
52 return unwrapApi(Apis.chat.voice_participants({ pathParams: { room_id: roomId } }))
53}
54
55export function getVoiceSpaceStates(spaceName: string): Promise<VoiceSpaceStates> {
56 return unwrapApi(Apis.chat.voice_space_states({ pathParams: { space_name: spaceName } }))
57}
58
59export function listVoiceHands(roomId: string): Promise<VoiceHands> {
60 return unwrapApi(Apis.chat.list_hands({ pathParams: { room_id: roomId } }))
61}
62
63export function raiseVoiceHand(roomId: string): Promise<VoiceHand> {
64 return unwrapApi(Apis.chat.raise_hand({ pathParams: { room_id: roomId } }))
65}
66
67export function cancelVoiceHand(roomId: string): Promise<void> {
68 return unwrapApi(Apis.chat.cancel_hand({ pathParams: { room_id: roomId } })).then(
69 () => undefined,
70 )
71}
72
73export function approveVoiceHand(roomId: string, userId: number): Promise<VoiceModeration> {
74 return unwrapApi(
75 Apis.chat.approve_hand({ pathParams: { room_id: roomId, user_id: userId } }),
76 )
77}
78
79export function denyVoiceHand(roomId: string, userId: number): Promise<VoiceModeration> {
80 return unwrapApi(Apis.chat.deny_hand({ pathParams: { room_id: roomId, user_id: userId } }))
81}
82
83export function revokeVoiceHand(roomId: string, userId: number): Promise<VoiceModeration> {
84 return unwrapApi(Apis.chat.revoke_hand({ pathParams: { room_id: roomId, user_id: userId } }))
85}
86
87export function moderateVoiceMute(
88 roomId: string,
89 userId: number,
90 muted: boolean,
91): Promise<VoiceModeration> {
92 return unwrapApi(
93 Apis.chat.moderate_mute({
94 pathParams: { room_id: roomId, user_id: userId },
95 params: { muted },
96 }),
97 )
98}
99
100export function kickVoiceParticipant(roomId: string, userId: number): Promise<VoiceModeration> {
101 return unwrapApi(
102 Apis.chat.kick_participant({ pathParams: { room_id: roomId, user_id: userId } }),
103 )
104}
105
106/**
107 * Shared participant-window layout. Coordinates are normalized against the
108 * free canvas span, so the same layout lands inside the bounds on every screen.
109 *
110 * Writes are per-key patches: a client only ever submits the windows it moved,
111 * so two members dragging different windows cannot erase each other's work. The
112 * returned stamp is the server's write time, which lets the caller tell a fresh
113 * value from a GET that was already in flight when it dragged.
114 */
115export function getVoiceLayout(roomId: string): Promise<VoiceLayoutTiles> {
116 return unwrapApi(
117 Apis.chat.get_voice_layout({ pathParams: { room_id: roomId } }),
118 ).then(data => data?.tiles ?? {})
119}
120
121/** Upsert the given windows; resolves to the server write stamp (seconds). */
122export async function saveVoiceLayoutTiles(
123 roomId: string,
124 tiles: Record<string, VoiceTileWrite>,
125): Promise<number> {
126 const data = await unwrapApi(
127 Apis.chat.save_voice_layout({
128 pathParams: { room_id: roomId },
129 data: { tiles, removed: [] },
130 }),
131 )
132 return data?.updated_at ?? 0
133}
134
135/** Drop the given windows back into the automatic grid. */
136export async function removeVoiceLayoutTiles(roomId: string, keys: string[]): Promise<number> {
137 const data = await unwrapApi(
138 Apis.chat.save_voice_layout({
139 pathParams: { room_id: roomId },
140 data: { tiles: {}, removed: keys },
141 }),
142 )
143 return data?.updated_at ?? 0
144}
No newline at end of file
Deleted web/src/entities/chat/index.ts
@@ -1,39 +0,0 @@
1// Public API of entities/chat — the 聊天室 domain: transport for every chat
2// endpoint the workspace uses, the row shapes read straight off the generated
3// envelopes, and the pure transcript maths (merge/dedupe/group/stamp) that both
4// the main stream and the thread panels share.
5// Consumers import from '@/entities/chat', never from deeper files.
6export * from './model/chat'
7export * from './model/meta'
8export * from './model/api'
9
10export {
11 approveVoiceHand,
12 cancelVoiceHand,
13 denyVoiceHand,
14 getVoiceConfig,
15 getVoiceLayout,
16 getVoiceSpaceStates,
17 kickVoiceParticipant,
18 listVoiceHands,
19 listVoiceParticipants,
20 moderateVoiceMute,
21 removeVoiceLayoutTiles,
22 raiseVoiceHand,
23 requestVoiceToken,
24 revokeVoiceHand,
25 saveVoiceLayoutTiles,
26} from './model/voice-api'
27export type {
28 VoiceConfigDto,
29 VoiceHand,
30 VoiceHands,
31 VoiceHandState,
32 VoiceModeration,
33 VoiceParticipant,
34 VoiceSpaceStates,
35 VoiceLayoutTiles,
36 VoiceToken,
37 VoiceTile,
38 VoiceTileWrite,
39} from './model/voice-api'
Deleted web/src/entities/issue/model/api.ts
@@ -1,371 +0,0 @@
1/**
2 * Issue-board transport: every call the 问题集 pages make, wrapped in the
3 * shared envelope contract so the UI only ever sees unwrapped rows or an
4 * `ApiFailure`.
5 *
6 * The generated client types paged endpoints against the generic `Page`
7 * schema (one shape for every list), so the row type is asserted here — the
8 * single place in the app allowed to do that, which keeps the casts out of the
9 * components and makes a schema drift show up as one localized error.
10 *
11 * Issues are addressable by uuid *or* board-scoped number (`?issue=42`), so
12 * every `issueId` parameter below accepts either form.
13 */
14import Apis from '@/shared/api'
15import { unwrapApi, type ApiEnvelope } from '@/shared/lib'
16import type {
17 Board,
18 CommentNode,
19 IssueComment,
20 IssueDetail,
21 IssueEvent,
22 IssueLabel,
23 IssueAttachment,
24 IssuePage,
25 IssuePatchCmd,
26 IssueQuery,
27 IssueState,
28 IssueUploadIntent,
29 Milestone,
30 NewIssueCmd,
31 SpaceMember,
32 WatchMode,
33} from './issue'
34import { SORT_PARAMS } from './meta'
35
36/**
37 * Empty strings are dropped, not sent: Alova omits `undefined` query values but
38 * forwards `''`, and the backend's `Option<Uuid>` / `Option<IssueState>`
39 * deserialization rejects an empty `milestone_id=` / `state=` with a 400.
40 */
41const nz = <T,>(value: T | '' | null | undefined): T | undefined => (value ? value : undefined)
42
43/** Empty selections are dropped; several become `a,b` (see the backend parser). */
44const csv = (values: readonly string[] | undefined): string | undefined =>
45 values && values.length > 0 ? values.join(',') : undefined
46
47/** Board id path params repeated by almost every issue endpoint. */
48export interface BoardRef {
49 space: string
50 board: string
51}
52
53const pageOf = <T>(method: PromiseLike<ApiEnvelope<unknown>>): Promise<IssuePage<T>> =>
54 unwrapApi<IssuePage<T> | null>(method as PromiseLike<ApiEnvelope<IssuePage<T> | null>>).then(p => ({
55 items: p?.items ?? [],
56 total: p?.total ?? 0,
57 limit: p?.limit ?? 0,
58 offset: p?.offset ?? 0,
59 }))
60
61const listOf = <T>(method: PromiseLike<ApiEnvelope<unknown>>): Promise<T[]> =>
62 unwrapApi<T[] | null>(method as PromiseLike<ApiEnvelope<T[] | null>>).then(rows => rows ?? [])
63
64const oneOf = <T>(method: PromiseLike<ApiEnvelope<unknown>>): Promise<T> =>
65 unwrapApi<T>(method as PromiseLike<ApiEnvelope<T>>)
66
67/* ------------------------------------------------------------------ board */
68
69export const getBoard = ({ space, board }: BoardRef) =>
70 unwrapApi<Board>(Apis.issues.get_board({ pathParams: { space_name: space, board_id: board } }))
71
72export const updateBoard = (
73 { space, board }: BoardRef,
74 cmd: { name?: string; prefix?: string | null; description?: string | null; icon?: string | null },
75) =>
76 oneOf<Board>(
77 Apis.issues.update_board({ pathParams: { space_name: space, board_id: board }, data: cmd }),
78 )
79
80export const deleteBoard = ({ space, board }: BoardRef) =>
81 unwrapApi<null>(
82 Apis.issues.delete_board({ pathParams: { space_name: space, board_id: board } }),
83 )
84
85/* ------------------------------------------------------------------ issue */
86
87/** One page of issues with the board's filters applied server-side. */
88export const listIssues = ({ space, board }: BoardRef, query: IssueQuery = {}) =>
89 pageOf<import('./issue').IssueListItem>(
90 Apis.issues.list_issues({
91 pathParams: { space_name: space, board_id: board },
92 params: {
93 // `all` is a UI-only choice: the backend filters "open" or "closed", and
94 // omitting the key at all is what "no state filter" means.
95 state: query.state && query.state !== 'all' ? (query.state as IssueState) : undefined,
96 q: nz(query.q),
97 // Multi-select travels as one comma-separated value: the query string
98 // stays readable and the OpenAPI param stays a plain string.
99 priority: csv(query.priorities),
100 label_id: csv(query.labelIds),
101 milestone_id: csv(query.milestoneIds),
102 assignee_id: csv(query.assigneeIds),
103 author_id: nz(query.authorId),
104 sort: query.sort ? SORT_PARAMS[query.sort] : undefined,
105 limit: query.limit,
106 offset: query.offset ?? 0,
107 },
108 }),
109 )
110
111export const getIssue = ({ space, board }: BoardRef, issueId: string) =>
112 unwrapApi<IssueDetail>(
113 Apis.issues.get_issue({ pathParams: { space_name: space, board_id: board, issue_id: issueId } }),
114 )
115
116export const createIssue = ({ space, board }: BoardRef, cmd: NewIssueCmd) =>
117 oneOf<IssueDetail>(
118 Apis.issues.create_issue({
119 pathParams: { space_name: space, board_id: board },
120 data: {
121 title: cmd.title,
122 description: cmd.description ?? null,
123 content: cmd.content ?? '',
124 priority: cmd.priority ?? 'none',
125 due_at: cmd.due_at ?? null,
126 milestone_id: cmd.milestone_id ?? null,
127 assignee_ids: cmd.assignee_ids ?? [],
128 label_ids: cmd.label_ids ?? [],
129 attachment_ids: cmd.attachment_ids ?? [],
130 },
131 }),
132 )
133
134export const updateIssue = ({ space, board }: BoardRef, issueId: string, cmd: IssuePatchCmd) =>
135 oneOf<IssueDetail>(
136 Apis.issues.update_issue({
137 pathParams: { space_name: space, board_id: board, issue_id: issueId },
138 data: cmd,
139 }),
140 )
141
142export const closeIssue = ({ space, board }: BoardRef, issueId: string) =>
143 oneOf<IssueDetail>(
144 Apis.issues.close_issue({ pathParams: { space_name: space, board_id: board, issue_id: issueId } }),
145 )
146
147export const reopenIssue = ({ space, board }: BoardRef, issueId: string) =>
148 oneOf<IssueDetail>(
149 Apis.issues.reopen_issue({ pathParams: { space_name: space, board_id: board, issue_id: issueId } }),
150 )
151
152export const deleteIssue = ({ space, board }: BoardRef, issueId: string) =>
153 unwrapApi<null>(
154 Apis.issues.delete_issue({ pathParams: { space_name: space, board_id: board, issue_id: issueId } }),
155 )
156
157export const setIssueWatch = ({ space, board }: BoardRef, issueId: string, mode: WatchMode) =>
158 unwrapApi<unknown>(
159 Apis.issues.upsert_issue_watch({
160 pathParams: { space_name: space, board_id: board, issue_id: issueId },
161 data: { mode },
162 }),
163 ).catch(() => null)
164
165/* ---------------------------------------------------------------- comment */
166
167export const listCommentTree = ({ space, board }: BoardRef, issueId: string) =>
168 listOf<CommentNode>(
169 Apis.issues.list_comments_tree({
170 pathParams: { space_name: space, board_id: board, issue_id: issueId },
171 }),
172 )
173
174export const createComment = ({ space, board }: BoardRef, issueId: string, content: string, parentId?: string) =>
175 oneOf<IssueComment>(
176 Apis.issues.create_comment({
177 pathParams: { space_name: space, board_id: board, issue_id: issueId },
178 data: { content, parent_id: parentId ?? null },
179 }),
180 )
181
182export const updateComment = (
183 ref: BoardRef,
184 issueId: string,
185 commentId: string,
186 content: string,
187) =>
188 oneOf<IssueComment>(
189 Apis.issues.update_comment({
190 pathParams: {
191 space_name: ref.space,
192 board_id: ref.board,
193 issue_id: issueId,
194 comment_id: commentId,
195 },
196 data: { content },
197 }),
198 )
199
200export const deleteComment = (ref: BoardRef, issueId: string, commentId: string) =>
201 unwrapApi<null>(
202 Apis.issues.delete_comment({
203 pathParams: {
204 space_name: ref.space,
205 board_id: ref.board,
206 issue_id: issueId,
207 comment_id: commentId,
208 },
209 }),
210 )
211
212/* ------------------------------------------------------------------ meta */
213
214export const listEvents = ({ space, board }: BoardRef, issueId: string, limit = 50) =>
215 pageOf<IssueEvent>(
216 Apis.issues.list_events({
217 pathParams: { space_name: space, board_id: board, issue_id: issueId },
218 params: { limit },
219 }),
220 )
221
222export const listLabels = ({ space, board }: BoardRef) =>
223 pageOf<IssueLabel>(
224 Apis.issues.list_labels({
225 pathParams: { space_name: space, board_id: board },
226 params: { limit: 100, is_archived: false },
227 }),
228 )
229
230export const createLabel = ({ space, board }: BoardRef, cmd: { name: string; color: string; description?: string | null }) =>
231 oneOf<IssueLabel>(
232 Apis.issues.create_label({
233 pathParams: { space_name: space, board_id: board },
234 data: { name: cmd.name, color: cmd.color, description: cmd.description ?? null },
235 }),
236 )
237
238export const updateLabel = (
239 { space, board }: BoardRef,
240 labelId: string,
241 cmd: { name?: string; color?: string; description?: string | null; is_archived?: boolean },
242) =>
243 oneOf<IssueLabel>(
244 Apis.issues.update_label({
245 pathParams: { space_name: space, board_id: board, label_id: labelId },
246 data: cmd,
247 }),
248 )
249
250export const deleteLabel = ({ space, board }: BoardRef, labelId: string) =>
251 unwrapApi<null>(
252 Apis.issues.delete_label({ pathParams: { space_name: space, board_id: board, label_id: labelId } }),
253 )
254
255/** Labels currently bound to one issue (`GET …/issues/{id}/labels`). */
256export const listIssueLabels = ({ space, board }: BoardRef, issueId: string) =>
257 listOf<IssueLabel>(
258 Apis.issues.list_labels_for_issue({
259 pathParams: { space_name: space, board_id: board, issue_id: issueId },
260 }),
261 )
262
263export const bindLabel = ({ space, board }: BoardRef, issueId: string, labelId: string) =>
264 unwrapApi<null>(
265 Apis.issues.bind_label({
266 pathParams: { space_name: space, board_id: board, issue_id: issueId, label_id: labelId },
267 }),
268 )
269
270export const unbindLabel = ({ space, board }: BoardRef, issueId: string, labelId: string) =>
271 unwrapApi<null>(
272 Apis.issues.unbind_label({
273 pathParams: { space_name: space, board_id: board, issue_id: issueId, label_id: labelId },
274 }),
275 )
276
277export const listMilestones = ({ space, board }: BoardRef, includeClosed = true) =>
278 pageOf<Milestone>(
279 Apis.issues.list_milestones({
280 pathParams: { space_name: space, board_id: board },
281 params: { limit: 100, ...(includeClosed ? {} : { is_closed: false }) },
282 }),
283 )
284
285export const createMilestone = ({ space, board }: BoardRef, cmd: { name: string; description?: string | null; due_at?: string | null }) =>
286 oneOf<Milestone>(
287 Apis.issues.create_milestone({
288 pathParams: { space_name: space, board_id: board },
289 data: { name: cmd.name, description: cmd.description ?? null, due_at: cmd.due_at ?? null },
290 }),
291 )
292
293export const updateMilestone = (
294 { space, board }: BoardRef,
295 milestoneId: string,
296 cmd: { name?: string; description?: string | null; due_at?: string | null; is_closed?: boolean },
297) =>
298 oneOf<Milestone>(
299 Apis.issues.update_milestone({
300 pathParams: { space_name: space, board_id: board, milestone_id: milestoneId },
301 data: cmd,
302 }),
303 )
304
305export const deleteMilestone = ({ space, board }: BoardRef, milestoneId: string) =>
306 unwrapApi<null>(
307 Apis.issues.delete_milestone({
308 pathParams: { space_name: space, board_id: board, milestone_id: milestoneId },
309 }),
310 )
311
312/* ---------------------------------------------------------------- members */
313
314/** Assignable people: the space's members (issue assignees must be members). */
315export const listSpaceMembers = (space: string) =>
316 pageOf<SpaceMember>(
317 Apis.space.list_space_members({ pathParams: { space_name: space }, params: { limit: 100 } }),
318 )
319
320/* ------------------------------------------------------------ attachments */
321
322/**
323 * Attachments follow the chat upload shape: reserve a row + presigned PUT,
324 * upload the bytes straight to the object store, then finalize (which verifies
325 * the real size/type). A draft is linked to the issue by `createIssue`'s
326 * `attachment_ids`; only finalized drafts can be linked.
327 */
328export const createIssueAttachment = (cmd: {
329 space_name: string
330 filename: string
331 content_type: string
332 size: number
333}) => oneOf<IssueUploadIntent>(Apis.issues.create_issue_attachment({ data: cmd }))
334
335export const finalizeIssueAttachment = (attachmentId: string) =>
336 oneOf<IssueAttachment>(
337 Apis.issues.finalize_issue_attachment({
338 pathParams: { attachment_id: attachmentId },
339 }),
340 )
341
342export const deleteIssueAttachment = (attachmentId: string) =>
343 unwrapApi<null>(
344 Apis.issues.delete_issue_attachment({ pathParams: { attachment_id: attachmentId } }),
345 )
346
347export const listIssueAttachments = ({ space, board }: BoardRef, issueId: string) =>
348 listOf<IssueAttachment>(
349 Apis.issues.list_issue_attachments({
350 pathParams: { space_name: space, board_id: board, issue_id: issueId },
351 }),
352 )
353
354/** Same-origin download/file URL for one attachment (works for `<img>` too). */
355export function issueAttachmentUrl(attachmentId: string): string {
356 return `/api/v1/issues/attachments/${encodeURIComponent(attachmentId)}/file`
357}
358
359/**
360 * PUT the raw bytes to the presigned URL. The object store allows `*` origins,
361 * so the browser talks to it directly and a dead bucket never looks like a
362 * dead API to the outage gate.
363 */
364export async function putIssueUpload(uploadUrl: string, file: File): Promise<void> {
365 const response = await fetch(uploadUrl, {
366 method: 'PUT',
367 headers: { 'content-type': file.type || 'application/octet-stream' },
368 body: file,
369 })
370 if (!response.ok) throw new Error(`upload failed: ${response.status}`)
371}
Deleted web/src/entities/issue/model/issue.ts
@@ -1,170 +0,0 @@
1/**
2 * Issue-board (问题集) row shapes.
3 *
4 * Types are derived from the generated response envelopes wherever the backend
5 * exposes one, so a schema change in `bun run gen:api` shows up here as a type
6 * error instead of a runtime surprise. Paged payloads are typed as the generic
7 * `Page` schema by the generator, so their item rows are declared structurally
8 * (they mirror `IssueListItemDto` / `EventDto` in `lib/issues`).
9 */
10import type {
11 AppResponseData_BoardDto,
12 AppResponseData_CommentDto,
13 AppResponseData_IssueAttachmentDto,
14 AppResponseData_IssueDto,
15 AppResponseData_IssueUploadIntentDto,
16 AppResponseData_LabelDto,
17 AppResponseData_MilestoneDto,
18 AppResponseData_Vec_CommentTreeNode,
19 IssuePriority,
20 IssueState,
21 UserDto,
22 WatchMode,
23} from '@/shared/api'
24
25export type { IssuePriority, IssueState, UserDto, WatchMode }
26
27/** One issue board (`GET /issues/{space}/boards`). */
28export type Board = NonNullable<AppResponseData_BoardDto['data']>
29
30/** Full issue payload (`GET …/issues/{issue_id}`). */
31export type IssueDetail = NonNullable<AppResponseData_IssueDto['data']>
32
33/** Board label (`GET …/labels`). */
34export type IssueLabel = NonNullable<AppResponseData_LabelDto['data']>
35
36/** Milestone (`GET …/milestones`). */
37export type Milestone = NonNullable<AppResponseData_MilestoneDto['data']>
38
39/** One issue attachment (`GET …/issues/{id}/attachments`). */
40export type IssueAttachment = NonNullable<AppResponseData_IssueAttachmentDto['data']>
41
42/** Presigned upload intent (`POST /issues/attachments`). */
43export type IssueUploadIntent = NonNullable<AppResponseData_IssueUploadIntentDto['data']>
44
45/** Issue comment, flat or inside a thread tree. */
46export type IssueComment = NonNullable<AppResponseData_CommentDto['data']>
47export type CommentNode = NonNullable<
48 NonNullable<AppResponseData_Vec_CommentTreeNode['data']>[number]
49>
50
51/** Row of `GET …/issues` — the list endpoint omits body/description. */
52export interface IssueListItem {
53 id: string
54 board_id: string
55 number: number
56 author?: UserDto | null
57 title: string
58 labels: IssueLabel[]
59 milestone_id?: string | null
60 priority: IssuePriority
61 due_at?: string | null
62 assignees: UserDto[]
63 state: IssueState
64 created_at: string
65 updated_at?: string | null
66 closed_at?: string | null
67}
68
69/** Activity line of `GET …/issues/{id}/events`. */
70export interface IssueEvent {
71 id: string
72 issue_id?: string | null
73 user?: UserDto | null
74 event_type: string
75 comment_id?: string | null
76 commit_sha?: string | null
77 old_title?: string | null
78 new_title?: string | null
79 old_ref?: string | null
80 new_ref?: string | null
81 content?: string | null
82 created_at: string
83}
84
85/** Space member row used by the assignee picker. */
86export interface SpaceMember {
87 user?: UserDto | null
88 alias_name?: string | null
89 status?: string | null
90}
91
92/** Offset page as emitted by the backend (`{items,total,limit,offset}`). */
93export interface IssuePage<T> {
94 items: T[]
95 total: number
96 limit: number
97 offset: number
98}
99
100/** Filters accepted by `GET …/issues` (plus paging). */
101export interface IssueQuery {
102 state?: IssueState | 'all'
103 q?: string
104 /** Multi-select: any of these priorities matches. */
105 priorities?: IssuePriority[]
106 labelIds?: string[]
107 /** `none` is a UI-only token meaning "no milestone" (filtered client-side). */
108 milestoneIds?: string[]
109 assigneeIds?: string[]
110 authorId?: number
111 sort?: IssueSort
112 limit?: number
113 offset?: number
114}
115
116/** Sort keys the backend understands; `created` is its default (newest first). */
117export type IssueSort = 'created' | 'updated' | 'due' | 'priority' | 'number'
118
119/**
120 * The board page's view state. The route persists it (see
121 * `pages/workspace/model/board-location`); the feature components only ever
122 * receive it as a plain object, so no UI slice depends on the page layer.
123 */
124export interface IssueBoardView {
125 /** `board` = kanban grouped by priority, `list` = table. */
126 view: 'board' | 'list'
127 state: IssueState | 'all'
128 /** Title keyword (backend `q`). */
129 q: string
130 /** Filter panel dimensions; empty arrays mean "no constraint". */
131 priorities: IssuePriority[]
132 labelIds: string[]
133 milestoneIds: string[]
134 assigneeIds: string[]
135 sort: IssueSort
136 /** 1-based page within the filtered result set. */
137 page: number
138 /** Open issue: board-scoped number (`42`) or uuid. Empty = board/list. */
139 issue: string
140}
141
142/** Body of `POST …/issues`. */
143export interface NewIssueCmd {
144 title: string
145 description?: string | null
146 content?: string
147 priority?: IssuePriority
148 due_at?: string | null
149 milestone_id?: string | null
150 assignee_ids?: number[]
151 label_ids?: string[]
152 /** Draft upload ids from `POST /issues/attachments` to link to the new issue. */
153 attachment_ids?: string[]
154}
155
156/**
157 * Body of `PATCH …/issues/{id}`. Three-state fields: absent = unchanged,
158 * `null` = cleared, value = set — so the caller must not send `undefined`
159 * for a field it wants cleared.
160 */
161export interface IssuePatchCmd {
162 title?: string
163 description?: string | null
164 content?: string
165 priority?: IssuePriority
166 due_at?: string | null
167 milestone_id?: string | null
168 assignee_ids?: number[]
169 label_ids?: string[]
170}
Deleted web/src/entities/issue/model/meta.ts
@@ -1,229 +0,0 @@
1/**
2 * Display metadata for issue rows: the labels, colours and ordering the board
3 * UI shows for values the API only names. Kept next to the domain (not in a
4 * component) because the kanban columns, the filter chips, the list table and
5 * the detail sidebar all have to agree on them.
6 */
7import { getLocale, isMessageKey, t } from '@/shared/i18n'
8import { fmtRelative } from '@/shared/lib'
9import type { IssueBoardView, IssuePriority, IssueState, IssueSort, WatchMode } from './issue'
10
11/** `issues.attachment.max_size` — the backend rejects anything larger. */
12export const MAX_ATTACHMENT_BYTES = 26_214_400
13
14/** `issues.max_board_prefix_len` — mirrored so the dialog validates before sending. */
15export const MAX_BOARD_PREFIX_LEN = 10
16
17/**
18 * Board-scoped issue key as the backend resolves it (`CAD-12`), falling back to
19 * the bare number so boards without a prefix keep today's links.
20 */
21export function issueRef(board: { prefix?: string | null } | null | undefined, number: number): string {
22 const prefix = board?.prefix?.trim()
23 return prefix ? `${prefix}-${number}` : String(number)
24}
25
26/** Display form of {@link issueRef}: `CAD-12`, or `#12` without a prefix. */
27export function issueLabel(board: { prefix?: string | null } | null | undefined, number: number): string {
28 const prefix = board?.prefix?.trim()
29 return prefix ? `${prefix}-${number}` : `#${number}`
30}
31
32/**
33 * Row stamp, Linear-style: relative while the change is fresh, then the month
34 * (`2026年10月` / `Oct 2026`) once "3天前" stops being useful.
35 */
36export function issueStamp(value: string | null | undefined): string {
37 const date = parseIssueDate(value)
38 if (!date) return ''
39 if (Date.now() - date.getTime() < 7 * 86_400_000) return fmtRelative(value as string)
40 return new Intl.DateTimeFormat(getLocale(), { month: 'short', year: 'numeric' }).format(date)
41}
42
43/** `^[A-Z][A-Z0-9]{0,9}$` — the shape `PATCH …/boards` accepts. */
44export function isValidBoardPrefix(value: string): boolean {
45 return /^[A-Z][A-Z0-9]{0,9}$/.test(value)
46}
47
48export interface PriorityMeta {
49 id: IssuePriority
50 label: string
51 /** Column dot / badge background. */
52 color: string
53 /** Badge text colour that stays readable on `color`. */
54 text: string
55 /** High → low, used for client-side ordering and column order. */
56 rank: number
57}
58
59/** Highest priority first — matches `?sort=priority` (backend sorts desc). */
60export const PRIORITIES: readonly PriorityMeta[] = [
61 { id: 'urgent', get label() { return t('label.priority.urgent') }, color: '#e5484d', text: '#ffffff', rank: 4 },
62 { id: 'high', get label() { return t('label.priority.high') }, color: '#f76808', text: '#ffffff', rank: 3 },
63 { id: 'medium', get label() { return t('label.priority.medium') }, color: '#ffc53d', text: '#3d2a00', rank: 2 },
64 { id: 'low', get label() { return t('label.priority.low') }, color: '#8fb8ff', text: '#12315e', rank: 1 },
65 { id: 'none', get label() { return t('label.priority.none') }, color: '#d6d9e0', text: '#3f434b', rank: 0 },
66] as const
67
68export const PRIORITY_META: Record<IssuePriority, PriorityMeta> = PRIORITIES.reduce(
69 (acc, meta) => ({ ...acc, [meta.id]: meta }),
70 {} as Record<IssuePriority, PriorityMeta>,
71)
72
73export function priorityOf(value: string | null | undefined): PriorityMeta {
74 return PRIORITY_META[value as IssuePriority] ?? PRIORITY_META.none
75}
76
77export const STATES: Readonly<Record<IssueState, { label: string; color: string }>> = {
78 open: { get label() { return t('label.issueState.open') }, color: '#3ba55d' },
79 closed: { get label() { return t('label.issueState.closed') }, color: '#66717d' },
80}
81
82export const STATE_FILTERS: ReadonlyArray<{ id: IssueState | 'all'; label: string }> = [
83 { id: 'open', get label() { return t('label.issueState.open') } },
84 { id: 'closed', get label() { return t('label.issueState.closed') } },
85 { id: 'all', get label() { return t('label.issueState.all') } },
86]
87
88export const SORTS: ReadonlyArray<{ id: IssueSort; label: string }> = [
89 { id: 'created', get label() { return t('label.issueSort.created') } },
90 { id: 'updated', get label() { return t('label.issueSort.updated') } },
91 { id: 'priority', get label() { return t('label.issueSort.priority') } },
92 { id: 'due', get label() { return t('label.issueSort.due') } },
93 { id: 'number', get label() { return t('label.issueSort.number') } },
94]
95
96/**
97 * The two layouts a 问题集 can open in. Stored per user (`board_view_mode`);
98 * an unknown or absent value falls back to the kanban.
99 */
100export function normalizeBoardView(value: string | null | undefined): IssueBoardView['view'] {
101 return value === 'list' ? 'list' : 'board'
102}
103
104export const WATCH_LABELS: Readonly<Record<WatchMode, string>> = {
105 get watching() {
106 return t('issue.watch.watching')
107 },
108 get unwatched() {
109 return t('issue.watch.unwatched')
110 },
111 get ignore() {
112 return t('issue.watch.ignore')
113 },
114}
115/** Sort values the backend rejects are dropped so the query stays valid. */
116export const SORT_PARAMS: Record<IssueSort, string> = {
117 created: 'created_at',
118 updated: 'updated_at',
119 priority: 'priority',
120 due: 'due_at',
121 number: 'number',
122}
123
124/**
125 * Rust's `time` crate renders offsets as `+00:00:00` (seconds in the offset),
126 * which `Date` cannot parse. Same fold as `shared/lib/format`.
127 */
128export function parseIssueDate(value: string | null | undefined): Date | null {
129 if (!value) return null
130 const date = new Date(value.replace(/([+-]\d{2}):(\d{2}):(\d{2})$/, '$1:$2'))
131 return Number.isNaN(date.getTime()) ? null : date
132}
133
134export interface DueMeta {
135 text: string
136 /** Overdue (or due today) on a still-open issue reads as a warning. */
137 tone: 'muted' | 'due' | 'over' | 'done'
138}
139
140/** Due date as `MM-DD`, qualified for open issues only. */
141export function describeDue(value: string | null | undefined, state: IssueState): DueMeta | null {
142 const date = parseIssueDate(value)
143 if (!date) return null
144 const day = new Intl.DateTimeFormat(getLocale(), { month: 'numeric', day: 'numeric' }).format(date)
145 if (state === 'closed') return { text: day, tone: 'done' }
146 const today = new Date()
147 today.setHours(0, 0, 0, 0)
148 const start = new Date(date)
149 start.setHours(0, 0, 0, 0)
150 const days = Math.round((start.getTime() - today.getTime()) / 86_400_000)
151 // Absolute date plus a relative phrase; Intl supplies the wording so the
152 // table stays out of the translation catalogue.
153 const relative = new Intl.RelativeTimeFormat(getLocale(), { numeric: 'auto' })
154 if (days < 0) return { text: `${day} · ${relative.format(days, 'day')}`, tone: 'over' }
155 if (days === 0) return { text: `${day} · ${relative.format(0, 'day')}`, tone: 'due' }
156 if (days <= 7) return { text: `${day} · ${relative.format(days, 'day')}`, tone: 'due' }
157 return { text: day, tone: 'muted' }
158}
159
160/** `#rgb` / `rgb` / bare hex → `#rrggbb`, so a label colour never breaks CSS. */
161export function normalizeColor(value: string | null | undefined, fallback = '#8b8f99'): string {
162 const raw = (value ?? '').trim().replace(/^#/, '')
163 if (raw.length === 3) return `#${raw.split('').map(c => c + c).join('')}`
164 if (raw.length === 6 && /^[0-9a-f]{6}$/i.test(raw)) return `#${raw.toLowerCase()}`
165 return fallback
166}
167
168/** Pick black or white text for a background so custom label colours stay legible. */
169export function readableText(background: string): string {
170 const hex = normalizeColor(background).slice(1)
171 const [r, g, b] = [0, 2, 4].map(i => Number.parseInt(hex.slice(i, i + 2), 16) / 255)
172 const channel = (v: number) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)
173 const luminance = 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
174 return luminance > 0.55 ? '#1c1e22' : '#ffffff'
175}
176
177/** Suggestions offered by the label colour picker (GitHub-ish palette). */
178export const LABEL_COLORS = [
179 '#e5484d',
180 '#f76808',
181 '#ffc53d',
182 '#46a758',
183 '#00a2c7',
184 '#66717d',
185 '#8e4ec6',
186 '#eb459e',
187 '#8b8f99',
188] as const
189
190/** `YYYY-MM-DD` for `<input type="date">`, from the backend's own timestamp. */
191export function dateInputValue(value: string | null | undefined): string {
192 const date = parseIssueDate(value)
193 if (!date) return ''
194 const pad = (n: number) => String(n).padStart(2, '0')
195 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
196}
197
198/** Send a date-only pick as UTC noon so it never shifts a day across timezones. */
199export function dateApiValue(input: string): string | null {
200 if (!input) return null
201 const [year, month, day] = input.split('-').map(Number)
202 if (!year || !month || !day) return null
203 return new Date(Date.UTC(year, month - 1, day, 12, 0, 0)).toISOString()
204}
205
206/**
207 * Activity line labels, keyed by the backend's `IssueEventType` snake_case
208 * values. Unknown types fall back to the raw value, so a new server-side event
209 * still renders (unreadable rather than missing).
210 */
211/**
212 * Activity line labels, keyed by the backend's `IssueEventType` snake_case
213 * values. Resolved lazily so the table follows the active locale; unknown types
214 * fall back to the raw value, so a new server-side event still renders.
215 */
216export const EVENT_LABELS: Readonly<Record<string, string>> = new Proxy(
217 {},
218 {
219 get: (_target, key: string) => {
220 const entry = `issue.event.${key}`
221 return isMessageKey(entry) ? t(entry) : undefined
222 },
223 },
224)
225
226export function eventLabel(type: string | null | undefined): string {
227 if (!type) return t('issue.event.unknown')
228 return EVENT_LABELS[type] ?? t('issue.event.fallback', { type })
229}
Deleted web/src/entities/issue/index.ts
@@ -1,5 +0,0 @@
1// Public API of the entities/issue slice — issue-board domain data.
2// Consumers import from '@/entities/issue', never from deeper files.
3export * from './model/issue'
4export * from './model/meta'
5export * from './model/api'
Deleted web/src/entities/pull-request/model/api.ts
@@ -1,351 +0,0 @@
1/**
2 * Pull-request transport: every call the PR pages make, wrapped in the shared
3 * envelope contract so the UI only ever sees unwrapped rows or an `ApiFailure`.
4 *
5 * PRs are addressable by uuid *or* number (`pull_ref`), so every `pull`
6 * parameter below accepts either form.
7 */
8import Apis from '@/shared/api'
9import { unwrapApi, type ApiEnvelope } from '@/shared/lib'
10import type {
11 InlineDraft,
12 MergeCmd,
13 MergeOutcome,
14 MergeStatus,
15 MyPullScope,
16 NewPullCmd,
17 NewPullCommentCmd,
18 Pull,
19 PullAssignee,
20 PullChecks,
21 PullComment,
22 PullCommentThread,
23 PullEvent,
24 IssuePullLink,
25 PullGate,
26 PullLabel,
27 PullPage,
28 PullPatchCmd,
29 PullQuery,
30 PullReview,
31 PullReviewer,
32 PullState,
33 PullSummary,
34 ChangedFile,
35 MergeCommitRow,
36 ReviewCmd,
37} from './pull'
38
39/** `pull_ref` (number as string or uuid) plus the repo the PR lives in. */
40export interface PullRef {
41 space: string
42 repo: string
43 pull: string
44}
45
46const nz = <T,>(value: T | '' | null | undefined): T | undefined => (value ? value : undefined)
47
48const pageOf = <T>(method: PromiseLike<ApiEnvelope<unknown>>): Promise<PullPage<T>> =>
49 unwrapApi<PullPage<T> | null>(method as PromiseLike<ApiEnvelope<PullPage<T> | null>>).then(p => ({
50 items: p?.items ?? [],
51 total: p?.total ?? 0,
52 limit: p?.limit ?? 0,
53 offset: p?.offset ?? 0,
54 }))
55
56const listOf = <T>(method: PromiseLike<ApiEnvelope<unknown>>): Promise<T[]> =>
57 unwrapApi<T[] | null>(method as PromiseLike<ApiEnvelope<T[] | null>>).then(rows => rows ?? [])
58
59const oneOf = <T>(method: PromiseLike<ApiEnvelope<unknown>>): Promise<T> =>
60 unwrapApi<T>(method as PromiseLike<ApiEnvelope<T>>)
61
62/** Mutation endpoints whose payload we ignore (the generated types disagree
63 * on whether `data` is null or the updated entity — accept both). */
64const ignore = <T>(method: PromiseLike<ApiEnvelope<T>>): Promise<null> =>
65 unwrapApi(method).then(() => null)
66
67const pathOf = (ref: PullRef) => ({
68 space_name: ref.space,
69 repo_name: ref.repo,
70 pull_ref: ref.pull,
71})
72
73/* ------------------------------------------------------------------- list */
74
75export const listPulls = (space: string, repo: string, query: PullQuery = {}) =>
76 pageOf<Pull>(
77 Apis.pulls.list_pulls({
78 pathParams: { space_name: space, repo_name: repo },
79 params: {
80 // `draft` is a UI-only choice the backend spells as state=open + draft=true.
81 state: query.state === 'all' || !query.state ? 'all' : query.state === 'draft' ? 'open' : query.state,
82 draft: query.state === 'draft' ? true : query.draft,
83 author: nz(query.author),
84 base: nz(query.base),
85 head: nz(query.head),
86 search: nz(query.search),
87 sort: nz(query.sort),
88 order: nz(query.order),
89 limit: query.limit,
90 offset: query.offset ?? 0,
91 },
92 }),
93 )
94
95/** Cross-repo inbox: PRs created by / needing review from / reviewed by me. */
96export const listMyPulls = (scope: MyPullScope, opts: { state?: PullState | 'all' | 'draft'; limit?: number; offset?: number } = {}) =>
97 pageOf<Pull>(
98 Apis.pulls.list_my_pulls({
99 pathParams: { scope },
100 params: {
101 // The inbox endpoint has no draft filter; `draft` maps to open.
102 state: opts.state && opts.state !== 'all' && opts.state !== 'draft' ? opts.state : 'open',
103 limit: opts.limit,
104 offset: opts.offset ?? 0,
105 },
106 }),
107 )
108
109export const getPull = (ref: PullRef) => oneOf<Pull>(Apis.pulls.get_pull({ pathParams: pathOf(ref) }))
110
111export const createPull = (space: string, repo: string, cmd: NewPullCmd) =>
112 oneOf<Pull>(
113 Apis.pulls.create_pull({
114 pathParams: { space_name: space, repo_name: repo },
115 data: {
116 title: cmd.title,
117 head_branch: cmd.head_branch,
118 base_branch: cmd.base_branch ?? null,
119 body: cmd.body ?? null,
120 draft: cmd.draft ?? false,
121 issue_id: cmd.issue_id ?? null,
122 },
123 }),
124 )
125
126export const updatePull = (ref: PullRef, cmd: PullPatchCmd) =>
127 oneOf<Pull>(Apis.pulls.update_pull({ pathParams: pathOf(ref), data: cmd }))
128
129export const deletePull = (ref: PullRef) =>
130 ignore(Apis.pulls.delete_pull({ pathParams: pathOf(ref) }))
131
132export const closePull = (ref: PullRef) => oneOf<Pull>(Apis.pulls.close_pull({ pathParams: pathOf(ref) }))
133export const reopenPull = (ref: PullRef) => oneOf<Pull>(Apis.pulls.reopen_pull({ pathParams: pathOf(ref) }))
134
135export const lockPull = (ref: PullRef) => ignore(Apis.pulls.lock_pull({ pathParams: pathOf(ref) }))
136export const unlockPull = (ref: PullRef) =>
137 ignore(Apis.pulls.unlock_pull({ pathParams: pathOf(ref) }))
138
139/* ----------------------------------------------------------------- merge */
140
141export const mergePull = (ref: PullRef, cmd: MergeCmd) =>
142 oneOf<MergeOutcome>(Apis.pulls.merge_pull({ pathParams: pathOf(ref), data: cmd }))
143
144export const updateBranchPull = (ref: PullRef) =>
145 oneOf<Pull>(Apis.pulls.update_branch_pull({ pathParams: pathOf(ref) }))
146
147export const getPullGate = (ref: PullRef) => oneOf<PullGate>(Apis.pulls.get_pull_gate({ pathParams: pathOf(ref) }))
148
149export const refreshPullSummary = (ref: PullRef) =>
150 oneOf<PullSummary>(Apis.pulls.refresh_pull_summary({ pathParams: pathOf(ref) }))
151
152/** Mergeability probe for the create dialog (`GET …/conflicts?base=&head=`). */
153export const checkMergeStatus = (space: string, repo: string, base: string, head: string) =>
154 oneOf<MergeStatus>(
155 Apis.conflicts.get_conflicts({
156 pathParams: { space_name: space, repo_name: repo },
157 params: { base, head },
158 }),
159 )
160
161/** Raw `.patch` download URL (browser navigation, not a JSON call). */
162export const pullPatchUrl = (ref: PullRef) =>
163 `/api/v1/repos/${encodeURIComponent(ref.space)}/${encodeURIComponent(ref.repo)}/pulls/${encodeURIComponent(ref.pull)}/patch`
164
165/* --------------------------------------------------------------- commits */
166
167export const listPullCommits = (ref: PullRef) =>
168 listOf<MergeCommitRow>(Apis.pulls.list_pull_commits({ pathParams: pathOf(ref) }))
169
170export const listPullFiles = (ref: PullRef) =>
171 listOf<ChangedFile>(Apis.pulls.get_pull_files({ pathParams: pathOf(ref) }))
172
173/* ---------------------------------------------------------------- checks */
174
175export const getPullChecks = (ref: PullRef) =>
176 oneOf<PullChecks>(Apis.pulls.get_pull_checks({ pathParams: pathOf(ref) }))
177
178/* --------------------------------------------------------------- comment */
179
180export const listCommentTree = (ref: PullRef) =>
181 listOf<PullCommentThread>(Apis.pulls.list_pull_comment_tree({ pathParams: pathOf(ref) }))
182
183export const listCommentsPage = (ref: PullRef, opts: { limit?: number; offset?: number } = {}) =>
184 pageOf<PullComment>(
185 Apis.pulls.list_pull_comments({
186 pathParams: pathOf(ref),
187 params: { limit: opts.limit, offset: opts.offset ?? 0 },
188 }),
189 )
190
191export const createComment = (ref: PullRef, cmd: NewPullCommentCmd) =>
192 oneOf<PullComment>(
193 Apis.pulls.create_pull_comment({
194 pathParams: pathOf(ref),
195 data: {
196 body: cmd.body,
197 parent_comment_id: cmd.parent_comment_id ?? null,
198 path: cmd.path ?? null,
199 line: cmd.line ?? null,
200 side: cmd.side ?? null,
201 commit_sha: cmd.commit_sha ?? null,
202 review_id: cmd.review_id ?? null,
203 },
204 }),
205 )
206
207export const updateComment = (ref: PullRef, commentId: string, body: string) =>
208 oneOf<PullComment>(
209 Apis.pulls.update_pull_comment({
210 pathParams: { ...pathOf(ref), comment_id: commentId },
211 data: { body },
212 }),
213 )
214
215export const deleteComment = (ref: PullRef, commentId: string) =>
216 ignore(
217 Apis.pulls.delete_pull_comment({ pathParams: { ...pathOf(ref), comment_id: commentId } }),
218 )
219
220export const resolveComment = (ref: PullRef, commentId: string) =>
221 ignore(
222 Apis.pulls.resolve_pull_comment({ pathParams: { ...pathOf(ref), comment_id: commentId } }),
223 )
224
225export const unresolveComment = (ref: PullRef, commentId: string) =>
226 ignore(
227 Apis.pulls.unresolve_pull_comment({ pathParams: { ...pathOf(ref), comment_id: commentId } }),
228 )
229
230/* ---------------------------------------------------------------- review */
231
232export const listReviews = (ref: PullRef) =>
233 listOf<PullReview>(Apis.reviews.list_reviews({ pathParams: pathOf(ref) }))
234
235export const getReview = (ref: PullRef, reviewId: string) =>
236 oneOf<PullReview>(
237 Apis.reviews.get_review({ pathParams: { ...pathOf(ref), review_id: reviewId } }),
238 )
239
240export const submitReview = (ref: PullRef, cmd: ReviewCmd) =>
241 oneOf<PullReview>(Apis.reviews.submit_review({ pathParams: pathOf(ref), data: cmd }))
242
243/** Publish a pending review (optionally with final body/inline drafts). */
244export const publishReview = (ref: PullRef, reviewId: string, cmd: ReviewCmd) =>
245 oneOf<PullReview>(
246 Apis.reviews.publish_review({ pathParams: { ...pathOf(ref), review_id: reviewId }, data: cmd }),
247 )
248
249export const dismissReview = (ref: PullRef, reviewId: string) =>
250 oneOf<PullReview>(
251 Apis.reviews.dismiss_review({ pathParams: { ...pathOf(ref), review_id: reviewId } }),
252 )
253
254export const listRequestedReviewers = (ref: PullRef) =>
255 listOf<PullReviewer>(Apis.reviews.list_requested_reviewers({ pathParams: pathOf(ref) }))
256
257export const requestReviewers = (ref: PullRef, reviewers: string[], required?: boolean) =>
258 listOf<PullReviewer>(
259 Apis.reviews.request_reviewers({
260 pathParams: pathOf(ref),
261 data: { reviewers, required: required ?? undefined },
262 }),
263 )
264
265export const removeRequestedReviewer = (ref: PullRef, userId: number) =>
266 ignore(
267 Apis.reviews.delete_requested_reviewer({ pathParams: { ...pathOf(ref), user_id: userId } }),
268 )
269
270/* ------------------------------------------------------------- meta side */
271
272export const listEvents = (ref: PullRef, opts: { limit?: number; offset?: number } = {}) =>
273 pageOf<PullEvent>(
274 Apis.pulls.list_pull_events({
275 pathParams: pathOf(ref),
276 params: { limit: opts.limit, offset: opts.offset ?? 0 },
277 }),
278 )
279
280export const listAssignees = (ref: PullRef) =>
281 listOf<PullAssignee>(Apis.pulls.list_pull_assignees({ pathParams: pathOf(ref) }))
282
283export const addAssignee = (ref: PullRef, userRef: string) =>
284 ignore(
285 Apis.pulls.add_pull_assignee({ pathParams: { ...pathOf(ref), user_ref: userRef } }),
286 )
287
288export const removeAssignee = (ref: PullRef, userRef: string) =>
289 ignore(
290 Apis.pulls.remove_pull_assignee({ pathParams: { ...pathOf(ref), user_ref: userRef } }),
291 )
292
293export const listPullLabels = (ref: PullRef) =>
294 listOf<PullLabel>(Apis.pulls.list_pull_labels({ pathParams: pathOf(ref) }))
295
296/** 可附加的标签候选(仓库目录标签库,未归档),供侧栏搜索下拉使用。 */
297export const listPullLabelOptions = (space: string, repo: string) =>
298 listOf<PullLabel>(
299 Apis.pulls.list_label_options({ pathParams: { space_name: space, repo_name: repo } }),
300 )
301
302export const attachLabel = (ref: PullRef, label: string) =>
303 ignore(Apis.pulls.attach_pull_label({ pathParams: pathOf(ref), data: { label } }))
304
305export const detachLabel = (ref: PullRef, labelRef: string) =>
306 ignore(
307 Apis.pulls.detach_pull_label({ pathParams: { ...pathOf(ref), label_ref: labelRef } }),
308 )
309
310/* ----------------------------------------------------- issue-side links */
311
312/** PRs linked to one issue (`GET …/issues/{id}/pulls`), with relation ids. */
313export const listIssuePulls = (space: string, board: string, issueId: string) =>
314 listOf<IssuePullLink>(
315 Apis.pulls.list_issue_pulls({
316 pathParams: { space_name: space, board_id: board, issue_id: issueId },
317 }),
318 )
319
320export const linkIssuePull = (
321 space: string,
322 board: string,
323 issueId: string,
324 cmd: { space_name: string; repo_name: string; pull_ref: string },
325) =>
326 ignore(
327 Apis.pulls.link_issue_pull({
328 pathParams: { space_name: space, board_id: board, issue_id: issueId },
329 data: cmd,
330 }),
331 )
332
333export const unlinkIssuePull = (space: string, board: string, issueId: string, relationId: string) =>
334 ignore(
335 Apis.pulls.unlink_issue_pull({
336 pathParams: {
337 space_name: space,
338 board_id: board,
339 issue_id: issueId,
340 relation_id: relationId,
341 },
342 }),
343 )
344
345/** Inline-draft helper kept next to the transport the files tab uses. */
346export const draftFrom = (path: string, line: number, side: string | null, body: string): InlineDraft => ({
347 path,
348 line,
349 side,
350 body,
351})
Deleted web/src/entities/pull-request/model/pull.ts
@@ -1,186 +0,0 @@
1/**
2 * Pull-request (PR) row shapes.
3 *
4 * Types are derived from the generated response envelopes wherever the backend
5 * exposes one, so a schema change in `bun run gen:api` shows up here as a type
6 * error. Paged payloads are typed as the generic `Page` schema by the
7 * generator, so their rows are cast structurally in `api.ts`.
8 */
9import type {
10 AppResponseData_MergePullDto,
11 AppResponseData_MergeStatusDto,
12 AppResponseData_PullChecksDto,
13 AppResponseData_PullCommentDto,
14 AppResponseData_PullDto,
15 AppResponseData_Vec_IssuePullLinkDto,
16 AppResponseData_PullGateDto,
17 AppResponseData_PullReviewDto,
18 AppResponseData_PullSummaryDto,
19 AppResponseData_Vec_ChangedPathDiffDto,
20 AppResponseData_Vec_LabelDto,
21 AppResponseData_Vec_MergeCommitDto,
22 AppResponseData_Vec_PullAssigneeDto,
23 AppResponseData_Vec_PullCommentThreadDto,
24 AppResponseData_Vec_PullReviewerDto,
25 MergeMethod,
26 PullState,
27 ReviewIntent,
28 ReviewState,
29 ReviewerState,
30 UserDto,
31} from '@/shared/api'
32
33export type {
34 MergeMethod,
35 PullState,
36 ReviewIntent,
37 ReviewState,
38 ReviewerState,
39 UserDto,
40}
41
42export const PULL_STATES = ['open', 'closed', 'merged'] as const
43export const MY_PULL_SCOPES = ['created', 'review-requested', 'reviewed'] as const
44export type MyPullScope = (typeof MY_PULL_SCOPES)[number]
45
46/** Full pull payload (`GET …/pulls/{pull_ref}`). */
47export type Pull = NonNullable<AppResponseData_PullDto['data']>
48
49export type PullComment = NonNullable<AppResponseData_PullCommentDto['data']>
50
51export type PullCommentThread = NonNullable<
52 NonNullable<AppResponseData_Vec_PullCommentThreadDto['data']>[number]
53>
54
55export type PullReview = NonNullable<AppResponseData_PullReviewDto['data']>
56
57export type PullReviewer = NonNullable<
58 NonNullable<AppResponseData_Vec_PullReviewerDto['data']>[number]
59>
60
61export type PullAssignee = NonNullable<
62 NonNullable<AppResponseData_Vec_PullAssigneeDto['data']>[number]
63>
64
65export type PullChecks = NonNullable<AppResponseData_PullChecksDto['data']>
66
67export type PullGate = NonNullable<AppResponseData_PullGateDto['data']>
68
69/** The gate embedded in `PullGate` (no standalone response envelope). */
70export type MergeGate = PullGate['gate']
71
72export type PullSummary = NonNullable<AppResponseData_PullSummaryDto['data']>
73
74/** `{pull, result, summary}` of a successful merge. */
75export type MergeOutcome = NonNullable<AppResponseData_MergePullDto['data']>
76
77export type MergeStatus = NonNullable<AppResponseData_MergeStatusDto['data']>
78
79export type MergeCommitRow = NonNullable<
80 NonNullable<AppResponseData_Vec_MergeCommitDto['data']>[number]
81>
82
83/** One changed file with its patch section (`GET …/files`). */
84export type ChangedFile = NonNullable<
85 NonNullable<AppResponseData_Vec_ChangedPathDiffDto['data']>[number]
86>
87
88/** Activity line of `GET …/events` (paged generically, so declared here). */
89export interface PullEvent {
90 id: string
91 pull_request_id: string
92 actor?: UserDto | null
93 event_type: string
94 commit_sha?: string | null
95 content?: string | null
96 created_at: string
97}
98
99/** A board label bound to a PR (`GET …/labels` → Vec<LabelDto>). */
100export type PullLabel = NonNullable<
101 NonNullable<AppResponseData_Vec_LabelDto['data']>[number]
102>
103
104/** Issue↔PR relation row (`GET …/issues/{id}/pulls`). */
105export type IssuePullLink = NonNullable<
106 NonNullable<AppResponseData_Vec_IssuePullLinkDto['data']>[number]
107>
108
109/** Offset page as emitted by the backend. */
110export interface PullPage<T> {
111 items: T[]
112 total: number
113 limit: number
114 offset: number
115}
116
117/** Filters accepted by `GET …/pulls` (plus paging). */
118export interface PullQuery {
119 /** `draft` is UI-only: it maps to state=open + draft=true server-side. */
120 state?: PullState | 'all' | 'draft'
121 author?: string
122 base?: string
123 head?: string
124 draft?: boolean
125 search?: string
126 sort?: string
127 order?: string
128 limit?: number
129 offset?: number
130}
131
132/** Body of `POST …/pulls/{ref}`. */
133export interface PullPatchCmd {
134 title?: string
135 body?: string | null
136 base_branch?: string | null
137 draft?: boolean | null
138 auto_merge?: boolean | null
139 issue_id?: string | null
140}
141
142/** Body of `POST …/pulls` (create). */
143export interface NewPullCmd {
144 title: string
145 head_branch: string
146 base_branch?: string | null
147 body?: string | null
148 draft?: boolean
149 issue_id?: string | null
150}
151
152/** Body of `POST …/merge`. */
153export interface MergeCmd {
154 method?: MergeMethod
155 title?: string | null
156 message?: string | null
157 fast_forward_only?: boolean
158 delete_branch_after_merge?: boolean
159 expect_head_sha?: string | null
160}
161
162/** Inline comment draft carried by `POST …/reviews` (submit review). */
163export interface InlineDraft {
164 path: string
165 line: number
166 side?: string | null
167 body?: string | null
168}
169
170/** Body of `POST …/reviews` and `POST …/reviews/{id}/submit`. */
171export interface ReviewCmd {
172 state: ReviewIntent
173 body?: string | null
174 comments?: InlineDraft[]
175}
176
177/** Body of `POST …/comments` — conversation or inline (path+line). */
178export interface NewPullCommentCmd {
179 body: string
180 parent_comment_id?: string | null
181 path?: string | null
182 line?: number | null
183 side?: string | null
184 commit_sha?: string | null
185 review_id?: string | null
186}
Deleted web/src/entities/pull-request/index.ts
@@ -1,7 +0,0 @@
1/**
2 * Public API of entities/pull-request — the PR domain model shared by the
3 * pull-request feature and the issue board's linked-PR panel. Consumers import
4 * from '@/entities/pull-request', never from deeper files.
5 */
6export * from './model/pull'
7export * from './model/api'
Deleted web/src/entities/repo/model/archives.ts
@@ -1,51 +0,0 @@
1/**
2 * Archives domain: on-demand archive / bundle creation and short-lived
3 * download links.
4 */
5import Apis from '@/shared/api'
6import { unwrapApi } from '@/shared/lib'
7import type { AppResponseData_ArchiveResultDto } from '@/shared/api'
8
9export type ArchiveResult = NonNullable<AppResponseData_ArchiveResultDto['data']>
10export type ArchiveFormat = 'tar' | 'tar_gz' | 'tar_bz2' | 'zip'
11
12export const createArchive = (
13 space: string,
14 repo: string,
15 cmd: { revision: string; format: ArchiveFormat; path_filter?: string },
16) =>
17 unwrapApi<ArchiveResult>(
18 Apis.archives.create_archive({
19 pathParams: { space_name: space, repo_name: repo },
20 data: { ...cmd, path_filter: cmd.path_filter || null },
21 }),
22 )
23
24export const createBundle = (space: string, repo: string) =>
25 unwrapApi<ArchiveResult>(
26 Apis.archives.create_bundle({ pathParams: { space_name: space, repo_name: repo } }),
27 )
28
29/** `GET /archives/{revision}?format=` deserialises the serde *variant* names,
30 * while the POST body takes the snake_case tags — map between the two. */
31const QUERY_FORMAT: Record<ArchiveFormat, string> = {
32 tar: 'Tar',
33 tar_gz: 'TarGz',
34 tar_bz2: 'TarBz2',
35 zip: 'Zip',
36}
37
38/** Re-issue a short-lived download URL for an already built archive. */
39export const queryArchive = (
40 space: string,
41 repo: string,
42 revision: string,
43 format: ArchiveFormat,
44 pathFilter?: string,
45) =>
46 unwrapApi<ArchiveResult>(
47 Apis.archives.get_archive({
48 pathParams: { space_name: space, repo_name: repo, revision },
49 params: { format: QUERY_FORMAT[format], path_filter: pathFilter || undefined },
50 }),
51 )
Deleted web/src/entities/repo/model/blame.ts
@@ -1,68 +0,0 @@
1/**
2 * Line-attribution (blame) and submodule listings — advanced read endpoints
3 * under `/repos/{s}/{r}`.
4 */
5import Apis from '@/shared/api'
6import { unwrapApi } from '@/shared/lib'
7import type {
8 AppResponseData_Vec_BlameRangeDto,
9 AppResponseData_Vec_SubmoduleDto,
10} from '@/shared/api'
11
12export type BlameRange = NonNullable<
13 NonNullable<AppResponseData_Vec_BlameRangeDto['data']>[number]
14>
15
16export type SubmoduleRow = NonNullable<
17 NonNullable<AppResponseData_Vec_SubmoduleDto['data']>[number]
18>
19
20/** Per-range attribution for `path` at `revision`. */
21export const fetchBlame = (space: string, repo: string, revision: string, path: string) =>
22 unwrapApi<BlameRange[] | null>(
23 Apis.blame.blame({
24 pathParams: { space_name: space, repo_name: repo },
25 params: { revision, path },
26 }),
27 ).then(rows => rows ?? [])
28
29/** Conflict content (with markers) for one path between two revisions. */
30export const fetchConflictDetail = (space: string, repo: string, base: string, head: string, path: string) =>
31 unwrapApi<{ path: string; content?: string | null }>(
32 Apis.conflicts.conflict_detail({
33 pathParams: { space_name: space, repo_name: repo },
34 params: { base, head, path },
35 }),
36 )
37
38/** Resolve a submodule path at `revision` to its repository path (space/name). */
39export const resolveSubmodule = (space: string, repo: string, revision: string, path: string) =>
40 unwrapApi<string>(
41 Apis.submodules.submodule_resolve({
42 pathParams: { space_name: space, repo_name: repo },
43 params: { revision, path },
44 }),
45 )
46
47/** `.gitmodules` entries visible at `revision`. */
48export const listSubmodules = (space: string, repo: string, revision: string) =>
49 unwrapApi<SubmoduleRow[] | null>(
50 Apis.submodules.submodule_list({
51 pathParams: { space_name: space, repo_name: repo },
52 params: { revision },
53 }),
54 ).then(rows => rows ?? [])
55
56/**
57 * Spread blame ranges over 1-based line numbers: entry i describes line i+1.
58 * Ranges are inclusive (`starting_line..ending_line`) per the backend dto.
59 */
60export function blameLineMap(ranges: BlameRange[], lineCount: number): (BlameRange | null)[] {
61 const lines: (BlameRange | null)[] = Array.from({ length: lineCount }, () => null)
62 for (const range of ranges) {
63 const start = Math.max(1, range.starting_line)
64 const end = Math.min(lineCount, range.ending_line)
65 for (let line = start; line <= end; line += 1) lines[line - 1] = range
66 }
67 return lines
68}
Deleted web/src/entities/repo/model/code-comments.ts
@@ -1,82 +0,0 @@
1/** 代码行级评论领域:浏览页与提交页共用的行级评论读写。 */
2import Apis from '@/shared/api'
3import { unwrapApi } from '@/shared/lib'
4import type { AppResponseData_CodeCommentDto, CreateCodeCommentCmd } from '@/shared/api'
5
6export type CodeComment = NonNullable<AppResponseData_CodeCommentDto['data']>
7
8/** 分页响应在生成器里是泛型 `Page`,行类型由此显式声明。 */
9export interface CodeCommentPageData {
10 items: CodeComment[]
11 total: number
12 limit: number
13 offset: number
14}
15
16export const listCodeComments = (
17 space: string,
18 repo: string,
19 params: { path?: string; revision?: string; resolved?: boolean; limit?: number; offset?: number } = {},
20) =>
21 Apis.codeComments
22 .list_code_comments({
23 pathParams: { space_name: space, repo_name: repo },
24 params: {
25 path: params.path,
26 revision: params.revision,
27 resolved: params.resolved,
28 limit: params.limit,
29 offset: params.offset,
30 },
31 })
32 .then(res => res as unknown as { data?: CodeCommentPageData })
33 .then(res => res.data ?? { items: [], total: 0, limit: 0, offset: 0 })
34
35export const createCodeComment = (space: string, repo: string, cmd: CreateCodeCommentCmd) =>
36 unwrapApi<CodeComment>(
37 Apis.codeComments.create_code_comment({
38 pathParams: { space_name: space, repo_name: repo },
39 data: cmd,
40 }),
41 )
42
43export const updateCodeComment = (space: string, repo: string, commentId: string, body: string) =>
44 unwrapApi<CodeComment>(
45 Apis.codeComments.update_code_comment({
46 pathParams: { space_name: space, repo_name: repo, comment_id: commentId },
47 data: { body },
48 }),
49 )
50
51export const deleteCodeComment = (space: string, repo: string, commentId: string) =>
52 unwrapApi<unknown>(
53 Apis.codeComments.delete_code_comment({
54 pathParams: { space_name: space, repo_name: repo, comment_id: commentId },
55 }),
56 )
57
58export const resolveCodeComment = (space: string, repo: string, commentId: string) =>
59 unwrapApi<CodeComment>(
60 Apis.codeComments.resolve_code_comment({
61 pathParams: { space_name: space, repo_name: repo, comment_id: commentId },
62 }),
63 )
64
65export const unresolveCodeComment = (space: string, repo: string, commentId: string) =>
66 unwrapApi<CodeComment>(
67 Apis.codeComments.unresolve_code_comment({
68 pathParams: { space_name: space, repo_name: repo, comment_id: commentId },
69 }),
70 )
71
72/** 把评论按 `path#line` 归组,供 diff / 代码视图按行渲染。 */
73export function groupCommentsByLine(rows: CodeComment[]): Map<string, CodeComment[]> {
74 const map = new Map<string, CodeComment[]>()
75 for (const row of rows) {
76 const key = `${row.path}#${row.line}`
77 const list = map.get(key) ?? []
78 list.push(row)
79 map.set(key, list)
80 }
81 return map
82}
No newline at end of file
Deleted web/src/entities/repo/model/commits.ts
@@ -1,68 +0,0 @@
1/**
2 * Commit-history domain: the commit list endpoints (including raw patch text
3 * and numstat) that feed the history tab and the commit view.
4 */
5import Apis from '@/shared/api'
6import { unwrapApi } from '@/shared/lib'
7import type { AppResponseData_CommitsPageDto } from '@/shared/api'
8import { normalizeDiffStyle, type DiffStyle, type NumstatRow } from './diff'
9
10export type CommitsPage = NonNullable<AppResponseData_CommitsPageDto['data']>
11export type CommitRow = NonNullable<CommitsPage['commits']>[number]
12
13/** One page of commit history for `revision` (optionally filtered to a path). */
14export async function listCommitsPage(
15 space: string,
16 repo: string,
17 opts: { revision: string; skip: number; limit: number; path?: string; firstParent?: boolean },
18): Promise<{ commits: CommitRow[]; total: number; hasMore: boolean }> {
19 const page = await unwrapApi<CommitsPage>(
20 Apis.commits.list_commits({
21 pathParams: { space_name: space, repo_name: repo },
22 params: {
23 revision: opts.revision,
24 skip: opts.skip,
25 max_count: opts.limit,
26 first_parent: opts.firstParent ?? false,
27 ...(opts.path ? { path: opts.path } : {}),
28 },
29 }),
30 )
31 return {
32 commits: page.commits ?? [],
33 total: page.total ?? 0,
34 hasMore: Boolean(page.has_more),
35 }
36}
37
38/** Single commit by sha — reuse the list endpoint so the payload shape (hex
39 * ids, unix times) matches everything else on the page. */
40export async function fetchCommit(space: string, repo: string, sha: string): Promise<CommitRow | null> {
41 const page = await listCommitsPage(space, repo, { revision: sha, skip: 0, limit: 1 })
42 return page.commits[0] ?? null
43}
44
45/** Per-file +/- counts for `base..head` (`view=numstat`; `shortstat` 500s on
46 * the server today, so totals are summed client-side). */
47export async function fetchNumstat(space: string, repo: string, base: string, head: string): Promise<NumstatRow[]> {
48 const data = await unwrapApi<unknown>(
49 Apis.diffs.get_diff({ pathParams: { space_name: space, repo_name: repo }, params: { base, head, view: 'numstat' } }),
50 )
51 return Array.isArray(data) ? (data as NumstatRow[]) : []
52}
53
54// TODO: crosses the repo/account boundary — inject the viewer's saved style
55// from the caller (or a preferences slice) instead of reaching into
56// Apis.account here. Kept as-is during the structural split.
57/** The viewer's saved diff layout. Read here rather than imported from
58 * another slice — features own their API surface. */
59export async function fetchDiffStyle(): Promise<DiffStyle> {
60 const prefs = await unwrapApi<{ diff_view_style?: string | null }>(Apis.account.get_preferences())
61 return normalizeDiffStyle(prefs?.diff_view_style)
62}
63
64/** Raw unified patch text for `base..head`. */
65export const fetchRawDiff = (space: string, repo: string, base: string, head: string) =>
66 unwrapApi<string>(
67 Apis.diffs.diff_raw({ pathParams: { space_name: space, repo_name: repo }, params: { base, head } }),
68 )
Deleted web/src/entities/repo/model/diff.ts
@@ -1,232 +0,0 @@
1/**
2 * Unified-diff parsing for the commit view.
3 *
4 * The backend exposes per-commit diffs as raw patch text
5 * (`GET /repos/{s}/{r}/diff/raw?base=&head=`) plus a numstat list, and there is
6 * no structured JSON diff — so we parse the patch here. File sections are kept
7 * as raw text and hunks are parsed lazily: an initial-import commit can carry
8 * megabytes of patch, and rendering every line of every file at once would
9 * freeze the page.
10 */
11
12import { getLocale, translate } from '@/shared/i18n'
13
14export type DiffFileStatus = 'added' | 'deleted' | 'renamed' | 'copied' | 'modified' | 'binary'
15
16export interface DiffFile {
17 path: string
18 oldPath: string | null
19 status: DiffFileStatus
20 /** Patch section for this file, parsed on demand by `parseHunks`. */
21 raw: string
22 additions: number
23 deletions: number
24}
25
26export interface DiffLine {
27 kind: 'context' | 'add' | 'del'
28 oldNo: number | null
29 newNo: number | null
30 text: string
31}
32
33export interface DiffHunk {
34 header: string
35 lines: DiffLine[]
36}
37
38/** Git's empty tree — the base for root commits (mirrors the server). */
39export const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
40
41/** `diff --git` appears at the start of a file section; the header line of the
42 * whole patch is empty, so we only split on real boundaries. */
43export function splitDiffFiles(text: string): DiffFile[] {
44 if (!text.trim()) return []
45 const chunks = text.split(/(?=^diff --git )/m).filter(chunk => chunk.startsWith('diff --git '))
46 return chunks.map(chunk => {
47 const header = /^diff --git a\/(.*?) b\/(.*?)$/m.exec(chunk)
48 let path = header ? unquoteGitPath(header[2] ?? '') : ''
49 let oldPath: string | null = header ? unquoteGitPath(header[1] ?? '') : null
50
51 const plus = /^\+\+\s+(.*)$/m.exec(chunk) ?? /^\+\+\+\s+(.*)$/m.exec(chunk)
52 const minus = /^---\s+(.*)$/m.exec(chunk)
53 const plusTarget = plus?.[1]?.trim() ?? ''
54 const minusTarget = minus?.[1]?.trim() ?? ''
55 if (plusTarget && plusTarget !== '/dev/null') path = stripAB(plusTarget)
56 if (minusTarget && minusTarget !== '/dev/null') oldPath = stripAB(minusTarget)
57 const renamed = /^rename from (.*)$/m.exec(chunk)
58 if (renamed) oldPath = unquoteGitPath(renamed[1].trim())
59
60 let status: DiffFileStatus = 'modified'
61 if (/^new file mode /m.test(chunk) || minusTarget === '/dev/null') status = 'added'
62 else if (/^deleted file mode /m.test(chunk) || plusTarget === '/dev/null') status = 'deleted'
63 else if (/^rename from /m.test(chunk)) status = 'renamed'
64 else if (/^copy from /m.test(chunk)) status = 'copied'
65 else if (/^Binary files /m.test(chunk)) status = 'binary'
66 // An added file has no predecessor to show.
67 if (status === 'added') oldPath = null
68
69 let additions = 0
70 let deletions = 0
71 for (const line of chunk.split('\n')) {
72 if (line.startsWith('+++') || line.startsWith('---')) continue
73 if (line.startsWith('+')) additions += 1
74 else if (line.startsWith('-')) deletions += 1
75 }
76 return {
77 path: path || translate(getLocale(), 'repo.unknownPath'),
78 oldPath,
79 status,
80 raw: chunk,
81 additions,
82 deletions,
83 }
84 })
85}
86
87/** `a/foo.py` / `"a/foo.py"` → `foo.py`. Git always prefixes the a/ b/ side,
88 * and the prefix can hide inside the quotes, so unquote first. */
89function stripAB(value: string): string {
90 const token = value.split(/\s/)[0] ?? ''
91 const unquoted = unquoteGitPath(token)
92 return unquoted === token ? token.replace(/^[ab]\//, '') : unquoted.replace(/^[ab]\//, '')
93}
94
95/**
96 * Git C-quotes paths with unusual bytes: `"caf\303\251.png"`, `"a\tb"`.
97 * Octal escapes are raw bytes, so decode them as UTF-8; anything we cannot
98 * parse is returned untouched rather than guessed at.
99 */
100export function unquoteGitPath(value: string): string {
101 if (!value.startsWith('"') || !value.endsWith('"')) return value
102 const inner = value.slice(1, -1)
103 if (!inner.includes('\\')) return inner
104 const bytes: number[] = []
105 for (let i = 0; i < inner.length; i += 1) {
106 const char = inner[i]
107 if (char !== '\\') {
108 bytes.push(...new TextEncoder().encode(char))
109 continue
110 }
111 const next = inner[i + 1]
112 const octal = /^\\([0-7]{1,3})/.exec(inner.slice(i))
113 if (octal) {
114 bytes.push(Number.parseInt(octal[1], 8))
115 i += octal[1].length
116 continue
117 }
118 const simple: Record<string, string> = { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', '"': '"', '\\': '\\' }
119 if (next && simple[next] !== undefined) {
120 bytes.push(...new TextEncoder().encode(simple[next]))
121 i += 1
122 continue
123 }
124 bytes.push(...new TextEncoder().encode(next ?? ''))
125 i += 1
126 }
127 return new TextDecoder('utf-8', { fatal: false }).decode(new Uint8Array(bytes))
128}
129
130/** Line cap so one giant file cannot take the page down. */
131export const MAX_DIFF_LINES = 1500
132
133export function parseHunks(raw: string, cap = MAX_DIFF_LINES): { hunks: DiffHunk[]; truncated: boolean } {
134 const hunks: DiffHunk[] = []
135 let current: DiffHunk | null = null
136 let oldNo = 0
137 let newNo = 0
138 let used = 0
139 let truncated = false
140 for (const line of raw.split('\n')) {
141 if (line.startsWith('@@')) {
142 const match = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
143 oldNo = match ? Number(match[1]) : 0
144 newNo = match ? Number(match[2]) : 0
145 current = { header: line, lines: [] }
146 hunks.push(current)
147 continue
148 }
149 if (!current) continue
150 if (line.startsWith('index ') || line.startsWith('\\ ') || /^(new|old|deleted|rename|copy|Binary|---|\+\+\+) /.test(line)) {
151 continue
152 }
153 if (line.startsWith('+')) {
154 current.lines.push({ kind: 'add', oldNo: null, newNo: newNo++, text: line.slice(1) })
155 used += 1
156 } else if (line.startsWith('-')) {
157 current.lines.push({ kind: 'del', oldNo: oldNo++, newNo: null, text: line.slice(1) })
158 used += 1
159 } else {
160 // Trailing-whitespace-stripped context lines arrive as an empty string.
161
162 current.lines.push({ kind: 'context', oldNo: oldNo++, newNo: newNo++, text: line.startsWith(' ') ? line.slice(1) : line })
163 used += 1
164 }
165 if (used >= cap) {
166 truncated = true
167 break
168 }
169 }
170 return { hunks, truncated }
171}
172
173/** numstat rows keyed by path — authoritative +/- counts (patch counting above
174 * is only a fallback for files numstat does not list). */
175export interface NumstatRow {
176 path: string
177 old_path?: string | null
178 additions: number
179 deletions: number
180}
181
182export function applyNumstat(files: DiffFile[], rows: NumstatRow[]): DiffFile[] {
183 if (!rows.length) return files
184 const byPath = new Map(rows.map(row => [row.path, row]))
185 return files.map(file => {
186 const row = byPath.get(file.path) ?? (file.oldPath ? byPath.get(file.oldPath) : undefined)
187 if (!row) return file
188 return { ...file, additions: row.additions, deletions: row.deletions }
189 })
190}
191
192/* ---- split (side-by-side) rendering ------------------------------------- */
193
194export interface SplitRow {
195 left: DiffLine | null
196 right: DiffLine | null
197}
198
199/**
200 * Pair a hunk's linear lines into side-by-side rows: a deletion followed by an
201 * addition becomes one row, context repeats on both sides, and lone adds/dels
202 * leave the opposite cell blank.
203 */
204export function toSplitRows(lines: DiffLine[]): SplitRow[] {
205 const rows: SplitRow[] = []
206 for (let i = 0; i < lines.length; i += 1) {
207 const line = lines[i]
208 if (line.kind === 'del') {
209 const next = lines[i + 1]
210 if (next?.kind === 'add') {
211 rows.push({ left: line, right: next })
212 i += 1
213 } else {
214 rows.push({ left: line, right: null })
215 }
216 continue
217 }
218 if (line.kind === 'add') {
219 rows.push({ left: null, right: line })
220 continue
221 }
222 rows.push({ left: line, right: line })
223 }
224 return rows
225}
226
227export type DiffStyle = 'unified' | 'split'
228
229/** Anything the server does not recognise behaves as unified there too. */
230export function normalizeDiffStyle(value: string | null | undefined): DiffStyle {
231 return value === 'split' ? 'split' : 'unified'
232}
Deleted web/src/entities/repo/model/fragment.ts
@@ -1,111 +0,0 @@
1/**
2 * HTML-fragment domain: url building and fetching of the server-rendered
3 * fragments under `/repos/{s}/{r}/html/*`, plus the `useHtmlFragment` hook.
4 *
5 * Fragments are maud-escaped (safe to inject) EXCEPT the README, which
6 * passes raw CommonMark HTML through — that one is sandboxed in an iframe.
7 */
8import { useEffect, useState } from 'react'
9import { trackFetch } from '@/shared/api'
10import { PREFETCH_FRESH_MS } from '@/shared/lib'
11import { peekCache, readCache, writeCache } from '@/shared/storage'
12
13const enc = encodeURIComponent
14
15export const repoBase = (space: string, repo: string) =>
16 `/api/v1/repos/${enc(space)}/${enc(repo)}`
17
18export function htmlFragmentUrl(
19 space: string,
20 repo: string,
21 fragment: string,
22 params: Record<string, string | number | undefined> = {},
23): string {
24 const q = new URLSearchParams()
25 for (const [key, value] of Object.entries(params)) {
26 if (value !== undefined && value !== '') q.set(key, String(value))
27 }
28 const qs = q.toString()
29 return `${repoBase(space, repo)}/html/${fragment}${qs ? `?${qs}` : ''}`
30}
31
32export interface HtmlFragment {
33 ok: boolean
34 status: number
35 html: string
36}
37
38/** Never throws on HTTP errors — callers branch on status (401 → JSON mode). */
39export async function fetchHtmlFragment(
40 url: string,
41 signal?: AbortSignal,
42): Promise<HtmlFragment> {
43 try {
44 const res = await trackFetch(url, { headers: { accept: 'text/html' }, signal })
45 const html = await res.text()
46 return { ok: res.ok, status: res.status, html }
47 } catch (err) {
48 if ((err as Error).name === 'AbortError') throw err
49 return { ok: false, status: 0, html: '' }
50 }
51}
52
53/**
54 * Fetch a server-rendered HTML fragment, local-first: a previously stored
55 * fragment for the same url paints immediately (first render when it is in
56 * the memory tier), the live fetch always runs and replaces it — mutable
57 * targets (a tree at a moving branch head) stay stale-while-revalidate, and
58 * only `ok` responses are ever stored. Returns `null` while nothing is
59 * available for the current url — stale results from another url never flash.
60 * Pass a cache-busting param to retry (it changes the url, hence the key).
61 */
62const htmlFragKey = (url: string) => `frag:${url}`
63
64/**
65 * Warm the fragment cache for one url (repo hover prefetch). Throttled by the
66 * same freshness window as `prefetch`, silent on failure, and only `ok`
67 * responses are stored — a 401/404 probe result must never be replayed.
68 */
69export async function prefetchHtmlFragment(url: string): Promise<void> {
70 const hit = peekCache<HtmlFragment>(htmlFragKey(url))
71 if (hit?.data.ok && Date.now() - hit.at < PREFETCH_FRESH_MS) return
72 try {
73 const frag = await fetchHtmlFragment(url)
74 if (frag.ok) writeCache(htmlFragKey(url), frag)
75 } catch {
76 /* a prefetch is an optimization; the live hook surfaces real failures */
77 }
78}
79
80export function useHtmlFragment(url: string | null): HtmlFragment | null {
81 const [state, setState] = useState<{ url: string; frag: HtmlFragment } | null>(() => {
82 if (!url) return null
83 const hit = peekCache<HtmlFragment>(htmlFragKey(url))
84 return hit?.data.ok ? { url, frag: hit.data } : null
85 })
86 useEffect(() => {
87 if (!url) return
88 let active = true
89 // Disk hydration: only fills in while this url still renders nothing, so
90 // it can never clobber the live response that usually wins the race.
91 void readCache<HtmlFragment>(htmlFragKey(url)).then(hit => {
92 if (!active || !hit?.data.ok) return
93 setState(prev => (prev && prev.url === url ? prev : { url, frag: hit.data }))
94 })
95 const controller = new AbortController()
96 fetchHtmlFragment(url, controller.signal).then(
97 frag => {
98 if (!active) return
99 if (frag.ok) writeCache(htmlFragKey(url), frag)
100 setState({ url, frag })
101 },
102 // Only aborts reject (fetchHtmlFragment absorbs HTTP/network errors).
103 () => {},
104 )
105 return () => {
106 active = false
107 controller.abort()
108 }
109 }, [url])
110 return state && state.url === url ? state.frag : null
111}
Deleted web/src/entities/repo/model/href.ts
@@ -1,103 +0,0 @@
1/**
2 * Web-path routing for server-rendered fragments.
3 *
4 * The HTML fragments link with forge-style web paths (`/demo/repo/tree/main/docs/`,
5 * `/demo/repo/commit/<sha>`, `/demo/repo/commits/main/app/main.py`,
6 * `/demo/repo` for the `..` row) — none of which exist as SPA routes. Map them
7 * back onto in-app state so every link in a fragment keeps working.
8 */
9
10export type WebTarget =
11 | { kind: 'tree'; path: string }
12 | { kind: 'blob'; path: string }
13 | { kind: 'commit'; sha: string }
14 | { kind: 'history'; path: string }
15 | { kind: 'up' }
16 | null
17
18export function parentPath(path: string): string {
19 return path.split('/').slice(0, -1).join('/')
20}
21
22/**
23 * Canonical in-app URL for a repo file, folder or any other repo view.
24 *
25 * GitHub-style README links are repo-relative (`../README.md`, `docs/`), but a
26 * `srcdoc` iframe has no URL of its own — it inherits the *parent document's*
27 * base, so the browser resolves them against `/app/<space>/repo/<repo>` and a
28 * copied or middle-clicked link loses the repo: `CHANGES.md` becomes
29 * `/app/minimax/repo/CHANGES.md`, whose last segment the router reads as the
30 * repo name. Rewriting every in-repo link to this absolute shape is what keeps
31 * "open in new tab" and "copy link address" honest.
32 */
33export function repoWebHref(
34 space: string,
35 repo: string,
36 parts: { path?: string; file?: string; rev?: string; tab?: string; extra?: Record<string, string> },
37): string {
38 const params = new URLSearchParams()
39 if (parts.rev) params.set('rev', parts.rev)
40 if (parts.tab && parts.tab !== 'code') params.set('tab', parts.tab)
41 if (parts.path) params.set('path', parts.path)
42 if (parts.file) params.set('file', parts.file)
43 for (const [key, value] of Object.entries(parts.extra ?? {})) params.set(key, value)
44 const query = params.toString()
45 const base = `/app/${encodeURIComponent(space)}/repo/${encodeURIComponent(repo)}`
46 return query ? `${base}?${query}` : base
47}
48
49/**
50 * Inverse of `repoWebHref`: read back an in-app repo URL produced by it.
51 *
52 * The README sandbox forwards its clicks to the host, and after link rewriting
53 * those hrefs are absolute app URLs rather than repo-relative paths, so the
54 * host needs to turn one back into a route. Returns null for any other URL
55 * (external site, another repo, a non-repo route).
56 */
57export function parseRepoAppHref(
58 href: string,
59 space: string,
60 repo: string,
61): { path?: string; file?: string; rev?: string; tab?: string } | null {
62 const prefix = `/app/${encodeURIComponent(space)}/repo/${encodeURIComponent(repo)}`
63 const [pathname, search = ''] = href.split('?')
64 if (pathname !== prefix) return null
65 const params = new URLSearchParams(search)
66 const out: { path?: string; file?: string; rev?: string; tab?: string } = {}
67 const path = params.get('path')
68 const file = params.get('file')
69 const rev = params.get('rev')
70 const tab = params.get('tab')
71 if (path) out.path = path
72 if (file) out.file = file
73 if (rev) out.rev = rev
74 if (tab) out.tab = tab
75 return out
76}
77
78export function parseWebHref(href: string, space: string, repo: string, rev: string): WebTarget {
79 const clean = href.split('#')[0].split('?')[0]
80 const prefix = `/${space}/${repo}`
81 if (clean === prefix || clean === `${prefix}/`) return { kind: 'up' }
82 if (!clean.startsWith(`${prefix}/`)) return null
83 let rest = clean.slice(prefix.length + 1)
84 try {
85 rest = decodeURIComponent(rest)
86 } catch {
87 /* leave the raw form when the author did not escape a % */
88 }
89 const match = /^(tree|blob|commits|commit)\/?([^/]*)\/?(.*)$/.exec(rest)
90 if (!match) return null
91 const [, kind, ref, tail] = match
92 if (kind === 'commit') {
93 // A commit link carries the full object id and no revision segment.
94 return /^[0-9a-f]{7,64}$/i.test(ref) ? { kind: 'commit', sha: ref.toLowerCase() } : null
95 }
96 // Never follow a link pinned to a different revision — it would silently
97 // change what "current revision" means for the rest of the page.
98 if (ref !== rev) return null
99 const path = tail.replace(/\/+$/, '')
100 if (kind === 'tree') return path ? { kind: 'tree', path } : null
101 if (kind === 'blob') return path ? { kind: 'blob', path } : null
102 return { kind: 'history', path }
103}
Deleted web/src/entities/repo/model/insights.ts
@@ -1,36 +0,0 @@
1/** Insights domain: language statistics, contributors and code activity. */
2import Apis from '@/shared/api'
3import { unwrapApi } from '@/shared/lib'
4import type {
5 AppResponseData_CodeActivityDto,
6 AppResponseData_Vec_ContributorStatDto,
7 AppResponseData_Vec_LanguageStatDto,
8} from '@/shared/api'
9
10export type LanguageStat = NonNullable<AppResponseData_Vec_LanguageStatDto['data']>[number]
11export type ContributorRow = NonNullable<AppResponseData_Vec_ContributorStatDto['data']>[number]
12export type CodeActivity = NonNullable<AppResponseData_CodeActivityDto['data']>
13
14export const readLanguageStats = (space: string, repo: string) =>
15 unwrapApi<LanguageStat[]>(
16 Apis.analysis.languages_read({ pathParams: { space_name: space, repo_name: repo } }),
17 )
18
19/** Rescans the tree and persists the result (also updates primary_language). */
20export const rescanLanguages = (space: string, repo: string) =>
21 unwrapApi<LanguageStat[]>(
22 Apis.analysis.languages({ pathParams: { space_name: space, repo_name: repo } }),
23 )
24
25export const listContributors = (space: string, repo: string) =>
26 unwrapApi<ContributorRow[]>(
27 Apis.analysis.contributors({ pathParams: { space_name: space, repo_name: repo } }),
28 )
29
30export const fetchCodeActivity = (space: string, repo: string, branch?: string) =>
31 unwrapApi<CodeActivity>(
32 Apis.analysis.code_activity({
33 pathParams: { space_name: space, repo_name: repo },
34 params: { branch: branch || undefined },
35 }),
36 )
Deleted web/src/entities/repo/model/lfs.ts
@@ -1,52 +0,0 @@
1/** LFS domain: lock list/create/unlock and the ours/theirs verify split. */
2import Apis from '@/shared/api'
3import { unwrapApi } from '@/shared/lib'
4import type {
5 AppResponseData_LfsLockListResponse,
6 AppResponseData_LfsVerifyLocksResponse,
7} from '@/shared/api'
8
9export type LfsLockRow = NonNullable<NonNullable<AppResponseData_LfsLockListResponse['data']>['locks']>[number]
10export type LfsLockPage = NonNullable<AppResponseData_LfsLockListResponse['data']>
11export type LfsVerifyPage = NonNullable<AppResponseData_LfsVerifyLocksResponse['data']>
12
13export const listLfsLocks = (space: string, repo: string, cursor?: string, limit = 50) =>
14 unwrapApi<LfsLockPage>(
15 Apis.lfs.list_locks({
16 pathParams: { space_name: space, repo_name: repo },
17 params: { cursor: cursor || undefined, limit },
18 }),
19 )
20
21export const createLfsLock = (space: string, repo: string, path: string, oid?: string) =>
22 unwrapApi<unknown>(
23 Apis.lfs.lock({
24 pathParams: { space_name: space, repo_name: repo },
25 data: { path, oid: oid || null },
26 }),
27 )
28
29export const unlockLfsPath = (space: string, repo: string, path: string, force = false) =>
30 unwrapApi<null>(
31 Apis.lfs.unlock({
32 pathParams: { space_name: space, repo_name: repo },
33 params: { path, force },
34 }),
35 )
36
37export const unlockLfsById = (space: string, repo: string, lockId: string, force = false) =>
38 unwrapApi<null>(
39 Apis.lfs.unlock_by_id({
40 pathParams: { space_name: space, repo_name: repo, lock_id: lockId },
41 params: { force },
42 }),
43 )
44
45/** Splits locks into ours / theirs, which is what `force` unlocks are for. */
46export const verifyLfsLocks = (space: string, repo: string, limit = 50) =>
47 unwrapApi<LfsVerifyPage>(
48 Apis.lfs.verify_locks({
49 pathParams: { space_name: space, repo_name: repo },
50 params: { limit },
51 }),
52 )
Deleted web/src/entities/repo/model/protection.ts
@@ -1,145 +0,0 @@
1/**
2 * Protection-rule domain: branch-protection policies (CRUD) and the push /
3 * merge / approval whitelists attached to them, plus the tag-protection
4 * whitelist endpoints (tag *policies* themselves have no HTTP surface yet —
5 * whitelist rows can only be managed against a known policy UUID).
6 */
7import Apis from '@/shared/api'
8import { unwrapApi, type ApiEnvelope } from '@/shared/lib'
9import type {
10 AppResponseData_BranchPolicyDto,
11 AppResponseData_Vec_BranchWhitelistDto,
12 AppResponseData_Vec_TagWhitelistDto,
13 WhitelistKind,
14} from '@/shared/api'
15
16export type { WhitelistKind }
17
18export type BranchPolicy = NonNullable<AppResponseData_BranchPolicyDto['data']>
19export type BranchWhitelistRow = NonNullable<
20 NonNullable<AppResponseData_Vec_BranchWhitelistDto['data']>[number]
21>
22export type TagWhitelistRow = NonNullable<
23 NonNullable<AppResponseData_Vec_TagWhitelistDto['data']>[number]
24>
25
26export const listPolicies = (space: string, repo: string) =>
27 unwrapApi<BranchPolicy[] | null>(Apis.protection.list_policies({ pathParams: { space_name: space, repo_name: repo } })).then(rows => rows ?? [])
28
29/** Fresh copy of one policy (edit dialog opens from the list row). */
30export const getPolicy = (space: string, repo: string, policyId: string) =>
31 unwrapApi<BranchPolicy>(Apis.protection.get_policy({ pathParams: { space_name: space, repo_name: repo, policy_id: policyId } }))
32
33export const createPolicy = (space: string, repo: string, cmd: CreatePolicyInput) =>
34 unwrapApi<BranchPolicy>(
35 Apis.protection.create_policy({
36 pathParams: { space_name: space, repo_name: repo },
37 data: {
38 branch_pattern: cmd.branch_pattern,
39 required_approvals: cmd.required_approvals,
40 enable_push: cmd.enable_push,
41 enable_force_push: cmd.enable_force_push,
42 enable_push_whitelist: cmd.enable_push_whitelist,
43 enable_merge_whitelist: cmd.enable_merge_whitelist,
44 enable_approvals_whitelist: cmd.enable_approvals_whitelist,
45 block_on_rejected_reviews: cmd.block_on_rejected_reviews,
46 block_on_change_requested_reviews: cmd.block_on_change_requested_reviews,
47 block_on_outdated_branch: cmd.block_on_outdated_branch,
48 dismiss_stale_approvals: cmd.dismiss_stale_approvals,
49 require_signed_commits: cmd.require_signed_commits,
50 protected_file_patterns: cmd.protected_file_patterns || null,
51 unprotected_file_patterns: cmd.unprotected_file_patterns || null,
52 },
53 }),
54 )
55
56export const updatePolicy = (space: string, repo: string, policyId: string, cmd: Partial<CreatePolicyInput>) =>
57 unwrapApi<BranchPolicy>(
58 Apis.protection.update_policy({
59 pathParams: { space_name: space, repo_name: repo, policy_id: policyId },
60 data: { ...cmd, protected_file_patterns: cmd.protected_file_patterns ?? null, unprotected_file_patterns: cmd.unprotected_file_patterns ?? null },
61 }),
62 )
63
64export const deletePolicy = (space: string, repo: string, policyId: string) =>
65 unwrapApi<null>(Apis.protection.delete_policy({ pathParams: { space_name: space, repo_name: repo, policy_id: policyId } }))
66
67export const listBranchWhitelists = (space: string, repo: string, policyId: string) =>
68 unwrapApi<BranchWhitelistRow[] | null>(
69 Apis.branch_protection.list_branch_whitelists({ pathParams: { space_name: space, repo_name: repo, policy_id: policyId } }),
70 ).then(rows => rows ?? [])
71
72export const addBranchWhitelist = (
73 space: string,
74 repo: string,
75 policyId: string,
76 cmd: { kind: WhitelistKind; user_id?: number | null; group_id?: string | null },
77) =>
78 unwrapApi<BranchWhitelistRow>(
79 Apis.branch_protection.add_branch_whitelist({
80 pathParams: { space_name: space, repo_name: repo, policy_id: policyId },
81 data: { kind: cmd.kind, user_id: cmd.user_id ?? null, group_id: cmd.group_id ?? null },
82 }),
83 )
84
85export const removeBranchWhitelist = (space: string, repo: string, policyId: string, whitelistId: string) =>
86 unwrapApi<null>(
87 Apis.branch_protection.remove_branch_whitelist({
88 pathParams: { space_name: space, repo_name: repo, policy_id: policyId, whitelist_id: whitelistId },
89 }),
90 )
91
92export const listTagWhitelists = (space: string, repo: string, policyId: string) =>
93 unwrapApi<TagWhitelistRow[] | null>(
94 Apis.tag_protection.list_tag_whitelists({ pathParams: { space_name: space, repo_name: repo, policy_id: policyId } }),
95 ).then(rows => rows ?? [])
96
97export const addTagWhitelist = (space: string, repo: string, policyId: string, userId: number) =>
98 unwrapApi<TagWhitelistRow>(
99 Apis.tag_protection.add_tag_whitelist({
100 pathParams: { space_name: space, repo_name: repo, policy_id: policyId },
101 data: { user_id: userId },
102 }),
103 )
104
105export const removeTagWhitelist = (space: string, repo: string, policyId: string, userId: number) =>
106 unwrapApi<null>(
107 Apis.tag_protection.remove_tag_whitelist({
108 pathParams: { space_name: space, repo_name: repo, policy_id: policyId, user_id: userId },
109 }),
110 )
111
112/** Create/update payload minus server-managed fields. */
113export interface CreatePolicyInput {
114 branch_pattern: string
115 required_approvals?: number | null
116 enable_push?: boolean | null
117 enable_force_push?: boolean | null
118 enable_push_whitelist?: boolean | null
119 enable_merge_whitelist?: boolean | null
120 enable_approvals_whitelist?: boolean | null
121 block_on_rejected_reviews?: boolean | null
122 block_on_change_requested_reviews?: boolean | null
123 block_on_outdated_branch?: boolean | null
124 dismiss_stale_approvals?: boolean | null
125 require_signed_commits?: boolean | null
126 protected_file_patterns?: string | null
127 unprotected_file_patterns?: string | null
128}
129
130/** Space members as user options for the whitelist pickers. */
131export interface UserOption {
132 user_id: string
133 username: string
134 display_name: string
135}
136
137export const listUserOptions = async (space: string): Promise<UserOption[]> => {
138 // The generic Page schema types `items` structurally; members rows are cast.
139 const page = await unwrapApi<{ items?: { user?: UserOption | null }[] } | null>(
140 Apis.space.list_space_members({ pathParams: { space_name: space }, params: { limit: 100 } }) as unknown as Promise<
141 ApiEnvelope<{ items?: { user?: UserOption | null }[] } | null>
142 >,
143 )
144 return (page?.items ?? []).map(row => row.user).filter((u): u is UserOption => Boolean(u?.user_id))
145}
Deleted web/src/entities/repo/model/refs.ts
@@ -1,106 +0,0 @@
1/**
2 * Refs domain: branches and tags — wire types and endpoints. The branch list
3 * is the source of truth for the ref picker and the branch management tab.
4 */
5import Apis from '@/shared/api'
6import { unwrapApi } from '@/shared/lib'
7import type {
8 AppResponseData_Page_BranchDto,
9 AppResponseData_Page_TagDto,
10} from '@/shared/api'
11
12export type BranchRow = NonNullable<
13 NonNullable<AppResponseData_Page_BranchDto['data']>['items']
14>[number]
15export type TagRow = NonNullable<NonNullable<AppResponseData_Page_TagDto['data']>['items']>[number]
16export type RefKind = 'branch' | 'tag'
17
18/** Server-side total matters: `RepoDto.branches_count` can lag behind the
19 * actual refs (imported repos), and the branch list is the truth. */
20export async function listBranches(
21 space: string,
22 repo: string,
23 limit = 100,
24): Promise<{ items: BranchRow[]; total: number }> {
25 const page = await unwrapApi<{ items?: BranchRow[]; total?: number }>(
26 Apis.branches.list_branches({
27 pathParams: { space_name: space, repo_name: repo },
28 params: { limit },
29 }),
30 )
31 const items = page.items ?? []
32 return { items, total: page.total ?? items.length }
33}
34
35export const createBranch = (space: string, repo: string, name: string, startPoint: string) =>
36 unwrapApi<BranchRow>(
37 Apis.branches.create_branch({
38 pathParams: { space_name: space, repo_name: repo },
39 data: { name, start_point: startPoint || null },
40 }),
41 )
42
43export const renameBranch = (space: string, repo: string, name: string, newName: string) =>
44 unwrapApi<null>(
45 Apis.branches.rename_branch({
46 pathParams: { space_name: space, repo_name: repo, name },
47 data: { new_name: newName },
48 }),
49 )
50
51/** `force` is what lets the server delete a branch nothing else contains. */
52export const deleteBranch = (space: string, repo: string, name: string, force = false) =>
53 unwrapApi<null>(
54 Apis.branches.delete_branch({
55 pathParams: { space_name: space, repo_name: repo, name },
56 params: { force },
57 }),
58 )
59
60export const setDefaultBranch = (space: string, repo: string, name: string) =>
61 unwrapApi<null>(
62 Apis.branches.set_default_branch({
63 pathParams: { space_name: space, repo_name: repo, name },
64 }),
65 )
66
67export async function listTags(
68 space: string,
69 repo: string,
70 limit = 100,
71): Promise<{ items: TagRow[]; total: number }> {
72 const page = await unwrapApi<{ items?: TagRow[]; total?: number }>(
73 Apis.tags.list_tags({
74 pathParams: { space_name: space, repo_name: repo },
75 params: { limit },
76 }),
77 )
78 const items = page.items ?? []
79 return { items, total: page.total ?? items.length }
80}
81
82/** Single tag incl. tagger/message for annotated tags. */
83export const fetchTag = (space: string, repo: string, name: string) =>
84 unwrapApi<TagRow>(Apis.tags.get_tag({ pathParams: { space_name: space, repo_name: repo, name } }))
85
86export const createTag = (
87 space: string,
88 repo: string,
89 cmd: { name: string; target: string; message?: string | null; annotated?: boolean },
90) =>
91 unwrapApi<TagRow>(
92 Apis.tags.create_tag({
93 pathParams: { space_name: space, repo_name: repo },
94 data: {
95 name: cmd.name,
96 target: cmd.target,
97 message: cmd.message ?? null,
98 annotated: cmd.annotated ?? false,
99 },
100 }),
101 )
102
103export const deleteTag = (space: string, repo: string, name: string) =>
104 unwrapApi<null>(
105 Apis.tags.delete_tag({ pathParams: { space_name: space, repo_name: repo, name } }),
106 )
Deleted web/src/entities/repo/model/releases.ts
@@ -1,53 +0,0 @@
1/** 发布记录领域:仓库 release 的读写。 */
2import Apis from '@/shared/api'
3import { unwrapApi } from '@/shared/lib'
4import type { AppResponseData_ReleaseDto, CreateReleaseCmd, UpdateReleaseCmd } from '@/shared/api'
5
6export type Release = NonNullable<AppResponseData_ReleaseDto['data']>
7
8/** 生成器把分页响应用泛型 `Page` 表达,因此在这里声明行类型。 */
9export interface ReleaseListPage {
10 items: Release[]
11 total: number
12 limit: number
13 offset: number
14}
15
16export const listReleases = (
17 space: string,
18 repo: string,
19 params: { limit?: number; offset?: number; draft?: boolean } = {},
20) =>
21 Apis.releases
22 .list_releases({
23 pathParams: { space_name: space, repo_name: repo },
24 params: { limit: params.limit, offset: params.offset, draft: params.draft },
25 })
26 .then(res => res as unknown as { data?: ReleaseListPage })
27 .then(res => res.data ?? { items: [], total: 0, limit: 0, offset: 0 })
28
29export const getRelease = (space: string, repo: string, tag: string) =>
30 unwrapApi<Release>(
31 Apis.releases.get_release({ pathParams: { space_name: space, repo_name: repo, tag } }),
32 )
33
34export const createRelease = (space: string, repo: string, cmd: CreateReleaseCmd) =>
35 unwrapApi<Release>(
36 Apis.releases.create_release({
37 pathParams: { space_name: space, repo_name: repo },
38 data: cmd,
39 }),
40 )
41
42export const updateRelease = (space: string, repo: string, tag: string, cmd: UpdateReleaseCmd) =>
43 unwrapApi<Release>(
44 Apis.releases.update_release({
45 pathParams: { space_name: space, repo_name: repo, tag },
46 data: cmd,
47 }),
48 )
49
50export const deleteRelease = (space: string, repo: string, tag: string) =>
51 unwrapApi<unknown>(
52 Apis.releases.delete_release({ pathParams: { space_name: space, repo_name: repo, tag } }),
53 )
Deleted web/src/entities/repo/model/remotes.ts
@@ -1,61 +0,0 @@
1/**
2 * Remotes domain: remote CRUD plus fetch / push / mirror-import.
3 * Tokens are per-request only — the server never stores them.
4 */
5import Apis from '@/shared/api'
6import { unwrapApi } from '@/shared/lib'
7import type {
8 AppResponseData_FetchResultDto,
9 AppResponseData_ImportResultDto,
10 AppResponseData_PushResultDto,
11 AppResponseData_Vec_RemoteDto,
12} from '@/shared/api'
13
14export type RemoteRow = NonNullable<AppResponseData_Vec_RemoteDto['data']>[number]
15export type FetchResult = NonNullable<AppResponseData_FetchResultDto['data']>
16export type PushResult = NonNullable<AppResponseData_PushResultDto['data']>
17export type ImportResult = NonNullable<AppResponseData_ImportResultDto['data']>
18
19export const listRemotes = (space: string, repo: string) =>
20 unwrapApi<RemoteRow[]>(
21 Apis.remotes.list_remotes({ pathParams: { space_name: space, repo_name: repo } }),
22 )
23
24export const upsertRemote = (space: string, repo: string, name: string, url: string) =>
25 unwrapApi<null>(
26 Apis.remotes.upsert_remote({
27 pathParams: { space_name: space, repo_name: repo, name },
28 data: { url },
29 }),
30 )
31
32export const removeRemote = (space: string, repo: string, name: string) =>
33 unwrapApi<null>(
34 Apis.remotes.remove({ pathParams: { space_name: space, repo_name: repo, name } }),
35 )
36
37/** `token` is sent per request only — it is never stored on the server. */
38export const fetchRemote = (space: string, repo: string, name: string, token?: string) =>
39 unwrapApi<FetchResult>(
40 Apis.remotes.fetch({
41 pathParams: { space_name: space, repo_name: repo },
42 data: { remote: name, auth: token ? { token } : null },
43 }),
44 )
45
46export const pushRemote = (space: string, repo: string, name: string, refspec?: string, token?: string) =>
47 unwrapApi<PushResult>(
48 Apis.remotes.push({
49 pathParams: { space_name: space, repo_name: repo },
50 data: { remote: name, refspec: refspec || null, auth: token ? { token } : null },
51 }),
52 )
53
54/** Mirror-import every ref from an external URL into this repo. */
55export const importRemote = (space: string, repo: string, url: string, token?: string) =>
56 unwrapApi<ImportResult>(
57 Apis.remotes.clone_import({
58 pathParams: { space_name: space, repo_name: repo },
59 data: { url, auth: token ? { token } : null },
60 }),
61 )
Deleted web/src/entities/repo/model/repo.ts
@@ -1,104 +0,0 @@
1/**
2 * Repository metadata domain: wire type, JSON endpoints and clone-url helpers.
3 */
4import Apis from '@/shared/api'
5import { unwrapApi } from '@/shared/lib'
6import type {
7 AppResponseData_Page_RepoSummaryDto,
8 AppResponseData_RepoDto,
9 UpdateRepoCmd,
10 WatchMode,
11} from '@/shared/api'
12
13export type RepoDetail = NonNullable<AppResponseData_RepoDto['data']>
14
15/** Row of the space's repo list (`GET /repos/{space}`). */
16export type RepoSummary = NonNullable<
17 NonNullable<AppResponseData_Page_RepoSummaryDto['data']>['items']
18>[number]
19
20/**
21 * Every repo the viewer can see in one space, name-sorted.
22 *
23 * The endpoint paginates, but the pickers that need this list (linking a PR to
24 * an issue, choosing an import target) address a repo by name and cannot work
25 * against a page, so the whole list is requested in one go.
26 */
27export const listSpaceRepos = (space: string) =>
28 unwrapApi<{ items?: RepoSummary[] }>(
29 Apis.repos.list_repos({
30 pathParams: { space_name: space },
31 params: { limit: 100, sort: 'name' },
32 }),
33 ).then(page => page?.items ?? [])
34
35export const fetchRepo = (space: string, repo: string) =>
36 unwrapApi<RepoDetail>(
37 Apis.repos.get_repo({ pathParams: { space_name: space, repo_name: repo } }),
38 )
39
40export const updateRepo = (space: string, repo: string, cmd: UpdateRepoCmd) =>
41 unwrapApi<RepoDetail>(
42 Apis.repos.update_repo({ pathParams: { space_name: space, repo_name: repo }, data: cmd }),
43 )
44
45export const archiveRepo = (space: string, repo: string) =>
46 unwrapApi<unknown>(
47 Apis.repos.archive_repo({ pathParams: { space_name: space, repo_name: repo } }),
48 )
49
50export const unarchiveRepo = (space: string, repo: string) =>
51 unwrapApi<unknown>(
52 Apis.repos.unarchive_repo({ pathParams: { space_name: space, repo_name: repo } }),
53 )
54
55export const starRepo = (space: string, repo: string) =>
56 unwrapApi<unknown>(
57 Apis.repos.star_repo({ pathParams: { space_name: space, repo_name: repo } }),
58 )
59
60export const unstarRepo = (space: string, repo: string) =>
61 unwrapApi<unknown>(
62 Apis.repos.unstar_repo({ pathParams: { space_name: space, repo_name: repo } }),
63 )
64
65export const setRepoWatch = (space: string, repo: string, mode: WatchMode) =>
66 unwrapApi<unknown>(
67 Apis.repos.upsert_watch({
68 pathParams: { space_name: space, repo_name: repo },
69 data: { mode },
70 }),
71 )
72
73/* ---- clone urls ---------------------------------------------------------- */
74
75export interface CloneTarget {
76 /** Wire id from the API (`https` covers the http:// scheme too). */
77 protocol: 'https' | 'ssh'
78 label: string
79 url: string
80}
81
82/**
83 * Clone addresses as the backend reports them (`RepoDto.http_clone_url` /
84 * `ssh_clone_url`, derived from `[git].base_url` / `http_clone_base_url` /
85 * `ssh_clone_*`). Never fabricate them client-side — the git transport may
86 * listen on another host/port than the web app.
87 */
88export function cloneTargets(repo: {
89 http_clone_url?: string | null
90 ssh_clone_url?: string | null
91}): CloneTarget[] {
92 const targets: CloneTarget[] = []
93 if (repo.http_clone_url) targets.push({ protocol: 'https', label: 'HTTPS', url: repo.http_clone_url })
94 if (repo.ssh_clone_url) targets.push({ protocol: 'ssh', label: 'SSH', url: repo.ssh_clone_url })
95 return targets
96}
97
98/** Best url for command snippets: HTTPS first (works without a key setup). */
99export function primaryCloneUrl(
100 repo: { http_clone_url?: string | null; ssh_clone_url?: string | null },
101 fallback: string | null = null,
102): string | null {
103 return cloneTargets(repo)[0]?.url ?? fallback
104}
Deleted web/src/entities/repo/model/topics.ts
@@ -1,26 +0,0 @@
1/** Topics domain: repo topic chips — wire type and endpoints. */
2import Apis from '@/shared/api'
3import { unwrapApi } from '@/shared/lib'
4import type { AppResponseData_Vec_TopicDto } from '@/shared/api'
5
6export type TopicRow = NonNullable<AppResponseData_Vec_TopicDto['data']>[number]
7
8export const listTopics = (space: string, repo: string) =>
9 unwrapApi<TopicRow[]>(
10 Apis.repos.list_repo_topics({ pathParams: { space_name: space, repo_name: repo } }),
11 )
12
13/** The server creates the topic on demand, so adding is idempotent. */
14export const addTopic = (space: string, repo: string, topic: string) =>
15 unwrapApi<TopicRow>(
16 Apis.repos.add_topic_to_repo({
17 pathParams: { space_name: space, repo_name: repo, topic_name: topic },
18 }),
19 )
20
21export const removeTopic = (space: string, repo: string, topic: string) =>
22 unwrapApi<null>(
23 Apis.repos.remove_topic_from_repo({
24 pathParams: { space_name: space, repo_name: repo, topic_name: topic },
25 }),
26 )
Deleted web/src/entities/repo/model/tree.ts
@@ -1,106 +0,0 @@
1/**
2 * Tree & blob domain: wire types, row normalization and the tree/blob
3 * endpoints.
4 *
5 * `GET /repos/{s}/{r}/tree/{rev}` is serialised as a bare `serde_json::Value`
6 * on the server, so the generated client types it as `unknown`. The real shape
7 * comes from gitbaby's `TreeEntry`:
8 * • `oid` → `{ "Sha1": [20 raw bytes] }` (or `Sha256`), not hex — and the
9 * blob endpoints only accept the full hex oid;
10 * • `name` → already repo-root-relative (`assets/logo.jpg`), so joining it
11 * onto the requested directory again yields `assets/assets/…`.
12 * Normalise both once, here.
13 */
14import Apis from '@/shared/api'
15import { unwrapApi } from '@/shared/lib'
16import type { AppResponseData_BlobContentDto } from '@/shared/api'
17
18export type BlobPayload = NonNullable<AppResponseData_BlobContentDto['data']>
19
20/** gitbaby `TreeEntry` wire mode tag. */
21export type TreeMode = 'Blob' | 'Exec' | 'Symlink' | 'Tree' | 'Submodule' | 'Commit'
22
23/** Normalized tree row — see `normalizeTreeRows` for what the raw payload looks like. */
24export interface TreeEntry {
25 /** Repo-root-relative path. */
26 path: string
27 /** Basename, for display. */
28 name: string
29 mode: TreeMode
30 /** Full hex object id, or null when the server sent something unparseable. */
31 oid: string | null
32 size: number | null
33}
34
35const MODES: TreeMode[] = ['Blob', 'Exec', 'Symlink', 'Tree', 'Submodule', 'Commit']
36
37function bytesToHex(value: unknown): string | null {
38 if (!Array.isArray(value) || value.length === 0) return null
39 let out = ''
40 for (const byte of value) {
41 if (typeof byte !== 'number' || !Number.isInteger(byte) || byte < 0 || byte > 255) return null
42 out += byte.toString(16).padStart(2, '0')
43 }
44 return out
45}
46
47/** `{ Sha1: […] }` / `[…]` / plain hex string → lowercase hex oid. */
48function oidToHex(oid: unknown): string | null {
49 if (typeof oid === 'string') return /^[0-9a-f]{4,64}$/i.test(oid) ? oid.toLowerCase() : null
50 const direct = bytesToHex(oid)
51 if (direct) return direct
52 if (oid && typeof oid === 'object' && !Array.isArray(oid)) {
53 for (const value of Object.values(oid as Record<string, unknown>)) {
54 const hex = bytesToHex(value)
55 if (hex) return hex
56 }
57 }
58 return null
59}
60
61function basename(path: string): string {
62 const trimmed = path.replace(/\/+$/, '')
63 const slash = trimmed.lastIndexOf('/')
64 return slash === -1 ? trimmed : trimmed.slice(slash + 1)
65}
66
67function normalizeTreeRows(rows: unknown, dir: string): TreeEntry[] {
68 if (!Array.isArray(rows)) return []
69 const out: TreeEntry[] = []
70 for (const raw of rows) {
71 if (!raw || typeof raw !== 'object') continue
72 const entry = raw as Record<string, unknown>
73 const rawName = typeof entry.name === 'string' ? entry.name.trim() : ''
74 if (!rawName || rawName === '.') continue
75 const path = dir && !rawName.startsWith(`${dir}/`) ? `${dir}/${rawName}` : rawName
76 const mode = MODES.includes(entry.mode as TreeMode) ? (entry.mode as TreeMode) : 'Blob'
77 out.push({
78 path,
79 name: basename(path),
80 mode,
81 oid: oidToHex(entry.oid),
82 size: typeof entry.size === 'number' ? entry.size : null,
83 })
84 }
85 return out
86}
87
88export async function fetchTreeJson(
89 space: string,
90 repo: string,
91 revision: string,
92 path: string,
93): Promise<TreeEntry[]> {
94 const data = await unwrapApi<unknown>(
95 Apis.trees.get_tree({
96 pathParams: { space_name: space, repo_name: repo, revision },
97 params: { path, show_trees: true },
98 }),
99 )
100 return normalizeTreeRows(data, path)
101}
102
103export const fetchBlob = (space: string, repo: string, oid: string) =>
104 unwrapApi<BlobPayload>(
105 Apis.blobs.blob_content({ pathParams: { space_name: space, repo_name: repo, oid } }),
106 )
Deleted web/src/entities/repo/ui/embed.css
@@ -1,373 +0,0 @@
1/*
2 * Unifies server-rendered HTML fragments (`[data-component="git-*"]`) with
3 * the app-shell design language. Colors come from the same `--shell-*`
4 * tokens the React components use; the README sandbox iframe receives a copy
5 * of those variables injected at runtime, so fragments and app chrome share
6 * one palette (light and dark).
7 */
8.repo-embed {
9 font-size: 13px;
10 line-height: 1.55;
11 color: var(--shell-ink, #1c1e22);
12 word-break: break-word;
13}
14/*
15 * Server fragments are laid out against the column they are mounted in, so
16 * that column is the query container; it also owns the horizontal pan when a
17 * fragment cannot fit at all.
18 */
19.repo-embed-scroll {
20 container-type: inline-size;
21 overflow-x: auto;
22}
23
24/* ---------- file tree -------------------------------------------------- */
25.repo-embed [data-component='git-tree'] {
26 border: 2px solid var(--shell-hairline, #dde0e6);
27 border-radius: 10px;
28 background: var(--shell-surface, #fff);
29 overflow: hidden;
30 /* Fixed layout keeps the three lanes from trading places; the width itself
31 comes from the container query below so a squeezed desktop column and a
32 phone get the same readable result. */
33 table-layout: fixed;
34}
35.repo-embed [data-component='git-tree'] thead {
36 background: var(--shell-field, #f2f3f6);
37}
38.repo-embed [data-component='git-tree'] th {
39 text-align: left;
40 font-family: ui-monospace, monospace;
41 font-size: 10px;
42 font-weight: 700;
43 text-transform: uppercase;
44 letter-spacing: 1.2px;
45 color: var(--shell-ink-muted, #8b8f99);
46 padding: 8px 12px;
47 white-space: nowrap;
48}
49/* Name keeps a share of the row, the commit column takes the rest and the
50 date stays a fixed lane so the three never trade places while scrolling. */
51.repo-embed [data-component='git-tree'] th:nth-child(1) { width: 34%; }
52.repo-embed [data-component='git-tree'] th:nth-child(3) { width: 88px; }
53.repo-embed [data-component='git-tree'] td:nth-child(2) a {
54 display: flex;
55 min-width: 0;
56 align-items: center;
57 gap: 8px;
58}
59.repo-embed [data-component='git-tree'] td:nth-child(2) a > span:last-child {
60 min-width: 0;
61 max-width: 300px;
62 overflow: hidden;
63 text-overflow: ellipsis;
64 white-space: nowrap;
65}
66/* The short sha is a fixed chip: it must never be squeezed into a vertical stack. */
67.repo-embed [data-component='git-tree'] td:nth-child(2) code {
68 flex: none;
69 white-space: nowrap;
70}
71.repo-embed [data-component='git-tree'] td:nth-child(1) a {
72 display: flex;
73 min-width: 0;
74 align-items: center;
75 gap: 7px;
76}
77.repo-tree-icon {
78 display: inline-flex;
79 width: 16px;
80 height: 16px;
81 flex: none;
82 color: var(--shell-ink-muted, #8b8f99);
83}
84.repo-tree-icon svg,
85.repo-tree-icon img {
86 display: block;
87 width: 16px;
88 height: 16px;
89}
90.repo-tree-name {
91 min-width: 0;
92 overflow: hidden;
93 text-overflow: ellipsis;
94 white-space: nowrap;
95}
96.repo-embed [data-component='git-tree'] th:last-child {
97 text-align: right;
98}
99.repo-embed [data-component='git-tree'] td {
100 padding: 7px 12px;
101 border-top: 1px solid var(--shell-hairline, #dde0e6);
102 vertical-align: middle;
103}
104.repo-embed [data-component='git-tree'] tbody tr:hover > td {
105 background: color-mix(in srgb, var(--shell-active, #dee1e8) 45%, transparent);
106}
107.repo-embed [data-component='git-tree'] td:last-child {
108 text-align: right;
109 font-size: 12px;
110 color: var(--shell-ink-muted, #8b8f99);
111 white-space: nowrap;
112}
113
114/* ---------- links, chips, code (shared) --------------------------------- */
115.repo-embed a {
116 color: inherit;
117 text-decoration: none;
118}
119.repo-embed a:hover {
120 text-decoration: underline;
121}
122.repo-embed [data-component='git-tree'] code,
123.repo-embed .markdown-body code {
124 background: var(--shell-active, #dee1e8);
125 border-radius: 4px;
126 padding: 1px 6px;
127 font-family: var(--font-mono, ui-monospace, monospace);
128 font-size: 11px;
129 text-decoration: none;
130}
131.repo-embed [data-component='git-tree'] td:first-child a {
132 font-weight: 600;
133}
134
135/* ---------- blob viewer -------------------------------------------------- */
136.repo-embed [data-component='git-blob-content'] {
137 border: 2px solid var(--shell-hairline, #dde0e6);
138 border-radius: 10px;
139 background: var(--shell-surface, #fff);
140 overflow: hidden;
141}
142.repo-embed [data-component='git-blob-content'] table {
143 width: 100%;
144 border-collapse: collapse;
145 font-family: ui-monospace, monospace;
146 font-size: 12px;
147}
148.repo-embed [data-component='git-blob-content'] tr[id^='L'] > td:first-child {
149 width: 3.5rem;
150 min-width: 3.5rem;
151 text-align: right;
152 padding: 1px 10px;
153 color: var(--shell-ink-muted, #8b8f99);
154 background: var(--shell-field, #f2f3f6);
155 border-right: 1px solid var(--shell-hairline, #dde0e6);
156 user-select: none;
157}
158.repo-embed [data-component='git-blob-content'] tr[id^='L'] > td:last-child {
159 white-space: pre;
160 padding: 1px 14px;
161}
162.repo-embed [data-component='git-blob-content'] tr[id^='L']:hover > td {
163 background: color-mix(in srgb, var(--shell-active, #dee1e8) 40%, transparent);
164}
165.repo-embed [data-component='git-blob-content'] tr[id^='L']:hover > td:first-child {
166 color: var(--shell-ink, #1c1e22);
167}
168
169/* ---------- language card (already framed by the aside card) ------------- */
170.repo-embed [data-component='git-languages'] {
171 border: 0;
172 border-radius: 0;
173 background: transparent;
174 padding: 0;
175 font-size: 12px;
176}
177.repo-embed [data-component='git-languages'] > div:first-of-type {
178 display: none; /* the aside card has its own heading */
179}
180/*
181 * Geometry is spelled out here rather than inherited from the app's Tailwind
182 * build: fragment utility classes only work when we happen to use the same
183 * class in our own components, which is not a contract worth relying on.
184 */
185.repo-embed [data-component='git-languages'] > div:nth-of-type(2) {
186 display: flex;
187 flex-wrap: nowrap;
188 height: 8px;
189 width: 100%;
190 overflow: hidden;
191 border-radius: 9999px;
192 background: var(--shell-field, #f2f3f6);
193}
194.repo-embed [data-component='git-languages'] > div:nth-of-type(2) > div { height: 100%; }
195/* Legend: one language per row, name left / share right. */
196.repo-embed [data-component='git-languages'] > div:last-of-type {
197 display: flex;
198 flex-direction: column;
199 flex-wrap: nowrap;
200 align-items: stretch;
201 gap: 3px;
202 padding-top: 6px;
203}
204.repo-embed [data-component='git-languages'] > div:last-of-type > div {
205 display: flex;
206 align-items: center;
207 gap: 6px;
208 min-width: 0;
209}
210.repo-embed [data-component='git-languages'] > div:last-of-type span:first-child {
211 width: 8px;
212 height: 8px;
213 flex: none;
214 border-radius: 9999px;
215}
216.repo-embed [data-component='git-languages'] > div:last-of-type span:nth-child(2) {
217 min-width: 0;
218 overflow: hidden;
219 text-overflow: ellipsis;
220 white-space: nowrap;
221 color: var(--shell-ink, #1c1e22);
222 font-weight: 500;
223}
224.repo-embed [data-component='git-languages'] > div:last-of-type span:last-child {
225 margin-left: auto;
226 padding-left: 8px;
227 flex: none;
228 color: var(--shell-ink-muted, #8b8f99);
229 font-variant-numeric: tabular-nums;
230}
231
232/* ---------- last-commit strip / error placeholders ----------------------- */
233.repo-embed [data-component='git-last-commit'] {
234 border: 0;
235 border-radius: 0;
236 padding: 2px 0;
237 background: transparent;
238}
239.repo-embed [data-component='git-err'] {
240 color: var(--shell-ink-muted, #8b8f99);
241 font-style: italic;
242}
243
244/* ---------- readme typography ------------------------------------------- */
245.repo-embed .markdown-body,
246.repo-embed [data-component='git-readme'] {
247 font-family: var(--font-sans, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, 'Noto Sans', Ubuntu, Cantarell, 'Helvetica Neue', Arial, 'PingFang SC', 'Source Han Sans SC', 'Microsoft YaHei UI', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif);
248}
249.repo-embed .markdown-body h1,
250.repo-embed [data-component='git-readme'] h1 {
251 font-family: inherit;
252 font-size: 24px;
253 font-weight: 700;
254 letter-spacing: -.01em;
255 border-bottom: 1px solid var(--shell-hairline, #dde0e6);
256 padding-bottom: 0.3em;
257 margin: 0.6em 0 0.5em;
258}
259.repo-embed .markdown-body h2,
260.repo-embed [data-component='git-readme'] h2 {
261 font-family: inherit;
262 font-size: 17.5px;
263 font-weight: 700;
264 letter-spacing: -.01em;
265 border-bottom: 1px solid var(--shell-hairline, #dde0e6);
266 padding-bottom: 0.25em;
267 margin: 1.4em 0 0.5em;
268}
269.repo-embed .markdown-body h3,
270.repo-embed [data-component='git-readme'] h3 {
271 font-family: inherit;
272 font-size: 15px;
273 font-weight: 600;
274 margin: 1.3em 0 0.4em;
275}
276.repo-embed .markdown-body h4,
277.repo-embed [data-component='git-readme'] h4 {
278 font-family: inherit;
279 font-size: 13.5px;
280 font-weight: 600;
281 margin: 1.2em 0 0.35em;
282}
283.repo-embed .markdown-body p,
284.repo-embed [data-component='git-readme'] p { margin: 0.55em 0; }
285.repo-embed .markdown-body a,
286.repo-embed [data-component='git-readme'] a {
287 color: var(--shell-ink, #23272b);
288}
289.repo-embed .markdown-body ul, .repo-embed .markdown-body ol,
290.repo-embed [data-component='git-readme'] ul,
291.repo-embed [data-component='git-readme'] ol { padding-left: 1.6em; margin: 0.55em 0; }
292.repo-embed .markdown-body blockquote,
293.repo-embed [data-component='git-readme'] blockquote {
294 margin: 0.7em 0;
295 padding: 0.15em 1em;
296 border-left: 3px solid var(--shell-active, #dee1e8);
297 color: var(--shell-ink-muted, #8b8f99);
298}
299.repo-embed .markdown-body pre,
300.repo-embed [data-component='git-readme'] pre {
301 background: var(--shell-field, #f2f3f6);
302 border: 1px solid var(--shell-hairline, #dde0e6);
303 border-radius: 8px;
304 padding: 12px;
305 overflow-x: auto;
306 font-family: var(--font-mono, ui-monospace, monospace);
307 font-size: 12.5px;
308 line-height: 1.65;
309 white-space: pre;
310}
311.repo-embed .markdown-body pre code,
312.repo-embed [data-component='git-readme'] pre code {
313 background: transparent;
314 padding: 0;
315 font-size: inherit;
316}
317.repo-embed .markdown-body table,
318.repo-embed [data-component='git-readme'] table {
319 border-collapse: collapse;
320 margin: 0.7em 0;
321}
322.repo-embed .markdown-body th, .repo-embed .markdown-body td,
323.repo-embed [data-component='git-readme'] th,
324.repo-embed [data-component='git-readme'] td {
325 border: 1px solid var(--shell-hairline, #dde0e6);
326 padding: 6px 10px;
327}
328.repo-embed .markdown-body th,
329.repo-embed [data-component='git-readme'] th { background: var(--shell-field, #f2f3f6); }
330.repo-embed .markdown-body img,
331.repo-embed [data-component='git-readme'] img { max-width: 100%; }
332.repo-embed .markdown-body hr,
333.repo-embed [data-component='git-readme'] hr {
334 border: none;
335 border-top: 1px solid var(--shell-hairline, #dde0e6);
336 margin: 1.2em 0;
337}
338
339/* ---------- generic fallbacks for unnamed fragment markup ---------------- */
340.repo-embed table { width: 100%; border-collapse: collapse; }
341.repo-embed th {
342 text-align: left;
343 font-size: 10px;
344 text-transform: uppercase;
345 letter-spacing: 1.2px;
346 color: var(--shell-ink-muted, #8b8f99);
347 padding: 8px 12px;
348}
349.repo-embed td {
350 padding: 7px 12px;
351 border-top: 1px solid var(--shell-hairline, #dde0e6);
352 vertical-align: top;
353}
354.repo-embed pre { overflow-x: auto; font-family: ui-monospace, monospace; font-size: 12px; }
355.repo-embed img { max-width: 100%; }
356
357/* The tree adapts to the column it actually got, not the viewport: a desktop
358 window with the sidebar and info aside open can be narrower than a tablet
359 in full screen. Below the threshold the date lane is dropped so the file
360 list itself never needs a horizontal pan. */
361@container (max-width: 560px) {
362 .repo-embed [data-component='git-tree'] th:nth-child(3),
363 .repo-embed [data-component='git-tree'] td:nth-child(3) { display: none; }
364 .repo-embed [data-component='git-tree'] th:nth-child(1) { width: 44%; }
365}
366
367/* ---------- media safety nets ---------------------------------------------
368 * Server markup sizes icons with Tailwind utilities (`h-4 w-4`) that are not
369 * shipped for fragments; an unsized inline <svg> would otherwise fall back to
370 * the 300x150 replaced-element default and blow out the layout. */
371.repo-embed img { height: auto; }
372.repo-embed video, .repo-embed canvas { max-width: 100%; }
373.repo-embed svg:not([width]):not([height]) { width: 1em; height: 1em; flex: none; }
Deleted web/src/entities/repo/index.ts
@@ -1,183 +0,0 @@
1/**
2 * Public API of entities/repo — the repository domain model shared by the
3 * repo features and the workspace page's repo area. Consumers import from
4 * '@/entities/repo', never from deeper files.
5 *
6 * Importing this barrel also loads `ui/embed.css` globally: it styles
7 * server-rendered fragments wherever they appear (file tree, README sandbox,
8 * aside language bar), so every repo surface is guaranteed to have it.
9 */
10import './ui/embed.css'
11
12export { default as embedCssText } from './ui/embed.css?raw'
13
14// repo metadata + clone urls
15export {
16 fetchRepo,
17 listSpaceRepos,
18 updateRepo,
19 archiveRepo,
20 unarchiveRepo,
21 starRepo,
22 unstarRepo,
23 setRepoWatch,
24 cloneTargets,
25 primaryCloneUrl,
26 type CloneTarget,
27 type RepoDetail,
28 type RepoSummary,
29} from './model/repo'
30
31// tree & blob
32export {
33 fetchTreeJson,
34 fetchBlob,
35 type BlobPayload,
36 type TreeEntry,
37 type TreeMode,
38} from './model/tree'
39
40// commits & diffs
41export {
42 listCommitsPage,
43 fetchCommit,
44 fetchNumstat,
45 fetchDiffStyle,
46 fetchRawDiff,
47 type CommitRow,
48 type CommitsPage,
49} from './model/commits'
50export {
51 applyNumstat,
52 normalizeDiffStyle,
53 parseHunks,
54 splitDiffFiles,
55 toSplitRows,
56 EMPTY_TREE,
57 type DiffFile,
58 type DiffFileStatus,
59 type DiffHunk,
60 type DiffLine,
61 type DiffStyle,
62 type NumstatRow,
63} from './model/diff'
64
65// refs (branches & tags)
66export {
67 listBranches,
68 createBranch,
69 renameBranch,
70 deleteBranch,
71 setDefaultBranch,
72 listTags,
73 fetchTag,
74 createTag,
75 deleteTag,
76 type BranchRow,
77 type TagRow,
78} from './model/refs'
79
80// settings domains
81export { listTopics, addTopic, removeTopic, type TopicRow } from './model/topics'
82export {
83 listRemotes,
84 upsertRemote,
85 removeRemote,
86 fetchRemote,
87 pushRemote,
88 importRemote,
89 type FetchResult,
90 type ImportResult,
91 type PushResult,
92 type RemoteRow,
93} from './model/remotes'
94export {
95 createArchive,
96 createBundle,
97 queryArchive,
98 type ArchiveFormat,
99 type ArchiveResult,
100} from './model/archives'
101export {
102 listContributors,
103 fetchCodeActivity,
104 readLanguageStats,
105 rescanLanguages,
106 type CodeActivity,
107 type ContributorRow,
108 type LanguageStat,
109} from './model/insights'
110export {
111 listLfsLocks,
112 createLfsLock,
113 unlockLfsPath,
114 unlockLfsById,
115 verifyLfsLocks,
116 type LfsLockPage,
117} from './model/lfs'
118
119// html fragments & web-path mapping
120export {
121 fetchHtmlFragment,
122 htmlFragmentUrl,
123 prefetchHtmlFragment,
124 useHtmlFragment,
125 type HtmlFragment,
126} from './model/fragment'
127export { parseWebHref, parseRepoAppHref, parentPath, repoWebHref } from './model/href'
128
129// protection rules (branch policies + whitelists)
130export {
131 listPolicies,
132 getPolicy,
133 createPolicy,
134 updatePolicy,
135 deletePolicy,
136 listBranchWhitelists,
137 addBranchWhitelist,
138 removeBranchWhitelist,
139 listTagWhitelists,
140 addTagWhitelist,
141 removeTagWhitelist,
142 type BranchPolicy,
143 type BranchWhitelistRow,
144 type TagWhitelistRow,
145 type WhitelistKind,
146 type CreatePolicyInput,
147 listUserOptions,
148 type UserOption,
149} from './model/protection'
150
151// advanced reads: blame & submodules
152export {
153 fetchBlame,
154 listSubmodules,
155 fetchConflictDetail,
156 resolveSubmodule,
157 blameLineMap,
158 type BlameRange,
159 type SubmoduleRow,
160} from './model/blame'
161// releases
162export {
163 listReleases,
164 getRelease,
165 createRelease,
166 updateRelease,
167 deleteRelease,
168 type Release,
169 type ReleaseListPage,
170} from './model/releases'
171
172// code line comments
173export {
174 listCodeComments,
175 createCodeComment,
176 updateCodeComment,
177 deleteCodeComment,
178 resolveCodeComment,
179 unresolveCodeComment,
180 groupCommentsByLine,
181 type CodeComment,
182 type CodeCommentPageData,
183} from './model/code-comments'
Deleted web/src/features/auth/model/api.ts
@@ -1,82 +0,0 @@
1import { getLocale, translate, type MessageKey } from '@/shared/i18n'
2
3interface Envelope<T> {
4 code: number
5 message: string
6 data?: T | null
7 error?: string | null
8 error_code?: string | null
9}
10
11export class ApiError extends Error {
12 readonly code: number
13 readonly errorCode?: string | null
14
15 constructor(message: string, code: number, errorCode?: string | null) {
16 super(message)
17 this.name = 'ApiError'
18 this.code = code
19 this.errorCode = errorCode
20 }
21}
22
23/**
24 * Localized user-facing error text. The server's own wording is the last
25 * resort: `error_code` and status are stable machine-readable handles that the
26 * dictionary can translate, but a validation message built from user input is
27 * not translatable and must pass through unchanged.
28 */
29export function toUserError(err: unknown): string {
30 if (err instanceof ApiError) {
31 const key = err.errorCode ? (`error.code.${err.errorCode}` as MessageKey) : null
32 if (key && key in ERROR_KEYS) {
33 return translate(getLocale(), key)
34 }
35 const byStatus = `error.status.${err.code}` as MessageKey
36 if (byStatus in ERROR_KEYS) return translate(getLocale(), byStatus)
37 return err.message
38 }
39 if (err instanceof TypeError) return translate(getLocale(), 'auth.error.network')
40 return translate(getLocale(), 'auth.error.unknown')
41}
42
43/**
44 * Key membership probe: the dictionaries are `Record<string, string>` at the
45 * boundary, so an unknown server code must be detected rather than assumed.
46 */
47const ERROR_KEYS = {
48 'error.code.invalid_credentials': true,
49 'error.code.unauthorized': true,
50 'error.code.token_expired': true,
51 'error.code.session_revoked': true,
52 'error.code.forbidden': true,
53 'error.code.not_found': true,
54 'error.code.conflict': true,
55 'error.code.rate_limited': true,
56 'error.code.bad_request': true,
57 'error.code.invalid_request_body': true,
58 'error.code.merge_blocked': true,
59 'error.code.import_disabled': true,
60 'error.code.import_upstream_failed': true,
61 'error.code.password_change_required': true,
62 'error.status.400': true,
63 'error.status.401': true,
64 'error.status.403': true,
65 'error.status.404': true,
66 'error.status.409': true,
67 'error.status.429': true,
68 'error.status.500': true,
69 'error.status.502': true,
70 'error.status.503': true,
71 'error.status.504': true,
72 'error.status.0': true,
73 'error.status.unknown': true,
74} as const satisfies Partial<Record<MessageKey, true>>
75
76export async function unwrap<T>(method: PromiseLike<Envelope<T>>): Promise<T | null> {
77 const res = await method
78 if (res.code !== 200) {
79 throw new ApiError(res.error || res.message || 'Request failed', res.code, res.error_code)
80 }
81 return res.data ?? null
82}
No newline at end of file
Deleted web/src/features/auth/model/session.ts
@@ -1,22 +0,0 @@
1import Apis, { type AppResponseData_MeResp } from '@/shared/api'
2import { ApiFailure, meKey, unwrapApi } from '@/shared/lib'
3import { invalidateCache } from '@/shared/storage'
4
5/** Identity behind `GET /me` — the same payload the workspace shell reads. */
6export type SessionUser = NonNullable<AppResponseData_MeResp['data']>
7
8/**
9 * Session probe behind the auth routes' guest guard. Shares `meKey()` with the
10 * workspace shell, so a signed-in visit redirects off an already cached
11 * identity and the fresh copy warms the shell's own frame.
12 */
13export function fetchSessionUser(): Promise<SessionUser> {
14 return unwrapApi<SessionUser>(Apis.account.me()).catch(err => {
15 // A refused session must not stay cached: the guard redirects on a cached
16 // identity, so a dead one would bounce the shell back to login forever.
17 if (err instanceof ApiFailure && (err.code === 401 || err.code === 403)) {
18 invalidateCache(meKey())
19 }
20 throw err
21 })
22}
Deleted web/src/features/auth/ui/auth-card.tsx
@@ -1,30 +0,0 @@
1import type { ReactNode } from 'react'
2
3export interface AuthCardProps {
4 /** Header badge (KernelMark or IconBadge), rendered centered above the title. */
5 badge?: ReactNode
6 heading: string
7 subheading?: ReactNode
8 /** Extra header content under the subheading (address chips, notes, ...). */
9 headerExtra?: ReactNode
10 children: ReactNode
11}
12
13/** 440px white card: the single content column of every auth screen. */
14export function AuthCard({ badge, heading, subheading, headerExtra, children }: AuthCardProps) {
15 return (
16 <section className="flex w-full max-w-[460px] flex-col gap-[18px] rounded-card border border-border bg-card p-6 sm:gap-[22px] sm:p-8 lg:max-w-[440px]">
17 <header className="flex w-full flex-col items-center gap-3 text-center">
18 {badge}
19 <h1 className="w-full font-mono text-[21px] font-bold leading-tight tracking-[-0.6px] text-foreground sm:text-[23px]">
20 {heading}
21 </h1>
22 {subheading && (
23 <p className="max-w-[340px] text-sm leading-[1.5] text-muted-foreground">{subheading}</p>
24 )}
25 {headerExtra}
26 </header>
27 {children}
28 </section>
29 )
30}
Deleted web/src/features/auth/ui/auth-footer.tsx
@@ -1,19 +0,0 @@
1import { Link } from 'react-router-dom'
2
3export interface AuthFooterProps {
4 question: string
5 action: string
6 to: string
7}
8
9/** "No account yet? Request access" row at the bottom of a card. */
10export function AuthFooter({ question, action, to }: AuthFooterProps) {
11 return (
12 <p className="flex w-full items-center justify-center gap-1.5 text-[13px] text-muted-foreground">
13 <span>{question}</span>
14 <Link to={to} className="font-bold text-foreground underline-offset-4 hover:underline">
15 {action}
16 </Link>
17 </p>
18 )
19}
Deleted web/src/features/auth/ui/auth-shell.tsx
@@ -1,87 +0,0 @@
1import { useEffect, type ReactNode } from 'react'
2import { Link } from 'react-router-dom'
3import { Moon, Sun } from 'lucide-react'
4import { useT } from '@/shared/i18n'
5import { getRegion, toggleTheme, useTheme } from '@/shared/lib'
6import { KernelMark } from '@/shared/brand'
7
8export interface AuthShellProps {
9 /** Breadcrumb current segment, e.g. "SIGN IN". */
10 current: string
11 /** Mono caption above the status row, unique per screen. */
12 hint: string
13 children: ReactNode
14}
15
16const mark = 'pointer-events-none absolute size-[18px] border-border'
17const markTL = 'left-4 top-4 sm:left-6 sm:top-6 lg:left-9 lg:top-9'
18const markTR = 'right-4 top-4 sm:right-6 sm:top-6 lg:right-9 lg:top-9'
19const markBL = 'bottom-4 left-4 sm:bottom-6 sm:left-6 lg:bottom-9 lg:left-9'
20const markBR = 'bottom-4 right-4 sm:bottom-6 sm:right-6 lg:bottom-9 lg:right-9'
21const crumbSep = 'font-mono text-[10px] text-border'
22
23/** Full-viewport chrome shared by all auth screens. */
24export function AuthShell({ current, hint, children }: AuthShellProps) {
25 const { t } = useT()
26 const theme = useTheme()
27 const region = getRegion()
28
29 // Each auth screen is a full document: keep the tab title in step with the
30 // active step and the interface language (current is already localized).
31 useEffect(() => {
32 document.title = `${current} · ${t('common.app.name')}`
33 }, [current, t])
34 return (
35 <main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4 py-10 sm:px-8 lg:p-6">
36 <div
37 aria-hidden
38 className="pointer-events-none absolute left-1/2 top-1/2 h-[400px] w-[340px] -translate-x-1/2 -translate-y-1/2 bg-[radial-gradient(closest-side,rgba(255,132,0,0.15),rgba(255,132,0,0))] sm:h-[480px] sm:w-[620px] lg:h-[600px] lg:w-[900px]"
39 />
40 <div
41 aria-hidden
42 className="pointer-events-none absolute inset-0 bg-gradient-to-b from-transparent via-transparent via-55% to-black/[0.04] dark:to-white/[0.05]"
43 />
44
45 <div aria-hidden className={`${mark} ${markTL} border-l border-t`} />
46 <div aria-hidden className={`${mark} ${markTR} border-r border-t`} />
47 <div aria-hidden className={`${mark} ${markBL} border-b border-l`} />
48 <div aria-hidden className={`${mark} ${markBR} border-b border-r`} />
49
50 <nav
51 aria-label={t('auth.shell.breadcrumb')}
52 className="absolute left-8 top-9 hidden items-center gap-2 font-mono text-[10px] tracking-[1.2px] sm:flex lg:left-[70px]"
53 >
54 <KernelMark size={14} radius={4} />
55 <Link to="/" className="font-bold text-foreground">
56 GitDataAI
57 </Link>
58 <span className={crumbSep}>/</span>
59 <span className="text-muted-foreground">AUTH</span>
60 <span className={crumbSep}>/</span>
61 <span className="font-bold text-warning-foreground">{current}</span>
62 </nav>
63
64 <button
65 type="button"
66 onClick={toggleTheme}
67 aria-label={theme === 'dark' ? t('auth.shell.switchToLight') : t('auth.shell.switchToDark')}
68 className="absolute right-[40px] top-[16px] z-20 flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring sm:right-[52px] sm:top-[20px] lg:right-[60px] lg:top-[30px]"
69 >
70 {theme === 'dark' ? <Sun size={15} aria-hidden /> : <Moon size={15} aria-hidden />}
71 </button>
72
73 <div className="relative z-10 flex w-full justify-center">{children}</div>
74
75 <div className="pointer-events-none absolute inset-x-0 bottom-[39px] flex flex-col items-center gap-[10px] px-4">
76 <p className="text-center font-mono text-[10px] leading-relaxed tracking-[1.2px] text-muted-foreground">
77 {hint}
78 </p>
79 <p className="flex items-center gap-2.5 font-mono text-[11px] tracking-[0.5px] text-muted-foreground">
80 <span>{t('auth.shell.region', { region }).toUpperCase()}</span>
81 <span className="text-border">·</span>
82 <span>© 2026 GitDataAI</span>
83 </p>
84 </div>
85 </main>
86 )
87}
Deleted web/src/features/auth/ui/chip.tsx
@@ -1,30 +0,0 @@
1import type { ReactNode } from 'react'
2
3export interface ChipProps {
4 children: ReactNode
5 className?: string
6}
7
8/** Small muted pill used for email addresses, purpose tags and token states. */
9export function Chip({ children, className = '' }: ChipProps) {
10 return (
11 <span
12 className={`inline-flex items-center gap-1.5 rounded-lg bg-muted px-3 py-1.5 ${className}`}
13 >
14 {children}
15 </span>
16 )
17}
18
19export interface PillProps {
20 children: ReactNode
21}
22
23/** Tighter uppercase mono tag next to an address chip. */
24export function Pill({ children }: PillProps) {
25 return (
26 <span className="inline-flex items-center rounded-md bg-muted px-2.5 py-1 font-mono text-[9.5px] font-bold tracking-[1px] text-muted-foreground uppercase">
27 {children}
28 </span>
29 )
30}
Deleted web/src/features/auth/ui/guest-only.tsx
@@ -1,39 +0,0 @@
1import type { ReactNode } from 'react'
2import { Navigate } from 'react-router-dom'
3import { Loader2 } from 'lucide-react'
4import { useT } from '@/shared/i18n'
5import { ApiFailure, meKey, useAsync } from '@/shared/lib'
6import { fetchSessionUser } from '../model/session'
7
8/**
9 * Gate for the auth entry screens (login / sign up / forgot password): a
10 * visitor who already holds a session belongs in the workspace, so the sign-in
11 * form must never be reachable — not by clicking a stale link, not by typing
12 * the URL, not by hitting Back into it.
13 *
14 * The probe shares `meKey()` with the shell, so an in-app visit redirects off
15 * the identity already on screen; a cold visit waits for `GET /me` and only
16 * paints the form once the session is known to be gone (a 401 also drops the
17 * cached identity, see `fetchSessionUser`).
18 */
19export function GuestOnly({ children }: { children: ReactNode }) {
20 const { t } = useT()
21 const me = useAsync(fetchSessionUser, [], { cacheKey: meKey() })
22
23 // A *refused* session outranks the cached identity — otherwise a signed-out
24 // browser would be redirected into a shell that can only answer 401. Any
25 // other failure (offline, 5xx) leaves the cached identity trusted, because
26 // the alternative is showing a sign-in form to someone already signed in.
27 const refused =
28 me.error instanceof ApiFailure && (me.error.code === 401 || me.error.code === 403)
29 if (!refused && me.data) return <Navigate to="/app" replace />
30 if (me.loading) {
31 return (
32 <main className="grid min-h-dvh place-items-center bg-background text-foreground" role="status">
33 <Loader2 size={22} className="animate-spin text-primary" aria-hidden />
34 <span className="sr-only">{t('common.action.loading')}</span>
35 </main>
36 )
37 }
38 return <>{children}</>
39}
Deleted web/src/features/auth/ui/icon-badge.tsx
@@ -1,14 +0,0 @@
1import type { ReactNode } from 'react'
2
3export interface IconBadgeProps {
4 children: ReactNode
5}
6
7/** 44px orange rounded badge holding the screen's header icon. */
8export function IconBadge({ children }: IconBadgeProps) {
9 return (
10 <span className="inline-flex size-11 shrink-0 items-center justify-center rounded-[12px] bg-primary text-primary-foreground">
11 {children}
12 </span>
13 )
14}
Deleted web/src/features/auth/index.ts
@@ -1,10 +0,0 @@
1// Public API of the features/auth slice.
2export { AuthShell, type AuthShellProps } from './ui/auth-shell'
3export { AuthCard, type AuthCardProps } from './ui/auth-card'
4export { AuthFooter, type AuthFooterProps } from './ui/auth-footer'
5export { KernelMark, type KernelMarkProps } from '@/shared/brand'
6export { IconBadge, type IconBadgeProps } from './ui/icon-badge'
7export { Chip, Pill, type ChipProps, type PillProps } from './ui/chip'
8export { GuestOnly } from './ui/guest-only'
9export { fetchSessionUser, type SessionUser } from './model/session'
10export { ApiError, toUserError, unwrap } from './model/api'
No newline at end of file
Deleted web/src/features/chat/model/prefetch.ts
@@ -1,18 +0,0 @@
1import { chatSeedKey, prefetch, roomKey } from '@/shared/lib'
2import { getRoom, listMessages, listRoomMembers, MESSAGE_PAGE_SIZE } from '@/entities/chat'
3
4/**
5 * Hover prefetch for one room: the expensive core of what the chat column
6 * loads on mount — the room row, the member directory and the tail seed page.
7 * Pins / threads / emojis are cheap and panel-dependent, so they stay
8 * mount-time fetches. Fire-and-forget by design: a failed prefetch is silent,
9 * the mounting hooks surface real errors themselves.
10 */
11export function prefetchRoomView(roomId: string): void {
12 if (!roomId) return
13 void prefetch(roomKey(roomId, 'room'), () => getRoom(roomId))
14 void prefetch(roomKey(roomId, 'members'), () => listRoomMembers(roomId).catch(() => []))
15 void prefetch(chatSeedKey(roomId, null), () =>
16 listMessages(roomId, { limit: MESSAGE_PAGE_SIZE }),
17 )
18}
Deleted web/src/features/chat/model/transcript.ts
@@ -1,387 +0,0 @@
1/**
2 * The transcript store: one reducer that owns every message-list mutation, so
3 * the HTTP seed, the WebSocket live stream and the user's own optimistic posts
4 * all funnel through the same ordering/dedupe rules.
5 *
6 * Invariants the reducer guarantees:
7 * - `rows` stays ascending by `seq` and free of duplicate ids (a `history`
8 * snapshot repeats rows the room channel already delivered);
9 * - `pending` holds only local rows, in send order, rendered after `rows`;
10 * - an edit or a reaction aggregate arriving over the socket never loses the
11 * local user's own `me` flag, because that payload is built for the *actor*;
12 * - trimming to {@link TRANSCRIPT_CAP} is counted in `olderTrimmed`, so the
13 * "load older" affordance keeps working instead of looking exhausted.
14 */
15import { mergeMessages, mergeReactions, tipSeq } from '@/entities/chat'
16import type { ChatMessage, PendingMessage } from '@/entities/chat'
17
18/** A reply-to target that is not part of the loaded window. */
19export type ResolvedRef =
20 | { state: 'loading' }
21 | { state: 'ready'; message: ChatMessage }
22 | { state: 'gone' }
23
24export interface TranscriptState {
25 roomId: string
26 /** Scope of the list: null is the room's main stream. */
27 threadId: string | null
28 rows: ChatMessage[]
29 pending: PendingMessage[]
30 refs: Record<string, ResolvedRef>
31 oldestSeq: number | null
32 newestSeq: number | null
33 hasOlder: boolean
34 loadingSeed: boolean
35 loadingOlder: boolean
36 loadingNewer: boolean
37 seeded: boolean
38 /** Fatal for this room: the transcript cannot be shown at all. */
39 error: string | null
40 /** The socket reported `resync_required{access revoked}` for this room. */
41 accessRevoked: boolean
42 /** Rows dropped from memory by the cap; older pages stay fetchable. */
43 olderTrimmed: number
44 highlightId: string | null
45 /** Bumped by live traffic so panels can refetch pins/members/threads. */
46 liveVersion: number
47 /** Bumped by live *message* traffic (read-state and scroll logic watch it). */
48 messageVersion: number
49}
50
51export type TranscriptAction =
52 /** New scope: drop everything, including the previous room's cursor. */
53 | { type: 'reset'; roomId: string; threadId: string | null }
54 | { type: 'seedStart' }
55 /**
56 * Prefill from the local-first store while the network seed is in flight.
57 * Same merge invariants as `seedDone` (dedupe by id, ascending by seq), but
58 * `loadingSeed` stays true — the fresh tail still replaces these rows.
59 */
60 | {
61 type: 'seedCached'
62 items: ChatMessage[]
63 hasMore: boolean
64 oldestSeq: number | null
65 newestSeq: number | null
66 }
67 | {
68 type: 'seedDone'
69 items: ChatMessage[]
70 hasMore: boolean
71 oldestSeq: number | null
72 newestSeq: number | null
73 }
74 | { type: 'seedError'; message: string }
75 | { type: 'olderStart' }
76 | { type: 'olderDone'; items: ChatMessage[]; hasMore: boolean; oldestSeq: number | null }
77 | { type: 'newerStart' }
78 /** Back to the tail: the window is replaced, never merged (a jumped-back reader
79 * has an intentional hole between what they read and what is new). */
80 | { type: 'tailReplaced'; items: ChatMessage[]; hasMore: boolean; oldestSeq: number | null; newestSeq: number | null }
81 | { type: 'liveHistory'; items: ChatMessage[] }
82 | { type: 'published'; message: ChatMessage }
83 | { type: 'edited'; message: ChatMessage }
84 | { type: 'deleted'; messageIds: string[] }
85 | { type: 'pinned'; messageId: string; pinned: boolean }
86 | { type: 'reactions'; messageId: string; reactions: readonly { emoji: string; count: number }[] }
87 | { type: 'pendingAdd'; message: PendingMessage }
88 | { type: 'pendingSent'; localId: string; message: ChatMessage }
89 | { type: 'pendingFail'; localId: string; error: string }
90 | { type: 'pendingRetry'; localId: string }
91 | { type: 'pendingDrop'; localId: string }
92 | { type: 'refStart'; messageId: string }
93 | { type: 'refDone'; messageId: string; message: ChatMessage | null }
94 | { type: 'highlight'; messageId: string | null }
95 | { type: 'accessRevoked' }
96 | { type: 'liveTick'; message?: boolean }
97
98export function createTranscriptState(roomId: string, threadId: string | null): TranscriptState {
99 return {
100 roomId,
101 threadId,
102 rows: [],
103 pending: [],
104 refs: {},
105 oldestSeq: null,
106 newestSeq: null,
107 hasOlder: false,
108 loadingSeed: true,
109 loadingOlder: false,
110 loadingNewer: false,
111 seeded: false,
112 error: null,
113 accessRevoked: false,
114 olderTrimmed: 0,
115 highlightId: null,
116 liveVersion: 0,
117 messageVersion: 0,
118 }
119}
120
121/**
122 * Does a live row belong to this list? The main stream and a thread panel never
123 * mix, mirroring the backend filter `thread_id IS NULL OR thread_broadcast`.
124 */
125export function belongsToScope(
126 message: Pick<ChatMessage, 'thread_id' | 'thread_broadcast'>,
127 threadId: string | null | undefined,
128): boolean {
129 if (threadId) return message.thread_id === threadId
130 return !message.thread_id || message.thread_broadcast === true
131}
132
133function merged(state: TranscriptState, incoming: ChatMessage[]) {
134 const { items, dropped } = mergeMessages(state.rows, incoming)
135 return {
136 rows: items,
137 newestSeq: Math.max(state.newestSeq ?? 0, tipSeq(items)),
138 olderTrimmed: state.olderTrimmed + dropped,
139 }
140}
141
142export function transcriptReducer(
143 state: TranscriptState,
144 action: TranscriptAction,
145): TranscriptState {
146 switch (action.type) {
147 case 'reset':
148 if (state.roomId === action.roomId && state.threadId === action.threadId) return state
149 return createTranscriptState(action.roomId, action.threadId)
150
151 case 'seedStart':
152 return { ...state, loadingSeed: true, error: null }
153
154 case 'seedCached': {
155 // The stored page renders while the live seed is still in flight; the
156 // reducer's merge keeps dedupe/ordering, and `seedDone` later replaces
157 // any row the server has since edited (incoming wins per id).
158 const next = merged(state, action.items)
159 return {
160 ...state,
161 ...next,
162 oldestSeq: action.oldestSeq ?? (next.rows.length ? next.rows[0].seq : state.oldestSeq),
163 newestSeq: action.newestSeq ?? next.newestSeq,
164 hasOlder: action.hasMore,
165 seeded: true,
166 }
167 }
168
169 case 'seedDone': {
170 const next = merged(state, action.items)
171 return {
172 ...state,
173 ...next,
174 oldestSeq: action.oldestSeq ?? (next.rows.length ? next.rows[0].seq : state.oldestSeq),
175 newestSeq: action.newestSeq ?? next.newestSeq,
176 hasOlder: action.hasMore,
177 loadingSeed: false,
178 seeded: true,
179 error: null,
180 }
181 }
182
183 case 'seedError':
184 // Keep whatever is on screen: a failed refresh must not blank a room that
185 // already rendered, but the caller still needs the reason.
186 return { ...state, loadingSeed: false, error: action.message }
187
188 case 'olderStart':
189 return { ...state, loadingOlder: true }
190
191 case 'olderDone': {
192 const next = merged(state, action.items)
193 return {
194 ...state,
195 ...next,
196 hasOlder: action.hasMore,
197 oldestSeq: action.oldestSeq ?? next.rows[0]?.seq ?? state.oldestSeq,
198 loadingOlder: false,
199 }
200 }
201
202 case 'newerStart':
203 return { ...state, loadingNewer: true }
204
205 case 'tailReplaced': {
206 const rows = [...action.items].sort((a, b) => a.seq - b.seq)
207 return {
208 ...state,
209 rows,
210 oldestSeq: action.oldestSeq ?? (rows[0]?.seq ?? null),
211 newestSeq: action.newestSeq ?? (rows[rows.length - 1]?.seq ?? state.newestSeq),
212 hasOlder: action.hasMore,
213 loadingSeed: false,
214 loadingNewer: false,
215 seeded: true,
216 highlightId: null,
217 olderTrimmed: 0,
218 liveVersion: state.liveVersion + 1,
219 messageVersion: state.messageVersion + 1,
220 }
221 }
222
223 case 'liveHistory': {
224 if (action.items.length === 0) return state
225 return {
226 ...state,
227 ...merged(state, action.items),
228 loadingSeed: false,
229 seeded: true,
230 liveVersion: state.liveVersion + 1,
231 messageVersion: state.messageVersion + 1,
232 }
233 }
234
235 case 'published': {
236 if (state.rows.some(row => row.id === action.message.id)) return state
237 return {
238 ...state,
239 ...merged(state, [action.message]),
240 loadingSeed: false,
241 seeded: true,
242 liveVersion: state.liveVersion + 1,
243 messageVersion: state.messageVersion + 1,
244 }
245 }
246
247 case 'edited': {
248 const index = state.rows.findIndex(row => row.id === action.message.id)
249 if (index < 0) return state
250 const rows = [...state.rows]
251 const previous = rows[index]
252 rows[index] = {
253 ...action.message,
254 reactions: mergeReactions(previous.reactions, action.message.reactions ?? []),
255 }
256 return { ...state, rows, liveVersion: state.liveVersion + 1 }
257 }
258
259 case 'deleted': {
260 const gone = new Set(action.messageIds)
261 const rows = state.rows.filter(row => !gone.has(row.id))
262 if (rows.length === state.rows.length) return state
263 return {
264 ...state,
265 rows,
266 liveVersion: state.liveVersion + 1,
267 messageVersion: state.messageVersion + 1,
268 }
269 }
270
271 case 'pinned': {
272 const index = state.rows.findIndex(row => row.id === action.messageId)
273 if (index < 0) return state
274 const rows = [...state.rows]
275 rows[index] = { ...rows[index], pinned: action.pinned }
276 return { ...state, rows, liveVersion: state.liveVersion + 1 }
277 }
278
279 case 'reactions': {
280 const index = state.rows.findIndex(row => row.id === action.messageId)
281 if (index < 0) return state
282 const rows = [...state.rows]
283 rows[index] = { ...rows[index], reactions: mergeReactions(rows[index].reactions, action.reactions) }
284 return { ...state, rows }
285 }
286
287 case 'pendingAdd':
288 return { ...state, pending: [...state.pending, action.message] }
289
290 case 'pendingSent': {
291 const pending = state.pending.filter(item => item.localId !== action.localId)
292 if (state.rows.some(row => row.id === action.message.id)) return { ...state, pending }
293 return {
294 ...state,
295 ...merged(state, [action.message]),
296 pending,
297 seeded: true,
298 loadingSeed: false,
299 }
300 }
301
302 case 'pendingFail':
303 return {
304 ...state,
305 pending: state.pending.map(item =>
306 item.localId === action.localId
307 ? { ...item, state: 'failed', error: action.error, sending: false }
308 : item,
309 ),
310 }
311
312 case 'pendingRetry':
313 return {
314 ...state,
315 pending: state.pending.map(item =>
316 item.localId === action.localId
317 ? { ...item, state: 'sending', error: undefined, sending: true }
318 : item,
319 ),
320 }
321
322 case 'pendingDrop':
323 return { ...state, pending: state.pending.filter(item => item.localId !== action.localId) }
324
325 case 'refStart':
326 if (state.refs[action.messageId]) return state
327 return { ...state, refs: { ...state.refs, [action.messageId]: { state: 'loading' } } }
328
329 case 'refDone':
330 return {
331 ...state,
332 refs: {
333 ...state.refs,
334 [action.messageId]: action.message
335 ? { state: 'ready', message: action.message }
336 : { state: 'gone' },
337 },
338 }
339
340 case 'highlight':
341 return { ...state, highlightId: action.messageId }
342
343 case 'accessRevoked':
344 return { ...state, accessRevoked: true, rows: [], pending: [], hasOlder: false }
345
346 case 'liveTick':
347 return action.message
348 ? { ...state, liveVersion: state.liveVersion + 1, messageVersion: state.messageVersion + 1 }
349 : { ...state, liveVersion: state.liveVersion + 1 }
350
351 default:
352 return state
353 }
354}
355
356/**
357 * The POST response and the socket echo of your own message race each other. The
358 * id match in `pendingSent` covers the normal path; when the response was lost
359 * (timeout) the echo is matched by identical content, attachments, reply and
360 * thread so a stuck「发送中」row cannot outlive the message it stands for.
361 */
362/**
363 * Only a *failed* row is eligible: its request already reported an error, so the
364 * only way the message exists on the server is that the response was lost. A
365 * pending row that is merely still in flight is matched by id in `pendingSent`.
366 */
367export function findEchoedPending(
368 pending: readonly PendingMessage[],
369 message: ChatMessage,
370 viewerId?: number,
371): PendingMessage | undefined {
372 if (pending.length === 0 || message.author_type !== 'User') return undefined
373 if (viewerId && message.author_id !== viewerId) return undefined
374 const key = (attachments: readonly { id: string }[]) => attachments.map(a => a.id).sort().join(',')
375 return pending.find(
376 item =>
377 item.state === 'failed' &&
378 item.content === message.content &&
379 key(item.attachments) === key(message.attachments) &&
380 (item.reply_to_id ?? null) === (message.reply_to_id ?? null) &&
381 (item.thread_id ?? null) === (message.thread_id ?? null),
382 )
383}
384
385export function isEmptyTranscript(state: TranscriptState): boolean {
386 return state.rows.length === 0 && state.pending.length === 0
387}
Deleted web/src/features/chat/model/use-composer.ts
@@ -1,402 +0,0 @@
1/**
2 * Composer state: the draft, the attachment queue and the send/edit handshake.
3 *
4 * Drafts are server state (`PUT /chat/rooms/{id}/draft`, thread-scoped), so the
5 * text survives a device switch — but they are saved debounced and never during
6 * the initial load, otherwise typing one character would write a row and every
7 * room switch would overwrite a draft the reader never touched.
8 *
9 * Attachments follow the backend's three-step contract (reserve → presigned PUT
10 * → finalize) and only a `ready` id may be referenced by a message, so a half
11 * uploaded file can never produce a broken post. Uploads are held while a send
12 * is in flight and flushed with it, which is what makes "type, attach, send"
13 * read as one action.
14 */
15import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
16import {
17 clearDraft,
18 describeFailure,
19 getDraft,
20 MAX_ATTACHMENTS,
21 MAX_MESSAGE_LEN,
22 MAX_UPLOAD_BYTES,
23 putUpload,
24 finalizeAttachment,
25 createAttachment,
26 readImageSize,
27 saveDraft,
28 textLength,
29 type Attachment,
30 type UploadItem,
31} from '@/entities/chat'
32import { t } from '@/shared/i18n'
33
34const DRAFT_SAVE_DEBOUNCE_MS = 1_200
35/** Rows the composer's own draft load must not clobber. */
36const DRAFT_MAX_LEN = MAX_MESSAGE_LEN
37
38export interface UseComposerOptions {
39 roomId: string
40 /** Drafts are per thread when one is open. */
41 threadId?: string | null
42 /** Freezes every request (voice channel, no access, outage gate). */
43 enabled: boolean
44 /** Message being edited: the draft machinery is bypassed entirely. */
45 editingId?: string | null
46 /** Seed text for the edit box. */
47 editingContent?: string
48 /** Called after a successful send / edit so the caller can clear reply targets. */
49 onSubmitted: (content: string, attachments: Attachment[]) => Promise<boolean> | boolean
50 /** Notify the socket that typing started (throttled by the client). */
51 onTyping?: () => void
52}
53
54export interface ComposerState {
55 text: string
56 setText: (value: string) => void
57 /** Attachments that finished all three upload steps and may be sent. */
58 readyAttachments: Attachment[]
59 uploads: UploadItem[]
60 addFiles: (files: File[]) => void
61 removeUpload: (localId: string) => void
62 retryUpload: (localId: string) => void
63 clearUploads: () => void
64 /** True when a file is still moving: send is deferred until it lands. */
65 uploading: boolean
66 blocked: string | null
67 overLength: boolean
68 remaining: number
69 canSend: boolean
70 submit: () => Promise<boolean>
71 /** Cancel an edit: clears the box and the draft machinery. */
72 reset: () => void
73 draftNotice: string | null
74}
75
76let uploadSeq = 0
77
78export function useComposer({
79 roomId,
80 threadId = null,
81 enabled,
82 editingId = null,
83 editingContent = '',
84 onSubmitted,
85 onTyping,
86}: UseComposerOptions): ComposerState {
87 const [text, setTextState] = useState(editingId ? editingContent.slice(0, DRAFT_MAX_LEN) : '')
88 const [uploads, setUploads] = useState<UploadItem[]>([])
89 const [draftNotice, setDraftNotice] = useState<string | null>(null)
90 const [loadedDraft, setLoadedDraft] = useState(false)
91 const touched = useRef(false)
92 const saveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
93 // Entering or leaving an edit is a scope change too: the box is handed to the
94 // message being corrected and comes back empty afterwards (the room's stored
95 // draft is then re-read, so nothing typed before the detour is lost).
96 const draftScope = `${roomId}\u0000${threadId ?? ''}`
97 const scopeKey = `${draftScope}\u0000edit:${editingId ?? ''}`
98 const urls = useRef<Set<string>>(new Set())
99 // Mirror of `uploads` for callbacks that must reason about the queue without
100 // writing new state from inside a state updater. React may invoke an updater
101 // more than once (StrictMode) and drops all but one result, so ids collected
102 // inside it can desync from the committed state.
103 const uploadsRef = useRef<UploadItem[]>([])
104 /** The single writer of the queue: keeps the ref and the state in lockstep. */
105 const commitUploads = useCallback((update: (current: UploadItem[]) => UploadItem[]) => {
106 const next = update(uploadsRef.current)
107 uploadsRef.current = next
108 setUploads(next)
109 }, [])
110
111 // A new scope starts with an empty box; the previous room's text belongs to
112 // that room's draft, never to this one.
113 const lastScope = useRef(scopeKey)
114 const lastDraftScope = useRef(draftScope)
115 useEffect(() => {
116 if (lastScope.current === scopeKey) return
117 const [previousRoom, previousThread] = lastDraftScope.current.split('\u0000')
118 lastScope.current = scopeKey
119 lastDraftScope.current = draftScope
120 // The debounce below fires on a timer, so switching rooms inside that window
121 // would otherwise either lose the text or write it into the *new* room's
122 // draft: flush it to the scope it was typed in first.
123 if (saveTimer.current) {
124 clearTimeout(saveTimer.current)
125 saveTimer.current = undefined
126 }
127 if (touched.current && text) {
128 saveDraft(previousRoom ?? roomId, text, previousThread || undefined).catch(() => undefined)
129 }
130 touched.current = false
131 setTextState('')
132 setLoadedDraft(false)
133 commitUploads(current => {
134 for (const item of current) if (item.previewUrl) URL.revokeObjectURL(item.previewUrl)
135 return []
136 })
137 urls.current.clear()
138 // `commitUploads` is a stable `useCallback`: listing it is what keeps the
139 // queue write and this reset on the same writer.
140 }, [draftScope, editingId, roomId, scopeKey, text, threadId, commitUploads])
141
142 /* --------------------------------------------------------------- draft */
143
144 useEffect(() => {
145 if (!enabled || editingId || touched.current) return
146 let active = true
147 getDraft(roomId, threadId ?? undefined).then(
148 draft => {
149 // Never overwrite what the user already started typing while this was in
150 // flight (a fast click into the room and straight into the box).
151 if (active && draft?.content && !touched.current) {
152 setTextState(draft.content.slice(0, DRAFT_MAX_LEN))
153 touched.current = true
154 }
155 if (active) setLoadedDraft(true)
156 },
157 () => active && setLoadedDraft(true),
158 )
159 return () => {
160 active = false
161 }
162 }, [enabled, editingId, roomId, threadId])
163
164 const persistDraft = useCallback(
165 (value: string) => {
166 if (!enabled || editingId) return
167 // An empty write deletes the draft server-side, which is what "the user
168 // erased the box" means; skipping it would resurrect the text next visit.
169 saveDraft(roomId, value, threadId ?? undefined).catch(() => {
170 setDraftNotice(t('chat.composer.draftSaveFailed'))
171 })
172 },
173 [enabled, editingId, roomId, threadId],
174 )
175
176 useEffect(() => {
177 if (!loadedDraft || editingId) return
178 if (saveTimer.current) clearTimeout(saveTimer.current)
179 saveTimer.current = setTimeout(() => {
180 saveTimer.current = undefined
181 // A scope switch flushes the pending write itself, so a timer that outlived
182 // the room it belongs to must never write into the new one.
183 if (lastScope.current !== scopeKey) return
184 persistDraft(text)
185 }, DRAFT_SAVE_DEBOUNCE_MS)
186 return () => {
187 if (saveTimer.current) clearTimeout(saveTimer.current)
188 }
189 // The timer must not restart on every render, only on text/scope changes.
190 // oxlint-disable-next-line react-hooks/exhaustive-deps -- 定时器只跟随 text/scope 重启;其余依赖变化会各自处理
191 }, [text, roomId, threadId, loadedDraft, editingId])
192
193 useEffect(
194 () => () => {
195 for (const url of urls.current) URL.revokeObjectURL(url)
196 urls.current.clear()
197 },
198 [],
199 )
200
201 const setText = useCallback(
202 (value: string) => {
203 touched.current = true
204 setDraftNotice(null)
205 setTextState(value)
206 if (value) onTyping?.()
207 },
208 [onTyping],
209 )
210
211 const reset = useCallback(() => {
212 touched.current = false
213 setTextState('')
214 commitUploads(current => {
215 for (const item of current) if (item.previewUrl) URL.revokeObjectURL(item.previewUrl)
216 return []
217 })
218 urls.current.clear()
219 if (enabled && !editingId) void clearDraft(roomId, threadId ?? undefined).catch(() => undefined)
220 }, [commitUploads, editingId, enabled, roomId, threadId])
221
222 /* ----------------------------------------------------------- attachments */
223
224 const patch = useCallback(
225 (localId: string, changes: Partial<UploadItem>) => {
226 commitUploads(current =>
227 current.map(item => (item.localId === localId ? { ...item, ...changes } : item)),
228 )
229 },
230 [commitUploads],
231 )
232
233 const runUpload = useCallback(
234 async (item: UploadItem) => {
235 patch(item.localId, { state: 'checking', error: undefined, progress: 0 })
236 try {
237 const intent = await createAttachment({
238 filename: item.file.name,
239 content_type: item.file.type || 'application/octet-stream',
240 size: item.file.size,
241 })
242 patch(item.localId, { attachmentId: intent.id, state: 'uploading', progress: 0.25 })
243 await putUpload(intent.upload_url, item.file)
244 patch(item.localId, { state: 'finalizing', progress: 0.8 })
245 const size = item.width ? { width: item.width, height: item.height } : await readImageSize(item.file)
246 const meta = await finalizeAttachment(intent.id, size ?? undefined)
247 patch(item.localId, {
248 state: 'ready',
249 progress: 1,
250 attachment: meta,
251 width: meta.width ?? undefined,
252 height: meta.height ?? undefined,
253 })
254 } catch (error) {
255 patch(item.localId, { state: 'failed', error: describeFailure(error), progress: 0 })
256 }
257 },
258 [patch],
259 )
260
261 const addFiles = useCallback(
262 (files: File[]) => {
263 if (!enabled || files.length === 0) return
264 // The batch is decided before state is touched: the ids handed to
265 // `runUpload` must be exactly the ids that reach the queue, otherwise
266 // every progress patch misses and the row spins on「上传中…」forever.
267 const accepted: UploadItem[] = []
268 const queued = uploadsRef.current.length
269 let overflow = false
270 for (const file of files) {
271 if (queued + accepted.length >= MAX_ATTACHMENTS) {
272 overflow = true
273 break
274 }
275 const localId = `upload:${Date.now().toString(36)}:${(uploadSeq += 1)}`
276 if (file.size > MAX_UPLOAD_BYTES) {
277 accepted.push({
278 localId,
279 file,
280 progress: 0,
281 state: 'too_big',
282 error: t('chat.composer.overLimit', { mb: Math.round(MAX_UPLOAD_BYTES / 1024 / 1024) }),
283 })
284 continue
285 }
286 const previewUrl = file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined
287 if (previewUrl) urls.current.add(previewUrl)
288 accepted.push({
289 localId,
290 file,
291 previewUrl,
292 progress: 0.05,
293 state: 'checking',
294 })
295 }
296 if (overflow) setDraftNotice(t('chat.composer.attachmentLimit', { max: MAX_ATTACHMENTS }))
297 if (accepted.length === 0) return
298 commitUploads(current => [...current, ...accepted])
299 for (const item of accepted) void runUpload(item)
300 },
301 [commitUploads, enabled, runUpload],
302 )
303
304 const removeUpload = useCallback(
305 (localId: string) => {
306 commitUploads(current => {
307 const item = current.find(candidate => candidate.localId === localId)
308 if (item?.previewUrl) {
309 URL.revokeObjectURL(item.previewUrl)
310 urls.current.delete(item.previewUrl)
311 }
312 return current.filter(candidate => candidate.localId !== localId)
313 })
314 },
315 [commitUploads],
316 )
317
318 const retryUpload = useCallback(
319 (localId: string) => {
320 const item = uploads.find(candidate => candidate.localId === localId)
321 if (item && item.state !== 'too_big') void runUpload(item)
322 },
323 [runUpload, uploads],
324 )
325
326 const clearUploads = useCallback(() => {
327 commitUploads(current => {
328 for (const item of current) if (item.previewUrl) URL.revokeObjectURL(item.previewUrl)
329 return []
330 })
331 }, [commitUploads])
332
333 /* ----------------------------------------------------------------- send */
334
335 const readyAttachments = useMemo(
336 () =>
337 uploads
338 .filter(item => item.state === 'ready' && item.attachment)
339 .map(item => item.attachment as unknown as Attachment),
340 [uploads],
341 )
342
343 const uploading = uploads.some(item =>
344 item.state === 'checking' || item.state === 'uploading' || item.state === 'finalizing',
345 )
346 const failedUploads = uploads.filter(item => item.state === 'failed' || item.state === 'too_big')
347 const blocked = uploading
348 ? t('chat.composer.blockedUploading')
349 : failedUploads.length > 0
350 ? t('chat.composer.blockedFailed')
351 : null
352 const length = textLength(text)
353 const overLength = length > DRAFT_MAX_LEN
354 const canSend =
355 enabled &&
356 !overLength &&
357 !blocked &&
358 (Boolean(text.trim()) || readyAttachments.length > 0)
359
360 const submit = useCallback(async () => {
361 const body = text.replace(/\s+$/, '')
362 if (!canSend) return false
363 const attachments = readyAttachments
364 // Clear first: the box must not keep the text if the send is slow, and the
365 // server-side draft is dropped by the same rule the eraser uses.
366 touched.current = false
367 setTextState('')
368 commitUploads(current => {
369 for (const item of current) if (item.previewUrl) URL.revokeObjectURL(item.previewUrl)
370 return []
371 })
372 urls.current.clear()
373 if (!editingId) void clearDraft(roomId, threadId ?? undefined).catch(() => undefined)
374 const ok = await onSubmitted(body, attachments)
375 if (!ok) {
376 // Nothing was sent: give the text back rather than losing the message.
377 setTextState(body)
378 touched.current = Boolean(body)
379 return false
380 }
381 return true
382 }, [canSend, commitUploads, editingId, onSubmitted, readyAttachments, roomId, text, threadId])
383
384 return {
385 text,
386 setText,
387 readyAttachments,
388 uploads,
389 addFiles,
390 removeUpload,
391 retryUpload,
392 clearUploads,
393 uploading,
394 blocked,
395 overLength,
396 remaining: Math.max(0, DRAFT_MAX_LEN - length),
397 canSend,
398 submit,
399 reset,
400 draftNotice,
401 }
402}
Deleted web/src/features/chat/model/use-room-data.ts
@@ -1,167 +0,0 @@
1/**
2 * Everything a room needs besides its transcript: the authoritative room row,
3 * the member directory (names, avatars, @-mention targets), online presence,
4 * the pin list, the thread index and the space's custom emojis.
5 *
6 * These are five independent reads with different failure modes, so each one
7 * keeps its own state and the room stays usable when any of them is missing:
8 * a private room whose member list is forbidden still chats, an unreachable
9 * presence endpoint just leaves every dot grey.
10 */
11import { useCallback, useEffect, useMemo, useState } from 'react'
12import { roomKey, spaceEmojisKey, useAsync, type AsyncState } from '@/shared/lib'
13import { useWsUserEvents } from '@/shared/ws'
14import {
15 listRoomMembers,
16 listPins,
17 listRoomThreads,
18 listSpaceEmojis,
19 listSpacePresences,
20 getRoom,
21 type ChatMessage,
22 type CustomEmoji,
23 type Presence,
24 type RoomBrief,
25 type RoomMember,
26 type ThreadBrief,
27} from '@/entities/chat'
28import { t } from '@/shared/i18n'
29
30export interface RoomData {
31 /** `GET /chat/rooms/{id}` — the room as the server sees it right now. */
32 room: AsyncState<RoomBrief | undefined>
33 members: AsyncState<RoomMember[]>
34 pins: AsyncState<ChatMessage[]>
35 threads: AsyncState<ThreadBrief[]>
36 /** Empty outside a space (the personal `@me` scope has no presence list). */
37 presences: AsyncState<Presence[]>
38 emojis: AsyncState<CustomEmoji[]>
39 reload: () => void
40 /** Thread indexed by its root message, so a row knows its own topic. */
41 threadOf: (messageId: string) => ThreadBrief | undefined
42 /** Display name for a user id, resolved from the room's member list. */
43 nameOf: (userId: number | null | undefined) => string
44}
45
46export interface UseRoomDataOptions {
47 roomId: string
48 /** Personal scope (`@me`) has no space-scoped presence/emoji endpoints. */
49 spaceName?: string
50 enabled?: boolean
51 /** Bumped by live `room_updated` / `thread_updated` traffic. */
52 version?: number
53}
54
55export function useRoomData({
56 roomId,
57 spaceName,
58 enabled = true,
59 version = 0,
60}: UseRoomDataOptions): RoomData {
61 const [reloadKey, setReloadKey] = useState(0)
62 const gate = enabled && Boolean(roomId)
63 // `version` rides along with the dependency list: live traffic that changes
64 // room state (a pin by someone else, a new thread) refreshes the side data
65 // without the user having to reopen the panel. Every read is local-first:
66 // reopening a room paints the stored payload while the refresh runs.
67 const deps = [gate ? roomId : '', gate, version, reloadKey]
68 const room = useAsync(() => (gate ? getRoom(roomId) : Promise.resolve(undefined)), deps, {
69 cacheKey: gate ? roomKey(roomId, 'room') : null,
70 })
71 const members = useAsync(
72 () => (gate ? listRoomMembers(roomId).catch(() => []) : Promise.resolve([])),
73 deps,
74 { cacheKey: gate ? roomKey(roomId, 'members') : null },
75 )
76 const pins = useAsync(
77 () => (gate ? listPins(roomId).catch(() => []) : Promise.resolve([])),
78 deps,
79 { cacheKey: gate ? roomKey(roomId, 'pins') : null },
80 )
81 const threads = useAsync(
82 () => (gate ? listRoomThreads(roomId).catch(() => []) : Promise.resolve([])),
83 deps,
84 { cacheKey: gate ? roomKey(roomId, 'threads') : null },
85 )
86 // Presence is live-only state: a stale online dot is worse than no dot.
87 // 队友的在线变化没有广播通道(`presence` 事件只回发给本人),所以在标签页
88 // 可见时每 30s 重拉一次,切回前台立刻补一次;隐藏时停表,不空转网络。
89 const [presenceTick, setPresenceTick] = useState(0)
90 const livePresence = gate && Boolean(spaceName) && spaceName !== '@me'
91 useEffect(() => {
92 if (!livePresence) return
93 const tick = () => setPresenceTick(value => value + 1)
94 let timer: ReturnType<typeof setInterval> | undefined
95 const start = () => {
96 if (timer === undefined) timer = setInterval(tick, 30_000)
97 }
98 const stop = () => {
99 if (timer !== undefined) {
100 clearInterval(timer)
101 timer = undefined
102 }
103 }
104 const onVisibility = () => {
105 if (document.visibilityState === 'visible') {
106 tick()
107 start()
108 } else {
109 stop()
110 }
111 }
112 if (document.visibilityState === 'visible') start()
113 document.addEventListener('visibilitychange', onVisibility)
114 return () => {
115 stop()
116 document.removeEventListener('visibilitychange', onVisibility)
117 }
118 }, [livePresence])
119 // 自己上线/改状态(`presence` 事件只回发给本人)时立即补拉一次:硬刷新直接
120 // 进房间时,挂载那一刻 socket 可能还没连上,首屏的在线状态会短暂缺失。
121 useWsUserEvents(event => {
122 if (event.kind === 'presence') setPresenceTick(value => value + 1)
123 })
124 const presences = useAsync(
125 () =>
126 gate && spaceName && spaceName !== '@me'
127 ? listSpacePresences(spaceName).catch(() => [])
128 : Promise.resolve([]),
129 [gate, spaceName, reloadKey, presenceTick],
130 )
131 const emojis = useAsync(
132 () =>
133 gate && spaceName && spaceName !== '@me'
134 ? listSpaceEmojis(spaceName).catch(() => [])
135 : Promise.resolve([]),
136 [gate, spaceName, reloadKey],
137 { cacheKey: gate && spaceName && spaceName !== '@me' ? spaceEmojisKey(spaceName) : null },
138 )
139
140 const reload = useCallback(() => setReloadKey(key => key + 1), [])
141
142 const byRoot = useMemo(() => {
143 const map = new Map<string, ThreadBrief>()
144 for (const thread of threads.data ?? []) map.set(thread.root_message_id, thread)
145 return map
146 }, [threads.data])
147
148 const users = useMemo(() => {
149 const map = new Map<number, string>()
150 for (const member of members.data ?? []) {
151 map.set(member.user.user_id, member.nick || member.user.display_name || member.user.username)
152 }
153 return map
154 }, [members.data])
155
156 const threadOf = useCallback((messageId: string) => byRoot.get(messageId), [byRoot])
157
158 const nameOf = useCallback(
159 (userId: number | null | undefined) => {
160 if (!userId) return t('common.state.unknownUser')
161 return users.get(userId) ?? t('shell.chat.userFallback', { id: String(userId).slice(0, 8) })
162 },
163 [users],
164 )
165
166 return { room, members, pins, threads, presences, emojis, reload, threadOf, nameOf }
167}
Deleted web/src/features/chat/model/use-room-stream.ts
@@ -1,745 +0,0 @@
1/**
2 * The chat room's data engine: one hook that owns the transcript of a scope (the
3 * room's main stream, or one thread inside it) and keeps it correct across HTTP
4 * pagination, WebSocket live traffic, reconnects and the local user's own
5 * optimistic posts.
6 *
7 * Division of labour, and why:
8 * - **HTTP** seeds the window and pages it (`before_seq` / `after_seq`), because
9 * the socket only ever walks *forward* from a cursor.
10 * - **The socket** (`@/shared/ws`) is the live source: `subscribe` answers with a
11 * `history` snapshot that is merged in, then `event` frames stream updates. The
12 * client resolves `gap` and `resync_required` itself; the cases it cannot
13 * (lost cursor, truncated walk) fall back to an HTTP re-seed here.
14 * - **The reducer** (`./transcript`) is the only writer of list state, so dedupe,
15 * ordering and the viewer's own reaction flags stay in exactly one place.
16 *
17 * Sends use the backend's `nonce` idempotency key: retrying after a timeout
18 * returns the original message instead of posting a duplicate.
19 */
20import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
21import { chatSeedKey } from '@/shared/lib'
22import { t } from '@/shared/i18n'
23import { readCache, writeCache } from '@/shared/storage'
24import {
25 clientNonce,
26 deleteMessage as deleteMessageApi,
27 describeFailure,
28 editMessage as editMessageApi,
29 failureReason,
30 findRetryAfterSeconds,
31 getReadState,
32 getMessage,
33 listMessages,
34 markRead as markReadApi,
35 MESSAGE_PAGE_SIZE,
36 sendMessage,
37 setMessagePin,
38 setReaction,
39 type Attachment,
40 type ChatMessage,
41 type MessagePage,
42 type PendingMessage,
43} from '@/entities/chat'
44import { useRoomWatch, useTypingIndicators, useWsFrames, useWsIssues, useWsUserEvents } from '@/shared/ws'
45import {
46 belongsToScope,
47 createTranscriptState,
48 findEchoedPending,
49 transcriptReducer,
50 type TranscriptState,
51} from './transcript'
52
53export interface UseRoomStreamOptions {
54 roomId: string
55 /** Set to read one thread instead of the room's main stream. */
56 threadId?: string | null
57 /** Local user id: identifies your own posts and ignores your typing echo. */
58 viewerId?: number
59 /** Stops all traffic (no room selected, voice channel, outage gate open …). */
60 enabled?: boolean
61 /** The unread cursor moved: the shell refreshes its sidebar badges. */
62 onUnreadChanged?: () => void
63 /** Room's `rate_limit_per_user`, mirrored so the countdown starts *before* the
64 * server has to refuse a second post. */
65 slowModeSeconds?: number
66 /** Side-channel room events (typing excluded — that is the registry's job). */
67 onRoomEvent?: (kind: string) => void
68}
69
70export interface SendInput {
71 content: string
72 attachments?: Attachment[]
73 replyToId?: string | null
74 /** Thread the post goes into; the main stream when omitted. */
75 threadId?: string | null
76 /** Also surface a thread reply in the main stream (`thread_broadcast`). */
77 threadBroadcast?: boolean
78}
79
80export interface RoomStream {
81 state: TranscriptState
82 /** Connection lifecycle of the shared socket. */
83 status: ReturnType<typeof useRoomWatch>
84 /** Ids currently typing in this scope (your own echo already filtered out). */
85 typingUserIds: number[]
86 /** Seq the viewer last read; the「新消息」divider sits right after it. */
87 lastReadSeq: number
88 /** True while the viewport is pinned to the newest row. */
89 atBottom: boolean
90 /** Rows that arrived while the viewport was scrolled away from the tail. */
91 missedCount: number
92 /** Seconds still blocked by the room's slow mode (0 when free to send). */
93 slowModeRemaining: number
94 /** Transient action failure (edit / delete / react / pin), shown above the composer. */
95 notice: string | null
96 dismissNotice: () => void
97 listRef: React.RefObject<HTMLDivElement | null>
98 reportAtBottom: (atBottom: boolean) => void
99 scrollToBottom: (options?: { smooth?: boolean }) => void
100 loadOlder: () => void
101 jumpTo: (messageId: string) => void
102 /** Back to the newest page: replaces the window instead of leaving a hole. */
103 returnToLatest: () => void
104 send: (input: SendInput) => Promise<boolean>
105 retryPending: (localId: string) => void
106 dismissPending: (localId: string) => void
107 editMessage: (messageId: string, content: string) => Promise<boolean>
108 deleteMessage: (messageId: string) => Promise<boolean>
109 toggleReaction: (messageId: string, emoji: string, on: boolean) => Promise<void>
110 togglePin: (messageId: string, pinned: boolean) => Promise<ChatMessage | undefined>
111 refresh: () => void
112}
113
114/** Coalesces the read-marker write: scrolling must not become a request storm. */
115const MARK_READ_DEBOUNCE_MS = 800
116
117export function useRoomStream({
118 roomId,
119 threadId = null,
120 viewerId,
121 enabled = true,
122 onUnreadChanged,
123 slowModeSeconds = 0,
124 onRoomEvent,
125}: UseRoomStreamOptions): RoomStream {
126 const [state, dispatch] = useReducer(
127 transcriptReducer,
128 { roomId, threadId },
129 scope => createTranscriptState(scope.roomId, scope.threadId),
130 )
131 const status = useRoomWatch(enabled ? roomId : null)
132 const scopeKey = `${roomId}\u0000${threadId ?? ''}`
133 const listRef = useRef<HTMLDivElement | null>(null)
134 // Scope-tagged state: a room/thread switch needs no reset effect, because a
135 // value stamped with the previous scope simply does not read as anything.
136 const [bottom, setBottom] = useState<{ scope: string; value: boolean; baseline: number }>({
137 scope: scopeKey,
138 value: true,
139 baseline: 0,
140 })
141 const [read, setRead] = useState<{ scope: string; seq: number }>({ scope: scopeKey, seq: 0 })
142 const [slowModeRemaining, setSlowModeRemaining] = useState(0)
143 const [notice, setNotice] = useState<string | null>(null)
144 const [reloadKey, setReloadKey] = useState(0)
145 const typing = useTypingIndicators(enabled ? roomId : null, threadId)
146 const atBottom = bottom.scope === scopeKey ? bottom.value : true
147 /** Tip captured when the reader left the tail; everything above it is seen. */
148 const missedBaseline = bottom.scope === scopeKey ? bottom.baseline : 0
149 const lastReadSeq = read.scope === scopeKey ? read.seq : 0
150 const readCursor = useRef(0)
151 const atBottomRef = useRef(true)
152 /** Newest row the transcript holds, mirrored for the debounced read marker. */
153 const tipRef = useRef(0)
154 const markReadTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
155 const inFlight = useRef<Set<string>>(new Set())
156 const pendingById = useRef<Record<string, PendingMessage>>({})
157 const slowTimer = useRef<ReturnType<typeof setInterval> | undefined>(undefined)
158
159 /* ------------------------------------------------------------ seeding */
160
161 useEffect(() => {
162 if (!enabled || !roomId) return
163 dispatch({ type: 'reset', roomId, threadId })
164 readCursor.current = 0
165 tipRef.current = 0
166 atBottomRef.current = true
167 let active = true
168 // A *successful* network seed outranks a slower disk read; a failed one
169 // must not block it — offline, the stored page is the whole transcript.
170 let seedSucceeded = false
171 dispatch({ type: 'seedStart' })
172 const seedKey = chatSeedKey(roomId, threadId ?? null)
173 // Local-first prefill: paint the stored tail page while the fetch runs.
174 void readCache<MessagePage>(seedKey).then(hit => {
175 if (!active || seedSucceeded || !hit) return
176 dispatch({
177 type: 'seedCached',
178 items: hit.data.items,
179 hasMore: hit.data.has_more,
180 oldestSeq: hit.data.oldest_seq ?? null,
181 newestSeq: hit.data.newest_seq ?? null,
182 })
183 })
184 listMessages(roomId, { threadId: threadId ?? undefined, limit: MESSAGE_PAGE_SIZE }).then(
185 page => {
186 if (!active) return
187 seedSucceeded = true
188 // The tail page is what the next visit prefills from.
189 writeCache(seedKey, page)
190 dispatch({
191 type: 'seedDone',
192 items: page.items,
193 hasMore: page.has_more,
194 oldestSeq: page.oldest_seq ?? null,
195 newestSeq: page.newest_seq ?? null,
196 })
197 },
198 (error: unknown) => {
199 if (active) dispatch({ type: 'seedError', message: describeFailure(error) })
200 },
201 )
202 return () => {
203 active = false
204 }
205 }, [enabled, roomId, threadId, reloadKey])
206
207 /**
208 * The unread cursor is room-level state (`thread_id` is not part of it), so a
209 * thread panel never touches it. The response also reports the room tip, which
210 * is what stops the first render from marking a room as read before the reader
211 * has actually seen anything.
212 */
213 useEffect(() => {
214 if (!enabled || !roomId || threadId) return
215 let active = true
216 getReadState(roomId).then(
217 current => {
218 if (!active) return
219 readCursor.current = current.last_read_seq
220 setRead({ scope: scopeKey, seq: current.last_read_seq })
221 },
222 () => {
223 // No read state yet means nothing was ever read: the divider will sit at
224 // the very first row, which is the honest rendering of that.
225 },
226 )
227 return () => {
228 active = false
229 }
230 }, [enabled, roomId, threadId, reloadKey, scopeKey])
231
232 /* -------------------------------------------------------- live traffic */
233
234 useWsFrames(frame => {
235 if (!enabled) return
236 if (!('room_id' in frame) || frame.room_id !== roomId) return
237 if (frame.type === 'history') {
238 const items = frame.messages.filter(message => belongsToScope(message, threadId))
239 if (items.length > 0) dispatch({ type: 'liveHistory', items })
240 return
241 }
242 if (frame.type === 'event') {
243 const event = frame.event
244 if (!event) return
245 switch (event.kind) {
246 case 'message_published':
247 if (belongsToScope(event.message, threadId)) {
248 dispatch({ type: 'published', message: event.message })
249 }
250 return
251 case 'message_edited':
252 if (belongsToScope(event.message, threadId)) {
253 dispatch({ type: 'edited', message: event.message })
254 }
255 return
256 case 'message_deleted':
257 dispatch({ type: 'deleted', messageIds: [event.message_id] })
258 return
259 case 'messages_deleted':
260 dispatch({ type: 'deleted', messageIds: event.message_ids })
261 return
262 case 'message_pinned':
263 dispatch({ type: 'pinned', messageId: event.message_id, pinned: event.pinned })
264 return
265 case 'reaction_updated':
266 dispatch({ type: 'reactions', messageId: event.message_id, reactions: event.reactions })
267 return
268 default:
269 // room_updated / member_* / thread_updated / typing: panels consume
270 // these; the transcript only needs to know the room moved.
271 onRoomEvent?.(event.kind)
272 dispatch({ type: 'liveTick' })
273 }
274 return
275 }
276 if (frame.type === 'resync_required' && frame.reason === 'access revoked') {
277 dispatch({ type: 'accessRevoked' })
278 }
279 })
280
281 /** Your own read marker, mirrored from another tab. */
282 useWsUserEvents(event => {
283 if (event.kind !== 'read_marker' || event.room_id !== roomId || threadId) return
284 const seq = Number.isFinite(event.last_read_seq) ? event.last_read_seq : 0
285 if (seq <= readCursor.current) return
286 readCursor.current = seq
287 setRead({ scope: scopeKey, seq })
288 })
289
290 /**
291 * Conditions the socket cannot resolve on its own. A gap or a lagged resync it
292 * already backfills; these two mean the local window is no longer trustworthy,
293 * so re-seed it over HTTP instead of showing a transcript with a hole in it.
294 */
295 useWsIssues(issue => {
296 if (!enabled) return
297 if ('room_id' in issue && issue.room_id !== undefined && issue.room_id !== roomId) return
298 if (issue.kind === 'server_error' && issue.error === 'forbidden') {
299 dispatch({ type: 'accessRevoked' })
300 setNotice(t('chat.room.accessDenied'))
301 return
302 }
303 if (
304 issue.kind === 'cursor_lost' ||
305 issue.kind === 'history_truncated' ||
306 (issue.kind === 'resync_required' && !issue.dropped)
307 ) {
308 setReloadKey(key => key + 1)
309 }
310 })
311
312 /* --------------------------------------------------------- read marker */
313
314 const flushRead = useCallback(() => {
315 if (!enabled || threadId) return
316 const target = atBottomRef.current ? tipRef.current : readCursor.current
317 if (target <= readCursor.current) return
318 markReadApi(roomId, { seq: target }).then(
319 current => {
320 if (current.last_read_seq > readCursor.current) {
321 readCursor.current = current.last_read_seq
322 setRead({ scope: scopeKey, seq: current.last_read_seq })
323 onUnreadChanged?.()
324 }
325 },
326 () => {
327 // A lost read marker is cosmetic and self-heals on the next scroll.
328 },
329 )
330 }, [enabled, onUnreadChanged, roomId, scopeKey, threadId])
331
332 const scheduleRead = useCallback(() => {
333 if (markReadTimer.current) clearTimeout(markReadTimer.current)
334 markReadTimer.current = setTimeout(flushRead, MARK_READ_DEBOUNCE_MS)
335 }, [flushRead])
336
337 useEffect(
338 () => () => {
339 if (markReadTimer.current) clearTimeout(markReadTimer.current)
340 },
341 [],
342 )
343
344 // The tail is what the reader has seen. Mirroring it into a ref keeps the
345 // debounced marker honest without a second render, and the marker itself is
346 // only ever scheduled while the viewport is pinned there.
347 useEffect(() => {
348 if (state.newestSeq !== null && state.newestSeq > tipRef.current) {
349 tipRef.current = state.newestSeq
350 }
351 if (atBottom && tipRef.current > readCursor.current) scheduleRead()
352 }, [atBottom, scheduleRead, state.newestSeq])
353
354 // Leaving the room (navigating to another channel) must not lose what was read
355 // while sitting at the tail, so the pending marker is flushed on unmount.
356 const flushRef = useRef(flushRead)
357 useEffect(() => {
358 flushRef.current = flushRead
359 })
360 useEffect(
361 () => () => {
362 if (atBottomRef.current) flushRef.current()
363 },
364 [],
365 )
366
367 /* ------------------------------------------------------------ slow mode */
368
369 const startSlowMode = useCallback((seconds: number) => {
370 const until = Date.now() + seconds * 1_000
371 if (slowTimer.current) clearInterval(slowTimer.current)
372 slowTimer.current = setInterval(() => {
373 const remaining = Math.max(0, Math.ceil((until - Date.now()) / 1_000))
374 setSlowModeRemaining(remaining)
375 if (remaining === 0 && slowTimer.current) {
376 clearInterval(slowTimer.current)
377 slowTimer.current = undefined
378 }
379 }, 1_000)
380 setSlowModeRemaining(seconds)
381 }, [])
382
383 useEffect(
384 () => () => {
385 if (slowTimer.current) clearInterval(slowTimer.current)
386 },
387 [],
388 )
389
390 /* ------------------------------------------------------------ sending */
391
392 const deliver = useCallback(
393 async (item: PendingMessage): Promise<boolean> => {
394 if (inFlight.current.has(item.localId)) return false
395 inFlight.current.add(item.localId)
396 try {
397 const message = await sendMessage(item.room_id, {
398 content: item.content,
399 nonce: item.nonce,
400 reply_to_id: item.reply_to_id ?? null,
401 thread_id: item.thread_id ?? null,
402 thread_broadcast: item.thread_broadcast ?? false,
403 attachments: item.attachments.map(attachment => attachment.id),
404 })
405 delete pendingById.current[item.localId]
406 dispatch({ type: 'pendingSent', localId: item.localId, message })
407 // The server answers with the room's own slow-mode failure; starting the
408 // countdown here keeps a fast typist from collecting 429s one by one.
409 if (slowModeSeconds > 0) startSlowMode(slowModeSeconds)
410 return true
411 } catch (error) {
412 const retry = findRetryAfterSeconds(error)
413 if (retry) startSlowMode(retry)
414 dispatch({ type: 'pendingFail', localId: item.localId, error: failureReason(error) })
415 return false
416 } finally {
417 inFlight.current.delete(item.localId)
418 }
419 },
420 [slowModeSeconds, startSlowMode],
421 )
422
423 const send = useCallback(
424 ({ content, attachments = [], replyToId, threadId: target, threadBroadcast }: SendInput) => {
425 const body = content.replace(/\s+$/, '')
426 if (!enabled || !roomId || (!body && attachments.length === 0)) return Promise.resolve(false)
427 const nonce = clientNonce()
428 const item: PendingMessage = {
429 localId: `local:${nonce}`,
430 nonce,
431 room_id: roomId,
432 author_id: viewerId ?? 0,
433 content: body,
434 created_at: new Date().toISOString(),
435 reply_to_id: replyToId ?? null,
436 thread_id: target ?? threadId ?? null,
437 thread_broadcast: threadBroadcast ?? false,
438 attachments,
439 state: 'sending',
440 sending: true,
441 }
442 pendingById.current[item.localId] = item
443 dispatch({ type: 'pendingAdd', message: item })
444 // The author must see their own post: pin the viewport back to the tail.
445 atBottomRef.current = true
446 setBottom({ scope: scopeKey, value: true, baseline: 0 })
447 return deliver(item)
448 },
449 [deliver, enabled, roomId, scopeKey, threadId, viewerId],
450 )
451
452 const retryPending = useCallback(
453 (localId: string) => {
454 const item = pendingById.current[localId]
455 if (!item) return
456 dispatch({ type: 'pendingRetry', localId })
457 void deliver(item)
458 },
459 [deliver],
460 )
461
462 const dismissPending = useCallback((localId: string) => {
463 delete pendingById.current[localId]
464 dispatch({ type: 'pendingDrop', localId })
465 }, [])
466
467 /** Reconcile a failed row whose message actually reached the server. */
468 useEffect(() => {
469 if (state.pending.length === 0) return
470 for (const row of state.rows) {
471 const echo = findEchoedPending(state.pending, row, viewerId)
472 if (echo) {
473 delete pendingById.current[echo.localId]
474 dispatch({ type: 'pendingDrop', localId: echo.localId })
475 }
476 }
477 }, [state.pending, state.rows, viewerId])
478
479 /* ---------------------------------------------------- message actions */
480
481 const reportFailure = useCallback((error: unknown) => {
482 setNotice(describeFailure(error))
483 if (typeof window !== 'undefined') {
484 window.setTimeout(() => setNotice(null), 6_000)
485 }
486 }, [])
487
488 const editMessage = useCallback(
489 async (messageId: string, content: string) => {
490 try {
491 dispatch({ type: 'edited', message: await editMessageApi(roomId, messageId, content) })
492 return true
493 } catch (error) {
494 reportFailure(error)
495 return false
496 }
497 },
498 [reportFailure, roomId],
499 )
500
501 const deleteMessage = useCallback(
502 async (messageId: string) => {
503 try {
504 await deleteMessageApi(roomId, messageId)
505 dispatch({ type: 'deleted', messageIds: [messageId] })
506 return true
507 } catch (error) {
508 reportFailure(error)
509 return false
510 }
511 },
512 [reportFailure, roomId],
513 )
514
515 const toggleReaction = useCallback(
516 async (messageId: string, emoji: string, on: boolean) => {
517 const local = state.rows.find(row => row.id === messageId)
518 if (!local) return
519 const reactions = local.reactions ?? []
520 const without = reactions.filter(reaction => reaction.emoji !== emoji)
521 // Optimistic, keyed off the *previous* aggregate so a concurrent live
522 // update can be out by one but never loses the viewer's own flag.
523 const current = reactions.find(reaction => reaction.emoji === emoji)
524 const next = on
525 ? [...without, { emoji, count: (current?.count ?? 0) + 1, me: true }]
526 : without
527 dispatch({ type: 'reactions', messageId, reactions: next })
528 try {
529 await setReaction(roomId, messageId, emoji, on)
530 } catch (error) {
531 dispatch({ type: 'reactions', messageId, reactions })
532 reportFailure(error)
533 }
534 },
535 [reportFailure, roomId, state.rows],
536 )
537
538 const togglePin = useCallback(
539 async (messageId: string, pinned: boolean) => {
540 try {
541 const message = await setMessagePin(roomId, messageId, pinned)
542 dispatch({ type: 'pinned', messageId, pinned })
543 return message
544 } catch (error) {
545 reportFailure(error)
546 return undefined
547 }
548 },
549 [reportFailure, roomId],
550 )
551
552 /* --------------------------------------------------------- pagination */
553
554 const loadOlder = useCallback(() => {
555 if (!enabled || state.loadingOlder || !state.hasOlder) return
556 const anchor = state.oldestSeq ?? state.rows[0]?.seq
557 if (anchor === undefined || anchor <= 1) return
558 dispatch({ type: 'olderStart' })
559 listMessages(roomId, {
560 beforeSeq: anchor,
561 limit: MESSAGE_PAGE_SIZE,
562 threadId: threadId ?? undefined,
563 }).then(
564 page =>
565 dispatch({
566 type: 'olderDone',
567 items: page.items,
568 hasMore: page.has_more,
569 oldestSeq: page.oldest_seq ?? null,
570 }),
571 () => dispatch({ type: 'olderDone', items: [], hasMore: false, oldestSeq: null }),
572 )
573 }, [enabled, roomId, state.hasOlder, state.loadingOlder, state.oldestSeq, state.rows, threadId])
574
575 /**
576 * Return to the newest page after a backwards jump. The window is *replaced*,
577 * not extended: rows between where the reader was and the tail were never
578 * fetched, and merging would show a transcript with an invisible hole in it.
579 */
580 const returnToLatest = useCallback(() => {
581 if (!enabled || !roomId) return
582 dispatch({ type: 'newerStart' })
583 listMessages(roomId, { limit: MESSAGE_PAGE_SIZE, threadId: threadId ?? undefined }).then(
584 page => {
585 // Back at the tail: this is exactly the window the next visit prefills
586 // from, so the stored seed follows the reader.
587 writeCache(chatSeedKey(roomId, threadId ?? null), page)
588 dispatch({
589 type: 'tailReplaced',
590 items: page.items,
591 hasMore: page.has_more,
592 oldestSeq: page.oldest_seq ?? null,
593 newestSeq: page.newest_seq ?? null,
594 })
595 tipRef.current = Math.max(tipRef.current, page.newest_seq ?? 0)
596 atBottomRef.current = true
597 setBottom({ scope: scopeKey, value: true, baseline: tipRef.current })
598 },
599 () => dispatch({ type: 'seedError', message: t('chat.transcript.seedFailed') }),
600 )
601 }, [enabled, roomId, scopeKey, threadId])
602
603 /**
604 * Open the window around a row that is not loaded — a pin, a search hit, a
605 * reply target. `get_message` yields its seq first, because a page can only be
606 * anchored on a cursor, and one call cannot straddle a row.
607 */
608 const jumpTo = useCallback(
609 (messageId: string) => {
610 if (!enabled || !roomId) return
611 dispatch({ type: 'highlight', messageId })
612 if (state.rows.some(row => row.id === messageId)) return
613 void (async () => {
614 try {
615 const target = await getMessage(roomId, messageId)
616 const page = await listMessages(roomId, {
617 beforeSeq: target.seq + 1,
618 limit: MESSAGE_PAGE_SIZE,
619 threadId: threadId ?? undefined,
620 })
621 dispatch({
622 type: 'seedDone',
623 items: page.items,
624 hasMore: page.has_more,
625 oldestSeq: page.oldest_seq ?? null,
626 newestSeq: page.newest_seq ?? null,
627 })
628 // "Missed" restarts at the row the reader asked for, not at the tail.
629 tipRef.current = Math.max(tipRef.current, page.newest_seq ?? target.seq)
630 atBottomRef.current = false
631 setBottom({ scope: scopeKey, value: false, baseline: target.seq })
632 dispatch({ type: 'highlight', messageId })
633 } catch (error) {
634 reportFailure(error)
635 }
636 })()
637 },
638 [enabled, reportFailure, roomId, scopeKey, state.rows, threadId],
639 )
640
641 /* ------------------------------------------------------- reply lookup */
642
643 const missingRefs = useMemo(
644 () =>
645 state.rows
646 .map(row => row.reply_to_id)
647 .filter((id): id is string => Boolean(id))
648 .filter(id => !state.refs[id] && !state.rows.some(row => row.id === id)),
649 [state.refs, state.rows],
650 )
651
652 useEffect(() => {
653 if (missingRefs.length === 0 || !enabled) return
654 let active = true
655 for (const id of missingRefs) {
656 dispatch({ type: 'refStart', messageId: id })
657 getMessage(roomId, id).then(
658 message => active && dispatch({ type: 'refDone', messageId: id, message }),
659 () => active && dispatch({ type: 'refDone', messageId: id, message: null }),
660 )
661 }
662 return () => {
663 active = false
664 }
665 }, [enabled, missingRefs, roomId])
666
667 /* -------------------------------------------------------- scroll glue */
668
669 const reportAtBottom = useCallback(
670 (value: boolean) => {
671 if (atBottomRef.current === value) return
672 atBottomRef.current = value
673 // The baseline is captured at the moment the reader leaves the tail, which
674 // is what makes the「N 条新消息」pill count only what they have not seen.
675 // The baseline is captured at the moment the reader leaves the tail, which
676 // is what makes the「N 条新消息」pill count only what they have not seen.
677 setBottom({ scope: scopeKey, value, baseline: value ? 0 : tipRef.current })
678 },
679 [scopeKey],
680 )
681
682 const scrollToBottom = useCallback(
683 (options?: { smooth?: boolean }) => {
684 const node = listRef.current
685 if (node) {
686 node.scrollTo({
687 top: node.scrollHeight,
688 behavior: options?.smooth ? 'smooth' : 'auto',
689 })
690 }
691 reportAtBottom(true)
692 },
693 [reportAtBottom],
694 )
695
696 // Pinned means "follow the tail": only a *change of the last row* scrolls, so
697 // prepending an older page never moves the viewport out from under the reader.
698 const lastTail = useRef<string | null>(null)
699 useEffect(() => {
700 const tail = state.rows[state.rows.length - 1]?.id ?? ''
701 const pendingTail = state.pending[state.pending.length - 1]?.localId ?? ''
702 const signature = `${tail}|${pendingTail}`
703 const changed = lastTail.current !== signature
704 lastTail.current = signature
705 if (!changed || !atBottomRef.current) return
706 const frame = requestAnimationFrame(() => {
707 const node = listRef.current
708 if (node) node.scrollTop = node.scrollHeight
709 })
710 return () => cancelAnimationFrame(frame)
711 }, [state.rows, state.pending])
712
713 const missedCount = useMemo(() => {
714 if (atBottom) return 0
715 let count = 0
716 for (const row of state.rows) if (row.seq > missedBaseline) count += 1
717 return count
718 }, [atBottom, missedBaseline, state.rows])
719
720 return {
721 state,
722 status,
723 typingUserIds: typing.map(indicator => Number(indicator.user_id)),
724 lastReadSeq,
725 atBottom,
726 missedCount,
727 slowModeRemaining,
728 notice,
729 dismissNotice: () => setNotice(null),
730 listRef,
731 reportAtBottom,
732 scrollToBottom,
733 loadOlder,
734 jumpTo,
735 returnToLatest,
736 send,
737 retryPending,
738 dismissPending,
739 editMessage,
740 deleteMessage,
741 toggleReaction,
742 togglePin,
743 refresh: () => setReloadKey(key => key + 1),
744 }
745}
Deleted web/src/features/chat/model/view-state.ts
@@ -1,23 +0,0 @@
1/**
2 * The chat column's view state, as the page passes it down.
3 *
4 * It is deliberately a plain shape: the page derives it from the URL (see
5 * `pages/workspace/model/chat-location`) and hands back a patcher, so the
6 * feature never imports react-router and never learns what a query string is.
7 * That is also what keeps every panel, thread and jump deep-linkable.
8 */
9export type ChatPanelKind = 'thread' | 'pins' | 'threads' | 'members' | null
10
11export interface ChatViewState {
12 /** Which right-hand column is open; `null` leaves just the transcript. */
13 panel: ChatPanelKind
14 /** Set with `panel: 'thread'`; the thread's own id. */
15 threadId?: string
16 /** One-shot targets: the view consumes them and clears the URL entry. */
17 replyToId?: string
18 editId?: string
19 jumpId?: string
20}
21
22/** Defaults are omitted from the URL, so a plain room reads `/app/x/chat/<id>`. */
23export const CHAT_VIEW_DEFAULTS: ChatViewState = { panel: null }
Deleted web/src/features/chat/model/voice-connection.ts
@@ -1,92 +0,0 @@
1/**
2 * The wiring seam between the tab-wide voice session (`@/shared/voice`) and the
3 * chat API (`@/entities/chat`).
4 *
5 * `shared/voice` must stay free of entities imports, so the credential minting
6 * lives here in the feature that owns both sides. Everything the session needs
7 * to survive - a refresh restore, a rejoin with a *fresh* token after a signal
8 * drop, the pre-join capability gate - funnels through this module.
9 */
10import { getVoiceConfig, requestVoiceToken } from '@/entities/chat'
11import { storedVoiceSession, voiceSession, type VoiceCredentials } from '@/shared/voice'
12
13let installed = false
14
15/** Mint a LiveKit access token + endpoint for one voice channel. */
16export async function voiceCredentials(roomId: string): Promise<VoiceCredentials> {
17 const token = await requestVoiceToken(roomId)
18 return {
19 url: token.url,
20 token: token.token,
21 canPublish: token.can_publish,
22 isStage: token.is_stage,
23 }
24}
25
26/**
27 * Restores an in-progress call after a page refresh.
28 *
29 * The connection cannot survive a reload, so the room id lives in
30 * sessionStorage and the tab re-joins silently - **muted**, because a browser
31 * blocks remote audio until a gesture anyway and rejoining with an open
32 * microphone would be worse than one extra click. The call bar then offers to
33 * restore sound.
34 */
35async function restoreStoredSession(): Promise<void> {
36 const stored = storedVoiceSession()
37 if (!stored) return
38 try {
39 const credentials = await voiceCredentials(stored.roomId)
40 await voiceSession.join({
41 roomId: stored.roomId,
42 roomLabel: stored.roomLabel,
43 spaceName: stored.spaceName,
44 ...credentials,
45 startWithMic: false,
46 })
47 } catch {
48 // A stale room (deleted channel, revoked access) simply does not restore;
49 // leaving clears the stored id so the next load stays quiet.
50 await voiceSession.leave()
51 }
52}
53
54/**
55 * Install the session's credential source and kick off the refresh restore.
56 * Called once from the app layer; idempotent, so a double render (StrictMode)
57 * cannot tear down and rebuild the connection the first pass just made.
58 */
59export function installVoiceSession(): void {
60 if (installed) return
61 installed = true
62 voiceSession.setCredentialSource(voiceCredentials)
63 void restoreStoredSession()
64}
65
66export interface JoinVoiceRoomOptions {
67 roomId: string
68 roomLabel?: string
69 spaceName?: string
70 /** Voice channels open the mic; Stage audiences join in listen-only mode. */
71 startWithMic: boolean
72 audioDeviceId?: string | null
73 videoDeviceId?: string | null
74 audioOutputDeviceId?: string | null
75}
76
77/** Capability gate + credential minting + connect, as one awaitable action. */
78export async function joinVoiceRoom(options: JoinVoiceRoomOptions): Promise<void> {
79 const config = await getVoiceConfig()
80 if (!config.enabled) throw new Error('livekit is not enabled')
81 const credentials = await voiceCredentials(options.roomId)
82 await voiceSession.join({
83 roomId: options.roomId,
84 roomLabel: options.roomLabel,
85 spaceName: options.spaceName,
86 ...credentials,
87 startWithMic: options.startWithMic,
88 audioDeviceId: options.audioDeviceId,
89 videoDeviceId: options.videoDeviceId,
90 audioOutputDeviceId: options.audioOutputDeviceId,
91 })
92}
Deleted web/src/features/chat/ui/voice/voice-avatar-tile.tsx

Diff too large to display.

Deleted web/src/features/chat/ui/voice/voice-call-bar.tsx

Diff too large to display.

Deleted web/src/features/chat/ui/voice/voice-danmaku-history.tsx

Diff too large to display.

Deleted web/src/features/chat/ui/voice/voice-danmaku-input.tsx

Diff too large to display.

Deleted web/src/features/chat/ui/voice/voice-danmaku.tsx

Diff too large to display.

Deleted web/src/features/chat/ui/voice/voice-device-sheet.tsx

Diff too large to display.

Deleted web/src/features/chat/ui/voice/voice-devices.ts

Diff too large to display.

Deleted web/src/features/chat/ui/voice/voice-format.ts

Diff too large to display.

Deleted web/src/features/chat/ui/voice/voice-room-panel.tsx

Diff too large to display.

Deleted web/src/features/chat/ui/voice/voice-tile-layout.ts

Diff too large to display.

Deleted web/src/features/chat/ui/voice/voice-tiles.tsx

Diff too large to display.

Deleted web/src/features/chat/ui/voice/voice.css

Diff too large to display.

Deleted web/src/features/chat/ui/chat-composer.tsx
@@ -1,478 +0,0 @@
1/**
2 * The composer band: message entry, @-mention completion, emoji insertion,
3 * attachment staging, and the reply / edit contexts.
4 *
5 * Details that are easy to get wrong and are handled here:
6 * - **Enter sends, Shift+Enter breaks a line — unless an IME is composing.**
7 * `e.isComposing` is checked first: confirming a Chinese candidate with Enter
8 * must never fire a half-finished message.
9 * - The mention list matches the backend's own token grammar (`@name`, trailing
10 * punctuation stripped), and an unresolvable name simply posts as plain text
11 * rather than being rejected.
12 * - Attachments stage in the strip above the box; the send button explains *why*
13 * it is unavailable (uploading, failed file, too long, slow mode) instead of
14 * silently ignoring the click.
15 * - Paste and drag-and-drop both feed the same queue as the file button.
16 */
17import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
18import {
19 AtSign,
20 FileText,
21 Loader2,
22 Paperclip,
23 RefreshCw,
24 Send,
25 Smile,
26 TriangleAlert,
27 X,
28} from 'lucide-react'
29import {
30 MAX_MESSAGE_LEN,
31 formatSize,
32 textLength,
33 type ChatMessage,
34 type CustomEmoji,
35 type RoomMember,
36} from '@/entities/chat'
37import { useT } from '@/shared/i18n'
38import { Popover } from './parts'
39import { EmojiPicker } from './emoji-picker'
40import type { ComposerState } from '../model/use-composer'
41
42const MAX_BOX_HEIGHT = 224
43
44export interface ComposerProps {
45 composer: ComposerState
46 roomLabel: string
47 /** Override the entry hint (voice channels post bullet comments). */
48 placeholder?: string
49 /** Thread scope changes the placeholder and the reply affordance. */
50 threadRoot?: ChatMessage
51 /** Quote-reply target, kept out of the text itself. */
52 replyTo?: ChatMessage | null
53 onCancelReply?: () => void
54 editing?: ChatMessage | null
55 onCancelEdit?: () => void
56 /** Room's `rate_limit_per_user`, mirrored so the box can explain itself. */
57 slowModeSeconds?: number
58 slowModeRemaining?: number
59 members: RoomMember[]
60 customEmojis?: CustomEmoji[]
61 spaceName?: string
62 /** Voice channel, deleted room, read-only …: replaces the box with a note. */
63 disabledNote?: string | null
64 onTyping?: () => void
65 onSubmit?: () => void
66}
67
68export function ChatComposer({
69 composer,
70 roomLabel,
71 placeholder,
72 replyTo,
73 onCancelReply,
74 editing,
75 onCancelEdit,
76 slowModeSeconds = 0,
77 slowModeRemaining = 0,
78 members,
79 customEmojis = [],
80 spaceName,
81 disabledNote,
82 onTyping,
83 onSubmit,
84}: ComposerProps) {
85 const { t } = useT()
86 const boxRef = useRef<HTMLTextAreaElement | null>(null)
87 const helpId = useId()
88 const [emojiOpen, setEmojiOpen] = useState(false)
89 const [mention, setMention] = useState<{ query: string; start: number } | null>(null)
90 const [active, setActive] = useState(0)
91 const { text, setText } = composer
92
93 // Grow with the content up to a limit, then scroll inside the box.
94 useEffect(() => {
95 const node = boxRef.current
96 if (!node) return
97 node.style.height = 'auto'
98 node.style.height = `${Math.min(node.scrollHeight, MAX_BOX_HEIGHT)}px`
99 }, [text])
100
101 useEffect(() => {
102 if (!disabledNote) boxRef.current?.focus()
103 }, [disabledNote, roomLabel])
104
105 const candidates = useMemo(() => {
106 if (!mention) return []
107 const query = mention.query.toLowerCase()
108 return members
109 .filter(member => {
110 const name = member.user.username.toLowerCase()
111 const display = (member.nick || member.user.display_name || '').toLowerCase()
112 return !query || name.startsWith(query) || display.includes(query)
113 })
114 .slice(0, 8)
115 }, [members, mention])
116
117 // Clamped during render instead of reset in an effect: the highlight must
118 // follow a shrinking candidate list without a second render pass.
119 const activeIndex = candidates.length > 0 ? Math.min(active, candidates.length - 1) : 0
120
121 const syncMention = useCallback((value: string, caret: number) => {
122 const upto = value.slice(0, caret)
123 const start = upto.lastIndexOf('@')
124 if (start < 0) return setMention(null)
125 // A space before the @ (or the very start) is what makes it a mention and
126 // not an e-mail address typed mid-sentence.
127 const boundary = start === 0 || /\s/.test(upto[start - 1] ?? '')
128 const token = upto.slice(start + 1)
129 if (!boundary || /\s/.test(token) || token.length > 40) return setMention(null)
130 setMention({ query: token, start })
131 }, [])
132
133 const insertMention = useCallback(
134 (member: RoomMember) => {
135 const node = boxRef.current
136 if (!mention || !node) return
137 const caret = node.selectionStart ?? text.length
138 const before = text.slice(0, mention.start)
139 const after = text.slice(caret)
140 const token = `@${member.user.username}`
141 const next = `${before}${token} ${after}`
142 setText(next.slice(0, MAX_MESSAGE_LEN + 200))
143 setMention(null)
144 requestAnimationFrame(() => {
145 node.focus()
146 const position = before.length + token.length + 1
147 node.setSelectionRange(position, position)
148 })
149 },
150 [mention, setText, text],
151 )
152
153 const insertEmoji = useCallback(
154 (emoji: string) => {
155 const node = boxRef.current
156 const caret = node?.selectionStart ?? text.length
157 const next = `${text.slice(0, caret)}${emoji}${text.slice(caret)}`
158 setText(next)
159 setEmojiOpen(false)
160 requestAnimationFrame(() => {
161 if (!node) return
162 node.focus()
163 const position = caret + emoji.length
164 node.setSelectionRange(position, position)
165 })
166 },
167 [setText, text],
168 )
169
170 const submit = useCallback(() => {
171 if (!composer.canSend || slowModeRemaining > 0) return
172 setMention(null)
173 onSubmit?.()
174 }, [composer.canSend, onSubmit, slowModeRemaining])
175
176 const onKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
177 // IME first: Enter belongs to the candidate window while it is open.
178 if (event.nativeEvent.isComposing || event.keyCode === 229) return
179 if (mention && candidates.length > 0) {
180 if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
181 event.preventDefault()
182 setActive(index => {
183 const current = Math.min(index, candidates.length - 1)
184 return (current + (event.key === 'ArrowDown' ? 1 : candidates.length - 1)) % candidates.length
185 })
186 return
187 }
188 if (event.key === 'Enter' || event.key === 'Tab') {
189 event.preventDefault()
190 const picked = candidates[activeIndex]
191 if (picked) insertMention(picked)
192 return
193 }
194 if (event.key === 'Escape') {
195 event.preventDefault()
196 setMention(null)
197 return
198 }
199 }
200 if (event.key === 'Enter' && !event.shiftKey) {
201 event.preventDefault()
202 submit()
203 return
204 }
205 if (event.key === 'Escape') {
206 if (editing) onCancelEdit?.()
207 else if (replyTo) onCancelReply?.()
208 }
209 }
210
211 const onPaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
212 const files = [...event.clipboardData.files]
213 if (files.length > 0) {
214 event.preventDefault()
215 composer.addFiles(files)
216 }
217 }
218
219 const onDrop = (event: React.DragEvent<HTMLDivElement>) => {
220 const files = [...event.dataTransfer.files]
221 if (files.length === 0) return
222 event.preventDefault()
223 composer.addFiles(files)
224 }
225
226 if (disabledNote) {
227 return (
228 <div className="flex h-11 items-center justify-center rounded-lg bg-shell-field px-3 text-[13px] text-shell-ink-muted">
229 {disabledNote}
230 </div>
231 )
232 }
233
234 const length = textLength(text)
235 const over = length > MAX_MESSAGE_LEN
236 const blocked = composer.blocked
237 const canSend = composer.canSend && slowModeRemaining === 0
238
239 return (
240 <div
241 onDragOver={event => event.preventDefault()}
242 onDrop={onDrop}
243 className="chat-composer"
244 >
245 {editing ? (
246 <ContextBar
247 icon={<span className="text-[11px]">✎</span>}
248 label={t('chat.composer.editLabel')}
249 preview={editing.content}
250 hint={t('chat.composer.escCancel')}
251 onCancel={onCancelEdit}
252 />
253 ) : replyTo ? (
254 <ContextBar
255 icon={<AtSign className="h-3.5 w-3.5" />}
256 label={t('chat.composer.replyLabel', { author: replyTo.author?.display_name || replyTo.author?.username || t('chat.composer.someMessage') })}
257 preview={replyTo.content}
258 hint={t('chat.composer.escCancel')}
259 onCancel={onCancelReply}
260 />
261 ) : null}
262
263 {composer.uploads.length > 0 ? (
264 <div className="flex flex-wrap gap-2 px-2.5 pt-2.5">
265 {composer.uploads.map(item => (
266 <div
267 key={item.localId}
268 className="relative flex h-14 w-32 items-center gap-1.5 overflow-hidden rounded-md border border-shell-hairline bg-shell-surface p-1.5"
269 >
270 {item.previewUrl ? (
271 <img src={item.previewUrl} alt="" className="h-10 w-10 shrink-0 rounded object-cover" />
272 ) : (
273 <FileText className="h-5 w-5 shrink-0 text-shell-ink-muted" />
274 )}
275 <div className="min-w-0 flex-1">
276 <p className="truncate text-[11px] font-medium">{item.file.name}</p>
277 <p className="truncate text-[10px] text-shell-ink-muted">
278 {item.state === 'ready'
279 ? formatSize(item.file.size)
280 : item.state === 'failed' || item.state === 'too_big'
281 ? (item.error ?? t('chat.composer.uploadFailedShort'))
282 : t('chat.composer.uploading')}
283 </p>
284 </div>
285 {item.state === 'failed' ? (
286 <button
287 type="button"
288 aria-label={t('chat.composer.retryUpload')}
289 onClick={() => composer.retryUpload(item.localId)}
290 className="grid h-5 w-5 place-content-center rounded bg-[#e5484d]/12 text-[#e5484d]"
291 >
292 <RefreshCw className="h-3 w-3" />
293 </button>
294 ) : item.state === 'ready' ? (
295 <span className="grid h-5 w-5 place-content-center rounded bg-[#3ba55d]/15 text-[#3ba55d]">
296 ✓
297 </span>
298 ) : item.state === 'too_big' ? null : (
299 <Loader2 className="h-4 w-4 shrink-0 animate-spin text-shell-ink-muted" />
300 )}
301 <button
302 type="button"
303 aria-label={t('chat.composer.removeFile', { name: item.file.name })}
304 onClick={() => composer.removeUpload(item.localId)}
305 className="absolute right-0.5 top-0.5 grid h-4 w-4 place-content-center rounded bg-shell-surface/90 text-shell-ink-muted hover:text-shell-ink"
306 >
307 <X className="h-3 w-3" />
308 </button>
309 </div>
310 ))}
311 </div>
312 ) : null}
313
314 <div className="chat-composer-input">
315 <div className="relative min-w-0 flex-1">
316 <textarea
317 ref={boxRef}
318 value={text}
319 rows={1}
320 placeholder={placeholder ?? t('chat.composer.entryHint', { room: roomLabel })}
321 aria-label={t('chat.composer.inputAria')}
322 aria-describedby={helpId}
323 spellCheck
324 onChange={event => {
325 setText(event.target.value)
326 syncMention(event.target.value, event.target.selectionStart ?? 0)
327 onTyping?.()
328 }}
329 onClick={event => syncMention(text, event.currentTarget.selectionStart ?? 0)}
330 onBlur={() => setMention(null)}
331 onKeyDown={onKeyDown}
332 onPaste={onPaste}
333 className="max-h-56 w-full resize-none bg-transparent py-1.5 text-[15px] leading-[1.5] outline-none placeholder:text-shell-ink-muted"
334 />
335 <Popover open={Boolean(mention) && candidates.length > 0} onClose={() => setMention(null)} className="w-64 p-1">
336 <p className="px-2 py-1 text-[10.5px] font-bold uppercase tracking-wide text-shell-ink-muted">
337 {t('chat.composer.mentionMembers')}
338 </p>
339 {candidates.map((member, index) => (
340 <button
341 key={member.user.user_id}
342 type="button"
343 onMouseEnter={() => setActive(index)}
344 onMouseDown={event => {
345 // mousedown, not click: the textarea's blur would otherwise
346 // close the list before the pick is registered.
347 event.preventDefault()
348 insertMention(member)
349 }}
350 className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[12.5px] ${
351 index === activeIndex ? 'bg-shell-active' : 'hover:bg-shell-field'
352 }`}
353 >
354 <span className="truncate font-medium">
355 {member.nick || member.user.display_name || member.user.username}
356 </span>
357 <span className="min-w-0 flex-1 truncate text-shell-ink-muted">@{member.user.username}</span>
358 {member.role !== 'member' ? (
359 <span className="shrink-0 rounded bg-shell-field px-1 text-[10px]">{member.role}</span>
360 ) : null}
361 </button>
362 ))}
363 </Popover>
364 </div>
365 </div>
366 <div className="chat-composer-toolbar">
367 <label className="chat-composer-tool" title={t('chat.composer.addAttachmentHint')}>
368 <input
369 type="file"
370 multiple
371 aria-label={t('chat.composer.addAttachment')}
372 className="sr-only"
373 onChange={event => {
374 const files = [...(event.target.files ?? [])]
375 composer.addFiles(files)
376 event.target.value = ''
377 }}
378 />
379 <Paperclip className="h-[18px] w-[18px]" />
380 </label>
381 <div className="relative shrink-0">
382 <button
383 type="button"
384 aria-label={t('chat.composer.insertEmoji')}
385 title={t('chat.composer.insertEmoji')}
386 aria-expanded={emojiOpen}
387 onClick={() => setEmojiOpen(value => !value)}
388 className="chat-composer-tool"
389 >
390 <Smile className="h-[18px] w-[18px]" />
391 </button>
392 <Popover open={emojiOpen} onClose={() => setEmojiOpen(false)} className="w-80">
393 <EmojiPicker custom={customEmojis} spaceName={spaceName} onPick={insertEmoji} />
394 </Popover>
395 </div>
396 <p id={helpId} className="chat-composer-help">
397 <span><kbd>Enter</kbd> {t('chat.composer.enterSends')}</span>
398 <span><kbd>Shift + Enter</kbd> {t('chat.composer.shiftEnterNewline')}</span>
399 </p>
400 <button
401 type="button"
402 aria-label={t('chat.composer.sendAria')}
403 title={
404 slowModeRemaining > 0
405 ? t('chat.composer.slowModeWait', { seconds: slowModeRemaining })
406 : blocked
407 ? blocked
408 : over
409 ? t('chat.composer.tooLongBy', { count: length - MAX_MESSAGE_LEN })
410 : t('chat.composer.sendWithEnter')
411 }
412 disabled={!canSend}
413 onClick={submit}
414 className="chat-send-button"
415 >
416 {slowModeRemaining > 0 ? (
417 <span className="text-[11px] font-bold tabular-nums">{slowModeRemaining}</span>
418 ) : (
419 <Send className="h-4 w-4" />
420 )}
421 <span>{editing ? t('chat.composer.save') : t('chat.composer.sendAria')}</span>
422 </button>
423 </div>
424
425 {(over || blocked || composer.draftNotice || (slowModeSeconds > 0 && length === 0)) && (
426 <div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 pb-2 text-[11.5px]">
427 {over ? (
428 <span className="flex items-center gap-1 text-[#e5484d]">
429 <TriangleAlert className="h-3 w-3" />
430 {t('chat.composer.tooLongDetail', { over: length - MAX_MESSAGE_LEN, max: MAX_MESSAGE_LEN })}
431 </span>
432 ) : null}
433 {blocked ? <span className="text-shell-ink-muted">{blocked}</span> : null}
434 {composer.draftNotice ? <span className="text-shell-ink-muted">{composer.draftNotice}</span> : null}
435 {slowModeSeconds > 0 && length === 0 ? (
436 <span className="text-shell-ink-muted">{t('chat.composer.slowModeDetail', { seconds: slowModeSeconds })}</span>
437 ) : null}
438 </div>
439 )}
440 </div>
441 )
442}
443
444function ContextBar({
445 icon,
446 label,
447 preview,
448 hint,
449 onCancel,
450}: {
451 icon: React.ReactNode
452 label: string
453 preview: string
454 hint: string
455 onCancel?: () => void
456}) {
457 const { t } = useT()
458 return (
459 <div className="flex items-center gap-2 rounded-t-lg border-b border-shell-hairline bg-shell-surface px-3 py-1.5">
460 {icon}
461 <span className="shrink-0 text-[12px] font-semibold">{label}</span>
462 <span className="min-w-0 flex-1 truncate text-[12px] text-shell-ink-muted">
463 {preview || t('chat.composer.attachmentPlaceholder')}
464 </span>
465 <span className="hidden shrink-0 text-[11px] text-shell-ink-muted lg:inline">{hint}</span>
466 {onCancel ? (
467 <button
468 type="button"
469 aria-label={t('common.action.cancel')}
470 onClick={onCancel}
471 className="shrink-0 text-shell-ink-muted hover:text-shell-ink"
472 >
473 <X className="h-3.5 w-3.5" />
474 </button>
475 ) : null}
476 </div>
477 )
478}
Deleted web/src/features/chat/ui/chat-polish.css

Diff too large to display.

Deleted web/src/features/chat/ui/chat-room.tsx

Diff too large to display.

Deleted web/src/features/chat/ui/emoji-picker.tsx

Diff too large to display.

Deleted web/src/features/chat/ui/parts.tsx

Diff too large to display.

Deleted web/src/features/chat/ui/side-panel.tsx

Diff too large to display.

Deleted web/src/features/chat/ui/system-lines.ts

Diff too large to display.

Deleted web/src/features/chat/ui/transcript.tsx

Diff too large to display.

Deleted web/src/features/chat/index.ts
@@ -1,15 +0,0 @@
1// Public API of features/chat — the 聊天室 column of the workspace page: the
2// transcript, the composer, the thread/pin/member panels and the room's
3// own dialogs. Realtime comes from the app-level socket in '@/shared/ws'; message
4// rows and transport live in '@/entities/chat'.
5// Consumers import from '@/features/chat', never from deeper files.
6export { ChatRoom, type ChatRoomProps } from './ui/chat-room'
7export { prefetchRoomView } from './model/prefetch'
8export {
9 installVoiceSession,
10 joinVoiceRoom,
11 voiceCredentials,
12 type JoinVoiceRoomOptions,
13} from './model/voice-connection'
14export { VoiceCallBar } from './ui/voice/voice-call-bar'
15export type { ChatPanelKind, ChatViewState } from './model/view-state'
Deleted web/src/features/issue-board/model/prefetch.ts

Diff too large to display.

Deleted web/src/features/issue-board/model/use-board-data.ts

Diff too large to display.

Deleted web/src/features/issue-board/ui/board-filters.tsx

Diff too large to display.

Deleted web/src/features/issue-board/ui/board-manage-modal.tsx

Diff too large to display.

Deleted web/src/features/issue-board/ui/board-polish.css

Diff too large to display.

Deleted web/src/features/issue-board/ui/dialog.tsx

Diff too large to display.

Deleted web/src/features/issue-board/ui/issue-board.tsx

Diff too large to display.

Deleted web/src/features/issue-board/ui/issue-detail.tsx

Diff too large to display.

Deleted web/src/features/issue-board/ui/issue-form-modal.tsx

Diff too large to display.

Deleted web/src/features/issue-board/ui/parts.tsx

Diff too large to display.

Deleted web/src/features/issue-board/index.ts

Diff too large to display.

Deleted web/src/features/notifications/model/api.ts

Diff too large to display.

Deleted web/src/features/notifications/model/live.ts

Diff too large to display.

Deleted web/src/features/notifications/model/target.ts

Diff too large to display.

Deleted web/src/features/notifications/model/use-unread-count.ts

Diff too large to display.

Deleted web/src/features/notifications/ui/meta.ts

Diff too large to display.

Deleted web/src/features/notifications/ui/notify-nav-item.tsx

Diff too large to display.

Deleted web/src/features/notifications/ui/notify-polish.css

Diff too large to display.

Deleted web/src/features/notifications/ui/notify-toaster.tsx

Diff too large to display.

Deleted web/src/features/notifications/ui/notify-view.tsx

Diff too large to display.

Deleted web/src/features/notifications/index.ts

Diff too large to display.

Deleted web/src/features/pull-request/model/filters.tsx

Diff too large to display.

Deleted web/src/features/pull-request/model/meta.ts

Diff too large to display.

Deleted web/src/features/pull-request/ui/checks-tab.tsx

Diff too large to display.

Deleted web/src/features/pull-request/ui/commits-tab.tsx

Diff too large to display.

Deleted web/src/features/pull-request/ui/conversation.tsx

Diff too large to display.

Deleted web/src/features/pull-request/ui/files-tab.tsx

Diff too large to display.

Deleted web/src/features/pull-request/ui/parts.tsx

Diff too large to display.

Deleted web/src/features/pull-request/ui/pull-detail.tsx

Diff too large to display.

Deleted web/src/features/pull-request/ui/pull-form-modal.tsx

Diff too large to display.

Deleted web/src/features/pull-request/ui/pulls-tab.tsx

Diff too large to display.

Deleted web/src/features/pull-request/index.ts

Diff too large to display.

Deleted web/src/features/repo-branches/model/branch.ts

Diff too large to display.

Deleted web/src/features/repo-branches/ui/branches-tab.tsx

Diff too large to display.

Deleted web/src/features/repo-branches/ui/submodules-section.tsx

Diff too large to display.

Deleted web/src/features/repo-branches/ui/tags-section.tsx

Diff too large to display.

Deleted web/src/features/repo-branches/index.ts

Diff too large to display.

Deleted web/.gitignore
@@ -1,29 +0,0 @@
1# Logs
2logs
3*.log
4npm-debug.log*
5yarn-debug.log*
6yarn-error.log*
7pnpm-debug.log*
8lerna-debug.log*
9
10node_modules
11dist
12dist-ssr
13*.local
14
15# Editor directories and files
16.vscode/*
17!.vscode/extensions.json
18.idea
19.DS_Store
20*.suo
21*.ntvs*
22*.njsproj
23*.sln
24*.sw?
25
26# ZCode session-local state (plans, caches)
27.zcode/
28
29target
No newline at end of file
Deleted web/.oxlintrc.json
@@ -1,9 +0,0 @@
1{
2 "$schema": "./node_modules/oxlint/configuration_schema.json",
3 "plugins": ["react", "typescript", "oxc"],
4 "ignorePatterns": ["src/shared/livekit/**"],
5 "rules": {
6 "react/rules-of-hooks": "error",
7 "react/only-export-components": ["warn", { "allowConstantExport": true }]
8 }
9}
Deleted web/alova.config.ts
@@ -1,43 +0,0 @@
1import { defineConfig } from '@alova/wormhole';
2
3export default defineConfig({
4 generator: [
5 {
6 input: 'openapi.json',
7 output: 'src/shared/api',
8 type: 'typescript',
9 version: 3,
10 global: 'Apis',
11 plugins: [
12 {
13 name: 'strict-ts-fix',
14 beforeCodeGenerate(_data, outputFile, ctx) {
15 if (outputFile.endsWith('index.ts')) {
16 const code = ctx.renderTemplate();
17 const fix = (raw: string) =>
18 raw
19 .replace(/baseURL:\s*''/, "baseURL: '/api/v1'")
20 .replace(
21 /beforeRequest:\s*method\s*=>\s*\{\s*},/,
22 'beforeRequest: (method) => { void method; },',
23 )
24 .replace(
25 /responded:\s*res\s*=>\s*\{\s*return\s*res\.json\(\);\s*}/,
26 `responded: async (res) => {
27 const contentType = res.headers.get('content-type') || '';
28 if (contentType.includes('application/json')) {
29 return res.json();
30 }
31 return res.text();
32 }`,
33 );
34 const apply = (raw: string) =>
35 `${fix(raw)}\n// Re-export the generated DTO types through the segment's public API so\n// consumers never deep-import globals.d (regenerated file, see alova.config).\nexport type * from './globals.d'\n`;
36 return typeof code === 'string' ? apply(code) : code.then(apply);
37 }
38 },
39 },
40 ],
41 },
42 ],
43});
Deleted web/build.rs
@@ -1,137 +0,0 @@
1use std::collections::BTreeMap;
2use std::env;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6
7fn main() {
8 println!("cargo:rerun-if-changed=build.rs");
9 for watched in [
10 "src",
11 "public",
12 "index.html",
13 "vite.config.ts",
14 "alova.config.ts",
15 "package.json",
16 "bun.lock",
17 "tsconfig.json",
18 "tsconfig.app.json",
19 "tsconfig.node.json",
20 ] {
21 println!("cargo:rerun-if-changed={watched}");
22 }
23
24 let root = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
25 let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));
26
27 if env::var_os("UI_SKIP_FRONTEND_BUILD").is_none() && !build_frontend(&root) {
28 println!(
29 "cargo:warning=frontend build via bun/npm unavailable or failed; \
30 embedding existing dist/ as-is"
31 );
32 }
33
34 let dist = root.join("dist");
35 let index = dist.join("index.html");
36 if !index.is_file() {
37 panic!(
38 "dist/index.html not found — run `bun install && bun run build` \
39 (or `npm run build`) first"
40 );
41 }
42
43 let mut files: BTreeMap<String, PathBuf> = BTreeMap::new();
44 collect(&dist, &dist, &mut files);
45
46 let mut src = String::from("// @generated by build.rs — embedded frontend (dist/)\n\n");
47 src.push_str(&format!(
48 "pub const INDEX: &[u8] = include_bytes!({:?});\n\n",
49 index
50 ));
51 src.push_str("pub const FILES: &[(&str, &[u8])] = &[\n");
52 for (rel, abs) in &files {
53 if rel == "index.html" {
54 continue; // INDEX 已覆盖
55 }
56 src.push_str(&format!(" ({:?}, include_bytes!({:?})),\n", rel, abs));
57 }
58 src.push_str("];\n\npub const PATHS: &[&str] = &[\n");
59 for rel in files.keys() {
60 src.push_str(&format!(" {:?},\n", rel));
61 }
62 src.push_str("];\n");
63
64 fs::write(out_dir.join("dist_embed.rs"), src).expect("write dist_embed.rs");
65}
66
67fn build_frontend(root: &Path) -> bool {
68 if !root.join("package.json").is_file() {
69 return false;
70 }
71 let tool = if command_ok("bun") {
72 "bun"
73 } else if command_ok("npm") {
74 npm_cmd()
75 } else {
76 return false;
77 };
78
79 let mut steps: Vec<(&str, &[&str])> = Vec::new();
80 if !root.join("node_modules").is_dir() {
81 steps.push((tool, &["install"]));
82 }
83 steps.push((tool, &["run", "build"]));
84
85 for (t, args) in steps {
86 eprintln!(
87 "cargo:warning=running `{t} {}` in {}",
88 args.join(" "),
89 root.display()
90 );
91 let ok = Command::new(t)
92 .args(args)
93 .current_dir(root)
94 .status()
95 .map(|s| s.success())
96 .unwrap_or(false);
97 if !ok {
98 return false;
99 }
100 }
101 true
102}
103
104#[cfg(windows)]
105fn npm_cmd() -> &'static str {
106 "npm.cmd"
107}
108#[cfg(not(windows))]
109fn npm_cmd() -> &'static str {
110 "npm"
111}
112
113fn command_ok(name: &str) -> bool {
114 Command::new(name)
115 .arg("--version")
116 .stdin(Stdio::null())
117 .stdout(Stdio::null())
118 .stderr(Stdio::null())
119 .status()
120 .is_ok_and(|s| s.success())
121}
122
123fn collect(base: &Path, dir: &Path, out: &mut BTreeMap<String, PathBuf>) {
124 for entry in fs::read_dir(dir).expect("read dist dir") {
125 let path = entry.expect("dist entry").path();
126 if path.is_dir() {
127 collect(base, &path, out);
128 } else {
129 let rel = path
130 .strip_prefix(base)
131 .unwrap()
132 .to_string_lossy()
133 .replace('\\', "/");
134 out.insert(rel, path);
135 }
136 }
137}
Deleted web/bun.lock
@@ -1,707 +0,0 @@
1{
2 "lockfileVersion": 1,
3 "configVersion": 1,
4 "workspaces": {
5 "": {
6 "name": "ui",
7 "dependencies": {
8 "@alova/wormhole": "^1.5.2",
9 "@floating-ui/dom": "^1.8.0",
10 "@livekit/protocol": "1.50.4",
11 "@tailwindcss/vite": "^4.3.3",
12 "alova": "^3.5.5",
13 "clsx": "2.1.1",
14 "dompurify": "^3.4.15",
15 "emoji-picker-react": "^4.22.2",
16 "events": "^3.3.0",
17 "html-to-image": "^1.11.13",
18 "livekit-client": "2.22.3",
19 "loglevel": "^1.9.2",
20 "lucide-react": "^1.39.0",
21 "marked": "^18.0.13",
22 "react": "^19.2.8",
23 "react-dom": "^19.2.8",
24 "react-router-dom": "^7.18.3",
25 "rxjs": "7.8.2",
26 "tailwindcss": "^4.3.3",
27 "typed-emitter": "^2.1.0",
28 "usehooks-ts": "3.1.1",
29 },
30 "devDependencies": {
31 "@types/node": "^24.13.3",
32 "@types/react": "^19.2.18",
33 "@types/react-dom": "^19.2.4",
34 "@vitejs/plugin-react": "^6.1.0",
35 "oxlint": "^1.79.0",
36 "steiger": "^0.6.0",
37 "typescript": "~6.0.2",
38 "vite": "^8.2.2",
39 },
40 },
41 },
42 "packages": {
43 "@alova/shared": ["@alova/shared@1.3.4", "", {}, "sha512-kdYHUqO35AEdnQHnzevyf+VhhfD0jc+EBbZv/aYkA4rwjVjf+vB6D/ZkTwiYFH1yQjwiGwF1ZbJv5a7rKGyNiw=="],
44
45 "@alova/wormhole": ["@alova/wormhole@1.5.2", "", { "dependencies": { "alova": "^3.3.4", "commander": "^14.0.0", "esbuild": "^0.25.6", "glob": "^11.0.3", "handlebars": "^4.7.8", "import-fresh": "^3.3.1", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "openapi-types": "^12.1.3", "ora": "5.4.1", "prettier": "^3.6.2", "swagger2openapi": "^7.0.8", "zod": "^3.25.68-alpha.11", "zod-validation-error": "^3.5.3" }, "bin": { "alova": "dist/bin/cli.js" } }, "sha512-87VU1d/hO4oVy5TbPKNsTByhyDFyCOrmAf+qpua3emd9BhvRLNitpIHBrgA7KgLjn9zV8tOLixE/V31RKWsV7g=="],
46
47 "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
48
49 "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
50
51 "@bufbuild/protobuf": ["@bufbuild/protobuf@1.10.1", "", {}, "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ=="],
52
53 "@clack/core": ["@clack/core@0.4.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-Pxhij4UXg8KSr7rPek6Zowm+5M22rbd2g1nfojHJkxp5YkFqiZ2+YLEM/XGVIzvGOcM0nqjIFxrpDwWRZYWYjA=="],
54
55 "@clack/prompts": ["@clack/prompts@0.9.1", "", { "dependencies": { "@clack/core": "0.4.1", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-JIpyaboYZeWYlyP0H+OoPPxd6nqueG/CmN6ixBiNFsIDHREevjIf0n0Ohh5gr5C8pEDknzgvz+pIJ8dMhzWIeg=="],
56
57 "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
58
59 "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
60
61 "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
62
63 "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
64
65 "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
66
67 "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
68
69 "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
70
71 "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
72
73 "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
74
75 "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
76
77 "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
78
79 "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
80
81 "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
82
83 "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
84
85 "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
86
87 "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
88
89 "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
90
91 "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
92
93 "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
94
95 "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
96
97 "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
98
99 "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
100
101 "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
102
103 "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
104
105 "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
106
107 "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
108
109 "@exodus/schemasafe": ["@exodus/schemasafe@1.3.0", "", {}, "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw=="],
110
111 "@feature-sliced/filesystem": ["@feature-sliced/filesystem@3.1.1", "", { "dependencies": { "typescript": "^5.8.3" } }, "sha512-P3WpHkOqdH4572ZhTfeg/Sbw0qkIuMw0QcLyTUygfwTZg1yd7hIv8QDYbhTddNTiFDgBRJTrYw/s6r2hnlo6Tw=="],
112
113 "@feature-sliced/steiger-plugin": ["@feature-sliced/steiger-plugin@0.7.0", "", { "dependencies": { "@feature-sliced/filesystem": "^3.1.0", "fastest-levenshtein": "^1.0.16", "lodash-es": "^4.17.21", "pluralize": "^8.0.0", "tsconfck": "^3.1.6", "web-tree-sitter": "^0.26.8" } }, "sha512-jd90OaXYW/6sQbJ9LjRhN4HFLrrqpp436V+6vpJQGpp/CVd+hynaLDtDfsZSxMgfgj8uvcow3zwwKRCnBlLWYQ=="],
114
115 "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
116
117 "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
118
119 "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
120
121 "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="],
122
123 "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
124
125 "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
126
127 "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
128
129 "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
130
131 "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
132
133 "@livekit/mutex": ["@livekit/mutex@1.1.1", "", {}, "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw=="],
134
135 "@livekit/protocol": ["@livekit/protocol@1.50.4", "", { "dependencies": { "@bufbuild/protobuf": "^1.10.0" } }, "sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA=="],
136
137 "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
138
139 "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
140
141 "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
142
143 "@oxc-project/types": ["@oxc-project/types@0.150.0", "", {}, "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw=="],
144
145 "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.83.0", "", { "os": "android", "cpu": "arm" }, "sha512-0yGY24EwsLk5YDe6F+VkmZyRHSwJDALa3nIrPpq7FXmp2lV2d0TzvBCGeZk+wgiULRGr5blhyr4QMp5KCXJUqA=="],
146
147 "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.83.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hHfJ0vc17A4iUjH5p9BsTUPYbYRNxGpvD2lbu1aBRk54bzNIx9o5TtYF39QPZcV95DagZd+4DEAw2RH3G2ZsMg=="],
148
149 "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.83.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hsOjYjszLb/3zym/TkzUMPAoQlTJcuzSyEPOAyA+skXJIX9M0o+4JfOtqopX/Vf4hSLrJ98j0nvFo23gzk8auQ=="],
150
151 "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.83.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-mjh5oH2EA+wl5yRJYT9K9G61O2zFlpuv+yf2JwZOi0+dq2FnTUtm1h8i+5Ik0fXPWIu/k84I1psZR9aQsLAnyA=="],
152
153 "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.83.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-fNHr64/YaO8YssuoDVC8+F4Uk5enR86q5uxfHkQrjAPs1dbAILOrD2uaud+J7MO8Fx774g44ERLD0IGIvZE48w=="],
154
155 "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.83.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Qpwy3zzAwMj+8/lyYItHmkSMwbkprFNWTK7jPYDOxSyxEhaSLOWYUTCMkjF334J8/WD0nznCCsoBbIH6hpsuIw=="],
156
157 "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.83.0", "", { "os": "linux", "cpu": "arm" }, "sha512-s+BirYLFq7JL2k9sP0XI3ZXJ9dYvJ8sX3jLCLoag7tt+zrSHpZxP0jqznfL+Gdgwu7ay0dYgGYJXrQvq3iWloA=="],
158
159 "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.83.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-7lihXt3vKr+GIyapNbHrnFHm/biiW30le6Zv/DExbAFPF6YwCQXVFlONPFehxs0CpGO4CBfYPM9rdDT+XMoIlg=="],
160
161 "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.83.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-q63JalLYVkZiZvls1z3PPUnpmQluOMXp0khqQMznCeAPLGydfNY8JhvuA4WlK57JfrvikU8wB5lPVveqpIXvew=="],
162
163 "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.83.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-krQmDF+dRbxvdqVPV88ZuOoPPu8X5BuqDA8Hd+qcS4YMRQCb+nexA57DazgGsc/rGdKBe3QmV0mnv0bdpW/p5g=="],
164
165 "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.83.0", "", { "os": "linux", "cpu": "none" }, "sha512-MmOl8Y6txEAXZU1RG8Rr264jQ6D7VPmqFsU/45x/FeWsGe32hklTqGrLE6UxHzp5Rjt0wP+20tY8YXKgSFB3mw=="],
166
167 "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.83.0", "", { "os": "linux", "cpu": "none" }, "sha512-u1rMymh0W3JZkq370kzQsYPULGWqhE09pZRqnZvUSoYaI9pVO5yVX+iYIslmWuEgwuzH9YAaOsScJiobWCHoOw=="],
168
169 "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.83.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-y0zK3HNwGysu7rqtE+BQG/d0bx5gh/KwlOtghN8oWeK1KcWzeaLqtZrbm8owqdma1lFyrce/hTO5ismuNu+INQ=="],
170
171 "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.83.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rS5gM0NgD7ngmuJmbIehsidtrOwKkLFwCQbKEeb9KuyQrrWNq5Zkn0uV6AYdXOMJ0grrWEiLwBuvMxt8w5vsNw=="],
172
173 "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.83.0", "", { "os": "linux", "cpu": "x64" }, "sha512-W2IH4EtpcPaWcvNGCA95YoDg4vxqE/ZiPCi3arrxEEpsK7+JQN9WYwrlYFx9pcdP6KPXqRqkv3zdQPHcx7b6YQ=="],
174
175 "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.83.0", "", { "os": "none", "cpu": "arm64" }, "sha512-6LyKkUyoajssTPLlZmDbZIbu4IZ5B4bGuRUnBgCGpEvHP3FQMaYITncHA/unPUo7q+Z+pIu2HhdkQ+8d1SG7iA=="],
176
177 "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.83.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Uz/fObEtF0jmNJQJ8CGRBKfefYstS0/wjD3s6IGzP8nUwsJykHQJBiN3npHwKiGRGn/vvBEgNr4B3cCzmmatvg=="],
178
179 "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.83.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-u7XcvPW6Bk58tY5iWs2ESb0vJjoE/kuSpHxopbwp/p3ZtWVQXZ6wor5w3ssVTHOqd/v8b+QdhSFWQ4grEUNWpA=="],
180
181 "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.83.0", "", { "os": "win32", "cpu": "x64" }, "sha512-LZRubd7ph13QmAg4fFecTYVZkiYbROR2Htaxh/ufWRkDhPOm2wrwaEYR89e0YpPFD3dqBrPoxS7myBw5hmYA7Q=="],
182
183 "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.9", "", { "os": "android", "cpu": "arm" }, "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw=="],
184
185 "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.9", "", { "os": "android", "cpu": "arm64" }, "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg=="],
186
187 "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw=="],
188
189 "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw=="],
190
191 "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g=="],
192
193 "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.9", "", { "os": "linux", "cpu": "arm" }, "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw=="],
194
195 "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw=="],
196
197 "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA=="],
198
199 "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.9", "", { "os": "linux", "cpu": "ppc64" }, "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw=="],
200
201 "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.9", "", { "os": "linux", "cpu": "s390x" }, "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ=="],
202
203 "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q=="],
204
205 "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ=="],
206
207 "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.9", "", { "os": "none", "cpu": "arm64" }, "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg=="],
208
209 "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww=="],
210
211 "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g=="],
212
213 "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
214
215 "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@2.3.0", "", {}, "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg=="],
216
217 "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
218
219 "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
220
221 "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
222
223 "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
224
225 "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
226
227 "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
228
229 "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
230
231 "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
232
233 "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
234
235 "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
236
237 "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
238
239 "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
240
241 "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
242
243 "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
244
245 "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
246
247 "@types/dom-mediacapture-record": ["@types/dom-mediacapture-record@1.0.22", "", {}, "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw=="],
248
249 "@types/node": ["@types/node@24.13.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug=="],
250
251 "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
252
253 "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
254
255 "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
256
257 "@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.1", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw=="],
258
259 "alova": ["alova@3.5.5", "", { "dependencies": { "@alova/shared": "1.3.4", "rate-limiter-flexible": "^5.0.3" } }, "sha512-V/w9xLNBpmBxx7Zeb+OAJapt9yD1L/hBoqi86xlRtug94F2xUkA1GFsJMeDeV/oL/iQCvW8fQyplGOiLXDkACg=="],
260
261 "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
262
263 "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
264
265 "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
266
267 "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
268
269 "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
270
271 "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
272
273 "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="],
274
275 "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
276
277 "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
278
279 "call-me-maybe": ["call-me-maybe@1.0.2", "", {}, "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ=="],
280
281 "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
282
283 "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
284
285 "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
286
287 "cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="],
288
289 "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
290
291 "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
292
293 "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
294
295 "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
296
297 "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
298
299 "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
300
301 "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
302
303 "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
304
305 "cosmiconfig": ["cosmiconfig@9.0.2", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg=="],
306
307 "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
308
309 "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
310
311 "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="],
312
313 "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
314
315 "dompurify": ["dompurify@3.4.15", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw=="],
316
317 "effector": ["effector@23.4.4", "", {}, "sha512-QkZboRN28K/iwxigDhlJcI3ux3aNbt8kYGGH/GkqWG0OlGeyuBhb7PdM89Iu+ogV8Lmz16xIlwnXR2UNWI6psg=="],
318
319 "emoji-picker-react": ["emoji-picker-react@4.22.2", "", { "dependencies": { "shipstyles": "1.0.0" }, "peerDependencies": { "react": ">=16.8" } }, "sha512-xd0YRbuqkdfVYLAMEiDhM/xx98OQZ4FWu3KQJjVBkQO956EpMTJY0wW4jtF1VGqYBYK4E1KDXyDa2e8M4PQFkw=="],
320
321 "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
322
323 "empathic": ["empathic@1.1.0", "", {}, "sha512-rsPft6CK3eHtrlp9Y5ALBb+hfK+DWnA4WFebbazxjWyx8vSm3rZeoM3z9irsjcqO3PYRzlfv27XIB4tz2DV7RA=="],
324
325 "enhanced-resolve": ["enhanced-resolve@5.25.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w=="],
326
327 "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
328
329 "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
330
331 "es6-promise": ["es6-promise@3.3.1", "", {}, "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg=="],
332
333 "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
334
335 "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
336
337 "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
338
339 "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
340
341 "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
342
343 "fastest-levenshtein": ["fastest-levenshtein@1.0.16", "", {}, "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg=="],
344
345 "fastq": ["fastq@1.20.3", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="],
346
347 "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
348
349 "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
350
351 "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
352
353 "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
354
355 "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
356
357 "glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
358
359 "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
360
361 "globby": ["globby@14.1.0", "", { "dependencies": { "@sindresorhus/merge-streams": "^2.1.0", "fast-glob": "^3.3.3", "ignore": "^7.0.3", "path-type": "^6.0.0", "slash": "^5.1.0", "unicorn-magic": "^0.3.0" } }, "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA=="],
362
363 "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
364
365 "handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="],
366
367 "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
368
369 "html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="],
370
371 "http2-client": ["http2-client@1.3.5", "", {}, "sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA=="],
372
373 "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
374
375 "ignore": ["ignore@7.0.9", "", {}, "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw=="],
376
377 "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
378
379 "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
380
381 "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
382
383 "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
384
385 "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
386
387 "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
388
389 "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
390
391 "is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="],
392
393 "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
394
395 "is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="],
396
397 "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
398
399 "jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="],
400
401 "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
402
403 "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="],
404
405 "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
406
407 "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="],
408
409 "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
410
411 "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
412
413 "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
414
415 "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
416
417 "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
418
419 "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
420
421 "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
422
423 "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
424
425 "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
426
427 "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
428
429 "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
430
431 "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
432
433 "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
434
435 "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
436
437 "livekit-client": ["livekit-client@2.22.3", "", { "dependencies": { "@livekit/mutex": "1.1.1", "@livekit/protocol": "1.50.4", "events": "^3.3.0", "jose": "^6.1.0", "loglevel": "^1.9.2", "machina": "^7.0.0", "sdp-transform": "^2.15.0", "tslib": "2.8.1", "typed-emitter": "^2.1.0", "webrtc-adapter": "9.0.6" }, "peerDependencies": { "@types/dom-mediacapture-record": "^1" } }, "sha512-jw9zBKXY5Gtr5MZ7vEON3QhMNccuDvYHck1PFSyG1aaateQPqgKZFBMgZkFZaXHIf9RV4MDW5xpTK2b/+qbwOg=="],
438
439 "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
440
441 "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
442
443 "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
444
445 "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="],
446
447 "loglevel": ["loglevel@1.9.2", "", {}, "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg=="],
448
449 "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
450
451 "lucide-react": ["lucide-react@1.46.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Bv+FZXgZPrxc/NCl1e7JJVQFLdiCxYgxNVhqoV7X0p6I8ADJo8DxBnK1auH0fZz4AmqOJ3jgneL4f1i8LJQRAA=="],
452
453 "machina": ["machina@7.0.1", "", {}, "sha512-0Xkgs8NeBvO2hW6bjJT8ewLWrMfh1haMPstaghpASWjXkLjRhtDiaJG1Kb7mslaOHxZYxpH7PIjpmirRVYbMMA=="],
454
455 "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
456
457 "marked": ["marked@18.0.13", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-xTxVzZsBFwunP6HDmtBkabUQEYArnP7/rMDGmPj9SlrKlQ4i8MdYVow+nJL0eOqwpUqhzBoTBRADGN6uYwPyOw=="],
458
459 "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
460
461 "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
462
463 "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
464
465 "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="],
466
467 "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
468
469 "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
470
471 "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
472
473 "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
474
475 "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
476
477 "node-fetch-h2": ["node-fetch-h2@2.3.0", "", { "dependencies": { "http2-client": "^1.2.5" } }, "sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg=="],
478
479 "node-readfiles": ["node-readfiles@0.2.0", "", { "dependencies": { "es6-promise": "^3.2.1" } }, "sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA=="],
480
481 "oas-kit-common": ["oas-kit-common@1.0.8", "", { "dependencies": { "fast-safe-stringify": "^2.0.7" } }, "sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ=="],
482
483 "oas-linter": ["oas-linter@3.2.2", "", { "dependencies": { "@exodus/schemasafe": "^1.0.0-rc.2", "should": "^13.2.1", "yaml": "^1.10.0" } }, "sha512-KEGjPDVoU5K6swgo9hJVA/qYGlwfbFx+Kg2QB/kd7rzV5N8N5Mg6PlsoCMohVnQmo+pzJap/F610qTodKzecGQ=="],
484
485 "oas-resolver": ["oas-resolver@2.5.6", "", { "dependencies": { "node-fetch-h2": "^2.3.0", "oas-kit-common": "^1.0.8", "reftools": "^1.1.9", "yaml": "^1.10.0", "yargs": "^17.0.1" }, "bin": { "resolve": "resolve.js" } }, "sha512-Yx5PWQNZomfEhPPOphFbZKi9W93CocQj18NlD2Pa4GWZzdZpSJvYwoiuurRI7m3SpcChrnO08hkuQDL3FGsVFQ=="],
486
487 "oas-schema-walker": ["oas-schema-walker@1.1.5", "", {}, "sha512-2yucenq1a9YPmeNExoUa9Qwrt9RFkjqaMAA1X+U7sbb0AqBeTIdMHky9SQQ6iN94bO5NW0W4TRYXerG+BdAvAQ=="],
488
489 "oas-validator": ["oas-validator@5.0.8", "", { "dependencies": { "call-me-maybe": "^1.0.1", "oas-kit-common": "^1.0.8", "oas-linter": "^3.2.2", "oas-resolver": "^2.5.6", "oas-schema-walker": "^1.1.5", "reftools": "^1.1.9", "should": "^13.2.1", "yaml": "^1.10.0" } }, "sha512-cu20/HE5N5HKqVygs3dt94eYJfBi0TsZvPVXDhbXQHiEityDN+RROTleefoKRKKJ9dFAF2JBkDHgvWj0sjKGmw=="],
490
491 "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
492
493 "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
494
495 "ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="],
496
497 "oxlint": ["oxlint@1.83.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.83.0", "@oxlint/binding-android-arm64": "1.83.0", "@oxlint/binding-darwin-arm64": "1.83.0", "@oxlint/binding-darwin-x64": "1.83.0", "@oxlint/binding-freebsd-x64": "1.83.0", "@oxlint/binding-linux-arm-gnueabihf": "1.83.0", "@oxlint/binding-linux-arm-musleabihf": "1.83.0", "@oxlint/binding-linux-arm64-gnu": "1.83.0", "@oxlint/binding-linux-arm64-musl": "1.83.0", "@oxlint/binding-linux-ppc64-gnu": "1.83.0", "@oxlint/binding-linux-riscv64-gnu": "1.83.0", "@oxlint/binding-linux-riscv64-musl": "1.83.0", "@oxlint/binding-linux-s390x-gnu": "1.83.0", "@oxlint/binding-linux-x64-gnu": "1.83.0", "@oxlint/binding-linux-x64-musl": "1.83.0", "@oxlint/binding-openharmony-arm64": "1.83.0", "@oxlint/binding-win32-arm64-msvc": "1.83.0", "@oxlint/binding-win32-ia32-msvc": "1.83.0", "@oxlint/binding-win32-x64-msvc": "1.83.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-cyDzSzaw3uzP0TeCeq3lLRPPoaUxkbB4ZOXj+kn+5r+BX9V+4bNVGk9lxer+WrgcpebH4JxLlJ3KQjveVztOLQ=="],
498
499 "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
500
501 "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
502
503 "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
504
505 "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
506
507 "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
508
509 "path-type": ["path-type@6.0.0", "", {}, "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ=="],
510
511 "patronum": ["patronum@2.3.0", "", { "peerDependencies": { "effector": "^23" } }, "sha512-BfKIOpoymVz6XnkOn8Fi5QZ1a3r/3lXdd8BcdHmYDbIXPTIRnD1EPFBFev/DheWnOge6/ZswEqgNF2ANLGOxLw=="],
512
513 "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
514
515 "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
516
517 "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="],
518
519 "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
520
521 "prettier": ["prettier@3.9.7", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-T3mF5P7HFzZVLDQuCUNtJj6WK1pcpLMweMNUVwGb01bLNkH9AkKxtZvmInw8K6bnn2O3d6/5RHl6zSHiBAD0Og=="],
522
523 "prexit": ["prexit@2.3.0", "", {}, "sha512-mX+LIbtS0anKtl2PykYabxninwloblUQMRO6CubeSmjxb+kKlATuJoH9UeN8NLE4TgIEFWfBXw7V3GkWbBrSmg=="],
524
525 "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
526
527 "rate-limiter-flexible": ["rate-limiter-flexible@5.0.5", "", {}, "sha512-+/dSQfo+3FYwYygUs/V2BBdwGa9nFtakDwKt4l0bnvNB53TNT++QSFewwHX9qXrZJuMe9j+TUaU21lm5ARgqdQ=="],
528
529 "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
530
531 "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
532
533 "react-router": ["react-router@7.18.4", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ=="],
534
535 "react-router-dom": ["react-router-dom@7.18.4", "", { "dependencies": { "react-router": "7.18.4" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw=="],
536
537 "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
538
539 "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
540
541 "reftools": ["reftools@1.1.9", "", {}, "sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w=="],
542
543 "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
544
545 "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
546
547 "restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="],
548
549 "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
550
551 "rolldown": ["rolldown@1.2.9", "", { "dependencies": { "@oxc-project/types": "=0.150.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.9", "@rolldown/binding-android-arm64": "1.2.9", "@rolldown/binding-darwin-arm64": "1.2.9", "@rolldown/binding-darwin-x64": "1.2.9", "@rolldown/binding-freebsd-x64": "1.2.9", "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", "@rolldown/binding-linux-arm64-gnu": "1.2.9", "@rolldown/binding-linux-arm64-musl": "1.2.9", "@rolldown/binding-linux-ppc64-gnu": "1.2.9", "@rolldown/binding-linux-s390x-gnu": "1.2.9", "@rolldown/binding-linux-x64-gnu": "1.2.9", "@rolldown/binding-linux-x64-musl": "1.2.9", "@rolldown/binding-openharmony-arm64": "1.2.9", "@rolldown/binding-win32-arm64-msvc": "1.2.9", "@rolldown/binding-win32-x64-msvc": "1.2.9" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg=="],
552
553 "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
554
555 "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
556
557 "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
558
559 "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
560
561 "sdp": ["sdp@3.2.2", "", {}, "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA=="],
562
563 "sdp-transform": ["sdp-transform@2.15.0", "", { "bin": { "sdp-verify": "checker.js" } }, "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw=="],
564
565 "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
566
567 "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
568
569 "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
570
571 "shipstyles": ["shipstyles@1.0.0", "", {}, "sha512-1AcihfXo/mSxqeOdoKqq/5TNv+vSH0dBYPFq88PUX5FBkK5pRmY5TP9SuMNio85PQ+euUI67+KpSVk1cUVDCgA=="],
572
573 "should": ["should@13.2.3", "", { "dependencies": { "should-equal": "^2.0.0", "should-format": "^3.0.3", "should-type": "^1.4.0", "should-type-adaptors": "^1.0.1", "should-util": "^1.0.0" } }, "sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ=="],
574
575 "should-equal": ["should-equal@2.0.0", "", { "dependencies": { "should-type": "^1.4.0" } }, "sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA=="],
576
577 "should-format": ["should-format@3.0.3", "", { "dependencies": { "should-type": "^1.3.0", "should-type-adaptors": "^1.0.1" } }, "sha512-hZ58adtulAk0gKtua7QxevgUaXTTXxIi8t41L3zo9AHvjXO1/7sdLECuHeIN2SRtYXpNkmhoUP2pdeWgricQ+Q=="],
578
579 "should-type": ["should-type@1.4.0", "", {}, "sha512-MdAsTu3n25yDbIe1NeN69G4n6mUnJGtSJHygX3+oN0ZbO3DTiATnf7XnYJdGT42JCXurTb1JI0qOBR65shvhPQ=="],
580
581 "should-type-adaptors": ["should-type-adaptors@1.1.0", "", { "dependencies": { "should-type": "^1.3.0", "should-util": "^1.0.0" } }, "sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA=="],
582
583 "should-util": ["should-util@1.0.1", "", {}, "sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g=="],
584
585 "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
586
587 "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
588
589 "slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="],
590
591 "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
592
593 "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
594
595 "steiger": ["steiger@0.6.0", "", { "dependencies": { "@clack/prompts": "^0.9.1", "@feature-sliced/steiger-plugin": "0.7.0", "chokidar": "^4.0.3", "cosmiconfig": "^9.0.0", "effector": "^23.4.2", "empathic": "^1.1.0", "fastest-levenshtein": "^1.0.16", "globby": "^14.1.0", "immer": "^10.1.1", "lodash-es": "^4.17.21", "micromatch": "^4.0.8", "patronum": "^2.3.0", "picocolors": "^1.1.1", "prexit": "^2.3.0", "yargs": "^17.7.2", "zod": "^3.25.76", "zod-validation-error": "^3.5.3" }, "bin": { "steiger": "dist/cli.mjs" } }, "sha512-XOG11zwU/m0Aq7bK9zCxH+//QemXis2P3pQJ99kYeaKhVr7YuR41cyN2bTQKrW9vTLw3Uykpai7ohfEX3nMN3Q=="],
596
597 "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
598
599 "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
600
601 "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
602
603 "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
604
605 "swagger2openapi": ["swagger2openapi@7.0.8", "", { "dependencies": { "call-me-maybe": "^1.0.1", "node-fetch": "^2.6.1", "node-fetch-h2": "^2.3.0", "node-readfiles": "^0.2.0", "oas-kit-common": "^1.0.8", "oas-resolver": "^2.5.6", "oas-schema-walker": "^1.1.5", "oas-validator": "^5.0.8", "reftools": "^1.1.9", "yaml": "^1.10.0", "yargs": "^17.0.1" }, "bin": { "swagger2openapi": "swagger2openapi.js", "oas-validate": "oas-validate.js", "boast": "boast.js" } }, "sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g=="],
606
607 "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
608
609 "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
610
611 "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
612
613 "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
614
615 "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
616
617 "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
618
619 "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
620
621 "typed-emitter": ["typed-emitter@2.1.0", "", { "optionalDependencies": { "rxjs": "*" } }, "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA=="],
622
623 "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
624
625 "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="],
626
627 "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
628
629 "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
630
631 "usehooks-ts": ["usehooks-ts@3.1.1", "", { "dependencies": { "lodash.debounce": "^4.0.8" }, "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA=="],
632
633 "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
634
635 "vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="],
636
637 "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
638
639 "web-tree-sitter": ["web-tree-sitter@0.26.13", "", {}, "sha512-5bUZ7vbQ1kcondet96wzP974+JfCZDeQ7bTpacICm2nnvHpa5cO0ByRsoMcAhUP+743vpkb4m0BFlVSm+Ye9VA=="],
640
641 "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
642
643 "webrtc-adapter": ["webrtc-adapter@9.0.6", "", { "dependencies": { "sdp": "^3.2.0" } }, "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog=="],
644
645 "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
646
647 "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
648
649 "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="],
650
651 "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
652
653 "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
654
655 "yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
656
657 "yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="],
658
659 "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
660
661 "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
662
663 "zod-validation-error": ["zod-validation-error@3.5.4", "", { "peerDependencies": { "zod": "^3.24.4" } }, "sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw=="],
664
665 "@feature-sliced/filesystem/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
666
667 "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
668
669 "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
670
671 "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
672
673 "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
674
675 "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g=="],
676
677 "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="],
678
679 "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
680
681 "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
682
683 "restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
684
685 "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
686
687 "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
688
689 "@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
690
691 "@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
692
693 "@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
694
695 "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
696
697 "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
698
699 "@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
700
701 "@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
702
703 "@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
704
705 "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
706 }
707}
Deleted web/Cargo.lock
@@ -1,7 +0,0 @@
1# This file is automatically @generated by Cargo.
2# It is not intended for manual editing.
3version = 4
4
5[[package]]
6name = "web"
7version = "0.1.0"
Deleted web/Cargo.toml
@@ -1,10 +0,0 @@
1[package]
2name = "web"
3version = "0.1.0"
4edition = "2024"
5authors = ["ZhenYi <zheyqaq@gmail.com>"]
6license = "MIT"
7readme = "README.md"
8
9[lib]
10path = "lib.rs"
No newline at end of file
Deleted web/index.html
@@ -1,29 +0,0 @@
1<!doctype html>
2<html lang="en">
3 <head>
4 <meta charset="UTF-8" />
5 <link rel="icon" type="image/svg+xml" href="/favicon.png" />
6 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7 <!-- iOS 只为"添加到主屏"的站点投递 Web Push,manifest 与 touch icon 是前提。 -->
8 <link rel="manifest" href="/manifest.webmanifest" />
9 <link rel="apple-touch-icon" href="/favicon.png" />
10 <meta name="theme-color" content="#090b0d" />
11 <meta
12 name="description"
13 content="GitDataAI brings repositories, issues, pull requests, and team communication into one engineering workspace."
14 />
15 <meta name="app:region" content="EU-CENTRAL-1" />
16 <meta name="app:min-age" content="13" />
17 <title>GitDataAI</title>
18 <link rel="preconnect" href="https://fonts.googleapis.com" />
19 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
20 <link
21 href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700;800&display=swap"
22 rel="stylesheet"
23 />
24 </head>
25 <body>
26 <div id="root"></div>
27 <script type="module" src="/src/app/index.tsx"></script>
28 </body>
29</html>
Deleted web/lib.rs
@@ -1,170 +0,0 @@
1//! 把前端构建产物(dist/)嵌入本库。
2//!
3//! `build.rs` 会调用 bun(无则 npm)执行前端构建,随后将 dist/ 下所有文件以
4//! `include_bytes!` 生成到 `$OUT_DIR/dist_embed.rs`,由本文件引入后对外提供:
5//!
6//! - [`index()`]:`index.html`(SPA 入口)
7//! - [`file(path)`]:按请求路径取任意静态资源,如 `/assets/index-xxx.js`
8//! - [`exists(path)`] / [`paths()`]:资源存在性查询与清单
9
10mod dist_embed {
11 include!(concat!(env!("OUT_DIR"), "/dist_embed.rs"));
12}
13
14/// 一个嵌入在库中的 dist 静态资源。
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct Asset {
17 /// 相对 dist/ 的路径(不含前导斜杠),如 `assets/index-xxx.js`。
18 pub path: &'static str,
19 /// 按扩展名推断的 MIME type。
20 pub mime: &'static str,
21 /// 文件内容。
22 pub content: &'static [u8],
23}
24
25/// SPA 入口 `index.html`。
26pub fn index() -> Asset {
27 Asset {
28 path: "index.html",
29 mime: "text/html; charset=utf-8",
30 content: dist_embed::INDEX,
31 }
32}
33
34/// 按请求路径查找嵌入的资源。
35///
36/// 接受 `/assets/x.js`、`assets/x.js`、带 `?query`/`#hash` 的路径;
37/// 根路径与 `index.html` 归一到 [`index()`];含 `..` 的越界路径返回 `None`。
38pub fn file(path: &str) -> Option<Asset> {
39 let key = normalize(path)?;
40 if key.is_empty() || key == "index.html" {
41 return Some(index());
42 }
43 dist_embed::FILES
44 .iter()
45 .find(|(p, _)| *p == key.as_str())
46 .map(|(p, content)| Asset {
47 path: p,
48 mime: mime_of(p),
49 content,
50 })
51}
52
53pub fn exists(path: &str) -> bool {
54 match normalize(path) {
55 None => false,
56 Some(key) => {
57 key.is_empty()
58 || key == "index.html"
59 || dist_embed::FILES.iter().any(|(p, _)| *p == key.as_str())
60 }
61 }
62}
63
64pub fn paths() -> &'static [&'static str] {
65 dist_embed::PATHS
66}
67
68fn normalize(path: &str) -> Option<String> {
69 let path = path.split(['?', '#']).next().unwrap_or(path);
70 let mut segs: Vec<&str> = Vec::new();
71 for seg in path.split('/') {
72 match seg {
73 "" | "." => {}
74 ".." => return None,
75 s => segs.push(s),
76 }
77 }
78 Some(segs.join("/"))
79}
80
81fn mime_of(path: &str) -> &'static str {
82 let ext = path
83 .rsplit_once('.')
84 .map_or("", |(_, e)| e)
85 .to_ascii_lowercase();
86 match ext.as_str() {
87 "html" | "htm" => "text/html; charset=utf-8",
88 "js" | "mjs" => "text/javascript; charset=utf-8",
89 "css" => "text/css; charset=utf-8",
90 "json" | "map" => "application/json",
91 "webmanifest" => "application/manifest+json",
92 "svg" => "image/svg+xml",
93 "png" => "image/png",
94 "jpg" | "jpeg" => "image/jpeg",
95 "gif" => "image/gif",
96 "webp" => "image/webp",
97 "avif" => "image/avif",
98 "ico" => "image/x-icon",
99 "wasm" => "application/wasm",
100 "ttf" => "font/ttf",
101 "otf" => "font/otf",
102 "woff" => "font/woff",
103 "woff2" => "font/woff2",
104 "eot" => "application/vnd.ms-fontobject",
105 "txt" => "text/plain; charset=utf-8",
106 "xml" => "application/xml",
107 "mp4" => "video/mp4",
108 "webm" => "video/webm",
109 "pdf" => "application/pdf",
110 _ => "application/octet-stream",
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 #[test]
119 fn index_is_html() {
120 let a = index();
121 assert_eq!(a.mime, "text/html; charset=utf-8");
122 assert!(
123 a.content
124 .to_ascii_lowercase()
125 .windows(5)
126 .any(|w| w == b"<html")
127 );
128 }
129
130 #[test]
131 fn lookup_with_and_without_leading_slash() {
132 let js = paths()
133 .iter()
134 .find(|p| p.ends_with(".js"))
135 .expect("js in dist");
136 let a = file(&format!("/{js}")).unwrap();
137 assert_eq!(a.path, *js);
138 assert_eq!(a.mime, "text/javascript; charset=utf-8");
139 assert_eq!(file(js).unwrap().content, a.content);
140 let css = paths()
141 .iter()
142 .find(|p| p.ends_with(".css"))
143 .expect("css in dist");
144 assert_eq!(file(css).unwrap().mime, "text/css; charset=utf-8");
145 }
146
147 #[test]
148 fn wasm_mime() {
149 if let Some(w) = paths().iter().find(|p| p.ends_with(".wasm")) {
150 assert_eq!(file(w).unwrap().mime, "application/wasm");
151 }
152 }
153
154 #[test]
155 fn root_and_index_normalize() {
156 assert_eq!(file("/").unwrap().path, "index.html");
157 assert_eq!(file("").unwrap().path, "index.html");
158 assert_eq!(file("/?q=1").unwrap().path, "index.html");
159 assert_eq!(file("index.html").unwrap().path, "index.html");
160 }
161
162 #[test]
163 fn traversal_rejected_and_missing_none() {
164 assert!(file("/../../etc/passwd").is_none());
165 assert!(file("assets/../../x").is_none());
166 assert!(file("no-such-file.xyz").is_none());
167 assert!(!exists("../x"));
168 assert!(exists("/"));
169 }
170}
Deleted web/llm.txt
@@ -1,8529 +0,0 @@
1<SYSTEM>This is the full developer documentation for Feature-Sliced Design</SYSTEM>
2
3# Feature-Sliced Design
4
5# A clearer structure for frontend codebases
6
7Rules and conventions for organizing front-end code to keep projects clear and stable as requirements change.
8
9[Get Started ](/docs/get-started/overview/)
10
11[ Community](https://t.me/feature_sliced)
12
13[Join active discussions around real FSD questions, with community help and FastTrack Bot support.](https://t.me/feature_sliced)
14
15[ Steiger](https://github.com/feature-sliced/steiger)
16
17[Turn FSD rules into automated checks for code structure.](https://github.com/feature-sliced/steiger)
18
19[ Docs for LLMs](/docs/llms)
20
21[FSD docs and resources for LLM-powered tools and AI use cases.](/docs/llms)
22
23## Popular guides
24
25[![Icon: Layers](/img/landing/icons/icon_layers.svg)](/docs/reference/layers)
26
27### [Layers](/docs/reference/layers)
28
29[Understand how layers structure your app](/docs/reference/layers)
30
31[![Icon: From a custom architecture](/img/landing/icons/icon_from_custom_architecture.svg)](/docs/guides/migration/from-custom)
32
33### [From a custom architecture](/docs/guides/migration/from-custom)
34
35[Migrate your existing project to FSD incrementally](/docs/guides/migration/from-custom)
36
37[![Icon: Public API](/img/landing/icons/icon_public_api.svg)](/docs/reference/public-api)
38
39### [Public API](/docs/reference/public-api)
40
41[Define clear boundaries between slices](/docs/reference/public-api)
42
43[![Icon: Cross-imports](/img/landing/icons/icon_cross_imports.svg)](/docs/guides/issues/cross-imports)
44
45### [Cross-imports](/docs/guides/issues/cross-imports)
46
47[Avoid the most common architectural violation](/docs/guides/issues/cross-imports)
48
49[![Icon: Excessive Entities](/img/landing/icons/icon_excessive_entities.svg)](/docs/guides/issues/excessive-entities)
50
51### [Excessive Entities](/docs/guides/issues/excessive-entities)
52
53[Avoid over-splitting your domain into too many entities](/docs/guides/issues/excessive-entities)
54
55[![Icon: FAQ](/img/landing/icons/icon_faq.svg)](/docs/get-started/faq)
56
57### [FAQ](/docs/get-started/faq)
58
59[Find answers about adopting FSD in practice](/docs/get-started/faq)
60
61# Alternatives
62
63History of architecture approaches
64
65## Big Ball of Mud
66
67[Section titled “Big Ball of Mud”](#big-ball-of-mud)
68
69> What is it; Why is it so common; When it starts to bring problems; What to do and how does FSD help in this
70
71* [(Article) Oleg Isonen - Last words on UI architecture before an AI takes over](https://oleg008.medium.com/last-words-on-ui-architecture-before-an-ai-takes-over-468c78f18f0d)
72* [(Report) Julia Nikolaeva, iSpring - Big Ball of Mud and other problems of the monolith, we have handled](http://youtu.be/gna4Ynz1YNI)
73* [(Article) DD - Big Ball of mud](https://thedomaindrivendesign.io/big-ball-of-mud/)
74
75## Smart & Dumb components
76
77[Section titled “Smart & Dumb components”](#smart--dumb-components)
78
79> About the approach; About applicability in the frontend; Methodology position
80
81About obsolescence, about a new view from the methodology
82
83Why component-containers approach is evil?
84
85* [(Article) Den Abramov-Presentation and Container Components (TLDR: deprecated)](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0)
86
87## Design Principles
88
89[Section titled “Design Principles”](#design-principles)
90
91> What are we talking about; FSD position
92
93SOLID, GRASP, KISS, YAGNI, … - and why they don’t work well together in practice
94
95And how does it aggregate these practices
96
97* [(Talk) Ilya Azin - Feature-Sliced Design (fragment about Design Principles)](https://youtu.be/SnzPAr_FJ7w?t=380)
98
99## DDD
100
101[Section titled “DDD”](#ddd)
102
103> About the approach; Why does it work poorly in practice
104
105What is the difference, how does it improve applicability, where does it adopt practices
106
107* [(Article) DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together](https://herbertograca.com/2017/11/16/explicit-architecture-01-ddd-hexagonal-onion-clean-cqrs-how-i-put-it-all-together/)
108* [(Talk) Ilya Azin - Feature-Sliced Design (fragment about Clean Architecture, DDD)](https://youtu.be/SnzPAr_FJ7w?t=528)
109
110## Clean Architecture
111
112[Section titled “Clean Architecture”](#clean-architecture)
113
114> About the approach; About applicability in the frontend; FSD position
115
116How are they similar (to many), how are they different
117
118* [(Thread) About use-case/interactor in the methodology](https://t.me/feature_sliced/3897)
119* [(Thread) About DI in the methodology](https://t.me/feature_sliced/4592)
120* [(Article) Alex Bespoyasov - Clean Architecture on frontend](https://bespoyasov.me/blog/clean-architecture-on-frontend/)
121* [(Article) DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together](https://herbertograca.com/2017/11/16/explicit-architecture-01-ddd-hexagonal-onion-clean-cqrs-how-i-put-it-all-together/)
122* [(Talk) Ilya Azin - Feature-Sliced Design (fragment about Clean Architecture, DDD)](https://youtu.be/SnzPAr_FJ7w?t=528)
123* [(Article) Misconceptions of Clean Architecture](http://habr.com/ru/company/mobileup/blog/335382/)
124
125## Frameworks
126
127[Section titled “Frameworks”](#frameworks)
128
129> About applicability in the frontend; Why frameworks do not solve problems; why there is no single approach; FSD position
130
131Framework-agnostic, conventional-approach
132
133* [(Article) About the reasons for creating the methodology (fragment about frameworks)](/docs/about/motivation)
134* [(Thread) About the applicability of the methodology for different frameworks](https://t.me/feature_sliced/3867)
135
136## Atomic Design
137
138[Section titled “Atomic Design”](#atomic-design)
139
140### What is it?
141
142[Section titled “What is it?”](#what-is-it)
143
144In Atomic Design, the scope of responsibility is divided into standardized layers.
145
146Atomic Design is broken down into **5 layers** (from top to bottom):
147
1481. `pages` - Functionality similar to the `pages` layer in FSD.
1492. `templates` - Components that define the structure of a page without tying to specific content.
1503. `organisms` - Modules consisting of molecules that have business logic.
1514. `molecules` - More complex components that generally do not contain business logic.
1525. `atoms` - UI components without business logic.
153
154Modules at one layer interact only with modules in the layers below, similar to FSD. That is, molecules are built from atoms, organisms from molecules, templates from organisms, and pages from templates. Atomic Design also implies the use of Public API within modules for isolation.
155
156### Applicability to frontend
157
158[Section titled “Applicability to frontend”](#applicability-to-frontend)
159
160Atomic Design is relatively common in projects. Atomic Design is more popular among web designers than in development. Web designers often use Atomic Design to create scalable and easily maintainable designs. In development, Atomic Design is often mixed with other architectural methodologies.
161
162However, since Atomic Design focuses on UI components and their composition, a problem arises with implementing business logic within the architecture.
163
164The problem is that Atomic Design does not provide a clear level of responsibility for business logic, leading to its distribution across various components and levels, complicating maintenance and testing. The business logic becomes blurred, making it difficult to clearly separate responsibilities and rendering the code less modular and reusable.
165
166### How does it relate to FSD?
167
168[Section titled “How does it relate to FSD?”](#how-does-it-relate-to-fsd)
169
170In the context of FSD, some elements of Atomic Design can be applied to create flexible and scalable UI components. The `atoms` and `molecules` layers can be implemented in `shared/ui` in FSD, simplifying the reuse and maintenance of basic UI elements.
171
172* shared/
173
174 * ui/
175
176 * atoms/
177
178 * …
179
180 * molecules/
181
182 * …
183
184A comparison of FSD and Atomic Design shows that both methodologies strive for modularity and reusability but focus on different aspects. Atomic Design is oriented towards visual components and their composition. FSD focuses on dividing the application’s functionality into independent modules and their interconnections.
185
186* [Atomic Design Methodology](https://atomicdesign.bradfrost.com/table-of-contents/)
187* [(Thread) About applicability in shared / ui](https://t.me/feature_sliced/1653)
188* [(Video) Briefly about Atomic Design](https://youtu.be/Yi-A20x2dcA)
189* [(Talk) Ilya Azin - Feature-Sliced Design (fragment about Atomic Design)](https://youtu.be/SnzPAr_FJ7w?t=587)
190
191## Feature Driven
192
193[Section titled “Feature Driven”](#feature-driven)
194
195> About the approach; About applicability in the frontend; FSD position
196
197About compatibility, historical development and comparison
198
199* [(Talk) Oleg Isonen - Feature Driven Architecture](https://youtu.be/BWAeYuWFHhs)
200* [Feature Driven-Short specification (from the point of view of FSD)](https://github.com/feature-sliced/documentation/tree/rc/feature-driven)
201
202# Mission
203
204Here we describe the goals and limitations of the applicability of the methodology-which we are guided by when developing the methodology
205
206* We see our goal as a balance between ideology and simplicity
207* We won’t be able to make a silver bullet that fits everyone
208
209**Nevertheless, the methodology should be close and accessible to a fairly wide range of developers**
210
211## Goals
212
213[Section titled “Goals”](#goals)
214
215### Intuitive clarity for a wide range of developers
216
217[Section titled “Intuitive clarity for a wide range of developers”](#intuitive-clarity-for-a-wide-range-of-developers)
218
219The methodology should be accessible - for most of the team in projects
220
221*Because even with all the future tools , it will not be enough, if only experienced seniors/leads will understand the methodology*
222
223### Solving everyday problems
224
225[Section titled “Solving everyday problems”](#solving-everyday-problems)
226
227The methodology should set out the reasons and solutions to our everyday problems when developing projects
228
229**And also-attach tools to all this (cli, linters)**
230
231So that developers can use a *battle-tested* approach that allows them to bypass long-standing problems of architecture and development
232
233> *@sergeysova: Imagine, that a developer writes code within the framework of the methodology and he has problems 10 times less often, simply because other people have thought out the solution to many problems.*
234
235## Limitations
236
237[Section titled “Limitations”](#limitations)
238
239We do not want to *impose our point of view*, and at the same time we understand that *many of our habits, as developers, interfere from day to day*
240
241Everyone has their own level of experience in designing and developing systems, **therefore, it is worth understanding the following:**
242
243* **Will not work**: very simple, very clear, for everyone
244
245 > *@sergeysova: Some concepts cannot be intuitively understood until you encounter problems and spend years solving them.*
246 >
247 > * *In math world: is graph theory.*
248 > * *In physics: quantum mechanics.*
249 > * *In programming: application architecture.*
250
251* **Possible and desirable**: simplicity, extensibility
252
253## See also
254
255[Section titled “See also”](#see-also)
256
257* [Architecture problems](/docs/about/understanding/architecture#problems)
258
259# Motivation
260
261The main idea of **Feature-Sliced Design** is to facilitate and reduce the cost of developing complex and developing projects, based on [combining research results, discussing the experience of various kinds of a wide range of developers](https://github.com/feature-sliced/documentation/discussions).
262
263Obviously, this will not be a silver bullet, and of course, the methodology will have its own [limits of applicability](/docs/about/mission).
264
265Nevertheless, there are reasonable questions regarding *the feasibility of such a methodology as a whole*
266
267Note
268
269More details [discussed in the discussion](https://github.com/feature-sliced/documentation/discussions/27)
270
271## Why are there not enough existing solutions?
272
273[Section titled “Why are there not enough existing solutions?”](#why-are-there-not-enough-existing-solutions)
274
275> It usually, these arguments:
276>
277> * *“Why you need some new methodology, if you already have long-established approaches and principles of design such as `SOLID`, `KISS`, `YAGNI`, `DDD`, `GRASP`, `DRY`, etc.”*
278> * *“All the problems are solved by good project documentation, tests, and structured processes”*
279> * *“Problems would not have happened if all developers are following all the above”*
280> * *“Everything was invented before you, you just can’t use it”*
281> * *“Take {FRAMEWORK\_NAME} - everything has already been decided for you there”*
282
283### Principles alone are not enough
284
285[Section titled “Principles alone are not enough”](#principles-alone-are-not-enough)
286
287**The existence of principles alone is not enough to design a good architecture**
288
289Not everyone knows them completely, even fewer understand and apply them correctly
290
291*The design principles are too general, and do not give a specific answer to the question: “How to design the structure and architecture of a scalable and flexible application?”*
292
293### Processes don’t always work
294
295[Section titled “Processes don’t always work”](#processes-dont-always-work)
296
297*Documentation/Tests/Processes* are, of course, good, but alas, even at high costs for them - **they do not always solve the problems posed by the architecture and the introduction of new people into the project**
298
299* The time of entry of each developer into the project is not greatly reduced, because the documentation will most often come out huge / outdated
300* Constantly make sure that everyone understands architecture in the same way-it also requires a huge amount of resources
301* Do not forget about the bus-factor
302
303### Existing frameworks cannot be applied everywhere
304
305[Section titled “Existing frameworks cannot be applied everywhere”](#existing-frameworks-cannot-be-applied-everywhere)
306
307* Existing solutions usually have a high entry threshold, which makes it difficult to find new developers
308* Also, most often, the choice of technology has already been determined before the onset of serious problems in the project, and therefore you need to be able to “work with what is” - **without being tied to the technology**
309
310> Q: *“In my project `React/Vue/Redux/Effector/Mobx/{YOUR_TECH}` - how can I better build the structure of entities and the relationships between them?”*
311
312### As a result
313
314[Section titled “As a result”](#as-a-result)
315
316We get *“unique as snowflakes”* projects, each of which requires a long immersion of the employee, and knowledge that is unlikely to be applicable on another project
317
318> @sergeysova: *“This is exactly the situation that currently exists in our field of frontend development: each lead will invent different architectures and project structures, while it is not a fact that these structures will pass the test of time, as a result, a maximum of two people can develop the project besides him, and each new developer needs to be immersed again.”*
319
320## Why do developers need the methodology?
321
322[Section titled “Why do developers need the methodology?”](#why-do-developers-need-the-methodology)
323
324### Focus on business features, not on architecture problems
325
326[Section titled “Focus on business features, not on architecture problems”](#focus-on-business-features-not-on-architecture-problems)
327
328The methodology allows you to save resources on designing a scalable and flexible architecture, instead directing the attention of developers to the development of the main functionality. At the same time, the architectural solutions themselves are standardized from project to project.
329
330*A separate question is that the methodology should earn the trust of the community, so that another developer can get acquainted with it and rely on it in solving the problems of his project within the time available to him*
331
332### An experience-proven solution
333
334[Section titled “An experience-proven solution”](#an-experience-proven-solution)
335
336The methodology is designed for developers who are aimed at *a proven solution for designing complex business logic*
337
338*However, it is clear that the methodology is generally about a set of best-practices, articles that address certain problems and cases during development. Therefore, the methodology will also be useful for the rest of the developers-who somehow face problems during development and design*
339
340### Project Health
341
342[Section titled “Project Health”](#project-health)
343
344The methodology will allow *to solve and track the problems of the project in advance, without requiring a huge amount of resources*
345
346**Most often, technical debt accumulates and accumulates over time, and the responsibility for its resolution lies on both the lead and the team**
347
348The methodology will allow you to *warn* possible problems in the scaling and development of the project in advance
349
350## Why does a business need a methodology?
351
352[Section titled “Why does a business need a methodology?”](#why-does-a-business-need-a-methodology)
353
354### Fast onboarding
355
356[Section titled “Fast onboarding”](#fast-onboarding)
357
358With the methodology, you can hire a person to the project who **is already previously familiar with this approach, and not train again**
359
360*People start to understand and benefit the project faster, and there are additional guarantees to find people for the next iterations of the project*
361
362### An experience-proven solution
363
364[Section titled “An experience-proven solution”](#an-experience-proven-solution-1)
365
366With the methodology, the business will get *a solution for most of the issues that arise during the development of systems*
367
368Since most often a business wants to get a framework / solution that would solve the lion’s share of problems during the development of the project
369
370### Applicability for different stages of the project
371
372[Section titled “Applicability for different stages of the project”](#applicability-for-different-stages-of-the-project)
373
374The methodology can benefit the project *both at the stage of project support and development, and at the MVP stage*
375
376Yes, the most important thing for MVP is *“features, not the architecture laid down for the future”*. But even in conditions of limited deadlines, knowing the best-practices from the methodology, you can *“do with little blood”*, when designing the MVP version of the system, finding a reasonable compromise (rather than modeling features “at random”)
377
378*The same can be said about testing*
379
380## When is our methodology not needed?
381
382[Section titled “When is our methodology not needed?”](#when-is-our-methodology-not-needed)
383
384* If the project will live for a short time
385* If the project does not need a supported architecture
386* If the business does not perceive the connection between the code base and the speed of feature delivery
387* If it is more important for the business to close orders as soon as possible, without further support
388
389### Business Size
390
391[Section titled “Business Size”](#business-size)
392
393* **Small business** - most often needs a ready-made and very fast solution. Only when the business grows (at least to almost average), he understands that in order for customers to continue using, it is necessary, among other things, to devote time to the quality and stability of the solutions being developed
394* **Medium-sized business** - usually understands all the problems of development, and even if it is necessary to *“arrange a race for features”*, he still spends time on quality improvements, refactoring and tests (and of course-on an extensible architecture)
395* **Big business** - usually already has an extensive audience, staff, and a much more extensive set of its practices, and probably even its own approach to architecture, so the idea of taking someone else’s comes to them not so often
396
397## Plans
398
399[Section titled “Plans”](#plans)
400
401The main part of the goals [is set out here](/docs/about/mission#goals), but in addition, it is worth talking about our expectations from the methodology in the future
402
403### Combining experience
404
405[Section titled “Combining experience”](#combining-experience)
406
407Now we are trying to combine all our diverse experience of the `core-team`, and get a methodology hardened by practice as a result
408
409Of course, we can get Angular 3.0 as a result, but it is much more important here to **investigate the very problem of designing the architecture of complex systems**
410
411*And yes - we have complaints about the current version of the methodology, but we want to work together to come to a single and optimal solution (taking into account, among other things, the experience of the community)*
412
413### Life outside the specification
414
415[Section titled “Life outside the specification”](#life-outside-the-specification)
416
417If everything goes well, then the methodology will not be limited only to the specification and the toolkit
418
419* Perhaps there will be reports, articles
420* There may be `CODE_MODEs` for migrations to other technologies of projects written according to the methodology
421* It is possible that as a result we will be able to reach the maintainers of large technological solutions
422 * *Especially for React, compared to other frameworks - this is the main problem, because it does not say how to solve certain problems*
423
424## See also
425
426[Section titled “See also”](#see-also)
427
428* [(Discussion) Don’t need a methodology?](https://github.com/feature-sliced/documentation/discussions/27)
429* [About the methodology’s mission: goals and limitations](/docs/about/mission)
430* [Types of knowledge in the project](/docs/about/understanding/knowledge-types)
431
432# Promote in company
433
434## Do the project and the company need a methodology?
435
436[Section titled “Do the project and the company need a methodology?”](#do-the-project-and-the-company-need-a-methodology)
437
438> About the justification of the application, Those duty
439
440## How can I submit a methodology to a business?
441
442[Section titled “How can I submit a methodology to a business?”](#how-can-i-submit-a-methodology-to-a-business)
443
444## How to prepare and justify a plan to move to the methodology?
445
446[Section titled “How to prepare and justify a plan to move to the methodology?”](#how-to-prepare-and-justify-a-plan-to-move-to-the-methodology)
447
448# Promote in team
449
450* Onboard newcomers
451* Development Guidelines (“where to search N module”, etc…)
452* New approach for tasks
453
454## See also
455
456[Section titled “See also”](#see-also)
457
458* [(Thread) The simplicity of the old approaches and the importance of mindfulness](https://t.me/feature_sliced/3360)
459* [(Thread) About the convenience of searching by layers](https://t.me/feature_sliced/1918)
460
461# Integration aspects
462
463## Summary
464
465[Section titled “Summary”](#summary)
466
467First 5 minutes (RU):
468
469[YouTube video player](https://www.youtube.com/embed/TFA6zRO_Cl0?start=2110)
470
471## Also
472
473[Section titled “Also”](#also)
474
475**Advantages**:
476
477* [Overview](/docs/get-started/overview)
478* CodeReview
479* Onboarding
480
481**Disadvantages:**
482
483* Mental complexity
484* High entry threshold
485* “Layers hell”
486* Typical problems of feature-based approaches
487
488# Partial Application
489
490> How to partially apply the methodology? Does it make sense? What if I ignore it?
491
492# Abstractions
493
494## The law of leaky abstractions
495
496[Section titled “The law of leaky abstractions”](#the-law-of-leaky-abstractions)
497
498## Why are there so many abstractions
499
500[Section titled “Why are there so many abstractions”](#why-are-there-so-many-abstractions)
501
502> Abstractions help to cope with the complexity of the project. The question is - will these abstractions be specific only for this project, or will we try to derive general abstractions based on the specifics of the frontend
503
504> Architecture and applications in general are inherently complex, and the only question is how to better distribute and describe this complexity
505
506## About scopes of responsibility
507
508[Section titled “About scopes of responsibility”](#about-scopes-of-responsibility)
509
510> About optional abstractions
511
512## See also
513
514[Section titled “See also”](#see-also)
515
516* [About the need for new layers](https://t.me/feature_sliced/2801)
517* [About the difficulty in understanding the methodology and layers](https://t.me/feature_sliced/2619)
518
519# About architecture
520
521## Problems
522
523[Section titled “Problems”](#problems)
524
525Usually, the conversation about architecture is raised when the development stops due to certain problems in the project.
526
527### Bus-factor & Onboarding
528
529[Section titled “Bus-factor & Onboarding”](#bus-factor--onboarding)
530
531Only a limited number of people understand the project and its architecture
532
533**Examples:**
534
535* *“It’s difficult to add a person to the development”*
536* *“For every problem, everyone has their own opinion on how to get around” (let’s envy the angular)*
537* *“I don’t understand what is happening in this big piece of monolith”*
538
539### Implicit and uncontrolled consequences
540
541[Section titled “Implicit and uncontrolled consequences”](#implicit-and-uncontrolled-consequences)
542
543A lot of implicit side effects during development/refactoring *(“everything depends on everything”)*
544
545**Examples:**
546
547* *“The feature imports the feature”*
548* *“I updated the store of one page, and the functionality fell off on the other”*
549* *“The logic is smeared all over the application, and it is impossible to track where the beginning is, where the end is”*
550
551### Uncontrolled reuse of logic
552
553[Section titled “Uncontrolled reuse of logic”](#uncontrolled-reuse-of-logic)
554
555It is difficult to reuse/modify existing logic
556
557At the same time, there are usually [two extremes](https://github.com/feature-sliced/documentation/discussions/14):
558
559* Either the logic is written completely from scratch for each module *(with possible repetitions in the existing codebase)*
560* Either there is a tendency to transfer all-all implemented modules to `shared` folders, thereby creating a large dump of modules *from it (where most are used only in one place)*
561
562**Examples:**
563
564* *“I have **N** implementations of the same business logic in my project, for which I still pay”*
565* *“There are 6 different components of the button/pop-up/… In the project”*
566* *“Dump of helpers”*
567
568## Requirements
569
570[Section titled “Requirements”](#requirements)
571
572Therefore, it seems logical to present the desired *requirements for an ideal architecture:*
573
574Note
575
576Wherever it says “easy”, it means “relatively easy for a wide range of developers”, because it is clear that [it will not be possible to make an ideal solution for absolutely everyone](/docs/about/mission#limitations)
577
578### Explicitness
579
580[Section titled “Explicitness”](#explicitness)
581
582* It should be **easy to master and explain** the project and its architecture to the team
583* The structure should reflect the real **business values of the project**
584* There must be explicit **side effects and connections** between abstractions
585* It should be **easy to detect duplicate logic** without interfering with unique implementations
586* There should be no **dispersion of logic** throughout the project
587* There should not be **too many heterogeneous abstractions and rules** for a good architecture
588
589### Control
590
591[Section titled “Control”](#control)
592
593* A good architecture should **speed up the solution of tasks, the introduction of features**
594
595* It should be possible to control the development of the project
596
597* It should be easy to **expand, modify, delete the code**
598
599* The \* decomposition and isolation of\*\* functionality must be observed
600
601* Each component of the system must be **easily replaceable and removable**
602
603 * *[No need to optimize for changes](https://youtu.be/BWAeYuWFHhs?t=1631) - we can’t predict the future*
604 * *[Better-optimize for deletion](https://youtu.be/BWAeYuWFHhs?t=1666) - based on the context that already exists*
605
606### Adaptability
607
608[Section titled “Adaptability”](#adaptability)
609
610* A good architecture should be applicable **to most projects**
611
612 * *With existing infrastructure solutions*
613 * *At any stage of development*
614
615* There should be no dependence on the framework and platform
616
617* It should be possible to **easily scale the project and the team**, with the possibility of parallelization of development
618
619* It should be easy **to adapt to changing requirements and circumstances**
620
621## See also
622
623[Section titled “See also”](#see-also)
624
625* [(React Berlin Talk) Oleg Isonen - Feature Driven Architecture](https://youtu.be/BWAeYuWFHhs)
626* [(React SPB Meetup #1) Sergey Sova - Feature Slices](https://t.me/feature_slices)
627* [(Article) About project modularization](https://alexmngn.medium.com/why-react-developers-should-modularize-their-applications-d26d381854c1)
628* [(Article) About Separation of Concerns and structuring by features](https://ryanlanciaux.com/blog/2017/08/20/a-feature-based-approach-to-react-development/)
629
630# Knowledge types in the project
631
632The following “types of knowledge” can be distinguished in any project:
633
634* **Fundamental knowledge**\
635 Knowledge that does not change much over time, such as algorithms, computer science, programming language mechanisms and its APIs.
636
637* **Technology stack**\
638 Knowledge of the set of technical solutions used in a project, including programming languages, frameworks, and libraries.
639
640* **Project knowledge**\
641 Knowledge that is specific to the current project and not valuable outside of it. This knowledge is essential for newly-onboarded developers to be able to contribute effectively.
642
643Note
644
645**Feature-Sliced Design** is designed to reduce reliance on “project knowledge”, take more responsibility, and make it easier to onboard new team members.
646
647## See also
648
649[Section titled “See also”](#see-also)
650
651* [(Video 🇷🇺) Ilya Klimov - On Types of Knowledge](https://youtu.be/4xyb_tA-uw0?t=249)
652
653# Naming
654
655Different developers have different experiences and contexts, which can lead to misunderstandings on the team when the same entities are called differently. For example:
656
657* Components for display can be called “ui”, “components”, “ui-kit”, “views”, …
658* The code that is reused throughout the application can be called “core”, “shared”, “app”, …
659* Business logic code can be called “store”, “model”, “state”, …
660
661## Naming in Feature-Sliced Design
662
663[Section titled “Naming in Feature-Sliced Design”](#naming-in-fsd)
664
665The methodology uses specific terms such as:
666
667* “app”, “process”, “page”, “feature”, “entity”, “shared” as layer names,
668* “ui’, “model”, “lib”, “api”, “config” as segment names.
669
670It is very important to stick to these terms to prevent confusion among team members and new developers joining the project. Using standard names also helps when asking for help from the community.
671
672## Naming Conflicts
673
674[Section titled “Naming Conflicts”](#when-can-naming-interfere)
675
676Naming conflicts can occur when terms used in the FSD methodology overlap with terms used in the business:
677
678* `FSD#process` vs simulated process in an application,
679* `FSD#page` vs log page,
680* `FSD#model` vs car model.
681
682For example, a developer who sees the word “process” in the code will spend extra time trying to figure out what process is meant. Such **collisions can disrupt the development process**.
683
684When the project glossary contains terminology specific to FSD, it is critical to be careful when discussing these terms with the team and technical disinterested parties.
685
686To communicate effectively with the team, it is recommended that the abbreviation “FSD” be used to prefix the methodology terms. For example, when talking about a process, you might say, “We can put this process on the FSD features layer.”
687
688Conversely, when communicating with non-technical stakeholders, it is better to limit the use of FSD terminology and refrain from mentioning the internal structure of the code base.
689
690## See also
691
692[Section titled “See also”](#see-also)
693
694* [(Discussion) Adaptability of naming](https://github.com/feature-sliced/documentation/discussions/16)
695* [(Discussion) Entity Naming Survey](https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-464894)
696* [(Discussion) “processes” vs “flows” vs …](https://github.com/feature-sliced/documentation/discussions/20)
697* [(Discussion) “model” vs “store” vs …](https://github.com/feature-sliced/documentation/discussions/68)
698
699# Needs driven
700
701TL;DR
702
703:::note TL;DR
704
705— *Can’t you formulate the goal that the new feature will solve? Or maybe the problem is that the task itself is not formulated? **The point is also that the methodology helps to pull out the problematic definition of tasks and goals***
706
707— *project does not live in static - requirements and functionality are constantly changing. Over time, the code turns into mush, because at the start the project was designed only for the initial impression of wishes. **And the task of a good architecture is also to be sharpened for changing development conditions.***
708
709## Why?
710
711[Section titled “Why?”](#why)
712
713To choose a clear name for an entity and understand its components, **you need to clearly understand what task will be solved with the help of all this code.**
714
715> *@sergeysova: During development, we try to give each entity or function a name that clearly reflects the intentions and meaning of the code being executed.*
716
717*After all, without understanding the task, it is impossible to write the right tests that cover the most important cases, put down errors that help the user in the right places, even it is banal not to interrupt the user’s flow because of fixable non-critical errors.*
718
719## What tasks are we talking about?
720
721[Section titled “What tasks are we talking about?”](#what-tasks-are-we-talking-about)
722
723Frontend develops applications and interfaces for end users, so we solve the tasks of these consumers.
724
725When a person comes to us, **he wants to solve some of his pain or close a need.**
726
727*The task of managers and analysts is to formulate this need, and implement developers taking into account the features of web development (loss of communication, backend error, typo, missed the cursor or finger).*
728
729**This very goal, with which the user came, is the task of the developers.**
730
731> *One small solved problem is a feature in the Feature-Sliced Design methodology — you need to cut the entire scope of project tasks into small goals.*
732
733## How does this affect development?
734
735[Section titled “How does this affect development?”](#how-does-this-affect-development)
736
737### Task decomposition
738
739[Section titled “Task decomposition”](#task-decomposition)
740
741When a developer begins to implement a task, in order to simplify the understanding and support of the code, he mentally **cuts it into stages**:
742
743* first *split into top-level entities* and *implement them*,
744* then these entities *split into smaller ones*
745* and so on
746
747*In the process of splitting into entities, the developer is forced to give them a name that would clearly reflect his idea and help to understand what task the code solves when reading the listing* *At the same time, we do not forget that we are trying to help the user reduce pain or realize needs*
748
749### Understanding the essence of the task
750
751[Section titled “Understanding the essence of the task”](#understanding-the-essence-of-the-task)
752
753But to give a clear name to an entity, **the developer must know enough about its purpose**
754
755* how is he going to use this entity,
756* what part of the user’s task does it implement, where else can this entity be applied,
757* in what other tasks can it participate,
758* and so on
759
760It is not difficult to draw a conclusion: **while the developer will reflect on the name of entities within the framework of the methodology, he will be able to find poorly formulated tasks even before writing the code.**
761
762> How to give a name to an entity if you do not understand well what tasks it can solve, how can you even divide a task into entities if you do not understand it well?
763
764## How to formulate it?
765
766[Section titled “How to formulate it?”](#how-to-formulate-it)
767
768**To formulate a task that is solved by features, you need to understand the task itself**, and this is already the responsibility of the project manager and analysts.
769
770*The methodology can only tell the developer what tasks the product manager should pay close attention to.*
771
772> *@sergeysova: the Whole frontend is primarily a display of information, any component in the first turn, displays, and then the task “to show the user something” has no practical value.*
773>
774> *Even without taking into account the specifics of the frontend can ask, “why do I have to show you”, so you can continue to ask until’t get out of pain or the need of the consumer.*
775
776As soon as we were able to get to the basic needs or pains, we can go back and figure out **how exactly your product or service can help the user with his goals**
777
778Any new task in your tracker is aimed at solving business problems, and the business tries to solve the user’s tasks at the same time earning money on it. This means that each task has certain goals, even if they are not spelled out in the description text.
779
780***The developer must clearly understand what goal this or that task is pursuing**, but not every company can afford to build processes perfectly, although this is a separate conversation, nevertheless, the developer may well “ping” the right managers himself to find out this and do his part of the work effectively.*
781
782## And what is the benefit?
783
784[Section titled “And what is the benefit?”](#and-what-is-the-benefit)
785
786Now let’s look at the whole process from beginning to end.
787
788### 1. Understanding user tasks
789
790[Section titled “1. Understanding user tasks”](#1-understanding-user-tasks)
791
792When a developer understands his pain and how the business closes them, he can offer solutions that are not available to the business due to the specifics of web development.
793
794> But of course, all this can work only if the developer is not indifferent to what he is doing and for what, otherwise *why then the methodology and some approaches?*
795
796### 2. Structuring and ordering
797
798[Section titled “2. Structuring and ordering”](#2-structuring-and-ordering)
799
800With the understanding of tasks comes **a clear structure both in the head and in the tasks along with the code**
801
802### 3. Understanding the feature and its components
803
804[Section titled “3. Understanding the feature and its components”](#3-understanding-the-feature-and-its-components)
805
806**One feature is one useful functionality for the user**
807
808* When several features are implemented in one feature, this is **a violation of borders**
809* The feature can be indivisible and growing - **and this is not bad**
810* **Bad** - when the feature does not answer the question *“What is the business value for the user?”*
811* There can be no “map-office” feature
812 * But `booking-meeting-on-the-map`, `search-for-an-employee`, `change-of-workplace` - **yes**
813
814> *@sergeysova: The point is that the feature contains only code that implements the functionality itself*, without unnecessary details and internal solutions (ideally)\*
815>
816> *Open the feature code **and see only what relates to the task** - no more*
817
818### 4. Profit
819
820[Section titled “4. Profit”](#4-profit)
821
822Business very rarely turns its course radically in the other direction, which means **the reflection of business tasks in the frontend application code is a very significant profit.**
823
824*Then you don’t have to explain to each new team member what this or that code does, and in general why it was added - **everything will be explained through the business tasks that are already reflected in the code.***
825
826> What is called [“Business Language” in Domain Driven Development](https://thedomaindrivendesign.io/developing-the-ubiquitous-language)
827
828***
829
830## Back to reality
831
832[Section titled “Back to reality”](#back-to-reality)
833
834If business processes are understood and good names are given at the design stage - *then it is not particularly problematic to transfer this understanding and logic to the code.*
835
836**However, in practice**, tasks and functionality are usually developed “too” iteratively and (or) there is no time to think through the design.
837
838**As a result, the feature makes sense today, and if you expand this feature in a month, you can rewrite the gender of the project.**
839
840> *\[[From the discussion](https://t.me/sergeysova/318)]: The developer tries to think 2-3 steps ahead, taking into account future wishes, but here he rests on his own experience*
841>
842> *Burns experience engineer usually immediately looking 10 steps ahead, and understand where one feature to divide and combine with the other*
843>
844> *But sometimes that comes the task which had to face the experience, and nowhere to take the understanding of how literacy to decompose, with the least unfortunate consequences in the future*
845
846## The role of methodology
847
848[Section titled “The role of methodology”](#the-role-of-methodology)
849
850**The methodology helps to solve the problems of developers, so that it is easier to solve the problems of users.**
851
852There is no solution to the problems of developers only for the sake of developers
853
854But in order for the developer to solve his tasks, **you need to understand the user’s tasks** - on the contrary, it will not work
855
856### Methodology requirements
857
858[Section titled “Methodology requirements”](#methodology-requirements)
859
860It becomes clear that you need to identify at least two requirements for **Feature-Sliced Design**:
861
8621. The methodology should tell **how to create features, processes and entities**
863
864 * Which means it should clearly explain *how to divide the code between them*, which means that the naming of these entities should also be laid down in the specification.
865
8662. The methodology should help the architecture **[easily adapt to the changing requirements of the project](/docs/about/understanding/architecture#adaptability)**
867
868## See also
869
870[Section titled “See also”](#see-also)
871
872* [(Post) Stimulation for a clear formulation of tasks (+ discussion)](https://t.me/sergeysova/318)
873
874 > ***The current article** is an adaptation of this discussion, you can read the full uncut version at the link*
875
876* [(Discussion) How to break the functionality and what it is](https://t.me/atomicdesign/18972)
877
878* [(Article) “How to better organize your applications”](https://alexmngn.medium.com/how-to-better-organize-your-react-applications-2fd3ea1920f1)
879
880# Signals of architecture
881
882> If there is a limitation on the part of the architecture, then there are obvious reasons for this, and consequences if they are ignored
883
884> The methodology and architecture gives signals, and how to deal with it depends on what risks you are ready to take on and what is most suitable for your team)
885
886## See also
887
888[Section titled “See also”](#see-also)
889
890* [(Thread) About signals from architecture and dataflow](https://t.me/feature_sliced/2070)
891* [(Thread) About the fundamental nature of architecture](https://t.me/feature_sliced/2492)
892* [(Thread) About highlighting weak points](https://t.me/feature_sliced/3979)
893* [(Thread) How to understand that the data model is swollen](https://t.me/feature_sliced/4228)
894
895# Branding Guidelines
896
897FSD’s visual identity is based on its core-concepts: `Layered`, `Sliced self-contained parts`, `Parts & Compose`, `Segmented`. But also we tend to design simple, pretty identity, which should convey the FSD philisophy and be easy to recognize.
898
899**Please, use FSD’s identity “as-is”, without changes but with our assets for your comfort.** This brand guide will help you to use FSD’s identity correctly.
900
901Caution
902
903FSD had [another legacy identity](https://drive.google.com/drive/folders/11Y-3qZ_C9jOFoW2UbSp11YasOhw4yBdl?usp=sharing) before. Old design didn’t represent core-concepts of methodology. Also it was created as pure draft, and should have been actualized.
904
905For a compatible and long-term use of the brand, we have been carefully rebranding for a year (2021-2022). **So that you can be sure when using identity of FSD 🍰**
906
907*But prefer namely actual identity, not old!*
908
909## Title
910
911[Section titled “Title”](#title)
912
913* ✅ **Correct:** `Feature-Sliced Design`, `FSD`
914* ❌ **Incorrect:** `Feature-Sliced`, `Feature Sliced`, `FeatureSliced`, `feature-sliced`, `feature sliced`, `FS`
915
916## Emojii
917
918[Section titled “Emojii”](#emojii)
919
920The cake 🍰 image represents FSD core concepts quite well, so it has been chosen as our signature emoji
921
922> Example: *”🍰 Architectural design methodology for Frontend projects”*
923
924## Logo & Palette
925
926[Section titled “Logo & Palette”](#logo--palette)
927
928FSD has few variations of logo for different context, but it recommended to prefer **primary**
929
930| | | |
931| -------------------------- | -------------------------------------------------------------------------------------------------------- | ----------------------- |
932| Theme | Logo (Ctrl/Cmd + Click for download) | Usage |
933| primary (#29BEDC, #517AED) | [![logo-primary](/_astro/logo-primary.CDj-FIdD_1C97sP.webp)](/_astro/logo-primary.CDj-FIdD.png) | Preferred in most cases |
934| flat (#3193FF) | [![logo-flat](/_astro/logo-flat.DtIserW__Z2t655E.webp)](/_astro/logo-flat.DtIserW_.png) | For one-color context |
935| monochrome (#FFF) | [![logo-monochrome](/_astro/logo-monochrome.0p3AxdYi_1oyFJP.webp)](/_astro/logo-monochrome.0p3AxdYi.png) | For grayscale context |
936| square (#3193FF) | [![logo-square](/_astro/logo-square.BjrpqCvQ_1VyYdu.webp)](/_astro/logo-square.BjrpqCvQ.png) | For square boundaries |
937
938## Banners & Schemes
939
940[Section titled “Banners & Schemes”](#banners--schemes)
941
942[![banner-primary](/_astro/banner-primary.CctEYme2_ZQCH7w.webp) ](/_astro/banner-primary.CctEYme2.jpg)[![banner-monochrome](/_astro/banner-monochrome.DFz3Ww19_Z295phS.webp)](/_astro/banner-monochrome.DFz3Ww19.jpg)
943
944## Social Preview
945
946[Section titled “Social Preview”](#social-preview)
947
948Work in progress…
949
950## Presentation template
951
952[Section titled “Presentation template”](#presentation-template)
953
954Work in progress…
955
956## See also
957
958[Section titled “See also”](#see-also)
959
960* [Discussion (github)](https://github.com/feature-sliced/documentation/discussions/399)
961* [History of development with references (figma)](https://www.figma.com/file/RPphccpoeasVB0lMpZwPVR/FSD-Brand?node-id=0%3A1)
962
963# FAQ
964
965Note
966
967You can ask your question in our [Telegram chat](https://t.me/feature_sliced), [Discord community](https://discord.gg/S8MzWTUsmp), and [GitHub Discussions](https://github.com/feature-sliced/documentation/discussions).
968
969### Is there a toolkit or a linter?
970
971[Section titled “Is there a toolkit or a linter?”](#is-there-a-toolkit-or-a-linter)
972
973Yes! We have a linter called [Steiger](https://github.com/feature-sliced/steiger) to check your project’s architecture and [folder generators](https://github.com/feature-sliced/awesome?tab=readme-ov-file#tools) through a CLI or IDEs.
974
975### Where to store the layout/template of pages?
976
977[Section titled “Where to store the layout/template of pages?”](#where-to-store-the-layouttemplate-of-pages)
978
979If you need plain markup layouts, you can keep them in `shared/ui`. If you need to use higher layers inside, there are a few options:
980
981* Perhaps you don’t need layouts at all? If the layout is only a few lines, it might be reasonable to duplicate the code in each page rather than try to abstract it.
982* If you do need layouts, you can have them as separate widgets or pages, and compose them in your router configuration in App. Nested routing is another option.
983
984### What is the difference between a feature and an entity?
985
986[Section titled “What is the difference between a feature and an entity?”](#what-is-the-difference-between-a-feature-and-an-entity)
987
988An *entity* is a real-life concept that your app is working with. A *feature* is an interaction that provides real-life value to your app’s users, the thing people want to do with your entities.
989
990For more information, along with examples, see the Reference page on [slices](/docs/reference/layers#entities).
991
992### Can I embed pages/features/entities into each other?
993
994[Section titled “Can I embed pages/features/entities into each other?”](#can-i-embed-pagesfeaturesentities-into-each-other)
995
996Yes, but this embedding should happen in higher layers. For example, inside a widget, you can import both features and then insert one feature into another as props/children.
997
998You cannot import one feature from another feature, this is prohibited by the [**import rule on layers**](/docs/reference/layers#import-rule-on-layers).
999
1000### What about Atomic Design?
1001
1002[Section titled “What about Atomic Design?”](#what-about-atomic-design)
1003
1004The current version of the methodology does not require nor prohibit the use of Atomic Design together with Feature-Sliced Design.
1005
1006For example, Atomic Design [can be applied well](https://t.me/feature_sliced/1653) for the `ui` segment of modules.
1007
1008### Are there any useful resources/articles/etc. about FSD?
1009
1010[Section titled “Are there any useful resources/articles/etc. about FSD?”](#are-there-any-useful-resourcesarticlesetc-about-fsd)
1011
1012Yes! <https://github.com/feature-sliced/awesome>
1013
1014### Why do I need Feature-Sliced Design?
1015
1016[Section titled “Why do I need Feature-Sliced Design?”](#why-do-i-need-feature-sliced-design)
1017
1018It helps you and your team to quickly overview the project in terms of its main value-bringing components. A standardized architecture helps to speed up onboarding and resolves debates about code structure. See the [motivation](/docs/about/motivation) page to learn more about why FSD was created.
1019
1020### Does a novice developer need an architecture/methodology?
1021
1022[Section titled “Does a novice developer need an architecture/methodology?”](#does-a-novice-developer-need-an-architecturemethodology)
1023
1024Rather yes than no
1025
1026*Usually, when you design and develop a project in one person, everything goes smoothly. But if there are pauses in development, new developers are added to the team - then problems come*
1027
1028### How do I work with the authorization context?
1029
1030[Section titled “How do I work with the authorization context?”](#how-do-i-work-with-the-authorization-context)
1031
1032Answered [here](/docs/guides/examples/auth)
1033
1034# Overview
1035
1036**Feature-Sliced Design** (FSD) is an architectural methodology for scaffolding front-end applications. Simply put, it’s a compilation of rules and conventions on organizing code. The main purpose of this methodology is to make the project more understandable and stable in the face of ever-changing business requirements.
1037
1038Apart from a set of conventions, FSD is also a toolchain. We have a [linter](https://github.com/feature-sliced/steiger) to check your project’s architecture, [folder generators](https://github.com/feature-sliced/awesome?tab=readme-ov-file#tools) through a CLI or IDEs, as well as a rich library of examples.
1039
1040## Is it right for me?
1041
1042[Section titled “Is it right for me?”](#is-it-right-for-me)
1043
1044FSD can be implemented in projects and teams of any size. It is right for your project if:
1045
1046* You’re doing **frontend** (UI on web, mobile, desktop, etc.)
1047* You’re building an **application**, not a library
1048
1049And that’s it! There are no restrictions on what programming language, UI framework, or state manager you use. You can also adopt FSD incrementally, use it in monorepos, and scale to great lengths by breaking your app into packages and implementing FSD individually within them.
1050
1051If you already have an architecture and you’re considering a switch to FSD, make sure that the current architecture is **causing trouble** in your team. For example, if your project has grown too large and inter-connected to efficiently implement new features, or if you’re expecting a lot of new members to join the team. If the current architecture works, maybe it’s not worth changing. But if you do decide to migrate, see the [Migration](/docs/guides/migration/from-custom) section for guidance.
1052
1053## Basic example
1054
1055[Section titled “Basic example”](#basic-example)
1056
1057Here is a simple project that implements FSD:
1058
1059* app/
1060
1061 * …
1062
1063* pages/
1064
1065 * …
1066
1067* shared/
1068
1069 * …
1070
1071These top-level folders are called *layers*. Let’s look deeper:
1072
1073* app/
1074
1075 * routes/
1076
1077 * …
1078
1079 * analytics/
1080
1081 * …
1082
1083* pages/
1084
1085 * home/
1086
1087 * …
1088
1089 * article-reader/
1090
1091 * ui/
1092
1093 * …
1094
1095 * api/
1096
1097 * …
1098
1099 * setings/
1100
1101 * …
1102
1103* shared/
1104
1105 * ui/
1106
1107 * …
1108
1109 * api/
1110
1111 * …
1112
1113Folders inside `📂 pages` are called *slices*. They divide the layer by domain (in this case, by pages).
1114
1115Folders inside `📂 app`, `📂 shared`, and `📂 pages/article-reader` are called *segments*, and they divide slices (or layers) by technical purpose, i.e. what the code is for.
1116
1117## Concepts
1118
1119[Section titled “Concepts”](#concepts)
1120
1121Layers, slices, and segments form a hierarchy like this:
1122
1123![Hierarchy of FSD concepts, described below](/_astro/visual_schema.SC2t6WGo_ZT4ECr.webp)
1124
1125Pictured above: three pillars, labeled left to right as “Layers”, “Slices”, and “Segments” respectively.
1126
1127The “Layers” pillar contains seven divisions arranged top to bottom and labeled “app”, “processes”, “pages”, “widgets”, “features”, “entities”, and “shared”. The “processes” division is crossed out. The “entities” division is connected to the second pillar “Slices” in a way that conveys that the second pillar is the content of “entities”.
1128
1129The “Slices” pillar contains three divisions arranged top to bottom and labeled “user”, “post”, and “comment”. The “post” division is connected to the third pillar “Segments” in the same way such that it’s the content of “post”.
1130
1131The “Segments” pillar contains three divisions, arranged top to bottom and labeled “ui”, “model”, and “api”.
1132
1133### Layers
1134
1135[Section titled “Layers”](#layers)
1136
1137Layers are standardized across all FSD projects. You don’t have to use all of the layers, but their names are important. There are currently seven of them (from top to bottom):
1138
11391. **App** — everything that makes the app run — routing, entrypoints, global styles, providers.
11402. **Processes** (deprecated) — complex inter-page scenarios.
11413. **Pages** — full pages or large parts of a page in nested routing.
11424. **Widgets** — large self-contained chunks of functionality or UI, usually delivering an entire use case.
11435. **Features** — *reused* implementations of entire product features, i.e. actions that bring business value to the user.
11446. **Entities** — business entities that the project works with, like `user` or `product`.
11457. **Shared** — reusable functionality, especially when it’s detached from the specifics of the project/business, though not necessarily.
1146
1147Caution
1148
1149Layers **App** and **Shared**, unlike other layers, do not have slices and are divided into segments directly.
1150
1151However, all other layers — **Entities**, **Features**, **Widgets**, and **Pages**, retain the structure in which you must first create slices, inside which you create the segments.
1152
1153The trick with layers is that modules on one layer can only know about and import from modules from the layers strictly below.
1154
1155### Slices
1156
1157[Section titled “Slices”](#slices)
1158
1159Next up are slices, which partition the code by business domain. You’re free to choose any names for them, and create as many as you wish. Slices make your codebase easier to navigate by keeping logically related modules close together.
1160
1161Slices cannot use other slices on the same layer, and that helps with high cohesion and low coupling.
1162
1163### Segments
1164
1165[Section titled “Segments”](#segments)
1166
1167Slices, as well as layers App and Shared, consist of segments, and segments group your code by its purpose. Segment names are not constrained by the standard, but there are several conventional names for the most common purposes:
1168
1169* `ui` — everything related to UI display: UI components, date formatters, styles, etc.
1170* `api` — backend interactions: request functions, data types, mappers, etc.
1171* `model` — the data model: schemas, interfaces, stores, and business logic.
1172* `lib` — library code that other modules on this slice need.
1173* `config` — configuration files and feature flags.
1174
1175Usually these segments are enough for most layers, you would only create your own segments in Shared or App, but this is not a rule.
1176
1177## Advantages
1178
1179[Section titled “Advantages”](#advantages)
1180
1181* **Uniformity** Since the structure is standardized, projects become more uniform, which makes onboarding new members easier for the team.
1182
1183* **Stability in face of changes and refactoring** A module on one layer cannot use other modules on the same layer, or the layers above. This allows you to make isolated modifications without unforeseen consequences to the rest of the app.
1184
1185* **Controlled reuse of logic** Depending on the layer, you can make code very reusable or very local. This keeps a balance between following the **DRY** principle and practicality.
1186
1187* **Orientation to business and users needs** The app is split into business domains and usage of the business language is encouraged in naming, so that you can do useful product work without fully understanding all other unrelated parts of the project.
1188
1189## Incremental adoption
1190
1191[Section titled “Incremental adoption”](#incremental-adoption)
1192
1193If you have an existing codebase that you want to migrate to FSD, we suggest the following strategy. We found it useful in our own migration experience.
1194
11951. Start by slowly shaping up the App and Shared layers module-by-module to create a foundation.
1196
11972. Distribute all of the existing UI across Widgets and Pages using broad strokes, even if they have dependencies that violate the rules of FSD.
1198
11993. Start gradually resolving import violations and also extracting Entities and possibly even Features.
1200
1201It’s advised to refrain from adding new large entities while refactoring or refactoring only certain parts of the project.
1202
1203## Next steps
1204
1205[Section titled “Next steps”](#next-steps)
1206
1207[Want to get a good grasp of how to think in FSD? ](/docs/get-started/tutorial)Check out the Tutorial
1208
1209[Have questions? ](https://t.me/feature_sliced)Drop by our Telegram chat and get help from the community
1210
1211# Tutorial
1212
1213## Part 1. On paper
1214
1215[Section titled “Part 1. On paper”](#part-1-on-paper)
1216
1217This tutorial will examine the Real World App, also known as Conduit. Conduit is a basic [Medium](https://medium.com/) clone — it lets you read and write articles as well as comment on the articles of others.
1218
1219![Conduit home page](/_astro/realworld-feed-anonymous.D_qjBKT-_1J5PBP.webp)
1220
1221This is a pretty small application, so we will keep it simple and avoid excessive decomposition. It’s highly likely that the entire app will fit into just three layers: **App**, **Pages**, and **Shared**. If not, we will introduce additional layers as we go. Ready?
1222
1223### Start by listing the pages
1224
1225[Section titled “Start by listing the pages”](#start-by-listing-the-pages)
1226
1227If we look at the screenshot above, we can assume at least the following pages:
1228
1229* Home (article feed)
1230* Sign in and sign up
1231* Article reader
1232* Article editor
1233* User profile viewer
1234* User profile editor (user settings)
1235
1236Every one of these pages will become its own *slice* on the Pages *layer*. Recall from the overview that slices are simply folders inside of layers and layers are simply folders with predefined names like `pages`.
1237
1238As such, our Pages folder will look like this:
1239
1240* pages/
1241
1242 * feed/
1243
1244 * …
1245
1246 * sign-in/
1247
1248 * …
1249
1250 * article-read/
1251
1252 * …
1253
1254 * article-edit/
1255
1256 * …
1257
1258 * profile/
1259
1260 * …
1261
1262 * settings/
1263
1264 * …
1265
1266The key difference of Feature-Sliced Design from an unregulated code structure is that pages cannot reference each other. That is, one page cannot import code from another page. This is due to the **import rule on layers**:
1267
1268*A module (file) in a slice can only import other slices when they are located on layers strictly below.*
1269
1270In this case, a page is a slice, so modules (files) inside this page can only reference code from layers below, not from the same layer, Pages.
1271
1272### Close look at the feed
1273
1274[Section titled “Close look at the feed”](#close-look-at-the-feed)
1275
1276![Anonymous user’s perspective](/_astro/realworld-feed-anonymous.D_qjBKT-_1J5PBP.webp)
1277
1278*Anonymous user’s perspective*
1279
1280![Authenticated user’s perspective](/_astro/realworld-feed-authenticated.C8sxEgXZ_Z27drBu.webp)
1281
1282*Authenticated user’s perspective*
1283
1284There are three dynamic areas on the feed page:
1285
12861. Sign-in links with an indication if you are signed in
12872. List of tags that triggers filtering in the feed
12883. One/two feeds of articles, each article with a like button
1289
1290The sign-in links are a part of a header that is common to all pages, we will revisit it separately.
1291
1292#### List of tags
1293
1294[Section titled “List of tags”](#list-of-tags)
1295
1296To build the list of tags, we need to fetch the available tags, render each tag as a chip, and store the selected tags in a client-side storage. These operations fall into categories “API interaction”, “user interface”, and “storage”, respectively. In Feature-Sliced Design, code is separated by purpose using *segments*. Segments are folders in slices, and they can have arbitrary names that describe the purpose, but some purposes are so common that there’s a convention for certain segment names:
1297
1298* 📂 `api/` for backend interactions
1299* 📂 `ui/` for code that handles rendering and appearance
1300* 📂 `model/` for storage and business logic
1301* 📂 `config/` for feature flags, environment variables and other forms of configuration
1302
1303We will place code that fetches tags into `api`, the tag component into `ui`, and the storage interaction into `model`.
1304
1305#### Articles
1306
1307[Section titled “Articles”](#articles)
1308
1309Using the same grouping principles, we can decompose the feed of articles into the same three segments:
1310
1311* 📂 `api/`: fetch paginated articles with like count; like an article
1312
1313* 📂 `ui/`:
1314
1315 * tab list that can render an extra tab if a tag is selected
1316 * individual article
1317 * functional pagination
1318
1319* 📂 `model/`: client-side storage of the currently loaded articles and current page (if needed)
1320
1321### Reuse generic code
1322
1323[Section titled “Reuse generic code”](#reuse-generic-code)
1324
1325Most pages are very different in intent, but certain things stay the same across the entire app — for example, the UI kit that conforms to the design language, or the convention on the backend that everything is done with a REST API with the same authentication method. Since slices are meant to be isolated, code reuse is facilitated by a lower layer, **Shared**.
1326
1327Shared is different from other layers in the sense that it contains segments, not slices. In this way, the Shared layer can be thought of as a hybrid between a layer and a slice.
1328
1329Usually, the code in Shared is not planned ahead of time, but rather extracted during development, because only during development does it become clear which parts of code are actually shared. However, it’s still helpful to keep a mental note of what kind of code naturally belongs in Shared:
1330
1331* 📂 `ui/` — the UI kit, pure appearance, no business logic. For example, buttons, modal dialogs, form inputs.
1332* 📂 `api/` — convenience wrappers around request making primitives (like `fetch()` on the Web) and, optionally, functions for triggering particular requests according to the backend specification.
1333* 📂 `config/` — parsing environment variables
1334* 📂 `i18n/` — configuration of language support
1335* 📂 `router/` — routing primitives and route constants
1336
1337Those are just a few examples of segment names in Shared, but you can omit any of them or create your own. The only important thing to remember when creating new segments is that segment names should describe **purpose (the why), not essence (the what)**. Names like “components”, “hooks”, “modals” *should not* be used because they describe what these files are, but don’t help to navigate the code inside. This requires people on the team to dig through every file in such folders and also keeps unrelated code close, which leads to broad areas of code being affected by refactoring and thus makes code review and testing harder.
1338
1339### Define a strict public API
1340
1341[Section titled “Define a strict public API”](#define-a-strict-public-api)
1342
1343In the context of Feature-Sliced Design, the term *public API* refers to a slice or segment declaring what can be imported from it by other modules in the project. For example, in JavaScript that can be an `index.js` file re-exporting objects from other files in the slice. This enables freedom in refactoring code inside a slice as long as the contract with the outside world (i.e. the public API) stays the same.
1344
1345For the Shared layer that has no slices, it’s usually more convenient to define a separate public API for each segment as opposed to defining one single index of everything in Shared. This keeps imports from Shared naturally organized by intent. For other layers that have slices, the opposite is true — it’s usually more practical to define one index per slice and let the slice decide its own set of segments that is unknown to the outside world because other layers usually have a lot less exports.
1346
1347Our slices/segments will appear to each other as follows:
1348
1349* pages/
1350
1351 * feed/
1352
1353 * index
1354
1355 * sign-in/
1356
1357 * index
1358
1359 * article-read/
1360
1361 * index
1362
1363 * …
1364
1365* shared/
1366
1367 * ui/
1368
1369 * index
1370
1371 * api/
1372
1373 * index
1374
1375 * …
1376
1377Whatever is inside folders like `pages/feed` or `shared/ui` is only known to those folders, and other files should not rely on the internal structure of these folders.
1378
1379### Large reused blocks in the UI
1380
1381[Section titled “Large reused blocks in the UI”](#large-reused-blocks-in-the-ui)
1382
1383Earlier we made a note to revisit the header that appears on every page. Rebuilding it from scratch on every page would be impractical, so it’s only natural to want to reuse it. We already have Shared to facilitate code reuse, however, there’s a caveat to putting large blocks of UI in Shared — the Shared layer is not supposed to know about any of the layers above.
1384
1385Between Shared and Pages there are three other layers: Entities, Features, and Widgets. Some projects may have something in those layers that they need in a large reusable block, and that means we can’t put that reusable block in Shared, or else it would be importing from upper layers, which is prohibited. That’s where the Widgets layer comes in. It is located above Shared, Entities, and Features, so it can use them all.
1386
1387In our case, the header is very simple — it’s a static logo and top-level navigation. The navigation needs to make a request to the API to determine if the user is currently logged in or not, but that can be handled by a simple import from the `api` segment. Therefore, we will keep our header in Shared.
1388
1389### Close look at a page with a form
1390
1391[Section titled “Close look at a page with a form”](#close-look-at-a-page-with-a-form)
1392
1393Let’s also examine a page that’s intended for editing, not reading. For example, the article writer:
1394
1395![Conduit post editor](/_astro/realworld-editor-authenticated.BlQVl7sz_136BD2.webp)
1396
1397It looks trivial, but contains several aspects of application development that we haven’t explored yet — form validation, error states, and data persistence.
1398
1399If we were to build this page, we would grab some inputs and buttons from Shared and put together a form in the `ui` segment of this page. Then, in the `api` segment, we would define a mutation request to create the article on the backend.
1400
1401To validate the request before sending, we need a validation schema, and a good place for it is the `model` segment, since it’s the data model. There we will produce error messages and display them using another component in the `ui` segment.
1402
1403To improve user experience, we could also persist the inputs to prevent accidental data loss. This is also a job of the `model` segment.
1404
1405### Summary
1406
1407[Section titled “Summary”](#summary)
1408
1409We have examined several pages and outlined a preliminary structure for our application:
1410
14111. Shared layer
1412
1413 1. `ui` will contain our reusable UI kit
1414 2. `api` will contain our primitive interactions with the backend
1415 3. The rest will be arranged on demand
1416
14172. Pages layer — each page is a separate slice
1418
1419 1. `ui` will contain the page itself and all of its parts
1420 2. `api` will contain more specialized data fetching, using `shared/api`
1421 3. `model` might contain client-side storage of the data that we will display
1422
1423Let’s get building!
1424
1425## Part 2. In code
1426
1427[Section titled “Part 2. In code”](#part-2-in-code)
1428
1429Now that we have a plan, let’s put it to practice. We will use React and [Remix](https://remix.run).
1430
1431There’s a template ready for this project, clone it from GitHub to get a headstart: <https://github.com/feature-sliced/tutorial-conduit/tree/clean>.
1432
1433Install dependencies with `npm install` and start the development server with `npm run dev`. Open `http://localhost:3000` and you should see a blank app.
1434
1435### Lay out the pages
1436
1437[Section titled “Lay out the pages”](#lay-out-the-pages)
1438
1439Let’s start by creating blank components for all our pages. Run the following command in your project:
1440
1441```bash
1442npx fsd pages feed sign-in article-read article-edit profile settings --segments ui
1443```
1444
1445This will create folders like `pages/feed/ui/` and an index file, `pages/feed/index.ts`, for every page.
1446
1447### Connect the feed page
1448
1449[Section titled “Connect the feed page”](#connect-the-feed-page)
1450
1451Let’s connect the root route of our application to the feed page. Create a component, `FeedPage.tsx` in `pages/feed/ui` and put the following inside it:
1452
1453pages/feed/ui/FeedPage.tsx
1454
1455```tsx
1456export function FeedPage() {
1457 return (
1458 <div className="home-page">
1459 <div className="banner">
1460 <div className="container">
1461 <h1 className="logo-font">conduit</h1>
1462 <p>A place to share your knowledge.</p>
1463 </div>
1464 </div>
1465 </div>
1466 );
1467}
1468```
1469
1470Then re-export this component in the feed page’s public API, the `pages/feed/index.ts` file:
1471
1472pages/feed/index.ts
1473
1474```ts
1475export { FeedPage } from "./ui/FeedPage";
1476```
1477
1478Now connect it to the root route. In Remix, routing is file-based, and the route files are located in the `app/routes` folder, which nicely coincides with Feature-Sliced Design.
1479
1480Use the `FeedPage` component in `app/routes/_index.tsx`:
1481
1482app/routes/\_index.tsx
1483
1484```tsx
1485import type { MetaFunction } from "@remix-run/node";
1486import { FeedPage } from "pages/feed";
1487
1488
1489export const meta: MetaFunction = () => {
1490 return [{ title: "Conduit" }];
1491};
1492
1493
1494export default FeedPage;
1495```
1496
1497Then, if you run the dev server and open the application, you should see the Conduit banner!
1498
1499![The banner of Conduit](/_astro/conduit-banner.BDGXatKB_wyI9W.webp)
1500
1501### API client
1502
1503[Section titled “API client”](#api-client)
1504
1505To talk to the RealWorld backend, let’s create a convenient API client in Shared. Create two segments, `api` for the client and `config` for variables like the backend base URL:
1506
1507```bash
1508npx fsd shared --segments api config
1509```
1510
1511Then create `shared/config/backend.ts`:
1512
1513shared/config/backend.ts
1514
1515```tsx
1516export { mockBackendUrl as backendBaseUrl } from "mocks/handlers";
1517```
1518
1519shared/config/index.ts
1520
1521```tsx
1522export { backendBaseUrl } from "./backend";
1523```
1524
1525Since the RealWorld project conveniently provides an [OpenAPI specification](https://github.com/gothinkster/realworld/blob/main/api/openapi.yml), we can take advantage of auto-generated types for our client. We will use [the `openapi-fetch` package](https://openapi-ts.pages.dev/openapi-fetch/) that comes with an additional type generator.
1526
1527Run the following command to generate up-to-date API typings:
1528
1529```bash
1530npm run generate-api-types
1531```
1532
1533This will create a file `shared/api/v1.d.ts`. We will use this file to create a typed API client in `shared/api/client.ts`:
1534
1535shared/api/client.ts
1536
1537```tsx
1538import createClient from "openapi-fetch";
1539
1540
1541import { backendBaseUrl } from "shared/config";
1542import type { paths } from "./v1";
1543
1544
1545export const { GET, POST, PUT, DELETE } = createClient<paths>({ baseUrl: backendBaseUrl });
1546```
1547
1548shared/api/index.ts
1549
1550```tsx
1551export { GET, POST, PUT, DELETE } from "./client";
1552```
1553
1554### Real data in the feed
1555
1556[Section titled “Real data in the feed”](#real-data-in-the-feed)
1557
1558We can now proceed to adding articles to the feed, fetched from the backend. Let’s begin by implementing an article preview component.
1559
1560Create `pages/feed/ui/ArticlePreview.tsx` with the following content:
1561
1562pages/feed/ui/ArticlePreview\.tsx
1563
1564```tsx
1565export function ArticlePreview({ article }) { /* TODO */ }
1566```
1567
1568Since we’re writing in TypeScript, it would be nice to have a typed article object. If we explore the generated `v1.d.ts`, we can see that the article object is available through `components["schemas"]["Article"]`. So let’s create a file with our data models in Shared and export the models:
1569
1570shared/api/models.ts
1571
1572```tsx
1573import type { components } from "./v1";
1574
1575
1576export type Article = components["schemas"]["Article"];
1577```
1578
1579shared/api/index.ts
1580
1581```tsx
1582export { GET, POST, PUT, DELETE } from "./client";
1583
1584
1585export type { Article } from "./models";
1586```
1587
1588Now we can come back to the article preview component and fill the markup with data. Update the component with the following content:
1589
1590pages/feed/ui/ArticlePreview\.tsx
1591
1592```tsx
1593import { Link } from "@remix-run/react";
1594import type { Article } from "shared/api";
1595
1596
1597interface ArticlePreviewProps {
1598 article: Article;
1599}
1600
1601
1602export function ArticlePreview({ article }: ArticlePreviewProps) {
1603 return (
1604 <div className="article-preview">
1605 <div className="article-meta">
1606 <Link to={`/profile/${article.author.username}`} prefetch="intent">
1607 <img src={article.author.image} alt="" />
1608 </Link>
1609 <div className="info">
1610 <Link
1611 to={`/profile/${article.author.username}`}
1612 className="author"
1613 prefetch="intent"
1614 >
1615 {article.author.username}
1616 </Link>
1617 <span className="date" suppressHydrationWarning>
1618 {new Date(article.createdAt).toLocaleDateString(undefined, {
1619 dateStyle: "long",
1620 })}
1621 </span>
1622 </div>
1623 <button className="btn btn-outline-primary btn-sm pull-xs-right">
1624 <i className="ion-heart"></i> {article.favoritesCount}
1625 </button>
1626 </div>
1627 <Link
1628 to={`/article/${article.slug}`}
1629 className="preview-link"
1630 prefetch="intent"
1631 >
1632 <h1>{article.title}</h1>
1633 <p>{article.description}</p>
1634 <span>Read more...</span>
1635 <ul className="tag-list">
1636 {article.tagList.map((tag) => (
1637 <li key={tag} className="tag-default tag-pill tag-outline">
1638 {tag}
1639 </li>
1640 ))}
1641 </ul>
1642 </Link>
1643 </div>
1644 );
1645}
1646```
1647
1648The like button doesn’t do anything for now, we will fix that when we get to the article reader page and implement the liking functionality.
1649
1650Now we can fetch the articles and render out a bunch of these cards. Fetching data in Remix is done with *loaders* — server-side functions that fetch exactly what a page needs. Loaders interact with the API on the page’s behalf, so we will put them in the `api` segment of a page:
1651
1652pages/feed/api/loader.ts
1653
1654```tsx
1655import { json } from "@remix-run/node";
1656
1657
1658import { GET } from "shared/api";
1659
1660
1661export const loader = async () => {
1662 const { data: articles, error, response } = await GET("/articles");
1663
1664
1665 if (error !== undefined) {
1666 throw json(error, { status: response.status });
1667 }
1668
1669
1670 return json({ articles });
1671};
1672```
1673
1674To connect it to the page, we need to export it with the name `loader` from the route file:
1675
1676pages/feed/index.ts
1677
1678```tsx
1679export { FeedPage } from "./ui/FeedPage";
1680export { loader } from "./api/loader";
1681```
1682
1683app/routes/\_index.tsx
1684
1685```tsx
1686import type { MetaFunction } from "@remix-run/node";
1687import { FeedPage } from "pages/feed";
1688
1689
1690export { loader } from "pages/feed";
1691
1692
1693export const meta: MetaFunction = () => {
1694 return [{ title: "Conduit" }];
1695};
1696
1697
1698export default FeedPage;
1699```
1700
1701And the final step is to render these cards in the feed. Update your `FeedPage` with the following code:
1702
1703pages/feed/ui/FeedPage.tsx
1704
1705```tsx
1706import { useLoaderData } from "@remix-run/react";
1707
1708
1709import type { loader } from "../api/loader";
1710import { ArticlePreview } from "./ArticlePreview";
1711
1712
1713export function FeedPage() {
1714 const { articles } = useLoaderData<typeof loader>();
1715
1716
1717 return (
1718 <div className="home-page">
1719 <div className="banner">
1720 <div className="container">
1721 <h1 className="logo-font">conduit</h1>
1722 <p>A place to share your knowledge.</p>
1723 </div>
1724 </div>
1725
1726
1727 <div className="container page">
1728 <div className="row">
1729 <div className="col-md-9">
1730 {articles.articles.map((article) => (
1731 <ArticlePreview key={article.slug} article={article} />
1732 ))}
1733 </div>
1734 </div>
1735 </div>
1736 </div>
1737 );
1738}
1739```
1740
1741### Filtering by tag
1742
1743[Section titled “Filtering by tag”](#filtering-by-tag)
1744
1745Regarding the tags, our job is to fetch them from the backend and to store the currently selected tag. We already know how to do fetching — it’s another request from the loader. We will use a convenience function `promiseHash` from a package `remix-utils`, which is already installed.
1746
1747Update the loader file, `pages/feed/api/loader.ts`, with the following code:
1748
1749pages/feed/api/loader.ts
1750
1751```tsx
1752import { json } from "@remix-run/node";
1753import type { FetchResponse } from "openapi-fetch";
1754import { promiseHash } from "remix-utils/promise";
1755
1756
1757import { GET } from "shared/api";
1758
1759
1760async function throwAnyErrors<T, O, Media extends `${string}/${string}`>(
1761 responsePromise: Promise<FetchResponse<T, O, Media>>,
1762) {
1763 const { data, error, response } = await responsePromise;
1764
1765
1766 if (error !== undefined) {
1767 throw json(error, { status: response.status });
1768 }
1769
1770
1771 return data as NonNullable<typeof data>;
1772}
1773
1774
1775export const loader = async () => {
1776 return json(
1777 await promiseHash({
1778 articles: throwAnyErrors(GET("/articles")),
1779 tags: throwAnyErrors(GET("/tags")),
1780 }),
1781 );
1782};
1783```
1784
1785You might notice that we extracted the error handling into a generic function `throwAnyErrors`. It looks pretty useful, so we might want to reuse it later, but for now let’s just keep an eye on it.
1786
1787Now, to the list of tags. It needs to be interactive — clicking on a tag should make that tag selected. By Remix convention, we will use the URL search parameters as our storage for the selected tag. Let the browser take care of storage while we focus on more important things.
1788
1789Update `pages/feed/ui/FeedPage.tsx` with the following code:
1790
1791pages/feed/ui/FeedPage.tsx
1792
1793```tsx
1794import { Form, useLoaderData } from "@remix-run/react";
1795import { ExistingSearchParams } from "remix-utils/existing-search-params";
1796
1797
1798import type { loader } from "../api/loader";
1799import { ArticlePreview } from "./ArticlePreview";
1800
1801
1802export function FeedPage() {
1803 const { articles, tags } = useLoaderData<typeof loader>();
1804
1805
1806 return (
1807 <div className="home-page">
1808 <div className="banner">
1809 <div className="container">
1810 <h1 className="logo-font">conduit</h1>
1811 <p>A place to share your knowledge.</p>
1812 </div>
1813 </div>
1814
1815
1816 <div className="container page">
1817 <div className="row">
1818 <div className="col-md-9">
1819 {articles.articles.map((article) => (
1820 <ArticlePreview key={article.slug} article={article} />
1821 ))}
1822 </div>
1823
1824
1825 <div className="col-md-3">
1826 <div className="sidebar">
1827 <p>Popular Tags</p>
1828
1829
1830 <Form>
1831 <ExistingSearchParams exclude={["tag"]} />
1832 <div className="tag-list">
1833 {tags.tags.map((tag) => (
1834 <button
1835 key={tag}
1836 name="tag"
1837 value={tag}
1838 className="tag-pill tag-default"
1839 >
1840 {tag}
1841 </button>
1842 ))}
1843 </div>
1844 </Form>
1845 </div>
1846 </div>
1847 </div>
1848 </div>
1849 </div>
1850 );
1851}
1852```
1853
1854Then we need to use the `tag` search parameter in our loader. Change the `loader` function in `pages/feed/api/loader.ts` to the following:
1855
1856pages/feed/api/loader.ts
1857
1858```tsx
1859import { json, type LoaderFunctionArgs } from "@remix-run/node";
1860import type { FetchResponse } from "openapi-fetch";
1861import { promiseHash } from "remix-utils/promise";
1862
1863
1864import { GET } from "shared/api";
1865
1866
1867async function throwAnyErrors<T, O, Media extends `${string}/${string}`>(
1868 responsePromise: Promise<FetchResponse<T, O, Media>>,
1869) {
1870 const { data, error, response } = await responsePromise;
1871
1872
1873 if (error !== undefined) {
1874 throw json(error, { status: response.status });
1875 }
1876
1877
1878 return data as NonNullable<typeof data>;
1879}
1880
1881
1882export const loader = async ({ request }: LoaderFunctionArgs) => {
1883 const url = new URL(request.url);
1884 const selectedTag = url.searchParams.get("tag") ?? undefined;
1885
1886
1887 return json(
1888 await promiseHash({
1889 articles: throwAnyErrors(
1890 GET("/articles", { params: { query: { tag: selectedTag } } }),
1891 ),
1892 tags: throwAnyErrors(GET("/tags")),
1893 }),
1894 );
1895};
1896```
1897
1898That’s it, no `model` segment necessary. Remix is pretty neat.
1899
1900### Pagination
1901
1902[Section titled “Pagination”](#pagination)
1903
1904In a similar fashion, we can implement the pagination. Feel free to give it a shot yourself or just copy the code below. There’s no one to judge you anyway.
1905
1906pages/feed/api/loader.ts
1907
1908```tsx
1909import { json, type LoaderFunctionArgs } from "@remix-run/node";
1910import type { FetchResponse } from "openapi-fetch";
1911import { promiseHash } from "remix-utils/promise";
1912
1913
1914import { GET } from "shared/api";
1915
1916
1917async function throwAnyErrors<T, O, Media extends `${string}/${string}`>(
1918 responsePromise: Promise<FetchResponse<T, O, Media>>,
1919) {
1920 const { data, error, response } = await responsePromise;
1921
1922
1923 if (error !== undefined) {
1924 throw json(error, { status: response.status });
1925 }
1926
1927
1928 return data as NonNullable<typeof data>;
1929}
1930
1931
1932/** Amount of articles on one page. */
1933export const LIMIT = 20;
1934
1935
1936export const loader = async ({ request }: LoaderFunctionArgs) => {
1937 const url = new URL(request.url);
1938 const selectedTag = url.searchParams.get("tag") ?? undefined;
1939 const page = parseInt(url.searchParams.get("page") ?? "", 10);
1940
1941
1942 return json(
1943 await promiseHash({
1944 articles: throwAnyErrors(
1945 GET("/articles", {
1946 params: {
1947 query: {
1948 tag: selectedTag,
1949 limit: LIMIT,
1950 offset: !Number.isNaN(page) ? page * LIMIT : undefined,
1951 },
1952 },
1953 }),
1954 ),
1955 tags: throwAnyErrors(GET("/tags")),
1956 }),
1957 );
1958};
1959```
1960
1961pages/feed/ui/FeedPage.tsx
1962
1963```tsx
1964import { Form, useLoaderData, useSearchParams } from "@remix-run/react";
1965import { ExistingSearchParams } from "remix-utils/existing-search-params";
1966
1967
1968import { LIMIT, type loader } from "../api/loader";
1969import { ArticlePreview } from "./ArticlePreview";
1970
1971
1972export function FeedPage() {
1973 const [searchParams] = useSearchParams();
1974 const { articles, tags } = useLoaderData<typeof loader>();
1975 const pageAmount = Math.ceil(articles.articlesCount / LIMIT);
1976 const currentPage = parseInt(searchParams.get("page") ?? "1", 10);
1977
1978
1979 return (
1980 <div className="home-page">
1981 <div className="banner">
1982 <div className="container">
1983 <h1 className="logo-font">conduit</h1>
1984 <p>A place to share your knowledge.</p>
1985 </div>
1986 </div>
1987
1988
1989 <div className="container page">
1990 <div className="row">
1991 <div className="col-md-9">
1992 {articles.articles.map((article) => (
1993 <ArticlePreview key={article.slug} article={article} />
1994 ))}
1995
1996
1997 <Form>
1998 <ExistingSearchParams exclude={["page"]} />
1999 <ul className="pagination">
2000 {Array(pageAmount)
2001 .fill(null)
2002 .map((_, index) =>
2003 index + 1 === currentPage ? (
2004 <li key={index} className="page-item active">
2005 <span className="page-link">{index + 1}</span>
2006 </li>
2007 ) : (
2008 <li key={index} className="page-item">
2009 <button
2010 className="page-link"
2011 name="page"
2012 value={index + 1}
2013 >
2014 {index + 1}
2015 </button>
2016 </li>
2017 ),
2018 )}
2019 </ul>
2020 </Form>
2021 </div>
2022
2023
2024 <div className="col-md-3">
2025 <div className="sidebar">
2026 <p>Popular Tags</p>
2027
2028
2029 <Form>
2030 <ExistingSearchParams exclude={["tag", "page"]} />
2031 <div className="tag-list">
2032 {tags.tags.map((tag) => (
2033 <button
2034 key={tag}
2035 name="tag"
2036 value={tag}
2037 className="tag-pill tag-default"
2038 >
2039 {tag}
2040 </button>
2041 ))}
2042 </div>
2043 </Form>
2044 </div>
2045 </div>
2046 </div>
2047 </div>
2048 </div>
2049 );
2050}
2051```
2052
2053So that’s also done. There’s also the tab list that can be similarly implemented, but let’s hold on to that until we implement authentication. Speaking of which!
2054
2055### Authentication
2056
2057[Section titled “Authentication”](#authentication)
2058
2059Authentication involves two pages — one to login and another to register. They are mostly the same, so it makes sense to keep them in the same slice, `sign-in`, so that they can reuse code if needed.
2060
2061Create `RegisterPage.tsx` in the `ui` segment of `pages/sign-in` with the following content:
2062
2063pages/sign-in/ui/RegisterPage.tsx
2064
2065```tsx
2066import { Form, Link, useActionData } from "@remix-run/react";
2067
2068
2069import type { register } from "../api/register";
2070
2071
2072export function RegisterPage() {
2073 const registerData = useActionData<typeof register>();
2074
2075
2076 return (
2077 <div className="auth-page">
2078 <div className="container page">
2079 <div className="row">
2080 <div className="col-md-6 offset-md-3 col-xs-12">
2081 <h1 className="text-xs-center">Sign up</h1>
2082 <p className="text-xs-center">
2083 <Link to="/login">Have an account?</Link>
2084 </p>
2085
2086
2087 {registerData?.error && (
2088 <ul className="error-messages">
2089 {registerData.error.errors.body.map((error) => (
2090 <li key={error}>{error}</li>
2091 ))}
2092 </ul>
2093 )}
2094
2095
2096 <Form method="post">
2097 <fieldset className="form-group">
2098 <input
2099 className="form-control form-control-lg"
2100 type="text"
2101 name="username"
2102 placeholder="Username"
2103 />
2104 </fieldset>
2105 <fieldset className="form-group">
2106 <input
2107 className="form-control form-control-lg"
2108 type="text"
2109 name="email"
2110 placeholder="Email"
2111 />
2112 </fieldset>
2113 <fieldset className="form-group">
2114 <input
2115 className="form-control form-control-lg"
2116 type="password"
2117 name="password"
2118 placeholder="Password"
2119 />
2120 </fieldset>
2121 <button className="btn btn-lg btn-primary pull-xs-right">
2122 Sign up
2123 </button>
2124 </Form>
2125 </div>
2126 </div>
2127 </div>
2128 </div>
2129 );
2130}
2131```
2132
2133We have a broken import to fix now. It involves a new segment, so create that:
2134
2135```bash
2136npx fsd pages sign-in -s api
2137```
2138
2139However, before we can implement the backend part of registering, we need some infrastructure code for Remix to handle sessions. That goes to Shared, in case any other page needs it.
2140
2141Put the following code in `shared/api/auth.server.ts`. This is highly Remix-specific, so don’t worry too much about it, just copy-paste:
2142
2143shared/api/auth.server.ts
2144
2145```tsx
2146import { createCookieSessionStorage, redirect } from "@remix-run/node";
2147import invariant from "tiny-invariant";
2148
2149
2150import type { User } from "./models";
2151
2152
2153invariant(
2154 process.env.SESSION_SECRET,
2155 "SESSION_SECRET must be set for authentication to work",
2156);
2157
2158
2159const sessionStorage = createCookieSessionStorage<{
2160 user: User;
2161}>({
2162 cookie: {
2163 name: "__session",
2164 httpOnly: true,
2165 path: "/",
2166 sameSite: "lax",
2167 secrets: [process.env.SESSION_SECRET],
2168 secure: process.env.NODE_ENV === "production",
2169 },
2170});
2171
2172
2173export async function createUserSession({
2174 request,
2175 user,
2176 redirectTo,
2177}: {
2178 request: Request;
2179 user: User;
2180 redirectTo: string;
2181}) {
2182 const cookie = request.headers.get("Cookie");
2183 const session = await sessionStorage.getSession(cookie);
2184
2185
2186 session.set("user", user);
2187
2188
2189 return redirect(redirectTo, {
2190 headers: {
2191 "Set-Cookie": await sessionStorage.commitSession(session, {
2192 maxAge: 60 * 60 * 24 * 7, // 7 days
2193 }),
2194 },
2195 });
2196}
2197
2198
2199export async function getUserFromSession(request: Request) {
2200 const cookie = request.headers.get("Cookie");
2201 const session = await sessionStorage.getSession(cookie);
2202
2203
2204 return session.get("user") ?? null;
2205}
2206
2207
2208export async function requireUser(request: Request) {
2209 const user = await getUserFromSession(request);
2210
2211
2212 if (user === null) {
2213 throw redirect("/login");
2214 }
2215
2216
2217 return user;
2218}
2219```
2220
2221And also export the `User` model from the `models.ts` file right next to it:
2222
2223shared/api/models.ts
2224
2225```tsx
2226import type { components } from "./v1";
2227
2228
2229export type Article = components["schemas"]["Article"];
2230export type User = components["schemas"]["User"];
2231```
2232
2233Before this code can work, the `SESSION_SECRET` environment variable needs to be set. Create a file called `.env` in the root of the project, write `SESSION_SECRET=` and then mash some keys on your keyboard to create a long random string. You should get something like this:
2234
2235.env
2236
2237```bash
2238SESSION_SECRET=dontyoudarecopypastethis
2239```
2240
2241Finally, add some exports to the public API to make use of this code:
2242
2243shared/api/index.ts
2244
2245```tsx
2246export { GET, POST, PUT, DELETE } from "./client";
2247
2248
2249export type { Article } from "./models";
2250
2251
2252export { createUserSession, getUserFromSession, requireUser } from "./auth.server";
2253```
2254
2255Now we can write the code that will talk to the RealWorld backend to actually do the registration. We will keep that in `pages/sign-in/api`. Create a file called `register.ts` and put the following code inside:
2256
2257pages/sign-in/api/register.ts
2258
2259```tsx
2260import { json, type ActionFunctionArgs } from "@remix-run/node";
2261
2262
2263import { POST, createUserSession } from "shared/api";
2264
2265
2266export const register = async ({ request }: ActionFunctionArgs) => {
2267 const formData = await request.formData();
2268 const username = formData.get("username")?.toString() ?? "";
2269 const email = formData.get("email")?.toString() ?? "";
2270 const password = formData.get("password")?.toString() ?? "";
2271
2272
2273 const { data, error } = await POST("/users", {
2274 body: { user: { email, password, username } },
2275 });
2276
2277
2278 if (error) {
2279 return json({ error }, { status: 400 });
2280 } else {
2281 return createUserSession({
2282 request: request,
2283 user: data.user,
2284 redirectTo: "/",
2285 });
2286 }
2287};
2288```
2289
2290pages/sign-in/index.ts
2291
2292```tsx
2293export { RegisterPage } from './ui/RegisterPage';
2294export { register } from './api/register';
2295```
2296
2297Almost done! Just need to connect the page and action to the `/register` route. Create `register.tsx` in `app/routes`:
2298
2299app/routes/register.tsx
2300
2301```tsx
2302import { RegisterPage, register } from "pages/sign-in";
2303
2304
2305export { register as action };
2306
2307
2308export default RegisterPage;
2309```
2310
2311Now if you go to `http://localhost:3000/register`, you should be able to create a user! The rest of the application won’t react to this yet, we’ll address that momentarily.
2312
2313In a very similar way, we can implement the login page. Give it a try or just grab the code and move on:
2314
2315pages/sign-in/api/sign-in.ts
2316
2317```tsx
2318import { json, type ActionFunctionArgs } from "@remix-run/node";
2319
2320
2321import { POST, createUserSession } from "shared/api";
2322
2323
2324export const signIn = async ({ request }: ActionFunctionArgs) => {
2325 const formData = await request.formData();
2326 const email = formData.get("email")?.toString() ?? "";
2327 const password = formData.get("password")?.toString() ?? "";
2328
2329
2330 const { data, error } = await POST("/users/login", {
2331 body: { user: { email, password } },
2332 });
2333
2334
2335 if (error) {
2336 return json({ error }, { status: 400 });
2337 } else {
2338 return createUserSession({
2339 request: request,
2340 user: data.user,
2341 redirectTo: "/",
2342 });
2343 }
2344};
2345```
2346
2347pages/sign-in/ui/SignInPage.tsx
2348
2349```tsx
2350import { Form, Link, useActionData } from "@remix-run/react";
2351
2352
2353import type { signIn } from "../api/sign-in";
2354
2355
2356export function SignInPage() {
2357 const signInData = useActionData<typeof signIn>();
2358
2359
2360 return (
2361 <div className="auth-page">
2362 <div className="container page">
2363 <div className="row">
2364 <div className="col-md-6 offset-md-3 col-xs-12">
2365 <h1 className="text-xs-center">Sign in</h1>
2366 <p className="text-xs-center">
2367 <Link to="/register">Need an account?</Link>
2368 </p>
2369
2370
2371 {signInData?.error && (
2372 <ul className="error-messages">
2373 {signInData.error.errors.body.map((error) => (
2374 <li key={error}>{error}</li>
2375 ))}
2376 </ul>
2377 )}
2378
2379
2380 <Form method="post">
2381 <fieldset className="form-group">
2382 <input
2383 className="form-control form-control-lg"
2384 name="email"
2385 type="text"
2386 placeholder="Email"
2387 />
2388 </fieldset>
2389 <fieldset className="form-group">
2390 <input
2391 className="form-control form-control-lg"
2392 name="password"
2393 type="password"
2394 placeholder="Password"
2395 />
2396 </fieldset>
2397 <button className="btn btn-lg btn-primary pull-xs-right">
2398 Sign in
2399 </button>
2400 </Form>
2401 </div>
2402 </div>
2403 </div>
2404 </div>
2405 );
2406}
2407```
2408
2409pages/sign-in/index.ts
2410
2411```tsx
2412export { RegisterPage } from './ui/RegisterPage';
2413export { register } from './api/register';
2414export { SignInPage } from './ui/SignInPage';
2415export { signIn } from './api/sign-in';
2416```
2417
2418app/routes/login.tsx
2419
2420```tsx
2421import { SignInPage, signIn } from "pages/sign-in";
2422
2423
2424export { signIn as action };
2425
2426
2427export default SignInPage;
2428```
2429
2430Now let’s give the users a way to actually get to these pages.
2431
2432### Header
2433
2434[Section titled “Header”](#header)
2435
2436As we discussed in part 1, the app header is commonly placed either in Widgets or in Shared. We will put it in Shared because it’s very simple and all the business logic can be kept outside of it. Let’s create a place for it:
2437
2438```bash
2439npx fsd shared ui
2440```
2441
2442Now create `shared/ui/Header.tsx` with the following contents:
2443
2444shared/ui/Header.tsx
2445
2446```tsx
2447import { useContext } from "react";
2448import { Link, useLocation } from "@remix-run/react";
2449
2450
2451import { CurrentUser } from "../api/currentUser";
2452
2453
2454export function Header() {
2455 const currentUser = useContext(CurrentUser);
2456 const { pathname } = useLocation();
2457
2458
2459 return (
2460 <nav className="navbar navbar-light">
2461 <div className="container">
2462 <Link className="navbar-brand" to="/" prefetch="intent">
2463 conduit
2464 </Link>
2465 <ul className="nav navbar-nav pull-xs-right">
2466 <li className="nav-item">
2467 <Link
2468 prefetch="intent"
2469 className={`nav-link ${pathname == "/" ? "active" : ""}`}
2470 to="/"
2471 >
2472 Home
2473 </Link>
2474 </li>
2475 {currentUser == null ? (
2476 <>
2477 <li className="nav-item">
2478 <Link
2479 prefetch="intent"
2480 className={`nav-link ${pathname == "/login" ? "active" : ""}`}
2481 to="/login"
2482 >
2483 Sign in
2484 </Link>
2485 </li>
2486 <li className="nav-item">
2487 <Link
2488 prefetch="intent"
2489 className={`nav-link ${pathname == "/register" ? "active" : ""}`}
2490 to="/register"
2491 >
2492 Sign up
2493 </Link>
2494 </li>
2495 </>
2496 ) : (
2497 <>
2498 <li className="nav-item">
2499 <Link
2500 prefetch="intent"
2501 className={`nav-link ${pathname == "/editor" ? "active" : ""}`}
2502 to="/editor"
2503 >
2504 <i className="ion-compose"></i>&nbsp;New Article{" "}
2505 </Link>
2506 </li>
2507
2508
2509 <li className="nav-item">
2510 <Link
2511 prefetch="intent"
2512 className={`nav-link ${pathname == "/settings" ? "active" : ""}`}
2513 to="/settings"
2514 >
2515 {" "}
2516 <i className="ion-gear-a"></i>&nbsp;Settings{" "}
2517 </Link>
2518 </li>
2519 <li className="nav-item">
2520 <Link
2521 prefetch="intent"
2522 className={`nav-link ${pathname.includes("/profile") ? "active" : ""}`}
2523 to={`/profile/${currentUser.username}`}
2524 >
2525 {currentUser.image && (
2526 <img
2527 width={25}
2528 height={25}
2529 src={currentUser.image}
2530 className="user-pic"
2531 alt=""
2532 />
2533 )}
2534 {currentUser.username}
2535 </Link>
2536 </li>
2537 </>
2538 )}
2539 </ul>
2540 </div>
2541 </nav>
2542 );
2543}
2544```
2545
2546Export this component from `shared/ui`:
2547
2548shared/ui/index.ts
2549
2550```tsx
2551export { Header } from "./Header";
2552```
2553
2554In the header, we rely on the context that’s kept in `shared/api`. Create that as well:
2555
2556shared/api/currentUser.ts
2557
2558```tsx
2559import { createContext } from "react";
2560
2561
2562import type { User } from "./models";
2563
2564
2565export const CurrentUser = createContext<User | null>(null);
2566```
2567
2568shared/api/index.ts
2569
2570```tsx
2571export { GET, POST, PUT, DELETE } from "./client";
2572
2573
2574export type { Article } from "./models";
2575
2576
2577export { createUserSession, getUserFromSession, requireUser } from "./auth.server";
2578export { CurrentUser } from "./currentUser";
2579```
2580
2581Now let’s add the header to the page. We want it to be on every single page, so it makes sense to simply add it to the root route and wrap the outlet (the place where the page will be rendered) with the `CurrentUser` context provider. This way our entire app and also the header has access to the current user object. We will also add a loader to actually obtain the current user object from cookies. Drop the following into `app/root.tsx`:
2582
2583app/root.tsx
2584
2585```tsx
2586import { cssBundleHref } from "@remix-run/css-bundle";
2587import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node";
2588import {
2589 Links,
2590 LiveReload,
2591 Meta,
2592 Outlet,
2593 Scripts,
2594 ScrollRestoration,
2595 useLoaderData,
2596} from "@remix-run/react";
2597
2598
2599import { Header } from "shared/ui";
2600import { getUserFromSession, CurrentUser } from "shared/api";
2601
2602
2603export const links: LinksFunction = () => [
2604 ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []),
2605];
2606
2607
2608export const loader = ({ request }: LoaderFunctionArgs) =>
2609 getUserFromSession(request);
2610
2611
2612export default function App() {
2613 const user = useLoaderData<typeof loader>();
2614
2615
2616 return (
2617 <html lang="en">
2618 <head>
2619 <meta charSet="utf-8" />
2620 <meta name="viewport" content="width=device-width, initial-scale=1" />
2621 <Meta />
2622 <Links />
2623 <link
2624 href="//code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css"
2625 rel="stylesheet"
2626 type="text/css"
2627 />
2628 <link
2629 href="//fonts.googleapis.com/css?family=Titillium+Web:700|Source+Serif+Pro:400,700|Merriweather+Sans:400,700|Source+Sans+Pro:400,300,600,700,300italic,400italic,600italic,700italic"
2630 rel="stylesheet"
2631 type="text/css"
2632 />
2633 <link rel="stylesheet" href="//demo.productionready.io/main.css" />
2634 <style>{`
2635 button {
2636 border: 0;
2637 }
2638 `}</style>
2639 </head>
2640 <body>
2641 <CurrentUser.Provider value={user}>
2642 <Header />
2643 <Outlet />
2644 </CurrentUser.Provider>
2645 <ScrollRestoration />
2646 <Scripts />
2647 <LiveReload />
2648 </body>
2649 </html>
2650 );
2651}
2652```
2653
2654At this point, you should end up with the following on the home page:
2655
2656![The feed page of Conduit, including the header, the feed, and the tags. The tabs are still missing.](/_astro/realworld-feed-without-tabs.D9jRoHoq_syx4L.webp)
2657
2658The feed page of Conduit, including the header, the feed, and the tags. The tabs are still missing.
2659
2660### Tabs
2661
2662[Section titled “Tabs”](#tabs)
2663
2664Now that we can detect the authentication state, let’s also quickly implement the tabs and post likes to be done with the feed page. We need another form, but this page file is getting kind of large, so let’s move these forms into adjacent files. We will create `Tabs.tsx`, `PopularTags.tsx`, and `Pagination.tsx` with the following content:
2665
2666pages/feed/ui/Tabs.tsx
2667
2668```tsx
2669import { useContext } from "react";
2670import { Form, useSearchParams } from "@remix-run/react";
2671
2672
2673import { CurrentUser } from "shared/api";
2674
2675
2676export function Tabs() {
2677 const [searchParams] = useSearchParams();
2678 const currentUser = useContext(CurrentUser);
2679
2680
2681 return (
2682 <Form>
2683 <div className="feed-toggle">
2684 <ul className="nav nav-pills outline-active">
2685 {currentUser !== null && (
2686 <li className="nav-item">
2687 <button
2688 name="source"
2689 value="my-feed"
2690 className={`nav-link ${searchParams.get("source") === "my-feed" ? "active" : ""}`}
2691 >
2692 Your Feed
2693 </button>
2694 </li>
2695 )}
2696 <li className="nav-item">
2697 <button
2698 className={`nav-link ${searchParams.has("tag") || searchParams.has("source") ? "" : "active"}`}
2699 >
2700 Global Feed
2701 </button>
2702 </li>
2703 {searchParams.has("tag") && (
2704 <li className="nav-item">
2705 <span className="nav-link active">
2706 <i className="ion-pound"></i> {searchParams.get("tag")}
2707 </span>
2708 </li>
2709 )}
2710 </ul>
2711 </div>
2712 </Form>
2713 );
2714}
2715```
2716
2717pages/feed/ui/PopularTags.tsx
2718
2719```tsx
2720import { Form, useLoaderData } from "@remix-run/react";
2721import { ExistingSearchParams } from "remix-utils/existing-search-params";
2722
2723
2724import type { loader } from "../api/loader";
2725
2726
2727export function PopularTags() {
2728 const { tags } = useLoaderData<typeof loader>();
2729
2730
2731 return (
2732 <div className="sidebar">
2733 <p>Popular Tags</p>
2734
2735
2736 <Form>
2737 <ExistingSearchParams exclude={["tag", "page", "source"]} />
2738 <div className="tag-list">
2739 {tags.tags.map((tag) => (
2740 <button
2741 key={tag}
2742 name="tag"
2743 value={tag}
2744 className="tag-pill tag-default"
2745 >
2746 {tag}
2747 </button>
2748 ))}
2749 </div>
2750 </Form>
2751 </div>
2752 );
2753}
2754```
2755
2756pages/feed/ui/Pagination.tsx
2757
2758```tsx
2759import { Form, useLoaderData, useSearchParams } from "@remix-run/react";
2760import { ExistingSearchParams } from "remix-utils/existing-search-params";
2761
2762
2763import { LIMIT, type loader } from "../api/loader";
2764
2765
2766export function Pagination() {
2767 const [searchParams] = useSearchParams();
2768 const { articles } = useLoaderData<typeof loader>();
2769 const pageAmount = Math.ceil(articles.articlesCount / LIMIT);
2770 const currentPage = parseInt(searchParams.get("page") ?? "1", 10);
2771
2772
2773 return (
2774 <Form>
2775 <ExistingSearchParams exclude={["page"]} />
2776 <ul className="pagination">
2777 {Array(pageAmount)
2778 .fill(null)
2779 .map((_, index) =>
2780 index + 1 === currentPage ? (
2781 <li key={index} className="page-item active">
2782 <span className="page-link">{index + 1}</span>
2783 </li>
2784 ) : (
2785 <li key={index} className="page-item">
2786 <button className="page-link" name="page" value={index + 1}>
2787 {index + 1}
2788 </button>
2789 </li>
2790 ),
2791 )}
2792 </ul>
2793 </Form>
2794 );
2795}
2796```
2797
2798And now we can significantly simplify the feed page itself:
2799
2800pages/feed/ui/FeedPage.tsx
2801
2802```tsx
2803import { useLoaderData } from "@remix-run/react";
2804
2805
2806import type { loader } from "../api/loader";
2807import { ArticlePreview } from "./ArticlePreview";
2808import { Tabs } from "./Tabs";
2809import { PopularTags } from "./PopularTags";
2810import { Pagination } from "./Pagination";
2811
2812
2813export function FeedPage() {
2814 const { articles } = useLoaderData<typeof loader>();
2815
2816
2817 return (
2818 <div className="home-page">
2819 <div className="banner">
2820 <div className="container">
2821 <h1 className="logo-font">conduit</h1>
2822 <p>A place to share your knowledge.</p>
2823 </div>
2824 </div>
2825
2826
2827 <div className="container page">
2828 <div className="row">
2829 <div className="col-md-9">
2830 <Tabs />
2831
2832
2833 {articles.articles.map((article) => (
2834 <ArticlePreview key={article.slug} article={article} />
2835 ))}
2836
2837
2838 <Pagination />
2839 </div>
2840
2841
2842 <div className="col-md-3">
2843 <PopularTags />
2844 </div>
2845 </div>
2846 </div>
2847 </div>
2848 );
2849}
2850```
2851
2852We also need to account for the new tab in the loader function:
2853
2854pages/feed/api/loader.ts
2855
2856```tsx
2857import { json, type LoaderFunctionArgs } from "@remix-run/node";
2858import type { FetchResponse } from "openapi-fetch";
2859import { promiseHash } from "remix-utils/promise";
2860
2861
2862import { GET, requireUser } from "shared/api";
2863
2864
2865async function throwAnyErrors<T, O, Media extends `${string}/${string}`>(
2866 responsePromise: Promise<FetchResponse<T, O, Media>>,
2867) {
2868 /* unchanged */
2869}
2870
2871
2872/** Amount of articles on one page. */
2873export const LIMIT = 20;
2874
2875
2876export const loader = async ({ request }: LoaderFunctionArgs) => {
2877 const url = new URL(request.url);
2878 const selectedTag = url.searchParams.get("tag") ?? undefined;
2879 const page = parseInt(url.searchParams.get("page") ?? "", 10);
2880
2881
2882 if (url.searchParams.get("source") === "my-feed") {
2883 const userSession = await requireUser(request);
2884
2885
2886 return json(
2887 await promiseHash({
2888 articles: throwAnyErrors(
2889 GET("/articles/feed", {
2890 params: {
2891 query: {
2892 limit: LIMIT,
2893 offset: !Number.isNaN(page) ? page * LIMIT : undefined,
2894 },
2895 },
2896 headers: { Authorization: `Token ${userSession.token}` },
2897 }),
2898 ),
2899 tags: throwAnyErrors(GET("/tags")),
2900 }),
2901 );
2902 }
2903
2904
2905 return json(
2906 await promiseHash({
2907 articles: throwAnyErrors(
2908 GET("/articles", {
2909 params: {
2910 query: {
2911 tag: selectedTag,
2912 limit: LIMIT,
2913 offset: !Number.isNaN(page) ? page * LIMIT : undefined,
2914 },
2915 },
2916 }),
2917 ),
2918 tags: throwAnyErrors(GET("/tags")),
2919 }),
2920 );
2921};
2922```
2923
2924Before we leave the feed page, let’s add some code that handles likes to posts. Change your `ArticlePreview.tsx` to the following:
2925
2926pages/feed/ui/ArticlePreview\.tsx
2927
2928```tsx
2929import { Form, Link } from "@remix-run/react";
2930import type { Article } from "shared/api";
2931
2932
2933interface ArticlePreviewProps {
2934 article: Article;
2935}
2936
2937
2938export function ArticlePreview({ article }: ArticlePreviewProps) {
2939 return (
2940 <div className="article-preview">
2941 <div className="article-meta">
2942 <Link to={`/profile/${article.author.username}`} prefetch="intent">
2943 <img src={article.author.image} alt="" />
2944 </Link>
2945 <div className="info">
2946 <Link
2947 to={`/profile/${article.author.username}`}
2948 className="author"
2949 prefetch="intent"
2950 >
2951 {article.author.username}
2952 </Link>
2953 <span className="date" suppressHydrationWarning>
2954 {new Date(article.createdAt).toLocaleDateString(undefined, {
2955 dateStyle: "long",
2956 })}
2957 </span>
2958 </div>
2959 <Form
2960 method="post"
2961 action={`/article/${article.slug}`}
2962 preventScrollReset
2963 >
2964 <button
2965 name="_action"
2966 value={article.favorited ? "unfavorite" : "favorite"}
2967 className={`btn ${article.favorited ? "btn-primary" : "btn-outline-primary"} btn-sm pull-xs-right`}
2968 >
2969 <i className="ion-heart"></i> {article.favoritesCount}
2970 </button>
2971 </Form>
2972 </div>
2973 <Link
2974 to={`/article/${article.slug}`}
2975 className="preview-link"
2976 prefetch="intent"
2977 >
2978 <h1>{article.title}</h1>
2979 <p>{article.description}</p>
2980 <span>Read more...</span>
2981 <ul className="tag-list">
2982 {article.tagList.map((tag) => (
2983 <li key={tag} className="tag-default tag-pill tag-outline">
2984 {tag}
2985 </li>
2986 ))}
2987 </ul>
2988 </Link>
2989 </div>
2990 );
2991}
2992```
2993
2994This code will send a POST request to `/article/:slug` with `_action=favorite` to mark the article as favorite. It won’t work yet, but as we start working on the article reader, we will implement this too.
2995
2996And with that we are officially done with the feed! Yay!
2997
2998### Article reader
2999
3000[Section titled “Article reader”](#article-reader)
3001
3002First, we need data. Let’s create a loader:
3003
3004```bash
3005npx fsd pages article-read -s api
3006```
3007
3008pages/article-read/api/loader.ts
3009
3010```tsx
3011import { json, type LoaderFunctionArgs } from "@remix-run/node";
3012import invariant from "tiny-invariant";
3013import type { FetchResponse } from "openapi-fetch";
3014import { promiseHash } from "remix-utils/promise";
3015
3016
3017import { GET, getUserFromSession } from "shared/api";
3018
3019
3020async function throwAnyErrors<T, O, Media extends `${string}/${string}`>(
3021 responsePromise: Promise<FetchResponse<T, O, Media>>,
3022) {
3023 const { data, error, response } = await responsePromise;
3024
3025
3026 if (error !== undefined) {
3027 throw json(error, { status: response.status });
3028 }
3029
3030
3031 return data as NonNullable<typeof data>;
3032}
3033
3034
3035export const loader = async ({ request, params }: LoaderFunctionArgs) => {
3036 invariant(params.slug, "Expected a slug parameter");
3037 const currentUser = await getUserFromSession(request);
3038 const authorization = currentUser
3039 ? { Authorization: `Token ${currentUser.token}` }
3040 : undefined;
3041
3042
3043 return json(
3044 await promiseHash({
3045 article: throwAnyErrors(
3046 GET("/articles/{slug}", {
3047 params: {
3048 path: { slug: params.slug },
3049 },
3050 headers: authorization,
3051 }),
3052 ),
3053 comments: throwAnyErrors(
3054 GET("/articles/{slug}/comments", {
3055 params: {
3056 path: { slug: params.slug },
3057 },
3058 headers: authorization,
3059 }),
3060 ),
3061 }),
3062 );
3063};
3064```
3065
3066pages/article-read/index.ts
3067
3068```tsx
3069export { loader } from "./api/loader";
3070```
3071
3072Now we can connect it to the route `/article/:slug` by creating the a route file called `article.$slug.tsx`:
3073
3074app/routes/article.$slug.tsx
3075
3076```tsx
3077export { loader } from "pages/article-read";
3078```
3079
3080The page itself consists of three main blocks — the article header with actions (repeated twice), the article body, and the comments section. This is the markup for the page, it’s not particularly interesting:
3081
3082pages/article-read/ui/ArticleReadPage.tsx
3083
3084```tsx
3085import { useLoaderData } from "@remix-run/react";
3086
3087
3088import type { loader } from "../api/loader";
3089import { ArticleMeta } from "./ArticleMeta";
3090import { Comments } from "./Comments";
3091
3092
3093export function ArticleReadPage() {
3094 const { article } = useLoaderData<typeof loader>();
3095
3096
3097 return (
3098 <div className="article-page">
3099 <div className="banner">
3100 <div className="container">
3101 <h1>{article.article.title}</h1>
3102
3103
3104 <ArticleMeta />
3105 </div>
3106 </div>
3107
3108
3109 <div className="container page">
3110 <div className="row article-content">
3111 <div className="col-md-12">
3112 <p>{article.article.body}</p>
3113 <ul className="tag-list">
3114 {article.article.tagList.map((tag) => (
3115 <li className="tag-default tag-pill tag-outline" key={tag}>
3116 {tag}
3117 </li>
3118 ))}
3119 </ul>
3120 </div>
3121 </div>
3122
3123
3124 <hr />
3125
3126
3127 <div className="article-actions">
3128 <ArticleMeta />
3129 </div>
3130
3131
3132 <div className="row">
3133 <Comments />
3134 </div>
3135 </div>
3136 </div>
3137 );
3138}
3139```
3140
3141What’s more interesting is the `ArticleMeta` and `Comments`. They contain write operations such as liking an article, leaving a comment, etc. To get them to work, we first need to implement the backend part. Create `action.ts` in the `api` segment of the page:
3142
3143pages/article-read/api/action.ts
3144
3145```tsx
3146import { redirect, type ActionFunctionArgs } from "@remix-run/node";
3147import { namedAction } from "remix-utils/named-action";
3148import { redirectBack } from "remix-utils/redirect-back";
3149import invariant from "tiny-invariant";
3150
3151
3152import { DELETE, POST, requireUser } from "shared/api";
3153
3154
3155export const action = async ({ request, params }: ActionFunctionArgs) => {
3156 const currentUser = await requireUser(request);
3157
3158
3159 const authorization = { Authorization: `Token ${currentUser.token}` };
3160
3161
3162 const formData = await request.formData();
3163
3164
3165 return namedAction(formData, {
3166 async delete() {
3167 invariant(params.slug, "Expected a slug parameter");
3168 await DELETE("/articles/{slug}", {
3169 params: { path: { slug: params.slug } },
3170 headers: authorization,
3171 });
3172 return redirect("/");
3173 },
3174 async favorite() {
3175 invariant(params.slug, "Expected a slug parameter");
3176 await POST("/articles/{slug}/favorite", {
3177 params: { path: { slug: params.slug } },
3178 headers: authorization,
3179 });
3180 return redirectBack(request, { fallback: "/" });
3181 },
3182 async unfavorite() {
3183 invariant(params.slug, "Expected a slug parameter");
3184 await DELETE("/articles/{slug}/favorite", {
3185 params: { path: { slug: params.slug } },
3186 headers: authorization,
3187 });
3188 return redirectBack(request, { fallback: "/" });
3189 },
3190 async createComment() {
3191 invariant(params.slug, "Expected a slug parameter");
3192 const comment = formData.get("comment");
3193 invariant(typeof comment === "string", "Expected a comment parameter");
3194 await POST("/articles/{slug}/comments", {
3195 params: { path: { slug: params.slug } },
3196 headers: { ...authorization, "Content-Type": "application/json" },
3197 body: { comment: { body: comment } },
3198 });
3199 return redirectBack(request, { fallback: "/" });
3200 },
3201 async deleteComment() {
3202 invariant(params.slug, "Expected a slug parameter");
3203 const commentId = formData.get("id");
3204 invariant(typeof commentId === "string", "Expected an id parameter");
3205 const commentIdNumeric = parseInt(commentId, 10);
3206 invariant(
3207 !Number.isNaN(commentIdNumeric),
3208 "Expected a numeric id parameter",
3209 );
3210 await DELETE("/articles/{slug}/comments/{id}", {
3211 params: { path: { slug: params.slug, id: commentIdNumeric } },
3212 headers: authorization,
3213 });
3214 return redirectBack(request, { fallback: "/" });
3215 },
3216 async followAuthor() {
3217 const authorUsername = formData.get("username");
3218 invariant(
3219 typeof authorUsername === "string",
3220 "Expected a username parameter",
3221 );
3222 await POST("/profiles/{username}/follow", {
3223 params: { path: { username: authorUsername } },
3224 headers: authorization,
3225 });
3226 return redirectBack(request, { fallback: "/" });
3227 },
3228 async unfollowAuthor() {
3229 const authorUsername = formData.get("username");
3230 invariant(
3231 typeof authorUsername === "string",
3232 "Expected a username parameter",
3233 );
3234 await DELETE("/profiles/{username}/follow", {
3235 params: { path: { username: authorUsername } },
3236 headers: authorization,
3237 });
3238 return redirectBack(request, { fallback: "/" });
3239 },
3240 });
3241};
3242```
3243
3244Export that from the slice and then from the route. While we’re at it, let’s also connect the page itself:
3245
3246pages/article-read/index.ts
3247
3248```tsx
3249export { ArticleReadPage } from "./ui/ArticleReadPage";
3250export { loader } from "./api/loader";
3251export { action } from "./api/action";
3252```
3253
3254app/routes/article.$slug.tsx
3255
3256```tsx
3257import { ArticleReadPage } from "pages/article-read";
3258
3259
3260export { loader, action } from "pages/article-read";
3261
3262
3263export default ArticleReadPage;
3264```
3265
3266Now, even though we haven’t implemented the like button on the reader page yet, the like button in the feed will start working! That’s because it’s been sending “like” requests to this route. Give that a try.
3267
3268`ArticleMeta` and `Comments` are, again, a bunch of forms. We’ve done this before, let’s grab their code and move on:
3269
3270pages/article-read/ui/ArticleMeta.tsx
3271
3272```tsx
3273import { Form, Link, useLoaderData } from "@remix-run/react";
3274import { useContext } from "react";
3275
3276
3277import { CurrentUser } from "shared/api";
3278import type { loader } from "../api/loader";
3279
3280
3281export function ArticleMeta() {
3282 const currentUser = useContext(CurrentUser);
3283 const { article } = useLoaderData<typeof loader>();
3284
3285
3286 return (
3287 <Form method="post">
3288 <div className="article-meta">
3289 <Link
3290 prefetch="intent"
3291 to={`/profile/${article.article.author.username}`}
3292 >
3293 <img src={article.article.author.image} alt="" />
3294 </Link>
3295
3296
3297 <div className="info">
3298 <Link
3299 prefetch="intent"
3300 to={`/profile/${article.article.author.username}`}
3301 className="author"
3302 >
3303 {article.article.author.username}
3304 </Link>
3305 <span className="date">{article.article.createdAt}</span>
3306 </div>
3307
3308
3309 {article.article.author.username == currentUser?.username ? (
3310 <>
3311 <Link
3312 prefetch="intent"
3313 to={`/editor/${article.article.slug}`}
3314 className="btn btn-sm btn-outline-secondary"
3315 >
3316 <i className="ion-edit"></i> Edit Article
3317 </Link>
3318 &nbsp;&nbsp;
3319 <button
3320 name="_action"
3321 value="delete"
3322 className="btn btn-sm btn-outline-danger"
3323 >
3324 <i className="ion-trash-a"></i> Delete Article
3325 </button>
3326 </>
3327 ) : (
3328 <>
3329 <input
3330 name="username"
3331 value={article.article.author.username}
3332 type="hidden"
3333 />
3334 <button
3335 name="_action"
3336 value={
3337 article.article.author.following
3338 ? "unfollowAuthor"
3339 : "followAuthor"
3340 }
3341 className={`btn btn-sm ${article.article.author.following ? "btn-secondary" : "btn-outline-secondary"}`}
3342 >
3343 <i className="ion-plus-round"></i>
3344 &nbsp;{" "}
3345 {article.article.author.following
3346 ? "Unfollow"
3347 : "Follow"}{" "}
3348 {article.article.author.username}
3349 </button>
3350 &nbsp;&nbsp;
3351 <button
3352 name="_action"
3353 value={article.article.favorited ? "unfavorite" : "favorite"}
3354 className={`btn btn-sm ${article.article.favorited ? "btn-primary" : "btn-outline-primary"}`}
3355 >
3356 <i className="ion-heart"></i>
3357 &nbsp; {article.article.favorited
3358 ? "Unfavorite"
3359 : "Favorite"}{" "}
3360 Post{" "}
3361 <span className="counter">
3362 ({article.article.favoritesCount})
3363 </span>
3364 </button>
3365 </>
3366 )}
3367 </div>
3368 </Form>
3369 );
3370}
3371```
3372
3373pages/article-read/ui/Comments.tsx
3374
3375```tsx
3376import { useContext } from "react";
3377import { Form, Link, useLoaderData } from "@remix-run/react";
3378
3379
3380import { CurrentUser } from "shared/api";
3381import type { loader } from "../api/loader";
3382
3383
3384export function Comments() {
3385 const { comments } = useLoaderData<typeof loader>();
3386 const currentUser = useContext(CurrentUser);
3387
3388
3389 return (
3390 <div className="col-xs-12 col-md-8 offset-md-2">
3391 {currentUser !== null ? (
3392 <Form
3393 preventScrollReset={true}
3394 method="post"
3395 className="card comment-form"
3396 >
3397 <div className="card-block">
3398 <textarea
3399 required
3400 className="form-control"
3401 name="comment"
3402 placeholder="Write a comment..."
3403 rows={3}
3404 ></textarea>
3405 </div>
3406 <div className="card-footer">
3407 <img
3408 src={currentUser.image}
3409 className="comment-author-img"
3410 alt=""
3411 />
3412 <button
3413 className="btn btn-sm btn-primary"
3414 name="_action"
3415 value="createComment"
3416 >
3417 Post Comment
3418 </button>
3419 </div>
3420 </Form>
3421 ) : (
3422 <div className="row">
3423 <div className="col-xs-12 col-md-8 offset-md-2">
3424 <p>
3425 <Link to="/login">Sign in</Link>
3426 &nbsp; or &nbsp;
3427 <Link to="/register">Sign up</Link>
3428 &nbsp; to add comments on this article.
3429 </p>
3430 </div>
3431 </div>
3432 )}
3433
3434
3435 {comments.comments.map((comment) => (
3436 <div className="card" key={comment.id}>
3437 <div className="card-block">
3438 <p className="card-text">{comment.body}</p>
3439 </div>
3440
3441
3442 <div className="card-footer">
3443 <Link
3444 to={`/profile/${comment.author.username}`}
3445 className="comment-author"
3446 >
3447 <img
3448 src={comment.author.image}
3449 className="comment-author-img"
3450 alt=""
3451 />
3452 </Link>
3453 &nbsp;
3454 <Link
3455 to={`/profile/${comment.author.username}`}
3456 className="comment-author"
3457 >
3458 {comment.author.username}
3459 </Link>
3460 <span className="date-posted">{comment.createdAt}</span>
3461 {comment.author.username === currentUser?.username && (
3462 <span className="mod-options">
3463 <Form method="post" preventScrollReset={true}>
3464 <input type="hidden" name="id" value={comment.id} />
3465 <button
3466 name="_action"
3467 value="deleteComment"
3468 style={{
3469 border: "none",
3470 outline: "none",
3471 backgroundColor: "transparent",
3472 }}
3473 >
3474 <i className="ion-trash-a"></i>
3475 </button>
3476 </Form>
3477 </span>
3478 )}
3479 </div>
3480 </div>
3481 ))}
3482 </div>
3483 );
3484}
3485```
3486
3487And with that our article reader is also complete! The buttons to follow the author, like a post, and leave a comment should now function as expected.
3488
3489![Article reader with functioning buttons to like and follow](/_astro/realworld-article-reader.sPAk2gad_2vDA54.webp)
3490
3491Article reader with functioning buttons to like and follow
3492
3493### Article editor
3494
3495[Section titled “Article editor”](#article-editor)
3496
3497This is the last page that we will cover in this tutorial, and the most interesting part here is how we’re going to validate form data.
3498
3499The page itself, `article-edit/ui/ArticleEditPage.tsx`, will be quite simple, extra complexity stowed away into two other components:
3500
3501pages/article-edit/ui/ArticleEditPage.tsx
3502
3503```tsx
3504import { Form, useLoaderData } from "@remix-run/react";
3505
3506
3507import type { loader } from "../api/loader";
3508import { TagsInput } from "./TagsInput";
3509import { FormErrors } from "./FormErrors";
3510
3511
3512export function ArticleEditPage() {
3513 const article = useLoaderData<typeof loader>();
3514
3515
3516 return (
3517 <div className="editor-page">
3518 <div className="container page">
3519 <div className="row">
3520 <div className="col-md-10 offset-md-1 col-xs-12">
3521 <FormErrors />
3522
3523
3524 <Form method="post">
3525 <fieldset>
3526 <fieldset className="form-group">
3527 <input
3528 type="text"
3529 className="form-control form-control-lg"
3530 name="title"
3531 placeholder="Article Title"
3532 defaultValue={article.article?.title}
3533 />
3534 </fieldset>
3535 <fieldset className="form-group">
3536 <input
3537 type="text"
3538 className="form-control"
3539 name="description"
3540 placeholder="What's this article about?"
3541 defaultValue={article.article?.description}
3542 />
3543 </fieldset>
3544 <fieldset className="form-group">
3545 <textarea
3546 className="form-control"
3547 name="body"
3548 rows={8}
3549 placeholder="Write your article (in markdown)"
3550 defaultValue={article.article?.body}
3551 ></textarea>
3552 </fieldset>
3553 <fieldset className="form-group">
3554 <TagsInput
3555 name="tags"
3556 defaultValue={article.article?.tagList ?? []}
3557 />
3558 </fieldset>
3559
3560
3561 <button className="btn btn-lg pull-xs-right btn-primary">
3562 Publish Article
3563 </button>
3564 </fieldset>
3565 </Form>
3566 </div>
3567 </div>
3568 </div>
3569 </div>
3570 );
3571}
3572```
3573
3574This page gets the current article (unless we’re writing from scratch) and fills in the corresponding form fields. We’ve seen this before. The interesting part is `FormErrors`, because it will receive the validation result and display it to the user. Let’s take a look:
3575
3576pages/article-edit/ui/FormErrors.tsx
3577
3578```tsx
3579import { useActionData } from "@remix-run/react";
3580import type { action } from "../api/action";
3581
3582
3583export function FormErrors() {
3584 const actionData = useActionData<typeof action>();
3585
3586
3587 return actionData?.errors != null ? (
3588 <ul className="error-messages">
3589 {actionData.errors.map((error) => (
3590 <li key={error}>{error}</li>
3591 ))}
3592 </ul>
3593 ) : null;
3594}
3595```
3596
3597Here we are assuming that our action will return the `errors` field, an array of human-readable error messages. We will get to the action shortly.
3598
3599Another component is the tags input. It’s just a plain input field with an additional preview of chosen tags. Not much to see here:
3600
3601pages/article-edit/ui/TagsInput.tsx
3602
3603```tsx
3604import { useEffect, useRef, useState } from "react";
3605
3606
3607export function TagsInput({
3608 name,
3609 defaultValue,
3610}: {
3611 name: string;
3612 defaultValue?: Array<string>;
3613}) {
3614 const [tagListState, setTagListState] = useState(defaultValue ?? []);
3615
3616
3617 function removeTag(tag: string): void {
3618 const newTagList = tagListState.filter((t) => t !== tag);
3619 setTagListState(newTagList);
3620 }
3621
3622
3623 const tagsInput = useRef<HTMLInputElement>(null);
3624 useEffect(() => {
3625 tagsInput.current && (tagsInput.current.value = tagListState.join(","));
3626 }, [tagListState]);
3627
3628
3629 return (
3630 <>
3631 <input
3632 type="text"
3633 className="form-control"
3634 id="tags"
3635 name={name}
3636 placeholder="Enter tags"
3637 defaultValue={tagListState.join(",")}
3638 onChange={(e) =>
3639 setTagListState(e.target.value.split(",").filter(Boolean))
3640 }
3641 />
3642 <div className="tag-list">
3643 {tagListState.map((tag) => (
3644 <span className="tag-default tag-pill" key={tag}>
3645 <i
3646 className="ion-close-round"
3647 role="button"
3648 tabIndex={0}
3649 onKeyDown={(e) =>
3650 [" ", "Enter"].includes(e.key) && removeTag(tag)
3651 }
3652 onClick={() => removeTag(tag)}
3653 ></i>{" "}
3654 {tag}
3655 </span>
3656 ))}
3657 </div>
3658 </>
3659 );
3660}
3661```
3662
3663Now, for the API part. The loader should look at the URL, and if it contains an article slug, that means we’re editing an existing article, and its data should be loaded. Otherwise, return nothing. Let’s create that loader:
3664
3665pages/article-edit/api/loader.ts
3666
3667```ts
3668import { json, type LoaderFunctionArgs } from "@remix-run/node";
3669import type { FetchResponse } from "openapi-fetch";
3670
3671
3672import { GET, requireUser } from "shared/api";
3673
3674
3675async function throwAnyErrors<T, O, Media extends `${string}/${string}`>(
3676 responsePromise: Promise<FetchResponse<T, O, Media>>,
3677) {
3678 const { data, error, response } = await responsePromise;
3679
3680
3681 if (error !== undefined) {
3682 throw json(error, { status: response.status });
3683 }
3684
3685
3686 return data as NonNullable<typeof data>;
3687}
3688
3689
3690export const loader = async ({ params, request }: LoaderFunctionArgs) => {
3691 const currentUser = await requireUser(request);
3692
3693
3694 if (!params.slug) {
3695 return { article: null };
3696 }
3697
3698
3699 return throwAnyErrors(
3700 GET("/articles/{slug}", {
3701 params: { path: { slug: params.slug } },
3702 headers: { Authorization: `Token ${currentUser.token}` },
3703 }),
3704 );
3705};
3706```
3707
3708The action will take the new field values, run them through our data schema, and if everything is correct, commit those changes to the backend, either by updating an existing article or creating a new one:
3709
3710pages/article-edit/api/action.ts
3711
3712```tsx
3713import { json, redirect, type ActionFunctionArgs } from "@remix-run/node";
3714
3715
3716import { POST, PUT, requireUser } from "shared/api";
3717import { parseAsArticle } from "../model/parseAsArticle";
3718
3719
3720export const action = async ({ request, params }: ActionFunctionArgs) => {
3721 try {
3722 const { body, description, title, tags } = parseAsArticle(
3723 await request.formData(),
3724 );
3725 const tagList = tags?.split(",") ?? [];
3726
3727
3728 const currentUser = await requireUser(request);
3729 const payload = {
3730 body: {
3731 article: {
3732 title,
3733 description,
3734 body,
3735 tagList,
3736 },
3737 },
3738 headers: { Authorization: `Token ${currentUser.token}` },
3739 };
3740
3741
3742 const { data, error } = await (params.slug
3743 ? PUT("/articles/{slug}", {
3744 params: { path: { slug: params.slug } },
3745 ...payload,
3746 })
3747 : POST("/articles", payload));
3748
3749
3750 if (error) {
3751 return json({ errors: error }, { status: 422 });
3752 }
3753
3754
3755 return redirect(`/article/${data.article.slug ?? ""}`);
3756 } catch (errors) {
3757 return json({ errors }, { status: 400 });
3758 }
3759};
3760```
3761
3762The schema doubles as a parsing function for `FormData`, which allows us to conveniently get the clean fields or just throw the errors to handle at the end. Here’s how that parsing function could look:
3763
3764pages/article-edit/model/parseAsArticle.ts
3765
3766```tsx
3767export function parseAsArticle(data: FormData) {
3768 const errors = [];
3769
3770
3771 const title = data.get("title");
3772 if (typeof title !== "string" || title === "") {
3773 errors.push("Give this article a title");
3774 }
3775
3776
3777 const description = data.get("description");
3778 if (typeof description !== "string" || description === "") {
3779 errors.push("Describe what this article is about");
3780 }
3781
3782
3783 const body = data.get("body");
3784 if (typeof body !== "string" || body === "") {
3785 errors.push("Write the article itself");
3786 }
3787
3788
3789 const tags = data.get("tags");
3790 if (typeof tags !== "string") {
3791 errors.push("The tags must be a string");
3792 }
3793
3794
3795 if (errors.length > 0) {
3796 throw errors;
3797 }
3798
3799
3800 return { title, description, body, tags: data.get("tags") ?? "" } as {
3801 title: string;
3802 description: string;
3803 body: string;
3804 tags: string;
3805 };
3806}
3807```
3808
3809Arguably, it’s a bit lengthy and repetitive, but that’s the price we pay for human-readable errors. This could also be a Zod schema, for example, but then we would have to render error messages on the frontend, and this form is not worth the complication.
3810
3811One last step — connect the page, the loader, and the action to the routes. Since we neatly support both creation and editing, we can export the same thing from both `editor._index.tsx` and `editor.$slug.tsx`:
3812
3813pages/article-edit/index.ts
3814
3815```tsx
3816export { ArticleEditPage } from "./ui/ArticleEditPage";
3817export { loader } from "./api/loader";
3818export { action } from "./api/action";
3819```
3820
3821app/routes/editor.\_index.tsx, app/routes/editor.$slug.tsx (same content)
3822
3823```tsx
3824import { ArticleEditPage } from "pages/article-edit";
3825
3826
3827export { loader, action } from "pages/article-edit";
3828
3829
3830export default ArticleEditPage;
3831```
3832
3833We’re done now! Log in and try creating a new article. Or “forget” to write the article and see the validation kick in.
3834
3835![The Conduit article editor, with the title field saying “New article” and the rest of the fields empty. Above the form there are two errors: “Describe what this article is about” and “Write the article itself”.](/_astro/realworld-article-editor.DqBN0Kbw_ZtW0VT.webp)
3836
3837The Conduit article editor, with the title field saying “New article” and the rest of the fields empty. Above the form there are two errors: **“Describe what this article is about”** and **“Write the article itself”**.
3838
3839The profile and settings pages are very similar to the article reader and editor, they are left as an exercise for the reader, that’s you :)
3840
3841# Handling API Requests
3842
3843## Shared API Requests
3844
3845[Section titled “Shared API Requests”](#shared-api-requests)
3846
3847Start by placing common API request logic in the `shared/api` directory. This makes it easy to reuse requests across your application and helps with faster prototyping. For many projects, this is all you’ll need for API calls.
3848
3849A typical file structure would be:
3850
3851* shared/
3852
3853 * client.ts
3854
3855 * index.ts
3856
3857 * endpoints/
3858
3859 * login.ts
3860
3861The `client.ts` file centralizes your HTTP request setup. It wraps your chosen method (like `fetch()` or an `axios` instance) and handles common configurations, such as:
3862
3863* Backend base URL.
3864* Default headers (e.g., for authentication).
3865* Data serialization.
3866
3867Here are examples for `axios` and `fetch`:
3868
3869* Axios
3870
3871 shared/api/client.ts
3872
3873 ```ts
3874 // Example using axios
3875 import axios from 'axios';
3876
3877
3878 export const client = axios.create({
3879 baseURL: 'https://your-api-domain.com/api/',
3880 timeout: 5000,
3881 headers: { 'X-Custom-Header': 'my-custom-value' }
3882 });
3883 ```
3884
3885* Fetch
3886
3887 shared/api/client.ts
3888
3889 ```ts
3890 export const client = {
3891 async post(endpoint: string, body: any, options?: RequestInit) {
3892 const response = await fetch(`https://your-api-domain.com/api${endpoint}`, {
3893 method: 'POST',
3894 body: JSON.stringify(body),
3895 ...options,
3896 headers: {
3897 'Content-Type': 'application/json',
3898 'X-Custom-Header': 'my-custom-value',
3899 ...options?.headers,
3900 },
3901 });
3902 return response.json();
3903 }
3904 // ... other methods like put, delete, etc.
3905 };
3906 ```
3907
3908Organize your individual API request functions in `shared/api/endpoints`, grouping them by the API endpoint.
3909
3910Note
3911
3912To keep examples focused, we omit form interaction and validation. For details on libraries like Zod or Valibot, refer to the [Type Validation and Schemas](/docs/guides/examples/types#type-validation-schemas-and-zod) article.
3913
3914shared/api/endpoints/login.ts
3915
3916```ts
3917import { client } from '../client';
3918
3919
3920export interface LoginCredentials {
3921 email: string;
3922 password: string;
3923}
3924
3925
3926export function login(credentials: LoginCredentials) {
3927 return client.post('/login', credentials);
3928}
3929```
3930
3931Use an `index.ts` file in `shared/api` to export your request functions.
3932
3933shared/api/index.ts
3934
3935```ts
3936export { client } from './client'; // If you want to export the client itself
3937export { login } from './endpoints/login';
3938export type { LoginCredentials } from './endpoints/login';
3939```
3940
3941## Slice-Specific API Requests
3942
3943[Section titled “Slice-Specific API Requests”](#slice-specific-api-requests)
3944
3945If an API request is only used by a specific slice (like a single page or feature) and won’t be reused, place it in the api segment of that slice. This keeps slice-specific logic neatly contained.
3946
3947* pages/
3948
3949 * login/
3950
3951 * index.ts
3952
3953 * api/
3954
3955 * login.ts
3956
3957 * ui/
3958
3959 * LoginPage.tsx
3960
3961pages/login/api/login.ts
3962
3963```ts
3964import { client } from 'shared/api';
3965
3966
3967interface LoginCredentials {
3968 email: string;
3969 password: string;
3970}
3971
3972
3973export function login(credentials: LoginCredentials) {
3974 return client.post('/login', credentials);
3975}
3976```
3977
3978You don’t need to export `login()` function in the page’s public API, because it’s unlikely that any other place in the app will need this request.
3979
3980Note
3981
3982Avoid placing API calls and response types in the `entities` layer prematurely. Backend responses may differ from what your frontend entities need. API logic in `shared/api` or a slice’s `api` segment allows you to transform data appropriately, keeping entities focused on frontend concerns.
3983
3984## Using Client Generators
3985
3986[Section titled “Using Client Generators”](#client-generators)
3987
3988If your backend has an OpenAPI specification, tools like [orval](https://orval.dev/) or [openapi-typescript](https://openapi-ts.dev/) can generate API types and request functions for you. Place the generated code in, for example, `shared/api/openapi`. Make sure to include `README.md` to document what those types are, and how to generate them.
3989
3990## Integrating with Server State Libraries
3991
3992[Section titled “Integrating with Server State Libraries”](#server-state-libraries)
3993
3994When using server state libraries like [TanStack Query (React Query)](https://tanstack.com/query/latest) or [Pinia Colada](https://pinia-colada.esm.dev/) you might need to share types or cache keys between slices. Use the `shared` layer for things like:
3995
3996* API data types
3997* Cache keys
3998* Common query/mutation options
3999
4000For more details on how to work with server state libraries, refer to [React Query article](/docs/guides/tech/with-react-query)
4001
4002# Authentication
4003
4004Broadly, authentication consists of the following steps:
4005
40061. Get the credentials from the user
40072. Send them to the backend
40083. Store the token to make authenticated requests
4009
4010## How to get credentials from the user
4011
4012[Section titled “How to get credentials from the user”](#how-to-get-credentials-from-the-user)
4013
4014We are assuming that your app is responsible for getting credentials. If you have authentication via OAuth, you can simply create a login page with a link to the OAuth provider’s login page and skip to [step 3](#how-to-store-the-token-for-authenticated-requests).
4015
4016### Dedicated page for login
4017
4018[Section titled “Dedicated page for login”](#dedicated-page-for-login)
4019
4020Usually, websites have dedicated pages for login, where you enter your username and password. These pages are quite simple, so they don’t require decomposition. Login and registration forms are quite similar in appearance, so they can even be grouped into one page. Create a slice for your login/registration page on the Pages layer:
4021
4022* pages/
4023
4024 * login/
4025
4026 * ui/
4027
4028 * LoginPage.tsx
4029 * RegisterPage.tsx
4030 * index.ts
4031 * …
4032
4033Here we created two components and exported them both in the index file of the slice. These components will contain forms that are responsible for presenting the user with understandable controls to get their credentials.
4034
4035### Dialog for login
4036
4037[Section titled “Dialog for login”](#dialog-for-login)
4038
4039If your app has a dialog for login that can be used on any page, consider making that dialog a widget. That way, you can still avoid too much decomposition, but have the freedom to reuse this dialog on any page.
4040
4041* widgets/
4042
4043 * login-dialog/
4044
4045 * ui/
4046
4047 * LoginDialog.tsx
4048
4049 * index.ts
4050
4051 * …
4052
4053The rest of this guide is written for the dedicated page approach, but the same principles apply to the dialog widget.
4054
4055### Client-side validation
4056
4057[Section titled “Client-side validation”](#client-side-validation)
4058
4059Sometimes, especially for registration, it makes sense to perform client-side validation to let the user know quickly that they made a mistake. Validation can take place in the `model` segment of the login page. Use a schema validation library, for example, [Zod](https://zod.dev) for JS/TS, and expose that schema to the `ui` segment:
4060
4061pages/login/model/registration-schema.ts
4062
4063```ts
4064import { z } from "zod";
4065
4066
4067export const registrationData = z.object({
4068 email: z.string().email(),
4069 password: z.string().min(6),
4070 confirmPassword: z.string(),
4071}).refine((data) => data.password === data.confirmPassword, {
4072 message: "Passwords do not match",
4073 path: ["confirmPassword"],
4074});
4075```
4076
4077Then, in the `ui` segment, you can use this schema to validate the user input:
4078
4079pages/login/ui/RegisterPage.tsx
4080
4081```tsx
4082import { registrationData } from "../model/registration-schema";
4083
4084
4085function validate(formData: FormData) {
4086 const data = Object.fromEntries(formData.entries());
4087 try {
4088 registrationData.parse(data);
4089 } catch (error) {
4090 // TODO: Show error message to the user
4091 }
4092}
4093
4094
4095export function RegisterPage() {
4096 return (
4097 <form onSubmit={(e) => validate(new FormData(e.target))}>
4098 <label htmlFor="email">E-mail</label>
4099 <input id="email" name="email" required />
4100
4101
4102 <label htmlFor="password">Password (min. 6 characters)</label>
4103 <input id="password" name="password" type="password" required />
4104
4105
4106 <label htmlFor="confirmPassword">Confirm password</label>
4107 <input id="confirmPassword" name="confirmPassword" type="password" required />
4108 </form>
4109 )
4110}
4111```
4112
4113## How to send credentials to the backend
4114
4115[Section titled “How to send credentials to the backend”](#how-to-send-credentials-to-the-backend)
4116
4117Create a function that makes a request to your backend’s login endpoint. This function can either be called directly in the component code using a mutation library (e.g. TanStack Query), or it can be called as a side effect in a state manager. As explained in the [guide for API requests](/docs/guides/examples/api-requests), you can put your request either in `shared/api` or in the `api` segment of your login page.
4118
4119### Two-factor authentication
4120
4121[Section titled “Two-factor authentication”](#two-factor-authentication)
4122
4123If your app supports two-factor authentication (2FA), you might have to redirect to another page where a user can enter a one-time password. Usually your `POST /login` request would return the user object with a flag indicating that the user has 2FA enabled. If that flag is set, redirect the user to the 2FA page.
4124
4125Since this page is very related to logging in, you can also keep it in the same slice, `login` on the Pages layer.
4126
4127You would also need another request function, similar to `login()` that we created above. Place them together, either in Shared, or in the `api` segment of the `login` page.
4128
4129## How to store the token for authenticated requests
4130
4131[Section titled “How to store the token for authenticated requests”](#how-to-store-the-token-for-authenticated-requests)
4132
4133Regardless of the authentication scheme you have, be it a simple login & password, OAuth, or two-factor authentication, at the end you will receive a token. This token should be stored so that subsequent requests can identify themselves.
4134
4135The ideal token storage for a web app is a **cookie** — it requires no manual token storage or handling. As such, cookie storage needs almost no consideration from the frontend architecture side. If your frontend framework has a server side (for example, [Remix](https://remix.run)), then you should store the server-side cookie infrastructure in `shared/api`. There is an example in [the Authentication section of the tutorial](/docs/get-started/tutorial#authentication) of how to do that with Remix.
4136
4137Sometimes, however, cookie storage is not an option. In this case, you will have to store the token manually. Apart from storing the token, you may also need to set up logic for refreshing your token when it expires. With FSD, there are several places where you can store the token, as well as several ways to make it available for the rest of the app.
4138
4139### In Shared
4140
4141[Section titled “In Shared”](#in-shared)
4142
4143This approach plays well with an API client defined in `shared/api` because the token is freely available for other request functions that require authentication to succeed. You can make the API client hold state, either with a reactive store or simply a module-level variable, and update that state in your `login()`/`logout()` functions.
4144
4145Automatic token refresh can be implemented as a middleware in the API client — something that can execute every time you make any request. It can work like this:
4146
4147* Authenticate and store the access token as well as the refresh token
4148* Make any request that requires authentication
4149* If the request fails with a status code that indicates token expiration, and there is a token in the store, make a refresh request, store the new tokens, and retry the original request
4150
4151One of the drawbacks of this approach is that the logic of managing and refreshing the token doesn’t have a dedicated place. This can be fine for some apps or teams, but if the token management logic is more complex, it may be preferable to separate responsibilities of making requests and managing tokens. You can do that by keeping your requests and API client in `shared/api`, but the token store and management logic in `shared/auth`.
4152
4153Another drawback of this approach is that if your backend returns an object of your current user’s information along with the token, you have to store that somewhere or discard that information and request it again from an endpoint like `/me` or `/users/current`.
4154
4155### In Entities
4156
4157[Section titled “In Entities”](#in-entities)
4158
4159It’s common for FSD projects to have an entity for a user and/or an entity for the current user. It can even be the same entity for both.
4160
4161Note
4162
4163The **current user** is also sometimes called “viewer” or “me”. This is to distinguish the single authenticated user, with permissions and private information, from a list of all users with publicly accessible information.
4164
4165To store the token in the User entity, create a reactive store in the `model` segment. That store can contain both the token and the user object.
4166
4167Since the API client is usually defined in `shared/api` or spreaded across the entities, the main challenge to this approach is making the token available to other requests that need it without breaking [the import rule on layers](/docs/reference/layers#import-rule-on-layers):
4168
4169> A module (file) in a slice can only import other slices when they are located on layers strictly below.
4170
4171There are several solutions to this challenge:
4172
41731. **Pass the token manually every time you make a request**\
4174 This is the simplest solution, but it quickly becomes cumbersome, and if you don’t have type safety, it’s easy to forget. It’s also not compatible with middlewares pattern for the API client in Shared.
41752. **Expose the token to the entire app with a context or a global store like `localStorage`**\
4176 The key to retrieve the token will be kept in `shared/api` so that the API client can access it. The reactive store of the token will be exported from the User entity, and the context provider (if needed) will be set up on the App layer. This gives more freedom for designing the API client, however, this creates an implicit dependency on higher layers to provide context. When following this approach, consider providing helpful error messages if the context or `localStorage` are not set up correctly.
41773. **Inject the token into the API client every time it changes**\
4178 If your store is reactive, you can create a subscription that will update the API client’s token store every time the store in the entity changes. This is similar to the previous solution in that they both create an implicit dependency on higher layers, but this one is more imperative (“push”), while the previous one is more declarative (“pull”).
4179
4180Once you overcome the challenge of exposing the token that is stored in the entity’s model, you can encode more business logic related to token management. For example, the `model` segment can contain logic to invalidate the token after a certain period of time, or to refresh the token when it expires. To actually make requests to the backend, use the `api` segment of the User entity or `shared/api`.
4181
4182### In Pages/Widgets (not recommended)
4183
4184[Section titled “In Pages/Widgets (not recommended)”](#in-pageswidgets-not-recommended)
4185
4186It is discouraged to store app-wide state like an access token in pages or widgets. Avoid placing your token store in the `model` segment of the login page, instead choose from the first two solutions, Shared or Entities.
4187
4188## Logout and token invalidation
4189
4190[Section titled “Logout and token invalidation”](#logout-and-token-invalidation)
4191
4192Usually, apps don’t have an entire page for logging out, but the logout functionality is still very important. It consists of an authenticated request to the backend and an update to the token store.
4193
4194If you store all your requests in `shared/api`, keep the logout request function there, close to the login function. Otherwise, consider keeping the logout request function next to the button that triggers it. For example, if you have a header widget that appears on every page and contains the logout link, put that request in the `api` segment of that widget.
4195
4196The update to the token store will have to be triggered from the place of the logout button, like a header widget. You can combine the request and the store update in the `model` segment of that widget.
4197
4198### Automatic logout
4199
4200[Section titled “Automatic logout”](#automatic-logout)
4201
4202Don’t forget to build failsafes for when a request to log out fails, or a request to refresh a login token fails. In both of these cases, you should clear the token store. If you keep your token in Entities, this code can be placed in the `model` segment as it is pure business logic. If you keep your token in Shared, placing this logic in `shared/api` might bloat the segment and dilute its purpose. If you’re noticing that your API segment contains two several unrelated things, consider splitting out the token management logic into another segment, for example, `shared/auth`.
4203
4204# Handling Assets
4205
4206Assets are static resources that your application uses. In FSD, assets follow the same placement principles as code. Group them by use case, not by type, and keep them close to the components that use them.
4207
4208Caution
4209
4210It’s a common practice to implement a custom `assets` segment for all static files, but this pattern is generally discouraged, as it violates FSD principles of high cohesion and locality of changes.
4211
4212If you are interested in a more detailed explanation, you can check articles on [Desegmentation](/docs/guides/issues/desegmented/) and [Slices and segments](/docs/reference/slices-segments/).
4213
4214## Slice-specific Assets
4215
4216[Section titled “Slice-specific Assets”](#slice-specific-assets)
4217
4218When an asset is only used by one page, widget, or feature, keep it inside that slice. This maintains encapsulation and makes it clear where the asset belongs:
4219
4220* pages/
4221
4222 * home/
4223
4224 * ui/
4225
4226 * hero-image.jpg
4227 * HomePage.tsx
4228
4229 * index.ts
4230
4231If your UI has many static assets like images, you can create a subfolder in the `ui` segment:
4232
4233* pages/
4234
4235 * home/
4236
4237 * ui/
4238
4239 * previews/
4240
4241 * cake.jpg
4242 * pizza.jpg
4243 * …
4244
4245 * HomePage.tsx
4246
4247 * index.ts
4248
4249### Non-UI Assets
4250
4251[Section titled “Non-UI Assets”](#non-ui-assets)
4252
4253Sometimes an asset is not part of the UI but is a crucial part of business logic. Consider this example:
4254
4255* features/
4256
4257 * billing/
4258
4259 * model/
4260
4261 * invoice-template.pdf
4262 * create-invoice.ts
4263
4264 * index.ts
4265
4266Here, `invoice-template.pdf` is tightly coupled with the business logic in `create-invoice.ts`. In cases like this, it’s preferable to place the file in the `model` segment to maintain high cohesion and localize changes.
4267
4268## Shared Assets
4269
4270[Section titled “Shared Assets”](#shared-assets)
4271
4272If a static asset is used multiple times, you can move it to `shared/ui`, so it is accessible to every other part of the application.
4273
4274* shared/
4275
4276 * ui/
4277
4278 * placeholders/
4279
4280 * cake.jpg
4281 * pizza.jpg
4282 * …
4283
4284The same approach can be applied for static assets that are used by shared components:
4285
4286* shared/
4287
4288 * ui/
4289
4290 * Dropdown.tsx
4291 * chevron.svg
4292
4293## Global Assets
4294
4295[Section titled “Global Assets”](#global-assets)
4296
4297Global assets include things like global stylesheets (such as a CSS reset) and fonts. The `app` layer is the right place for these files since they’re only used by the application entrypoint.
4298
4299* app/
4300
4301 * styles/
4302
4303 * reset.css
4304 * global.css
4305
4306 * main.ts
4307
4308## Public Folder
4309
4310[Section titled “Public Folder”](#public)
4311
4312Most bundlers and frameworks provide a `public` folder at the root of your project. Files here are served as-is without processing.
4313
4314Some build tools like [Vite](https://vite.dev/config/shared-options#publicdir) allow the public folder location to be changed, while others like [Astro](https://docs.astro.build/en/basics/project-structure/#public) don’t have this option. If your build tool doesn’t support this change, that’s fine. The `public` folder is not part of the FSD structure and won’t cause naming collisions.
4315
4316## Summary
4317
4318[Section titled “Summary”](#summary)
4319
4320| Asset Type | Location |
4321| ------------------------- | ----------------------------------------- |
4322| Slice-specific assets | Inside the slice (e.g., `pages/home/ui/`) |
4323| Reusable icons and images | `shared/ui/` |
4324| Global styles | `app/styles/` |
4325| Fonts | `app/fonts/`, `public/` or `app/public` |
4326| Static files, favicon | `public/` or `app/public` |
4327
4328Follow the FSD principle: place assets where they are used, and move them to `shared` when there’s an actual need for reuse across slices.
4329
4330# Page Layouts
4331
4332This guide examines the abstraction of a *page layout* — when several pages share the same overall structure, and differ only in the main content.
4333
4334Note
4335
4336Is your question not covered by this guide? Post your question by leaving feedback on this article (blue button on the right) and we will consider expanding this guide!
4337
4338## Simple layout
4339
4340[Section titled “Simple layout”](#simple-layout)
4341
4342The simplest layout can be seen on this page. It has a header with site navigation, two sidebars, and a footer with external links. There is no complicated business logic, and the only dynamic parts are sidebars and the switchers on the right side of the header. Such a layout can be placed entirely in `shared/ui` or in `app/layouts`, with props filling in the content for the sidebars:
4343
4344shared/ui/layout/Layout.tsx
4345
4346```tsx
4347import { Link, Outlet } from "react-router-dom";
4348import { useThemeSwitcher } from "./useThemeSwitcher";
4349
4350
4351export function Layout({ siblingPages, headings }) {
4352 const [theme, toggleTheme] = useThemeSwitcher();
4353
4354
4355 return (
4356 <div>
4357 <header>
4358 <nav>
4359 <ul>
4360 <li> <Link to="/">Home</Link> </li>
4361 <li> <Link to="/docs">Docs</Link> </li>
4362 <li> <Link to="/blog">Blog</Link> </li>
4363 </ul>
4364 </nav>
4365 <button onClick={toggleTheme}>{theme}</button>
4366 </header>
4367 <main>
4368 <SiblingPageSidebar siblingPages={siblingPages} />
4369 <Outlet /> {/* This is where the main content goes */}
4370 <HeadingsSidebar headings={headings} />
4371 </main>
4372 <footer>
4373 <ul>
4374 <li>GitHub</li>
4375 <li>Twitter</li>
4376 </ul>
4377 </footer>
4378 </div>
4379 );
4380}
4381```
4382
4383shared/ui/layout/useThemeSwitcher.ts
4384
4385```ts
4386export function useThemeSwitcher() {
4387 const [theme, setTheme] = useState("light");
4388
4389
4390 function toggleTheme() {
4391 setTheme(theme === "light" ? "dark" : "light");
4392 }
4393
4394
4395 useEffect(() => {
4396 document.body.classList.remove("light", "dark");
4397 document.body.classList.add(theme);
4398 }, [theme]);
4399
4400
4401 return [theme, toggleTheme] as const;
4402}
4403```
4404
4405The code of sidebars is left as an exercise for the reader 😉.
4406
4407## Using widgets in the layout
4408
4409[Section titled “Using widgets in the layout”](#using-widgets-in-the-layout)
4410
4411Sometimes you want to include certain business logic in the layout, especially if you’re using deeply nested routes with a router like [React Router](https://reactrouter.com/). Then you can’t store the layout in Shared or in Widgets due to [the import rule on layers](/docs/reference/layers#import-rule-on-layers):
4412
4413> A module in a slice can only import other slices when they are located on layers strictly below.
4414
4415Before we discuss solutions, we need to discuss if it’s even a problem in the first place. Do you *really need* that layout, and if so, does it *really need* to be a Widget? If the block of business logic in question is reused on 2-3 pages, and the layout is simply a small wrapper for that widget, consider one of these two options:
4416
44171. **Write the layout inline on the App layer, where you configure the routing**\
4418 This is great for routers that support nesting, because you can group certain routes and apply the layout only to them.
4419
44202. **Just copy-paste it**\
4421 The urge to abstract code is often very overrated. It is especially the case for layouts, which rarely change. At some point, if one of these pages will need to change, you can simply do the change without needlessly affecting other pages. If you’re worried that someone might forget to update the other pages, you can always leave a comment that describes the relationship between the pages.
4422
4423If none of the above are applicable, there are two solutions to include a widget in the layout:
4424
44251. **Use render props or slots**\
4426 Most frameworks allow you to pass a piece of UI externally. In React, it’s called [render props](https://www.patterns.dev/react/render-props-pattern/), in Vue it’s called [slots](https://vuejs.org/guide/components/slots).
44272. **Move the layout to the App layer**\
4428 You can also store your layout on the App layer, for example, in `app/layouts`, and compose any widgets you want.
4429
4430## Further reading
4431
4432[Section titled “Further reading”](#further-reading)
4433
4434* There’s an example of how to build a layout with authentication with React and Remix (equivalent to React Router) in the [tutorial](/docs/get-started/tutorial).
4435
4436# Types
4437
4438This guide concerns data types from typed languages like TypeScript and describes where they fit within FSD.
4439
4440Note
4441
4442Is your question not covered by this guide? Post your question by leaving feedback on this article (blue button on the right) and we will consider expanding this guide!
4443
4444## Utility types
4445
4446[Section titled “Utility types”](#utility-types)
4447
4448Utility types are types that don’t have much meaning on their own and are usually used with other types. For example:
4449
4450```ts
4451type ArrayValues<T extends readonly unknown[]> = T[number];
4452```
4453
4454To make utility types available across your project, either install a library like [`type-fest`](https://github.com/sindresorhus/type-fest), or create your own library in `shared/lib`. Make sure to clearly indicate what new types *should* be added to this library, and what types *don’t belong* there. For example, call it `shared/lib/utility-types` and add a README inside that describes what is a utility type in your team.
4455
4456Don’t overestimate the potential reusability of a utility type. Just because it can be reused, doesn’t mean it will be, and as such, not every utility type needs to be in Shared. Some utility types are fine right next to where they are needed:
4457
4458* pages/
4459
4460 * home/
4461
4462 * api/
4463
4464 * ArrayValues.ts utility type
4465 * getMemoryUsageMetrics.ts uses the utility type
4466
4467Caution
4468
4469Resist the temptation to create a `shared/types` folder, or to add a `types` segment to your slices. The category “types” is similar to the category “components” or “hooks” in that it describes what the contents are, not what they are for. Segments should describe the purpose of the code, not the essence.
4470
4471## Business entities and their cross-references
4472
4473[Section titled “Business entities and their cross-references”](#business-entities-and-their-cross-references)
4474
4475Among the most important types in an app are the types of business entities, i.e. the real-world things that your app works with. For example, in a music streaming app, you might have business entities *Song*, *Album*, etc.
4476
4477Business entities often come from the backend, so the first step is to type the backend responses. It’s convenient to have a function to make a request to every endpoint, and to type the response of this function. For extra type safety, you may want to run the response through a schema validation library like [Zod](https://zod.dev).
4478
4479For example, if you keep all your requests in Shared, you could do it like this:
4480
4481shared/api/songs.ts
4482
4483```ts
4484import type { Artist } from "./artists";
4485
4486
4487interface Song {
4488 id: number;
4489 title: string;
4490 artists: Array<Artist>;
4491}
4492
4493
4494export function listSongs() {
4495 return fetch('/api/songs').then((res) => res.json() as Promise<Array<Song>>);
4496}
4497```
4498
4499You might notice that the `Song` type references a different entity, `Artist`. This is a benefit of storing your requests in Shared — real-world types are often intertwined. If we kept this function in `entities/song/api`, we wouldn’t be able to simply import `Artist` from `entities/artist`, because FSD restricts cross-imports between slices with [the import rule on layers](/docs/reference/layers#import-rule-on-layers):
4500
4501> A module in a slice can only import other slices when they are located on layers strictly below.
4502
4503There are two ways to deal with this issue:
4504
45051. **Parametrize your types**\
4506 You can make your types accept type arguments as slots for connections with other entities, and even impose constraints on those slots. For example:
4507
4508 entities/song/model/song.ts
4509
4510 ```ts
4511 interface Song<ArtistType extends { id: string }> {
4512 id: number;
4513 title: string;
4514 artists: Array<ArtistType>;
4515 }
4516 ```
4517
4518 This works better for some types than others. A simple type like `Cart = { items: Array<Product> }` can easily be made to work with any type of product. More connected types, like `Country` and `City`, may not be as easy to separate.
4519
45202. **Cross-import (but do it right)**\
4521 To make cross-imports between entities in FSD, you can use a special public API specifically for each slice that will be cross-importing. For example, if we have entities `song`, `artist`, and `playlist`, and the latter two need to reference `song`, we can make two special public APIs for both of them in the `song` entity with the `@x` notation:
4522
4523 * entities/
4524
4525 * song/
4526
4527 * @x/
4528
4529 * artist.ts a public API for the `artist` entity to import from
4530 * playlist.ts a public API for the `playlist` entity to import from
4531
4532 * index.ts regular public API
4533
4534 The contents of a file `📄 entities/song/@x/artist.ts` are similar to `📄 entities/song/index.ts`:
4535
4536 entities/song/@x/artist.ts
4537
4538 ```ts
4539 export type { Song } from "../model/song.ts";
4540 ```
4541
4542 Then the `📄 entities/artist/model/artist.ts` can import `Song` like this:
4543
4544 entities/artist/model/artist.ts
4545
4546 ```ts
4547 import type { Song } from "entities/song/@x/artist";
4548
4549
4550 export interface Artist {
4551 name: string;
4552 songs: Array<Song>;
4553 }
4554 ```
4555
4556 By making explicit connections between entities, we stay on top of inter-dependencies and maintain a decent level of domain separation.
4557
4558## Data transfer objects and mappers
4559
4560[Section titled “Data transfer objects and mappers”](#data-transfer-objects-and-mappers)
4561
4562Data transfer objects, or DTOs, is a term that describes the shape of data that comes from the backend. Sometimes, the DTO is fine to use as is, but sometimes it’s inconvenient for the frontend. That’s where mappers come in — they transform a DTO into a more convenient shape.
4563
4564### Where to put DTOs
4565
4566[Section titled “Where to put DTOs”](#where-to-put-dtos)
4567
4568If you have backend types in a separate package (for example, if you share code between the frontend and the backend), then just import your DTOs from there and you’re done! If you don’t share code between the backend and frontend, then you need to keep DTOs somewhere in your frontend codebase, and we will explore this case below.
4569
4570If you have your request functions in `shared/api`, that’s where the DTOs should be, right next to the function that uses them:
4571
4572shared/api/songs.ts
4573
4574```ts
4575import type { ArtistDTO } from "./artists";
4576
4577
4578interface SongDTO {
4579 id: number;
4580 title: string;
4581 artist_ids: Array<ArtistDTO["id"]>;
4582}
4583
4584
4585export function listSongs() {
4586 return fetch('/api/songs').then((res) => res.json() as Promise<Array<SongDTO>>);
4587}
4588```
4589
4590As mentioned in the previous section, storing your requests and DTOs in Shared comes with the benefit of being able to reference other DTOs.
4591
4592### Where to put mappers
4593
4594[Section titled “Where to put mappers”](#where-to-put-mappers)
4595
4596Mappers are functions that accept a DTO for transformation, and as such, they should be located near the definition of the DTO. In practice this means that if your requests and DTOs are defined in `shared/api`, then the mappers should go there as well:
4597
4598shared/api/songs.ts
4599
4600```ts
4601import type { ArtistDTO } from "./artists";
4602
4603
4604interface SongDTO {
4605 id: number;
4606 title: string;
4607 disc_no: number;
4608 artist_ids: Array<ArtistDTO["id"]>;
4609}
4610
4611
4612interface Song {
4613 id: string;
4614 title: string;
4615 /** The full title of the song, including the disc number. */
4616 fullTitle: string;
4617 artistIds: Array<string>;
4618}
4619
4620
4621function adaptSongDTO(dto: SongDTO): Song {
4622 return {
4623 id: String(dto.id),
4624 title: dto.title,
4625 fullTitle: `${dto.disc_no} / ${dto.title}`,
4626 artistIds: dto.artist_ids.map(String),
4627 };
4628}
4629
4630
4631export function listSongs() {
4632 return fetch('/api/songs').then(async (res) => (await res.json()).map(adaptSongDTO));
4633}
4634```
4635
4636If your requests and stores are defined in entity slices, then all this code would go there, keeping in mind the limitations of cross-imports between slices:
4637
4638entities/song/api/dto.ts
4639
4640```ts
4641import type { ArtistDTO } from "entities/artist/@x/song";
4642
4643
4644export interface SongDTO {
4645 id: number;
4646 title: string;
4647 disc_no: number;
4648 artist_ids: Array<ArtistDTO["id"]>;
4649}
4650```
4651
4652entities/song/api/mapper.ts
4653
4654```ts
4655import type { SongDTO } from "./dto";
4656
4657
4658export interface Song {
4659 id: string;
4660 title: string;
4661 /** The full title of the song, including the disc number. */
4662 fullTitle: string;
4663 artistIds: Array<string>;
4664}
4665
4666
4667export function adaptSongDTO(dto: SongDTO): Song {
4668 return {
4669 id: String(dto.id),
4670 title: dto.title,
4671 fullTitle: `${dto.disc_no} / ${dto.title}`,
4672 artistIds: dto.artist_ids.map(String),
4673 };
4674}
4675```
4676
4677entities/song/api/listSongs.ts
4678
4679```ts
4680import { adaptSongDTO } from "./mapper";
4681
4682
4683export function listSongs() {
4684 return fetch('/api/songs').then(async (res) => (await res.json()).map(adaptSongDTO));
4685}
4686```
4687
4688entities/song/model/songs.ts
4689
4690```ts
4691import { createSlice, createEntityAdapter } from "@reduxjs/toolkit";
4692
4693
4694import { listSongs } from "../api/listSongs";
4695
4696
4697export const fetchSongs = createAsyncThunk('songs/fetchSongs', listSongs);
4698
4699
4700const songAdapter = createEntityAdapter();
4701const songsSlice = createSlice({
4702 name: "songs",
4703 initialState: songAdapter.getInitialState(),
4704 reducers: {},
4705 extraReducers: (builder) => {
4706 builder.addCase(fetchSongs.fulfilled, (state, action) => {
4707 songAdapter.upsertMany(state, action.payload);
4708 })
4709 },
4710});
4711```
4712
4713### How to deal with nested DTOs
4714
4715[Section titled “How to deal with nested DTOs”](#how-to-deal-with-nested-dtos)
4716
4717The most problematic part is when a response from the backend contains several entities. For example, if the song included not just the authors’ IDs, but the entire author objects. In this case, it is impossible for entities not to know about each other (unless we want to discard the data or have a firm conversation with the backend team). Instead of coming up with solutions for indirect connections between slices (such as a common middleware that would dispatch actions to other slices), prefer explicit cross-imports with the `@x` notation. Here is how we can implement it with Redux Toolkit:
4718
4719entities/song/model/songs.ts
4720
4721```ts
4722import {
4723 createSlice,
4724 createEntityAdapter,
4725 createAsyncThunk,
4726 createSelector,
4727} from '@reduxjs/toolkit'
4728import { normalize, schema } from 'normalizr'
4729
4730
4731import { getSong } from "../api/getSong";
4732
4733
4734// Define normalizr entity schemas
4735export const artistEntity = new schema.Entity('artists')
4736export const songEntity = new schema.Entity('songs', {
4737 artists: [artistEntity],
4738})
4739
4740
4741const songAdapter = createEntityAdapter()
4742
4743
4744export const fetchSong = createAsyncThunk(
4745 'songs/fetchSong',
4746 async (id: string) => {
4747 const data = await getSong(id)
4748 // Normalize the data so reducers can load a predictable payload, like:
4749 // `action.payload = { songs: {}, artists: {} }`
4750 const normalized = normalize(data, songEntity)
4751 return normalized.entities
4752 }
4753)
4754
4755
4756export const slice = createSlice({
4757 name: 'songs',
4758 initialState: songAdapter.getInitialState(),
4759 reducers: {},
4760 extraReducers: (builder) => {
4761 builder.addCase(fetchSong.fulfilled, (state, action) => {
4762 songAdapter.upsertMany(state, action.payload.songs)
4763 })
4764 },
4765})
4766
4767
4768const reducer = slice.reducer
4769export default reducer
4770```
4771
4772entities/song/@x/artist.ts
4773
4774```ts
4775export { fetchSong } from "../model/songs";
4776```
4777
4778entities/artist/model/artists.ts
4779
4780```ts
4781import { createSlice, createEntityAdapter } from '@reduxjs/toolkit'
4782
4783
4784import { fetchSong } from 'entities/song/@x/artist'
4785
4786
4787const artistAdapter = createEntityAdapter()
4788
4789
4790export const slice = createSlice({
4791 name: 'users',
4792 initialState: artistAdapter.getInitialState(),
4793 reducers: {},
4794 extraReducers: (builder) => {
4795 builder.addCase(fetchSong.fulfilled, (state, action) => {
4796 // And handle the same fetch result by inserting the artists here
4797 artistAdapter.upsertMany(state, action.payload.artists)
4798 })
4799 },
4800})
4801
4802
4803const reducer = slice.reducer
4804export default reducer
4805```
4806
4807This slightly limits the benefits of slice isolation, but it accurately represents a connection between these two entities that we have no control over. If these entities are to ever be refactored, they have to be refactored together.
4808
4809## Global types and Redux
4810
4811[Section titled “Global types and Redux”](#global-types-and-redux)
4812
4813Global types are types that will be used across the whole application. There are two kinds of global types, based on what they need to know about:
4814
48151. Generic types that don’t have any application specifics
48162. Types that need to know about the whole application
4817
4818The first case is simple to resolve — place your types in Shared, in an appropriate segment. For example, if you have an interface for a global variable for analytics, you can put it in `shared/analytics`.
4819
4820Caution
4821
4822Avoid creating the `shared/types` folder. It groups unrelated things based only on the property of “being a type”, and that property is usually not useful when searching for code in a project.
4823
4824The second case is commonly encountered in projects with Redux without RTK. Your final store type is only available once you add all the reducers together, but this store type needs to be available to selectors that you use across the app. For example, here’s your typical store definition:
4825
4826app/store/index.ts
4827
4828```ts
4829import { combineReducers, rootReducer } from "redux";
4830
4831
4832import { songReducer } from "entities/song";
4833import { artistReducer } from "entities/artist";
4834
4835
4836const rootReducer = combineReducers(songReducer, artistReducer);
4837
4838
4839const store = createStore(rootReducer);
4840
4841
4842type RootState = ReturnType<typeof rootReducer>;
4843type AppDispatch = typeof store.dispatch;
4844```
4845
4846It would be nice to have typed Redux hooks `useAppDispatch` and `useAppSelector` in `shared/store`, but they cannot import `RootState` and `AppDispatch` from the App layer due to the [import rule on layers](/docs/reference/layers#import-rule-on-layers):
4847
4848> A module in a slice can only import other slices when they are located on layers strictly below.
4849
4850The recommended solution in this case is to create an implicit dependency between layers Shared and App. These two types, `RootState` and `AppDispatch` are unlikely to change, and they will be familiar to Redux developers, so we don’t have to worry about them as much.
4851
4852In TypeScript, you can do it by declaring the types as global like this:
4853
4854app/store/index.ts
4855
4856```ts
4857/* same content as in the code block before… */
4858
4859
4860declare type RootState = ReturnType<typeof rootReducer>;
4861declare type AppDispatch = typeof store.dispatch;
4862```
4863
4864shared/store/index.ts
4865
4866```ts
4867import { useDispatch, useSelector, type TypedUseSelectorHook } from "react-redux";
4868
4869
4870export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
4871export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
4872```
4873
4874## Enums
4875
4876[Section titled “Enums”](#enums)
4877
4878The general rule with enums is that they should be defined **as close to the usage locations as possible**. When an enum represents values specific to a single feature, it should be defined in that same feature.
4879
4880The choice of segment should be dictated by usage locations as well. If your enum contains, for example, positions of a toast on the screen, it should be placed in the `ui` segment. If it represents the loading state of a backend operation, it should be placed in the `api` segment.
4881
4882Some enums are truly common across the whole project, like general backend response statuses or design system tokens. In this case, you can place them in Shared, and choose the segment based on what the enum represents (`api` for response statuses, `ui` for design tokens, etc.).
4883
4884## Type validation schemas and Zod
4885
4886[Section titled “Type validation schemas and Zod”](#type-validation-schemas-and-zod)
4887
4888If you want to validate that your data conforms to a certain shape or constraints, you can define a validation schema. In TypeScript, a popular library for this job is [Zod](https://zod.dev). Validation schemas should also be colocated with the code that uses them, as much as possible.
4889
4890Validation schemas are similar to mappers (as discussed in the [Data transfer objects and mappers](#data-transfer-objects-and-mappers) section) in the sense that they take a data transfer object and parse it, producing an error if the parsing fails.
4891
4892One of the most common cases of validation is for the data that comes from the backend. Typically, you want to fail the request when the data doesn’t match the schema, so it makes sense to put the schema in the same place as the request function, which is usually the `api` segment.
4893
4894If your data comes through user input, like a form, the validation should happen as the data is being entered. You can place your schema in the `ui` segment, next to the form component, or in the `model` segment, if the `ui` segment is too crowded.
4895
4896## Typings of component props and context
4897
4898[Section titled “Typings of component props and context”](#typings-of-component-props-and-context)
4899
4900In general, it’s best to keep the props or context interface in the same file as the component or context that uses them. If you have a framework with single-file components, like Vue or Svelte, and you can’t define the props interface in the same file, or you want to share that interface between several components, create a separate file in the same folder, typically, the `ui` segment.
4901
4902Here’s an example with JSX (React or Solid):
4903
4904pages/home/ui/RecentActions.tsx
4905
4906```ts
4907interface RecentActionsProps {
4908 actions: Array<{ id: string; text: string }>;
4909}
4910
4911
4912export function RecentActions({ actions }: RecentActionsProps) {
4913 /* … */
4914}
4915```
4916
4917And here’s an example with the interface stored in a separate file for Vue:
4918
4919pages/home/ui/RecentActionsProps.ts
4920
4921```ts
4922export interface RecentActionsProps {
4923 actions: Array<{ id: string; text: string }>;
4924}
4925```
4926
4927pages/home/ui/RecentActions.vue
4928
4929```html
4930<script setup lang="ts">
4931 import type { RecentActionsProps } from "./RecentActionsProps";
4932
4933
4934 const props = defineProps<RecentActionsProps>();
4935</script>
4936```
4937
4938## Ambient declaration files (`*.d.ts`)
4939
4940[Section titled “Ambient declaration files (\*.d.ts)”](#ambient-declaration-files-dts)
4941
4942Some packages, for example, [Vite](https://vitejs.dev) or [ts-reset](https://www.totaltypescript.com/ts-reset), require ambient declaration files to work across your app. Usually, they aren’t large or complicated, so they often don’t require any architecting, it’s fine to just throw them in the `src/` folder. To keep the `src` more organized, you can keep them on the App layer, in `app/ambient/`.
4943
4944Other packages simply don’t have typings, and you might want to declare them as untyped or even write your own typings for them. A good place for those typings would be `shared/lib`, in a folder like `shared/lib/untyped-packages`. Create a `%LIBRARY_NAME%.d.ts` file there and declare the types you need:
4945
4946shared/lib/untyped-packages/use-react-screenshot.d.ts
4947
4948```ts
4949// This library doesn't have typings, and we didn't want to bother writing our own.
4950declare module "use-react-screenshot";
4951```
4952
4953## Auto-generation of types
4954
4955[Section titled “Auto-generation of types”](#auto-generation-of-types)
4956
4957It’s common to generate types from external sources, for example, generating backend types from an OpenAPI schema. In this case, create a dedicated place in your codebase for these types, like `shared/api/openapi`. Ideally, you should also include a README in that folder that describes what these files are, how to regenerate them, etc.
4958
4959# Cross-imports
4960
4961A **cross-import** is an import **between different slices within the same layer**.
4962
4963For example:
4964
4965* importing `features/product` from `features/cart`
4966* importing `widgets/sidebar` from `widgets/header`
4967
4968Cross-imports are a code smell: a warning sign that slices are becoming coupled. In some situations they may be hard to avoid, but they should always be deliberate and either documented or shared within the team/project.
4969
4970Note
4971
4972The `shared` and `app` layers do not have the concept of a slice, so imports *within* those layers are **not** considered cross-imports.
4973
4974## Why is this a code smell?
4975
4976[Section titled “Why is this a code smell?”](#why-is-this-a-code-smell)
4977
4978Cross-imports are not just a matter of style—they are generally considered a **code smell** because they blur the boundaries between domains and introduce implicit dependencies.
4979
4980Consider a case where the `cart` slice directly depends on `product` business logic. At first glance, this might seem convenient. However, this creates several problems:
4981
49821. **Unclear ownership and responsibility.** When `cart` imports from `product`, it becomes unclear which slice “owns” the shared logic. If the `product` team changes their internal implementation, they might unknowingly break `cart`. This ambiguity makes it harder to reason about the codebase and assign responsibility for bugs or features.
4983
49842. **Reduced isolation and testability.** One of the main benefits of sliced architecture is that each slice can be developed, tested, and deployed independently. Cross-imports break this isolation—testing `cart` now requires setting up `product` as well, and changes in one slice can cause unexpected test failures in another.
4985
49863. **Increased cognitive load.** Working on `cart` also requires accounting for how `product` is structured and how it behaves. As cross-imports accumulate, tracing the impact of a change requires following more code across slice boundaries, and even small edits demand more context to be held in mind.
4987
49884. **Path to circular dependencies.** Cross-imports often start as one-way dependencies but can evolve into bidirectional ones (A imports B, B imports A). This tends to lock slices together, making dependencies harder to untangle and increasing refactoring cost over time.
4989
4990The purpose of clear domain boundaries is to keep each slice focused and changeable within its own responsibility. When dependencies are loose, it becomes easier to predict the impact of a change and to keep review and testing scope contained. Cross-imports weaken this separation, expanding the impact of changes and increasing refactoring cost over time—this is why they are treated as a code smell worth addressing.
4991
4992In the sections below, we outline how these issues typically appear in real projects and what strategies you can use to address them.
4993
4994## Entities layer cross-imports
4995
4996[Section titled “Entities layer cross-imports”](#entities-layer-cross-imports)
4997
4998Cross-imports in `entities` are often caused by splitting entities too granularly. Before reaching for `@x`, consider whether the boundaries should be merged instead. Some teams use `@x` as a dedicated cross-import surface for `entities`, but it should be treated as a **last resort** — a **necessary compromise**, not a recommended approach.
4999
5000Think of `@x` as an explicit gateway for unavoidable domain references—not a general-purpose reuse mechanism. Overuse tends to lock entity boundaries together and makes refactoring more costly over time.
5001
5002For details about `@x`, see the [Public API documentation](/docs/reference/public-api).
5003
5004For concrete examples of cross-references between business entities, see:
5005
5006* [Types guide — Business entities and their cross-references](/docs/guides/examples/types#business-entities-and-their-cross-references)
5007* [Layers reference — Entities](/docs/reference/layers#entities)
5008
5009## Features and widgets: Multiple strategies
5010
5011[Section titled “Features and widgets: Multiple strategies”](#features-and-widgets-multiple-strategies)
5012
5013In the `features` and `widgets` layers, it’s usually more realistic to say there are **multiple strategies** for handling cross-imports, rather than declaring them **always forbidden**. This section focuses less on code and more on the **patterns** you can choose from depending on your team and product context.
5014
5015### Strategy A: Slice merge
5016
5017[Section titled “Strategy A: Slice merge”](#strategy-a-slice-merge)
5018
5019If two slices are not truly independent and they are always changed together, merge them into a single larger slice.
5020
5021Example (before):
5022
5023* `features/profile`
5024* `features/profileSettings`
5025
5026If these keep cross-importing each other and effectively move as one unit, they are likely one feature in practice. In that case, merging into `features/profile` is often the simpler and cleaner choice.
5027
5028### Strategy B: Push shared domain flows down into `entities` (domain-only)
5029
5030[Section titled “Strategy B: Push shared domain flows down into entities (domain-only)”](#strategy-b-push-shared-domain-flows-down-into-entities-domain-only)
5031
5032If multiple features share a domain-level flow, move that flow into a domain slice inside `entities` (for example, `entities/session`).
5033
5034Key principles:
5035
5036* `entities` contains **domain types and domain logic only**
5037* UI remains in `features` / `widgets`
5038* features import and use the domain logic from `entities`
5039
5040For example, if both `features/auth` and `features/profile` need session validation, place session-related domain functions in `entities/session` and reuse them from both features.
5041
5042For more guidance, see [Layers reference — Entities](/docs/reference/layers#entities).
5043
5044### Strategy C: Compose from an upper layer (pages / app)
5045
5046[Section titled “Strategy C: Compose from an upper layer (pages / app)”](#strategy-c-compose-from-an-upper-layer-pages--app)
5047
5048Instead of connecting slices within the same layer via cross-imports, compose them at a higher level (`pages` / `app`). This approach uses **Inversion of Control (IoC)** patterns—rather than slices knowing about each other, an upper layer assembles and connects them.
5049
5050Common IoC techniques include:
5051
5052* **Render props (React)**: Pass components or render functions as props
5053* **Slots (Vue)**: Use named slots to inject content from parent components
5054* **Dependency injection**: Pass dependencies through props or context
5055
5056#### Basic composition example (React):
5057
5058[Section titled “Basic composition example (React):”](#basic-composition-example-react)
5059
5060features/userProfile/index.ts
5061
5062```tsx
5063export { UserProfilePanel } from './ui/UserProfilePanel';
5064```
5065
5066features/activityFeed/index.ts
5067
5068```tsx
5069export { ActivityFeed } from './ui/ActivityFeed';
5070```
5071
5072pages/UserDashboardPage.tsx
5073
5074```tsx
5075import React from 'react';
5076import { UserProfilePanel } from '@/features/userProfile';
5077import { ActivityFeed } from '@/features/activityFeed';
5078
5079
5080export function UserDashboardPage() {
5081 return (
5082 <div>
5083 <UserProfilePanel />
5084 <ActivityFeed />
5085 </div>
5086 );
5087}
5088```
5089
5090With this structure, `features/userProfile` and `features/activityFeed` do not know about each other. `pages/UserDashboardPage` composes them to build the full screen.
5091
5092#### Render props example (React):
5093
5094[Section titled “Render props example (React):”](#render-props-example-react)
5095
5096When one feature needs to render content from another, use render props to invert the dependency:
5097
5098features/commentList/ui/CommentList.tsx
5099
5100```tsx
5101interface CommentListProps {
5102 comments: Comment[];
5103 renderUserAvatar?: (userId: string) => React.ReactNode;
5104}
5105
5106
5107export function CommentList({ comments, renderUserAvatar }: CommentListProps) {
5108 return (
5109 <ul>
5110 {comments.map(comment => (
5111 <li key={comment.id}>
5112 {renderUserAvatar?.(comment.userId)}
5113 <span>{comment.text}</span>
5114 </li>
5115 ))}
5116 </ul>
5117 );
5118}
5119```
5120
5121pages/PostPage.tsx
5122
5123```tsx
5124import { CommentList } from '@/features/commentList';
5125import { UserAvatar } from '@/features/userProfile';
5126
5127
5128export function PostPage() {
5129 return (
5130 <CommentList
5131 comments={comments}
5132 renderUserAvatar={(userId) => <UserAvatar userId={userId} />}
5133 />
5134 );
5135}
5136```
5137
5138Now `CommentList` doesn’t import from `userProfile`—the page injects the avatar component.
5139
5140#### Slots example (Vue):
5141
5142[Section titled “Slots example (Vue):”](#slots-example-vue)
5143
5144Vue’s slot system provides a natural way to compose features without cross-imports:
5145
5146features/commentList/ui/CommentList.vue
5147
5148```vue
5149<template>
5150 <ul>
5151 <li v-for="comment in comments" :key="comment.id">
5152 <slot name="avatar" :userId="comment.userId" />
5153 <span>{{ comment.text }}</span>
5154 </li>
5155 </ul>
5156</template>
5157
5158
5159<script setup lang="ts">
5160defineProps<{
5161 comments: Comment[];
5162}>();
5163</script>
5164```
5165
5166pages/PostPage.vue
5167
5168```vue
5169<template>
5170 <CommentList :comments="comments">
5171 <template #avatar="{ userId }">
5172 <UserAvatar :userId="userId" />
5173 </template>
5174 </CommentList>
5175</template>
5176
5177
5178<script setup lang="ts">
5179import { CommentList } from '@/features/commentList';
5180import { UserAvatar } from '@/features/userProfile';
5181</script>
5182```
5183
5184The `CommentList` feature remains independent of `userProfile`. The page uses slots to compose them together.
5185
5186### Strategy D: Cross-feature reuse only via Public API
5187
5188[Section titled “Strategy D: Cross-feature reuse only via Public API”](#strategy-d-cross-feature-reuse-only-via-public-api)
5189
5190If the above strategies don’t fit your case and cross-feature reuse is truly unavoidable, allow it only through an explicit **Public API** (for example: exported hooks or UI components). Avoid directly accessing another slice’s `store`/`model` or internal implementation details.
5191
5192Unlike strategies A-C which aim to eliminate cross-imports, this strategy accepts them while minimizing the risks through strict boundaries.
5193
5194#### Example code:
5195
5196[Section titled “Example code:”](#example-code)
5197
5198features/auth/index.ts
5199
5200```tsx
5201export { useAuth } from './model/useAuth';
5202export { AuthButton } from './ui/AuthButton';
5203```
5204
5205features/profile/ui/ProfileMenu.tsx
5206
5207```tsx
5208import React from 'react';
5209import { useAuth, AuthButton } from '@/features/auth';
5210
5211
5212export function ProfileMenu() {
5213 const { user } = useAuth();
5214
5215
5216 if (!user) {
5217 return <AuthButton />;
5218 }
5219
5220
5221 return <div>{user.name}</div>;
5222}
5223```
5224
5225For example, prevent `features/profile` from importing from paths like `features/auth/model/internal/*`. Restrict usage to only what `features/auth` explicitly exposes as its Public API.
5226
5227## When should cross-imports be treated as a problem?
5228
5229[Section titled “When should cross-imports be treated as a problem?”](#when-should-cross-imports-be-treated-as-a-problem)
5230
5231After reviewing these strategies, a natural question is:
5232
5233> When is a cross-import acceptable to keep, and when should it be treated as a code smell and refactored?
5234
5235Common warning signs:
5236
5237* directly depending on another slice’s store/model/business logic
5238* deep imports into another slice’s internal files
5239* **bidirectional dependencies** (A imports B, and B imports A)
5240* changes in one slice frequently breaking another slice
5241* flows that should be composed in `pages` / `app`, but are forced into cross-imports within the same layer
5242
5243When you see these signals, treat the cross-import as a **code smell** and consider applying at least one of the strategies above.
5244
5245## How strict you are is a team/project decision
5246
5247[Section titled “How strict you are is a team/project decision”](#how-strict-you-are-is-a-teamproject-decision)
5248
5249How strictly to enforce these rules depends on the team and project.
5250
5251For example:
5252
5253* In **early-stage products** with heavy experimentation, allowing some cross-imports may be a pragmatic speed trade-off.
5254* In **long-lived or regulated systems** (for example, fintech or large-scale services), stricter boundaries often pay off in maintainability and stability.
5255
5256Cross-imports are not an absolute prohibition here. They are dependencies that are **generally best avoided**, but sometimes used intentionally.
5257
5258If you do introduce a cross-import:
5259
5260* treat it as a deliberate architectural choice
5261* document the reasoning
5262* revisit it periodically as the system evolves
5263
5264Teams should align on:
5265
5266* what strictness level they want
5267* how to reflect it in lint rules, code review, and documentation
5268* when and how to reevaluate existing cross-imports over time
5269
5270## References
5271
5272[Section titled “References”](#references)
5273
5274* [(Thread) About the supposed inevitability of cross-ports](https://t.me/feature_sliced/4515)
5275* [(Thread) About resolving cross-ports in entities](https://t.me/feature_sliced/3678)
5276* [(Thread) About cross-imports and responsibility](https://t.me/feature_sliced/3287)
5277* [(Thread) About imports between segments](https://t.me/feature_sliced/4021)
5278* [(Thread) About cross-imports inside shared](https://t.me/feature_sliced/3618)
5279
5280# Desegmentation
5281
5282Desegmentation (also known as horizontal slicing or packaging by layer) is a code organization pattern where files are grouped by their technical roles rather than by the business domains they serve. This means code with similar technical functions is stored in the same place, regardless of the business logic it handles.
5283
5284This approach is popular in meta-frameworks like Next and Nuxt due to its simplicity, as it’s easy to get started and enables features like auto-imports and file-based routing:
5285
5286* app
5287
5288 * components
5289
5290 * DeliveryCard.jsx
5291 * DeliveryChoice.jsx
5292 * RegionSelect.jsx
5293 * UserAvatar.jsx
5294
5295 * actions
5296
5297 * delivery.js
5298 * region.js
5299 * user.js
5300
5301 * composables
5302
5303 * delivery.js
5304 * region.js
5305 * user.js
5306
5307 * constants
5308
5309 * delivery.js
5310 * region.js
5311 * user.js
5312
5313 * utils
5314
5315 * delivery.js
5316 * region.js
5317 * user.js
5318
5319 * stores
5320
5321 * delivery
5322
5323 * getters.js
5324 * actions.js
5325
5326This pattern also occurs in FSD codebases, in the form of generic folders:
5327
5328* features
5329
5330 * delivery
5331
5332 * ui
5333
5334 * components ⚠️
5335
5336* entities
5337
5338 * recommendations
5339
5340 * utils ⚠️
5341
5342Files can also be a source of desegmentation. Files like `types.ts` can aggregate multiple domains, complicating navigation and future refactoring, especially in layers like `pages` or `widgets`:
5343
5344* pages
5345
5346 * delivery
5347
5348 * index.ts
5349
5350 * ui
5351
5352 * DeliveryCard.tsx
5353 * DeliveryChoice.tsx
5354 * UserAvatar.tsx
5355
5356 * model
5357
5358 * types.ts ⚠️
5359 * utils.ts ⚠️
5360
5361 * api
5362
5363 * endpoints.ts ⚠️
5364
5365- types.ts
5366
5367 pages/delivery/model/types.ts
5368
5369 ```ts
5370 // ❌ Bad: Mixed business domains in generic file
5371 export interface DeliveryOption {
5372 id: string;
5373 name: string;
5374 price: number;
5375 }
5376
5377
5378 export interface UserInfo {
5379 id: string;
5380 name: string;
5381 avatar: string;
5382 }
5383 ```
5384
5385- utils.ts
5386
5387 pages/delivery/model/utils.ts
5388
5389 ```ts
5390 // ❌ Bad: Mixed business domains in generic file
5391 export function formatDeliveryPrice(price: number) {
5392 return `$${price.toFixed(2)}`;
5393 }
5394
5395
5396 export function getUserInitials(name: string) {
5397 return name.split(' ').map(n => n[0]).join('');
5398 }
5399 ```
5400
5401- endpoints.ts
5402
5403 pages/delivery/api/endpoints.ts
5404
5405 ```ts
5406 // ❌ Bad: Mixed business domains in generic file
5407 export async function fetchDeliveryOptions() { /* ... */ }
5408 export async function fetchUserInfo() { /* ... */ }
5409 ```
5410
5411## The Problem
5412
5413[Section titled “The Problem”](#the-problem)
5414
5415While this structure is easy to start with, it can lead to scalability issues in larger projects:
5416
5417* Low Cohesion: Modifying a single feature often requires editing files in multiple large folders, such as `pages`, `components`, and `stores`.
5418
5419* Tight Coupling: Components can have unexpected dependencies, leading to complex and tangled dependency chains.
5420
5421* Difficult Refactoring: It requires additional effort to manually extract code related to a specific domain.
5422
5423## Solution
5424
5425[Section titled “Solution”](#solution)
5426
5427Group all code that relates to a specific domain in one place.
5428
5429Avoid generic folder names such as `types`, `components`, `utils`, as well as generic file names like `types.ts`, `utils.ts`, or `helpers.ts`. Instead, use names that directly reflect the domain they represent.
5430
5431Avoid generic file names like `types.ts` if possible, especially in slices with multiple domains:
5432
5433* pages
5434
5435 * delivery
5436
5437 * index.tsx
5438
5439 * ui
5440
5441 * DeliveryPage.tsx
5442 * DeliveryCard.tsx
5443 * DeliveryChoice.tsx
5444 * UserInfo.tsx
5445
5446 * model
5447
5448 * delivery.ts
5449 * user.ts
5450
5451## See Also
5452
5453[Section titled “See Also”](#see-also)
5454
5455* [(Article) About Low Coupling and High Cohesion clearly](https://enterprisecraftsmanship.com/posts/cohesion-coupling-difference/)
5456* [(Article) Low Coupling and High Cohesion. The Law of Demeter](https://medium.com/german-gorelkin/low-coupling-high-cohesion-d36369fb1be9)
5457
5458# Excessive Entities
5459
5460The `entities` layer in Feature-Sliced Design is one of the lower layers that’s primarily for business logic. That makes it widely accessible — all layers except for `shared` can access it. However, its global nature means that changes to `entities` can have a widespread impact, requiring careful design to avoid costly refactors.
5461
5462Excessive entities can lead to ambiguity (what code belongs to this layer), coupling, and constant import dilemmas (code scattered across sibling entities).
5463
5464## How to keep `entities` layer clean
5465
5466[Section titled “How to keep entities layer clean”](#how-to-keep-entities-layer-clean)
5467
5468### 0. Consider having no `entities` layer
5469
5470[Section titled “0. Consider having no entities layer”](#0-consider-having-no-entities-layer)
5471
5472You might think that your application won’t be Feature-Sliced if you don’t include this layer, but it is completely fine for the application to have no `entities` layer. It doesn’t break FSD in any way, on the contrary, it simplifies the architecture and keeps the `entities` layer available for future scaling. For example, if your application acts as a thin client, most likely it doesn’t need `entities` layer.
5473
5474What are thick and thin clients?
5475
5476*Thick* vs. *thin client* distinction refers to how the application processes data:
5477
5478* *Thin* clients rely on the backend for most data processing. Client-side business logic is minimal and involves only exchanging data with the backend.
5479* *Thick* clients handle significant client-side business logic, making them suitable candidates for the `entities` layer.
5480
5481Keep in mind that this classification is not strictly binary, and different parts of the same application may act as a “thick” or a “thin” client.
5482
5483### 1. Avoid preemptive slicing
5484
5485[Section titled “1. Avoid preemptive slicing”](#1-avoid-preemptive-slicing)
5486
5487In contrast to previous versions, FSD 2.1 encourages deferred decomposition of slices instead of preemptive, and this approach also extends to `entities` layer. At first, you can place all your code in the `model` segment of your page (widget, feature), and then consider refactoring it later, when business requirements are stable.
5488
5489Remember: the later you move code to the `entities` layer, the less dangerous your potential refactors will be — code in Entities may affect functionality of any slice on higher layers.
5490
5491### 2. Avoid Unnecessary Entities
5492
5493[Section titled “2. Avoid Unnecessary Entities”](#2-avoid-unnecessary-entities)
5494
5495Do not create an entity for every piece of business logic. Instead, leverage types from `shared/api` and place logic in the `model` segment of a current slice. For reusable business logic, use the `model` segment within an entity slice while keeping data definitions in `shared/api`:
5496
5497* entities
5498
5499 * order
5500
5501 * index.ts
5502
5503 * model
5504
5505 * apply-discount.ts Business logic using OrderDto from shared/api
5506
5507* shared
5508
5509 * api
5510
5511 * index.ts
5512
5513 * endpoints
5514
5515 * order.ts
5516
5517### 3. Exclude CRUD Operations from Entities
5518
5519[Section titled “3. Exclude CRUD Operations from Entities”](#3-exclude-crud-operations-from-entities)
5520
5521CRUD operations, while essential, often involve boilerplate code without significant business logic. Including them in the `entities` layer can clutter it and obscure meaningful code. Instead, place CRUD operations in `shared/api`:
5522
5523* shared
5524
5525 * api
5526
5527 * client.ts
5528
5529 * index.ts
5530
5531 * endpoints
5532
5533 * order.ts Contains all order-related CRUD operations
5534 * products.ts
5535 * cart.ts
5536
5537For complex CRUD operations (e.g., atomic updates, rollbacks, or transactions), evaluate whether the `entities` layer is appropriate, but use it with caution.
5538
5539### 4. Store Authentication Data in `shared`
5540
5541[Section titled “4. Store Authentication Data in shared”](#4-store-authentication-data-in-shared)
5542
5543Prefer `shared` layer to creating a `user` entity for authentication data, such as tokens or user DTOs returned from the backend. These are context-specific and unlikely to be reused outside authentication scope:
5544
5545* Authentication responses (e.g., tokens or DTOs) often lack fields needed for broader reuse or vary by context (e.g., private vs. public user profiles).
5546* Using entities for auth data can lead to cross-layer imports (e.g., `entities` into `shared`) or usage of `@x` notation, complicating the architecture.
5547
5548Instead, store authentication-related data in `shared/auth` or `shared/api`:
5549
5550* shared
5551
5552 * auth
5553
5554 * use-auth.ts authenticated user info or token
5555 * index.ts
5556
5557 * api
5558
5559 * client.ts
5560
5561 * index.ts
5562
5563 * endpoints
5564
5565 * order.ts
5566
5567For more details on implementing authentication, see [the Authentication guide](/docs/guides/examples/auth).
5568
5569### 5. Minimize Cross-Imports
5570
5571[Section titled “5. Minimize Cross-Imports”](#5-minimize-cross-imports)
5572
5573FSD permits cross-imports via `@x` notation, but they can introduce technical issues like circular dependencies. To avoid this, design entities within isolated business contexts to eliminate the need for cross-imports:
5574
5575Non-Isolated Business Context (Avoid):
5576
5577* entities
5578
5579 * order
5580
5581 * @x
5582 * model
5583
5584 * order-item
5585
5586 * @x
5587 * model
5588
5589 * order-customer-info
5590
5591 * @x
5592 * model
5593
5594Isolated Business Context (Preferred):
5595
5596* entities
5597
5598 * order-info
5599
5600 * index.ts
5601
5602 * model
5603
5604 * order-info.ts
5605
5606An isolated context encapsulates all related logic (e.g., order items and customer info) within a single module, reducing complexity and preventing external modifications to tightly coupled logic.
5607
5608# From a custom architecture
5609
5610This guide describes an approach that might be helpful when migrating from a custom self-made architecture to Feature-Sliced Design.
5611
5612Here is the folder structure of a typical custom architecture. We will be using it as an example in this guide.\
5613Click on the blue arrow to open the folder.
5614
5615* src/
5616
5617 * actions/
5618
5619 * product/
5620
5621 * …
5622
5623 * order/
5624
5625 * …
5626
5627 * api/
5628
5629 * …
5630
5631 * components/
5632
5633 * …
5634
5635 * containers/
5636
5637 * …
5638
5639 * constants/
5640
5641 * …
5642
5643 * i18n/
5644
5645 * …
5646
5647 * modules/
5648
5649 * …
5650
5651 * helpers/
5652
5653 * …
5654
5655 * routes/
5656
5657 * products.jsx
5658 * products.\[id].jsx
5659
5660 * utils/
5661
5662 * …
5663
5664 * reducers/
5665
5666 * …
5667
5668 * selectors/
5669
5670 * …
5671
5672 * styles/
5673
5674 * …
5675
5676 * App.jsx
5677
5678 * index.jsx
5679
5680## Before you start
5681
5682[Section titled “Before you start”](#before-you-start)
5683
5684The most important question to ask your team when considering to switch to Feature-Sliced Design is — *do you really need it?* We love Feature-Sliced Design, but even we recognize that some projects are perfectly fine without it.
5685
5686Here are some reasons to consider making the switch:
5687
56881. New team members are complaining that it’s hard to get to a productive level
56892. Making modifications to one part of the code **often** causes another unrelated part to break
56903. Adding new functionality is difficult due to the sheer amount of things you need to think about
5691
5692**Avoid switching to FSD against the will of your teammates**, even if you are the lead.\
5693First, convince your teammates that the benefits outweigh the cost of migration and the cost of learning a new architecture instead of the established one.
5694
5695Also keep in mind that any kind of architectural changes are not immediately observable to the management. Make sure they are on board with the switch before starting and explain to them why it might benefit the project.
5696
5697Tip
5698
5699If you need help convincing the project manager that FSD is beneficial, consider some of these points:
5700
57011. Migration to FSD can happen incrementally, so it will not halt the development of new features
57022. A good architecture can significantly decrease the time that a new developer needs to get productive
57033. FSD is a documented architecture, so the team doesn’t have to continuously spend time on maintaining their own documentation
5704
5705***
5706
5707If you made the decision to start migrating, then the first thing you want to do is to set up an alias for `📁 src`. It will be helpful later to refer to top-level folders. We will consider `@` as an alias for `./src` for the rest of this guide.
5708
5709## Step 1. Divide the code by pages
5710
5711[Section titled “Step 1. Divide the code by pages”](#divide-code-by-pages)
5712
5713Most custom architectures already have a division by pages, however small or large in logic. If you already have `📁 pages`, you may skip this step.
5714
5715If you only have `📁 routes`, create `📁 pages` and try to move as much component code from `📁 routes` as possible. Ideally, you would have a tiny route and a larger page. As you’re moving code, create a folder for each page and add an index file:
5716
5717Note
5718
5719For now, it’s okay if your pages reference each other. You can tackle that later, but for now, focus on establishing a prominent division by pages.
5720
5721Route file:
5722
5723src/routes/products.\[id].js
5724
5725```js
5726export { ProductPage as default } from "@/pages/product"
5727```
5728
5729Page index file:
5730
5731src/pages/product/index.js
5732
5733```js
5734export { ProductPage } from "./ProductPage.jsx"
5735```
5736
5737Page component file:
5738
5739src/pages/product/ProductPage.jsx
5740
5741```jsx
5742export function ProductPage(props) {
5743 return <div />;
5744}
5745```
5746
5747## Step 2. Separate everything else from the pages
5748
5749[Section titled “Step 2. Separate everything else from the pages”](#separate-everything-else-from-pages)
5750
5751Create a folder `📁 src/shared` and move everything that doesn’t import from `📁 pages` or `📁 routes` there. Create a folder `📁 src/app` and move everything that does import the pages or routes there, including the routes themselves.
5752
5753Remember that the Shared layer doesn’t have slices, so it’s fine if segments import from each other.
5754
5755You should end up with a file structure like this:
5756
5757* src/
5758
5759 * app/
5760
5761 * routes/
5762
5763 * products.jsx
5764 * products.\[id].jsx
5765
5766 * App.jsx
5767
5768 * index.js
5769
5770 * pages/
5771
5772 * product/
5773
5774 * index.js
5775
5776 * ui/
5777
5778 * ProductPage.jsx
5779
5780 * catalog/
5781
5782 * …
5783
5784 * shared/
5785
5786 * actions/
5787
5788 * …
5789
5790 * api/
5791
5792 * …
5793
5794 * components/
5795
5796 * …
5797
5798 * containers/
5799
5800 * …
5801
5802 * constants/
5803
5804 * …
5805
5806 * i18n/
5807
5808 * …
5809
5810 * modules/
5811
5812 * …
5813
5814 * helpers/
5815
5816 * …
5817
5818 * utils/
5819
5820 * …
5821
5822 * reducers/
5823
5824 * …
5825
5826 * selectors/
5827
5828 * …
5829
5830 * styles/
5831
5832 * …
5833
5834## Step 3. Tackle cross-imports between pages
5835
5836[Section titled “Step 3. Tackle cross-imports between pages”](#tackle-cross-imports-between-pages)
5837
5838Find all instances where one page is importing from the other and do one of the two things:
5839
58401. Copy-paste the imported code into the depending page to remove the dependency
5841
58422. Move the code to a proper segment in Shared:
5843
5844 * if it’s a part of the UI kit, move it to `📁 shared/ui`;
5845 * if it’s a configuration constant, move it to `📁 shared/config`;
5846 * if it’s a backend interaction, move it to `📁 shared/api`.
5847
5848Note
5849
5850**Copy-pasting isn’t architecturally wrong**, in fact, sometimes it may be more correct to duplicate than to abstract into a new reusable module. The reason is that sometimes the shared parts of pages start drifting apart, and you don’t want dependencies getting in your way in these cases.
5851
5852However, there is still sense in the DRY (“don’t repeat yourself”) principle, so make sure you’re not copy-pasting business logic. Otherwise you will need to remember to fix bugs in several places at once.
5853
5854## Step 4. Unpack the Shared layer
5855
5856[Section titled “Step 4. Unpack the Shared layer”](#unpack-shared-layer)
5857
5858You might have a lot of stuff in the Shared layer on this step, and you generally want to avoid that. The reason is that the Shared layer may be a dependency for any other layer in your codebase, so making changes to that code is automatically more prone to unintended consequences.
5859
5860Find all the objects that are only used on one page and move it to the slice of that page. And yes, *that applies to actions, reducers, and selectors, too*. There is no benefit in grouping all actions together, but there is benefit in colocating relevant actions close to their usage.
5861
5862You should end up with a file structure like this:
5863
5864* src/
5865
5866 * app/
5867
5868 * …
5869
5870 * pages/
5871
5872 * product/
5873
5874 * actions/
5875
5876 * …
5877
5878 * reducers/
5879
5880 * …
5881
5882 * selectors/
5883
5884 * …
5885
5886 * ui/
5887
5888 * Component.jsx
5889 * Container.jsx
5890 * ProductPage.jsx
5891
5892 * index.js
5893
5894 * catalog/
5895
5896 * …
5897
5898 * shared/ only objects that are reused
5899
5900 * actions/
5901
5902 * …
5903
5904 * api/
5905
5906 * …
5907
5908 * components/
5909
5910 * …
5911
5912 * containers/
5913
5914 * …
5915
5916 * constants/
5917
5918 * …
5919
5920 * i18n/
5921
5922 * …
5923
5924 * modules/
5925
5926 * …
5927
5928 * helpers/
5929
5930 * …
5931
5932 * utils/
5933
5934 * …
5935
5936 * reducers/
5937
5938 * …
5939
5940 * selectors/
5941
5942 * …
5943
5944 * styles/
5945
5946 * …
5947
5948## Step 5. Organize code by technical purpose
5949
5950[Section titled “Step 5. Organize code by technical purpose”](#organize-by-technical-purpose)
5951
5952In FSD, division by technical purpose is done with *segments*. There are a few common ones:
5953
5954* `ui` — everything related to UI display: UI components, date formatters, styles, etc.
5955* `api` — backend interactions: request functions, data types, mappers, etc.
5956* `model` — the data model: schemas, interfaces, stores, and business logic.
5957* `lib` — library code that other modules on this slice need.
5958* `config` — configuration files and feature flags.
5959
5960You can create your own segments, too, if you need. Make sure not to create segments that group code by what it is, like `components`, `actions`, `types`, `utils`. Instead, group the code by what it’s for.
5961
5962Reorganize your pages to separate code by segments. You should already have a `ui` segment, now it’s time to create other segments, like `model` for your actions, reducers, and selectors, or `api` for your thunks and mutations.
5963
5964Also reorganize the Shared layer to remove these folders:
5965
5966* `📁 components`, `📁 containers` — most of it should become `📁 shared/ui`;
5967* `📁 helpers`, `📁 utils` — if there are some reused helpers left, group them together by function, like dates or type conversions, and move theses groups to `📁 shared/lib`;
5968* `📁 constants` — again, group by function and move to `📁 shared/config`.
5969
5970## Optional steps
5971
5972[Section titled “Optional steps”](#optional-steps)
5973
5974### Step 6. Form entities/features from Redux slices that are used on several pages
5975
5976[Section titled “Step 6. Form entities/features from Redux slices that are used on several pages”](#form-entities-features-from-redux)
5977
5978Usually, these reused Redux slices will describe something relevant to the business, for example, products or users, so these can be moved to the Entities layer, one entity per one folder. If the Redux slice is related to an action that your users want to do in your app, like comments, then you can move it to the Features layer.
5979
5980Entities and features are meant to be independent from each other. If your business domain contains inherent connections between entities, refer to the [guide on business entities](/docs/guides/examples/types#business-entities-and-their-cross-references) for advice on how to organize these connections.
5981
5982The API functions related to these slices can stay in `📁 shared/api`.
5983
5984### Step 7. Refactor your modules
5985
5986[Section titled “Step 7. Refactor your modules”](#refactor-your-modules)
5987
5988The `📁 modules` folder is commonly used for business logic, so it’s already pretty similar in nature to the Features layer from FSD. Some modules might also be describe large chunks of the UI, like an app header. In that case, you should migrate them to the Widgets layer.
5989
5990### Step 8. Form a clean UI foundation in `shared/ui`
5991
5992[Section titled “Step 8. Form a clean UI foundation in shared/ui”](#form-clean-ui-foundation)
5993
5994`📁 shared/ui` should ideally contain a set of UI elements that don’t have any business logic encoded in them. They should also be highly reusable.
5995
5996Refactor the UI components that used to be in `📁 components` and `📁 containers` to separate out the business logic. Move that business logic to the higher layers. If it’s not used in too many places, you could even consider copy-pasting.
5997
5998## See also
5999
6000[Section titled “See also”](#see-also)
6001
6002* [(Talk in Russian) Ilya Klimov — Крысиные бега бесконечного рефакторинга: как не дать техническому долгу убить мотивацию и продукт](https://youtu.be/aOiJ3k2UvO4)
6003
6004# Migration from v1 to v2
6005
6006## Why v2?
6007
6008[Section titled “Why v2?”](#why-v2)
6009
6010The original concept of **feature-slices** [was announced](https://t.me/feature_slices) in 2018.
6011
6012Since then, many transformations of the methodology have taken place, but at the same time **[the basic principles were preserved](https://feature-sliced.github.io/featureslices.dev/v1.0.html)**:
6013
6014* Using a *standardized* frontend project structure
6015* Splitting the application in the first place-according to *business logic*
6016* Use of *isolated features* to prevent implicit side effects and cyclic dependencies
6017* Using the *Public API* with a ban on climbing “into the insides” of the module
6018
6019At the same time, in the previous version of the methodology, there were still **weak points** that
6020
6021* Sometimes it leads to boilerplate code
6022* Sometimes it leads to excessive complication of the code base and non-obvious rules between abstractions
6023* Sometimes it leads to implicit architectural solutions, which prevented the project from being pulled up and new people from onboarding
6024
6025The new version of the methodology ([v2](https://github.com/feature-sliced/documentation)) is designed **to eliminate these shortcomings, while preserving the existing advantages** of the approach.
6026
6027Since 2018, [has also developed](https://github.com/kof/feature-driven-architecture/issues) another similar methodology - [**feature-driven**](https://github.com/feature-sliced/documentation/tree/rc/feature-driven), which was first announced by [Oleg Isonen](https://github.com/kof).
6028
6029After merging of the two approaches, we have **improved and refined existing practices** - towards greater flexibility, clarity and efficiency in application.
6030
6031> As a result, this has even affected the name of the methodology - *“feature-slice**d**”*
6032
6033## Why does it make sense to migrate the project to v2?
6034
6035[Section titled “Why does it make sense to migrate the project to v2?”](#why-does-it-make-sense-to-migrate-the-project-to-v2)
6036
6037Caution
6038
6039The current version of the methodology is under development and some details *may change*
6040
6041#### 🔍 More transparent and simple architecture
6042
6043[Section titled “🔍 More transparent and simple architecture”](#-more-transparent-and-simple-architecture)
6044
6045The methodology (v2) offers **more intuitive and more common abstractions and ways of separating logic among developers.**
6046
6047All this has an extremely positive effect on attracting new people, as well as studying the current state of the project, and distributing the business logic of the application.
6048
6049#### 📦 More flexible and honest modularity
6050
6051[Section titled “📦 More flexible and honest modularity”](#-more-flexible-and-honest-modularity)
6052
6053The methodology (v2) allows **to distribute logic in a more flexible way:**
6054
6055* With the ability to refactor isolated parts from scratch
6056* With the ability to rely on the same abstractions, but without unnecessary interweaving of dependencies
6057* With simpler requirements for the location of the new module *(layer => slice => segment)*
6058
6059#### 🚀 More specifications, plans, community
6060
6061[Section titled “🚀 More specifications, plans, community”](#-more-specifications-plans-community)
6062
6063At the moment, the `core-team` is actively working on the latest (v2) version of the methodology
6064
6065So it is for her:
6066
6067* there will be more described cases / problems
6068* there will be more guides on the application
6069* there will be more real examples
6070* in general, there will be more documentation for onboarding new people and studying the concepts of the methodology
6071* the toolkit will be developed in the future to comply with the concepts and conventions on architecture
6072
6073> Of course, there will be user support for the first version as well - but the latest version is still a priority for us
6074>
6075> In the future, with the next major updates, you will still have access to the current version (v2) of the methodology, **without risks for your teams and projects**
6076
6077## Changelog
6078
6079[Section titled “Changelog”](#changelog)
6080
6081### `BREAKING` Layers
6082
6083[Section titled “BREAKING Layers”](#breaking-layers)
6084
6085Now the methodology assumes explicit allocation of layers at the top level
6086
6087* `/app` > `/processes` > **`/pages`** > **`/features`** > `/entities` > `/shared`
6088
6089* *That is, not everything is now treated as features/pages*
6090
6091* This approach allows you to [explicitly set rules for layers](https://t.me/atomicdesign/18708):
6092
6093* The **higher the layer** of the module is located , the more **context** it has
6094
6095 *(in other words-each module of the layer - can import only the modules of the underlying layers, but not higher)*
6096
6097* The **lower the layer of the** module is located , the more **danger and responsibility** to make changes to it
6098
6099 *(because it is usually the underlying layers that are more overused)*
6100
6101### `BREAKING` Shared
6102
6103[Section titled “BREAKING Shared”](#breaking-shared)
6104
6105The infrastructure abstractions `/ui`, `/lib`, `/api`, which used to lie in the src root of the project, are now separated by a separate directory `/src/shared`
6106
6107* `shared/ui` - Still the same general uikit of the application (optional)
6108 * *At the same time, no one forbids using `Atomic Design` here as before*
6109* `shared/lib` - A set of auxiliary libraries for implementing logic
6110 * *Still - without a dump of helpers*
6111* `shared/api` - A common entry point for accessing the API
6112 * *Can also be registered locally in each feature / page - but it is not recommended*
6113* As before - there should be no explicit binding to business logic in `shared`
6114 * *If necessary, you need to take this relationship to the `entities` level or even higher*
6115
6116### `NEW` Entities, Processes
6117
6118[Section titled “NEW Entities, Processes”](#new-entities-processes)
6119
6120In v2 **, other new abstractions** have been added to eliminate the problems of logic complexity and high coupling.
6121
6122* `/entities` - layer **business entities** containing slices that are related directly to the business models or synthetic entities required only on frontend
6123 * *Examples: `user`, `i18n`, `order`, `blog`*
6124
6125* `/processes` - layer **business processes**, penetrating app
6126
6127 * **The layer is optional**, it is usually recommended to use it when *the logic grows and begins to blur in several pages*
6128 * *Examples: `payment`, `auth`, `quick-tour`*
6129
6130### `BREAKING` Abstractions & Naming
6131
6132[Section titled “BREAKING Abstractions & Naming”](#breaking-abstractions--naming)
6133
6134Now specific abstractions and [clear recommendations for naming them](/docs/about/understanding/naming)are defined
6135
6136#### Layers
6137
6138[Section titled “Layers”](#layers)
6139
6140* `/app` — **application initialization layer**
6141 * *Previous versions: `app`, `core`,`init`, `src/index` (and this happens)*
6142* `/processes` — [**business process layer**](https://github.com/feature-sliced/documentation/discussions/20)
6143 * *Previous versions: `processes`, `flows`, `workflows`*
6144* `/pages` — **application page layer**
6145 * *Previous versions: `pages`, `screens`, `views`, `layouts`, `components`, `containers`*
6146* `/features` — [**functionality parts layer**](https://github.com/feature-sliced/documentation/discussions/23)
6147 * *Previous versions: `features`, `components`, `containers`*
6148* `/entities` — [**business entity layer**](https://github.com/feature-sliced/documentation/discussions/18#discussioncomment-422649)
6149 * *Previous versions: `entities`, `models`, `shared`*
6150* `/shared` — [**layer of reused infrastructure code**](https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-453020) 🔥
6151 * *Previous versions: `shared`, `common`, `lib`*
6152
6153#### Segments
6154
6155[Section titled “Segments”](#segments)
6156
6157* `/ui` — [**UI segment**](https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-453132) 🔥
6158 * *Previous versions: `ui`, `components`, `view`*
6159* `/model` — [**BL-segment**](https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-472645) 🔥
6160 * *Previous versions: `model`, `store`, `state`, `services`, `controller`*
6161* `/lib` — segment **of auxiliary code**
6162 * *Previous versions: `lib`, `libs`, `utils`, `helpers`*
6163* `/api` — [**API segment**](https://github.com/feature-sliced/documentation/discussions/66)
6164 * *Previous versions: `api`, `service`, `requests`, `queries`*
6165* `/config` — **application configuration segment**
6166 * *Previous versions: `config`, `env`, `get-env`*
6167
6168### `REFINED` Low coupling
6169
6170[Section titled “REFINED Low coupling”](#refined-low-coupling)
6171
6172Now it is much easier to [observe the principle of low coupling](/docs/reference/slices-segments#zero-coupling-high-cohesion) between modules, thanks to the new layers.
6173
6174*At the same time, it is still recommended to avoid as much as possible cases where it is extremely difficult to “uncouple” modules*
6175
6176## See also
6177
6178[Section titled “See also”](#see-also)
6179
6180* [Notes from the report “React SPB Meetup #1”](https://t.me/feature_slices)
6181* [React Berlin Talk - Oleg Isonen “Feature Driven Architecture”](https://www.youtube.com/watch?v=BWAeYuWFHhs)
6182* [Comparison with v1 (community-chat)](https://t.me/feature_sliced/493)
6183* [New ideas v2 with explanations (atomicdesign-chat)](https://t.me/atomicdesign/18708)
6184* [Discussion of abstractions and naming for the new version of the methodology (v2)](https://github.com/feature-sliced/documentation/discussions/31)
6185
6186# Migration from v2.0 to v2.1
6187
6188The main change in v2.1 is the new mental model for decomposing an interface — pages first.
6189
6190In v2.0, FSD would recommend identifying entities and features in your interface, considering even the smallest bits of entity representation and interactivity for decomposition. Then you would build widgets and pages from entities and features. In this model of decomposition, most of the logic was in entities and features, and pages were just compositional layers that didn’t have much significance on their own.
6191
6192In v2.1, we recommend starting with pages, and possibly even stopping there. Most people already know how to separate the app into individual pages, and pages are also a common starting point when trying to locate a component in the codebase. In this new model of decomposition, you keep most of the UI and logic in each individual page, maintaining a reusable foundation in Shared. If a need arises to reuse business logic across several pages, you can move it to a layer below.
6193
6194Another addition to Feature-Sliced Design is the standardization of cross-imports between entities with the `@x`-notation.
6195
6196## How to migrate
6197
6198[Section titled “How to migrate”](#how-to-migrate)
6199
6200There are no breaking changes in v2.1, which means that a project written with FSD v2.0 is also a valid project in FSD v2.1. However, we believe that the new mental model is more beneficial for teams and especially onboarding new developers, so we recommend making minor adjustments to your decomposition.
6201
6202### Merge slices
6203
6204[Section titled “Merge slices”](#merge-slices)
6205
6206A simple way to start is by running our linter, [Steiger](https://github.com/feature-sliced/steiger), on the project. Steiger is built with the new mental model, and the most helpful rules will be:
6207
6208* [`insignificant-slice`](https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/insignificant-slice) — if an entity or feature is only used in one page, this rule will suggest merging that entity or feature into the page entirely.
6209* [`excessive-slicing`](https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/excessive-slicing) — if a layer has too many slices, it’s usually a sign that the decomposition is too fine-grained. This rule will suggest merging or grouping some slices to help project navigation.
6210
6211```bash
6212npx steiger src
6213```
6214
6215This will help you identify which slices are only used once, so that you could reconsider if they are really necessary. In such considerations, keep in mind that a layer forms some kind of global namespace for all the slices inside of it. Just as you wouldn’t pollute the global namespace with variables that are only used once, you should treat a place in the namespace of a layer as valuable, to be used sparingly.
6216
6217### Standardize cross-imports
6218
6219[Section titled “Standardize cross-imports”](#standardize-cross-imports)
6220
6221If you had cross-imports between in your project before (we don’t judge!), you may now take advantage of a new notation for cross-importing in Feature-Sliced Design — the `@x`-notation. It looks like this:
6222
6223entities/B/some/file.ts
6224
6225```ts
6226import type { EntityA } from "entities/A/@x/B";
6227```
6228
6229For more details, check out the [Public API for cross-imports](/docs/reference/public-api#public-api-for-cross-imports) section in the reference.
6230
6231# Usage with Astro
6232
6233## File-based routing
6234
6235[Section titled “File-based routing”](#file-based-routing)
6236
6237Astro expects routes in `📁 src/pages`, but FSD uses it as a layer for page slices. We can’t use the same folder for both. To fix this, use `📁 src/_pages` as an FSD `🥞 pages` layer, and use `📁 src/pages` only for routing.
6238
6239* src
6240
6241 * pages Astro routing (thin entry points)
6242
6243 * 404.astro built-in 404 error page
6244 * index.astro
6245
6246 * \_pages FSD pages layer
6247
6248 * home
6249
6250 * ui
6251
6252 * HomePage.astro
6253
6254 * index.ts
6255
6256The route file only imports and renders the page from the FSD layer.
6257
6258src/pages/index.astro
6259
6260```astro
6261---
6262import { HomePage } from '@/_pages/home';
6263---
6264
6265
6266<HomePage />
6267```
6268
6269## Setting up path aliases
6270
6271[Section titled “Setting up path aliases”](#setting-up-path-aliases)
6272
6273Add a path alias in `tsconfig.json` to import from `src` using `@/`:
6274
6275tsconfig.json
6276
6277```json
6278{
6279 "extends": "astro/tsconfigs/strict",
6280 "compilerOptions": {
6281 "paths": {
6282 "@/*": ["./src/*"]
6283 }
6284 }
6285}
6286```
6287
6288## Working with integrations
6289
6290[Section titled “Working with integrations”](#working-with-integrations)
6291
6292Some Astro integrations like [Starlight](https://starlight.astro.build/) use content collections for their content. These integrations often expect content in specific folders like `📁 src/content/docs`.
6293
6294If the integration doesn’t let you change the root path, that’s fine — keep it as is. Usually content collection folders don’t conflict with FSD layers so FSD layers (`🥞 _pages`, `🥞 shared`, etc.) can live alongside these folders:
6295
6296* src
6297
6298 * \_pages FSD pages layer
6299
6300 * …
6301
6302 * content Integration content (Starlight docs, etc.)
6303
6304 * docs
6305
6306 * getting-started.md
6307
6308 * shared FSD shared layer
6309
6310 * …
6311
6312Let the integration handle its own routing and rendering, while your FSD structure manages the application-specific code.
6313
6314## See also
6315
6316[Section titled “See also”](#see-also)
6317
6318* [Astro Routing](https://docs.astro.build/en/guides/routing/)
6319* [Astro Project Structure](https://docs.astro.build/en/basics/project-structure/)
6320
6321# Usage with Electron
6322
6323Electron applications have a special architecture consisting of multiple processes with different responsibilities. Applying FSD in such a context requires adapting the structure to the Electron specifics.
6324
6325* src
6326
6327 * app Common app layer
6328
6329 * main Main process
6330
6331 * index.ts Main process entry point
6332
6333 * preload Preload script and Context Bridge
6334
6335 * index.ts Preload entry point
6336
6337 * renderer Renderer process
6338
6339 * index.html Renderer process entry point
6340
6341 * main
6342
6343 * features
6344
6345 * user
6346
6347 * ipc
6348
6349 * get-user.ts
6350 * send-user.ts
6351
6352 * entities
6353
6354 * shared
6355
6356 * renderer
6357
6358 * pages
6359
6360 * settings
6361
6362 * ipc
6363
6364 * get-user.ts
6365 * save-user.ts
6366
6367 * ui
6368
6369 * user.tsx
6370
6371 * index.ts
6372
6373 * home
6374
6375 * ui
6376
6377 * home.tsx
6378
6379 * index.ts
6380
6381 * widgets
6382
6383 * features
6384
6385 * entities
6386
6387 * shared
6388
6389 * shared Common code between main and renderer
6390
6391 * ipc IPC description (event names, contracts)
6392
6393## Public API rules
6394
6395[Section titled “Public API rules”](#public-api-rules)
6396
6397Each process must have its own public API. For example, you can’t import modules from `main` to `renderer`. Only the `src/shared` folder is public for both processes. It’s also necessary for describing contracts for process interaction.
6398
6399## Additional changes to the standard structure
6400
6401[Section titled “Additional changes to the standard structure”](#additional-changes-to-the-standard-structure)
6402
6403It’s suggested to use a new `ipc` segment, where interaction between processes takes place. The `pages` and `widgets` layers, based on their names, should not be present in `src/main`. You can use `features`, `entities` and `shared`. The `app` layer in `src` contains entry points for `main` and `renderer`, as well as the IPC. It’s not desirable for segments in the `app` layer to have intersection points
6404
6405## Interaction example
6406
6407[Section titled “Interaction example”](#interaction-example)
6408
6409src/shared/ipc/channels.ts
6410
6411```typescript
6412export const CHANNELS = {
6413 GET_USER_DATA: 'GET_USER_DATA',
6414 SAVE_USER: 'SAVE_USER',
6415} as const;
6416
6417
6418export type TChannelKeys = keyof typeof CHANNELS;
6419```
6420
6421src/shared/ipc/events.ts
6422
6423```typescript
6424import { CHANNELS } from './channels';
6425
6426
6427export interface IEvents {
6428 [CHANNELS.GET_USER_DATA]: {
6429 args: void,
6430 response?: { name: string; email: string; };
6431 };
6432 [CHANNELS.SAVE_USER]: {
6433 args: { name: string; };
6434 response: void;
6435 };
6436}
6437```
6438
6439src/shared/ipc/preload.ts
6440
6441```typescript
6442import { CHANNELS } from './channels';
6443import type { IEvents } from './events';
6444
6445
6446type TOptionalArgs<T> = T extends void ? [] : [args: T];
6447
6448
6449export type TElectronAPI = {
6450 [K in keyof typeof CHANNELS]: (...args: TOptionalArgs<IEvents[typeof CHANNELS[K]]['args']>) => IEvents[typeof CHANNELS[K]]['response'];
6451};
6452```
6453
6454src/app/preload/index.ts
6455
6456```typescript
6457import { contextBridge, ipcRenderer } from 'electron';
6458import { CHANNELS, type TElectronAPI } from 'shared/ipc';
6459
6460
6461const API: TElectronAPI = {
6462 [CHANNELS.GET_USER_DATA]: () => ipcRenderer.sendSync(CHANNELS.GET_USER_DATA),
6463 [CHANNELS.SAVE_USER]: args => ipcRenderer.invoke(CHANNELS.SAVE_USER, args),
6464} as const;
6465
6466
6467contextBridge.exposeInMainWorld('electron', API);
6468```
6469
6470src/main/features/user/ipc/send-user.ts
6471
6472```typescript
6473import { ipcMain } from 'electron';
6474import { CHANNELS } from 'shared/ipc';
6475
6476
6477export const sendUser = () => {
6478 ipcMain.on(CHANNELS.GET_USER_DATA, ev => {
6479 ev.returnValue = {
6480 name: 'John Doe',
6481 email: 'john.doe@example.com',
6482 };
6483 });
6484};
6485```
6486
6487src/renderer/pages/user-settings/ipc/get-user.ts
6488
6489```typescript
6490import { CHANNELS } from 'shared/ipc';
6491
6492
6493export const getUser = () => {
6494 const user = window.electron[CHANNELS.GET_USER_DATA]();
6495
6496
6497 return user ?? { name: 'John Donte', email: 'john.donte@example.com' };
6498};
6499```
6500
6501## See also
6502
6503[Section titled “See also”](#see-also)
6504
6505* [Process Model Documentation](https://www.electronjs.org/docs/latest/tutorial/process-model)
6506* [Context Isolation Documentation](https://www.electronjs.org/docs/latest/tutorial/context-isolation)
6507* [Inter-Process Communication Documentation](https://www.electronjs.org/docs/latest/tutorial/ipc)
6508* [Example](https://github.com/feature-sliced/examples/tree/master/examples/electron)
6509
6510# Usage with Next.js
6511
6512Caution
6513
6514To avoid conflicts, rename **both** `app` and `pages` FSD layers to `_app` and `_pages`, regardless of which router you use.
6515
6516## src Folder
6517
6518[Section titled “src Folder”](#src-folder)
6519
6520Next.js expects special `app` or `pages` folders either in the root of the project or in the `src` folder. Generally, it is easier to place Next.js folders in the root of the project so that the `src` folder contains only FSD code, but it is not mandatory.
6521
6522## App Router
6523
6524[Section titled “App Router”](#app-router)
6525
6526Next.js uses the `app` folder for the App Router and the `pages` folder for the Pages Router, which conflicts with FSD layer names. To solve this conflict, use prefixed names for FSD layers, such as `_app` instead of `app` and `_pages` instead of `pages`. This approach is also compatible with the official [linter](https://github.com/feature-sliced/steiger).
6527
6528* app Next.js app folder
6529
6530 * api/
6531
6532 * get-example/
6533
6534 * route.ts
6535
6536 * example/
6537
6538 * page.tsx
6539
6540* src/
6541
6542 * \_app/ FSD layer
6543
6544 * api-routes/ API routes
6545
6546 * …
6547
6548 * \_pages/ FSD layer
6549
6550 * example/
6551
6552 * index.ts
6553
6554 * ui/
6555
6556 * example.tsx
6557
6558 * widgets/
6559
6560 * …
6561
6562 * features/
6563
6564 * …
6565
6566 * entities/
6567
6568 * …
6569
6570 * shared/
6571
6572 * …
6573
6574Example of re-exporting a page from `src/_pages` in the Next.js `app`:
6575
6576app/example/page.tsx
6577
6578```tsx
6579export { ExamplePage as default, metadata } from '@/_pages/example';
6580```
6581
6582### Server and client public APIs
6583
6584[Section titled “Server and client public APIs”](#server-and-client-public-apis)
6585
6586In Next.js App Router, modules that can be used on the client and server-only modules may exist together within a single slice. If a server-only module is exported from `index.ts`, server-only side effects can propagate into the client module graph when a Client Component imports that slice, which can lead to build errors.
6587
6588When this problem occurs, add `index.server.ts` to the public API.
6589
6590* `index.server.ts`: Modules that must only be imported on the server, such as Server Components or data access functions marked with `server-only`
6591
6592### Middleware
6593
6594[Section titled “Middleware”](#middleware)
6595
6596If you use middleware in your project, it must be located in the project root alongside the Next.js `app` and `pages` folders.
6597
6598### Instrumentation
6599
6600[Section titled “Instrumentation”](#instrumentation)
6601
6602The `instrumentation.js` file allows you to monitor the performance and behavior of your application. If you use it, it must be located in the project root, similar to `middleware.js`.
6603
6604## Pages Router
6605
6606[Section titled “Pages Router”](#pages-router)
6607
6608### Conflict between FSD and Next.js in the `pages` layer
6609
6610[Section titled “Conflict between FSD and Next.js in the pages layer”](#conflict-between-fsd-and-nextjs-in-the-pages-layer)
6611
6612Routes should be placed in the `pages` folder in the root of the project, similar to `app` folder for the App Router. The structure inside `src` where the layer folders are located remains unchanged.
6613
6614* pages/ Pages folder (Next.js)
6615
6616 * \_app.tsx
6617
6618 * api/
6619
6620 * example.ts API route re-export
6621
6622 * example/
6623
6624 * index.tsx
6625
6626* src/
6627
6628 * \_app/ FSD layer
6629
6630 * custom-app/
6631
6632 * custom-app.tsx Custom App component
6633
6634 * api-routes/
6635
6636 * get-example-data.ts API route
6637
6638 * \_pages/ FSD layer
6639
6640 * example/
6641
6642 * index.ts
6643
6644 * ui/
6645
6646 * example.tsx
6647
6648 * widgets/
6649
6650 * …
6651
6652 * features/
6653
6654 * …
6655
6656 * entities/
6657
6658 * …
6659
6660 * shared/
6661
6662 * …
6663
6664Example of re-exporting a page from `src/_pages` in the Next.js `pages`:
6665
6666pages/example/index.tsx
6667
6668```tsx
6669export { Example as default } from '@/_pages/example';
6670```
6671
6672### Custom `_app` component
6673
6674[Section titled “Custom \_app component”](#custom-_app-component)
6675
6676You can place your Custom App component in `src/_app/_app` or `src/_app/custom-app`:
6677
6678src/\_app/custom-app/custom-app.tsx
6679
6680```tsx
6681import type { AppProps } from 'next/app';
6682
6683
6684export const MyApp = ({ Component, pageProps }: AppProps) => {
6685 return (
6686 <>
6687 <p>My Custom App component</p>
6688 <Component { ...pageProps } />
6689 </>
6690 );
6691};
6692```
6693
6694pages/\_app.tsx
6695
6696```tsx
6697export { App as default } from '@/_app/custom-app';
6698```
6699
6700## Route Handlers (API routes)
6701
6702[Section titled “Route Handlers (API routes)”](#route-handlers-api-routes)
6703
6704Use the `api-routes` segment in the `_app` layer to work with Route Handlers.
6705
6706Be mindful when writing backend code in the FSD structure — FSD is primarily intended for frontends, meaning that’s what people will expect to find. If you need a lot of endpoints, consider separating them into a different package in a monorepo.
6707
6708* App Router
6709
6710 src/\_app/api-routes/get-example-data.ts
6711
6712 ```tsx
6713 import { getExamplesList } from '@/shared/db';
6714
6715
6716 export const getExampleData = () => {
6717 try {
6718 const examplesList = getExamplesList();
6719
6720
6721 return Response.json({ examplesList });
6722 } catch {
6723 return Response.json(null, {
6724 status: 500,
6725 statusText: 'Ouch, something went wrong',
6726 });
6727 }
6728 };
6729 ```
6730
6731 app/api/example/route.ts
6732
6733 ```tsx
6734 export { getExampleData as GET } from '@/_app/api-routes';
6735 ```
6736
6737* Pages Router
6738
6739 src/\_app/api-routes/get-example-data.ts
6740
6741 ```tsx
6742 import type { NextApiRequest, NextApiResponse } from 'next';
6743
6744
6745 const config = {
6746 api: {
6747 bodyParser: {
6748 sizeLimit: '1mb',
6749 },
6750 },
6751 maxDuration: 5,
6752 };
6753
6754
6755 const handler = (req: NextApiRequest, res: NextApiResponse<ResponseData>) => {
6756 res.status(200).json({ message: 'Hello from FSD' });
6757 };
6758
6759
6760 export const getExampleData = { config, handler } as const;
6761 ```
6762
6763 src/\_app/api-routes/index.ts
6764
6765 ```tsx
6766 export { getExampleData } from './get-example-data';
6767 ```
6768
6769 app/api/example.ts
6770
6771 ```tsx
6772 import { getExampleData } from '@/_app/api-routes';
6773
6774
6775 export const config = getExampleData.config;
6776 export default getExampleData.handler;
6777 ```
6778
6779## Additional recommendations
6780
6781[Section titled “Additional recommendations”](#additional-recommendations)
6782
6783* Use the `db` segment in the `shared` layer to describe database queries and their further use in higher layers.
6784* Caching and revalidating queries logic is better kept in the same place as the queries themselves.
6785
6786## See also
6787
6788[Section titled “See also”](#see-also)
6789
6790* [Next.js Project Structure](https://nextjs.org/docs/app/getting-started/project-structure)
6791* [Next.js Page Layouts](https://nextjs.org/docs/app/getting-started/layouts-and-pages)
6792
6793# Usage with NuxtJS
6794
6795It is possible to implement FSD in a NuxtJS project, but conflicts arise due to the differences between NuxtJS project structure requirements and FSD principles:
6796
6797* Initially, NuxtJS offers a project file structure without a `src` folder, i.e. in the root of the project.
6798* The file routing is in the `pages` folder, while in FSD this folder is reserved for the flat slice structure.
6799
6800## Adding an alias for the `src` directory
6801
6802[Section titled “Adding an alias for the src directory”](#adding-an-alias-for-the-src-directory)
6803
6804Add an `alias` object to your config:
6805
6806nuxt.config.ts
6807
6808```ts
6809export default defineNuxtConfig({
6810 devtools: { enabled: true }, // Not FSD related, enabled at project startup
6811 alias: {
6812 "@": '../src'
6813 },
6814})
6815```
6816
6817## Choose how to configure the router
6818
6819[Section titled “Choose how to configure the router”](#choose-how-to-configure-the-router)
6820
6821In NuxtJS, there are two ways to customize the routing - using a config and using a file structure. In the case of file-based routing, you will create index.vue files in folders inside the app/routes directory, and in the case of configure, you will configure the routers in the `router.options.ts` file.
6822
6823### Routing using config
6824
6825[Section titled “Routing using config”](#routing-using-config)
6826
6827In the `app` layer, create a `router.options.ts` file, and export a config object from it:
6828
6829app/router.options.ts
6830
6831```ts
6832import type { RouterConfig } from '@nuxt/schema';
6833
6834
6835export default <RouterConfig> {
6836 routes: (_routes) => [],
6837};
6838```
6839
6840To add a `Home` page to your project, you need to do the following steps:
6841
6842* Add a page slice inside the `pages` layer
6843* Add the appropriate route to the `app/router.config.ts` config
6844
6845To create a page slice, let’s use the [CLI](https://github.com/feature-sliced/cli):
6846
6847```shell
6848fsd pages home
6849```
6850
6851Create a `home-page.vue` file inside the ui segment, access it using the Public API
6852
6853src/pages/home/index.ts
6854
6855```ts
6856export { default as HomePage } from './ui/home-page';
6857```
6858
6859Thus, the file structure will look like this:
6860
6861* src
6862
6863 * app
6864
6865 * router.config.ts
6866
6867 * pages
6868
6869 * home
6870
6871 * ui
6872
6873 * home-page.vue
6874
6875 * index.ts
6876
6877Finally, let’s add a route to the config:
6878
6879app/router.config.ts
6880
6881```ts
6882import type { RouterConfig } from '@nuxt/schema'
6883
6884
6885export default <RouterConfig> {
6886 routes: (_routes) => [
6887 {
6888 name: 'home',
6889 path: '/',
6890 component: () => import('@/pages/home.vue').then(r => r.default || r)
6891 }
6892 ],
6893}
6894```
6895
6896### File Routing
6897
6898[Section titled “File Routing”](#file-routing)
6899
6900First of all, create a `src` directory in the root of your project, and create app and pages layers inside this directory and a routes folder inside the app layer. Thus, your file structure should look like this:
6901
6902* src
6903
6904 * app
6905
6906 * routes
6907
6908 * pages Pages folder, related to FSD
6909
6910In order for NuxtJS to use the routes folder inside the `app` layer for file routing, you need to modify `nuxt.config.ts` as follows:
6911
6912nuxt.config.ts
6913
6914```ts
6915export default defineNuxtConfig({
6916 devtools: { enabled: true }, // Not FSD related, enabled at project startup
6917 alias: {
6918 "@": '../src'
6919 },
6920 dir: {
6921 pages: './src/app/routes'
6922 }
6923})
6924```
6925
6926Now, you can create routes for pages within `app` and connect pages from `pages` to them.
6927
6928For example, to add a `Home` page to your project, you need to do the following steps:
6929
6930* Add a page slice inside the `pages` layer
6931* Add the corresponding route inside the `app` layer
6932* Connect the page from the slice with the route
6933
6934To create a page slice, let’s use the [CLI](https://github.com/feature-sliced/cli):
6935
6936```shell
6937fsd pages home
6938```
6939
6940Create a `home-page.vue` file inside the ui segment, access it using the Public API
6941
6942src/pages/home/index.ts
6943
6944```ts
6945export { default as HomePage } from './ui/home-page';
6946```
6947
6948Create a route for this page inside the `app` layer:
6949
6950* src
6951
6952 * app
6953
6954 * routes
6955
6956 * index.vue
6957
6958 * pages
6959
6960 * home
6961
6962 * ui
6963
6964 * home-page.vue
6965
6966 * index.ts
6967
6968Add your page component inside the `index.vue` file:
6969
6970src/app/routes/index.vue
6971
6972```html
6973<script setup>
6974 import { HomePage } from '@/pages/home';
6975</script>
6976
6977
6978<template>
6979 <HomePage/>
6980</template>
6981```
6982
6983## What to do with `layouts`?
6984
6985[Section titled “What to do with layouts?”](#what-to-do-with-layouts)
6986
6987You can place layouts inside the `app` layer, to do this you need to modify the config as follows:
6988
6989nuxt.config.ts
6990
6991```ts
6992export default defineNuxtConfig({
6993 devtools: { enabled: true }, // Not related to FSD, enabled at project startup
6994 alias: {
6995 "@": '../src'
6996 },
6997 dir: {
6998 pages: './src/app/routes',
6999 layouts: './src/app/layouts'
7000 }
7001})
7002```
7003
7004## See also
7005
7006[Section titled “See also”](#see-also)
7007
7008* [Documentation on changing directory config in NuxtJS](https://nuxt.com/docs/api/nuxt-config#dir)
7009* [Documentation on changing router config in NuxtJS](https://nuxt.com/docs/guide/recipes/custom-routing#router-config)
7010* [Documentation on changing aliases in NuxtJS](https://nuxt.com/docs/api/nuxt-config#alias)
7011
7012# Usage with TanStack Query
7013
7014## Where to store keys
7015
7016[Section titled “Where to store keys”](#where-to-store-keys)
7017
7018* In shared
7019
7020 * src/
7021
7022 * app/
7023
7024 * …
7025
7026 * pages/
7027
7028 * …
7029
7030 * widgets/
7031
7032 * …
7033
7034 * features/
7035
7036 * …
7037
7038 * entities/
7039
7040 * …
7041
7042 * shared/
7043
7044 * api/
7045
7046 * queries/ Query factories
7047
7048 * example.ts
7049 * another-example.ts
7050
7051 src/shared/api/index.ts
7052
7053 ```ts
7054 export { exampleQueries } from './queries/example';
7055 ```
7056
7057* In shared split by controllers
7058
7059 If the number of endpoints is large enough and you also want to store them in `shared`, it is better to split everything by controllers and use a public API for each of them.
7060
7061 * src/
7062
7063 * app/
7064
7065 * …
7066
7067 * pages/
7068
7069 * …
7070
7071 * widgets/
7072
7073 * …
7074
7075 * features/
7076
7077 * …
7078
7079 * entities/
7080
7081 * …
7082
7083 * shared/
7084
7085 * api/
7086
7087 * example/
7088
7089 * index.ts
7090 * example.query.ts Query factory with keys and functions for the example controller
7091 * get-example.ts
7092 * create-example.ts
7093 * update-example.ts
7094 * delete-example.ts
7095
7096 * another-example/
7097
7098 * index.ts
7099 * another-example.query.ts Query factory with keys and functions for the another-example controller
7100 * get-another-example.ts
7101 * create-another-example.ts
7102 * update-another-example.ts
7103 * delete-another-example.ts
7104
7105 src/shared/api/example/index.ts
7106
7107 ```ts
7108 export { exampleQueries } from "./example.query";
7109 ```
7110
7111* In entities
7112
7113 *If the project already has a division into entities*, and each request corresponds to a single entity, the cleanest approach is to split by entity. In this case, we suggest using the following structure:
7114
7115 * src/
7116
7117 * app/
7118
7119 * …
7120
7121 * pages/
7122
7123 * …
7124
7125 * widgets/
7126
7127 * …
7128
7129 * features/
7130
7131 * …
7132
7133 * entities/
7134
7135 * example/
7136
7137 * api/
7138
7139 * example.query.ts Query factory with keys and functions
7140 * get-example.ts
7141 * create-example.ts
7142 * update-example.ts
7143 * delete-example.ts
7144
7145 * shared/
7146
7147 * …
7148
7149 If there are connections between entities (for example, the Country entity has a field-list of City entities), you can use the [public API for cross-imports](/docs/reference/public-api#public-api-for-cross-imports).
7150
7151## Where to store mutations
7152
7153[Section titled “Where to store mutations”](#where-to-store-mutations)
7154
7155It is not recommended to mix mutations with queries. There are several options:
7156
7157* In the api segment near the place of use
7158
7159 src/pages/example/api/use-update-example.ts
7160
7161 ```tsx
7162 export const useUpdateExample = () => {
7163 const queryClient = useQueryClient();
7164
7165
7166 return useMutation({
7167 mutationFn: async ({ id, newTitle }) => {
7168 const { data } = await apiClient.patch(`/posts/${ id }`, { title: newTitle });
7169
7170
7171 return data;
7172 },
7173 onSuccess: newPost => {
7174 queryClient.setQueryData(postsQueries.ids(id), newPost);
7175 },
7176 });
7177 };
7178 ```
7179
7180* In shared or entities via mutationFn
7181
7182 src/pages/example/ui/example.tsx
7183
7184 ```tsx
7185 export const Example = () => {
7186 const [title, setTitle] = useState('');
7187
7188
7189 const { mutate, isPending } = useMutation({
7190 mutationFn: mutations.createExample,
7191 });
7192
7193
7194 const handleChange = ({ target: { value } }: ChangeEvent<HTMLInputElement>) => {
7195 setTitle(value);
7196 };
7197
7198
7199 const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
7200 e.preventDefault();
7201 mutate({ title, userId: DEFAULT_USER_ID });
7202 };
7203
7204
7205 return (
7206 <form onSubmit={ handleSubmit }>
7207 <Input onChange={ handleChange } value={ title } />
7208 <Button type="submit" disabled={ isPending }>Create</Button>
7209 </form>
7210 );
7211 };
7212 ```
7213
7214## Organising queries
7215
7216[Section titled “Organising queries”](#organizing-queries)
7217
7218### Query factory
7219
7220[Section titled “Query factory”](#query-factory)
7221
7222A query factory is an object where key values are functions that return a list of query keys.
7223
7224```ts
7225const keyFactory = {
7226 all: () => ["entity"],
7227 lists: () => [...keyFactory.all(), "list"],
7228};
7229```
7230
7231`queryOptions` — a built-in utility in react-query\@v5
7232
7233One of the best ways to share `queryKey` and `queryFn` in multiple places is to use the `queryOptions` helper function [(more details here)](https://tanstack.com/query/latest/docs/framework/react/guides/query-options).
7234
7235```ts
7236import { queryOptions } from '@tanstack/react-query';
7237
7238
7239const groupOptions = (id: number) => queryOptions({
7240 queryKey: ['groups', id],
7241 queryFn: () => fetchGroups(id),
7242 gcTime: 5 * 1000,
7243});
7244```
7245
7246### 1. Creating a query factory
7247
7248[Section titled “1. Creating a query factory”](#creating-query-factory)
7249
7250src/shared/api/post/post.queries.ts
7251
7252```tsx
7253import { queryOptions } from '@tanstack/react-query';
7254import { getPosts } from './get-posts';
7255import { getDetailPost, type DetailPostQuery } from './get-detail-post';
7256
7257
7258export const POST_QUERIES = {
7259 all: () => ['posts'],
7260 lists: () => [...POST_QUERIES.all(), 'list'],
7261 list: (page: number, limit: number) => queryOptions({
7262 queryKey: [...POST_QUERIES.lists(), page, limit],
7263 queryFn: () => getPosts(page, limit),
7264 placeholderData: prev => prev,
7265 }),
7266 details: () => [...POST_QUERIES.all(), 'detail'],
7267 detail: (query?: DetailPostQuery) => queryOptions({
7268 queryKey: [...POST_QUERIES.details(), query?.id],
7269 queryFn: () => getDetailPost({ id: query?.id }),
7270 }),
7271};
7272```
7273
7274### 2. Using a query factory in application code
7275
7276[Section titled “2. Using a query factory in application code”](#using-query-factory)
7277
7278src/pages/post/ui/post.tsx
7279
7280```tsx
7281import { useParams } from 'react-router';
7282import { postApi } from '@/shared/api/post';
7283import { useQuery } from '@tanstack/react-query';
7284
7285
7286interface Params {
7287 postId: string;
7288}
7289
7290
7291export const Post = () => {
7292 const { postId } = useParams<Params>();
7293
7294
7295 const {
7296 data: post,
7297 error,
7298 isLoading,
7299 isError
7300 } = useQuery(postApi.POST_QUERIES.detail({ id: parseInt(postId ?? '', 10) }));
7301
7302
7303 if (isLoading) {
7304 return (
7305 <div>Loading...</div>
7306 );
7307 }
7308
7309
7310 if (isError || !post) {
7311 return (
7312 <div>{ error?.message }</div>
7313 );
7314 }
7315
7316
7317 return (
7318 <div>
7319 <p>Post id: { post.id }</p>
7320 <div>
7321 <h1>{ post.title }</h1>
7322 <div>
7323 <p>{ post.body }</p>
7324 </div>
7325 </div>
7326 <div>Owner: { post.userId }</div>
7327 </div>
7328 );
7329};
7330```
7331
7332### Benefits of using a query factory
7333
7334[Section titled “Benefits of using a query factory”](#query-factory-benefits)
7335
7336* **Structured requests:** A factory allows you to organise all API requests in one place, making your code more readable and maintainable.
7337* **Convenient access to queries and keys:** The factory provides convenient methods for accessing different types of queries and their keys.
7338* **Easy refetching:** The factory makes it easy to refetch without needing to change query keys in different parts of the application.
7339
7340## Pagination
7341
7342[Section titled “Pagination”](#pagination)
7343
7344Pagination uses the same query factory from the [organising queries](#organizing-queries) section above, with the addition of `placeholderData` to prevent the UI from flickering when navigating between pages.
7345
7346### Usage in a component
7347
7348[Section titled “Usage in a component”](#pagination-usage)
7349
7350src/pages/home/ui/home.tsx
7351
7352```tsx
7353export const Home = () => {
7354 const [page, setPage] = usePageParam(DEFAULT_PAGE);
7355
7356
7357 const { data, isFetching, isLoading } = useQuery(postApi.POST_QUERIES.list(page, DEFAULT_ITEMS_ON_SCREEN));
7358
7359
7360 return (
7361 <>
7362 <Pagination
7363 onChange={ (_, page) => setPage(page) }
7364 page={ page }
7365 count={ data?.totalPages }
7366 variant="outlined"
7367 color="primary"
7368 />
7369 <Posts posts={ data?.posts } />
7370 </>
7371 );
7372};
7373```
7374
7375## Infinite scroll
7376
7377[Section titled “Infinite scroll”](#infinite-scroll)
7378
7379`useInfiniteQuery` is used to implement “load more” or infinite scroll patterns.
7380
7381### 1. Query factory with `infiniteQueryOptions`
7382
7383[Section titled “1. Query factory with infiniteQueryOptions”](#infinite-query-factory)
7384
7385src/shared/api/post/post.queries.ts
7386
7387```tsx
7388import { infiniteQueryOptions } from '@tanstack/react-query';
7389import { getPosts } from './get-posts';
7390
7391
7392export const POST_QUERIES = {
7393 all: () => ['posts'],
7394 lists: () => [...POST_QUERIES.all(), 'list'],
7395 infinite: (limit: number) => infiniteQueryOptions({
7396 queryKey: [...POST_QUERIES.lists(), 'infinite', limit],
7397 queryFn: ({ pageParam }) => getPosts(pageParam, limit),
7398 initialPageParam: 0,
7399 getNextPageParam: (lastPage) =>
7400 lastPage.skip + lastPage.limit < lastPage.total
7401 ? lastPage.skip / lastPage.limit + 1
7402 : undefined,
7403 }),
7404};
7405```
7406
7407### 2. Usage in a component
7408
7409[Section titled “2. Usage in a component”](#infinite-query-usage)
7410
7411src/pages/post-feed/ui/post-feed.tsx
7412
7413```tsx
7414import { useInfiniteQuery } from '@tanstack/react-query';
7415import { postApi } from '@/shared/api/post';
7416
7417
7418export const PostFeed = () => {
7419 const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
7420 useInfiniteQuery(postApi.POST_QUERIES.infinite(10));
7421
7422
7423 const posts = data?.pages.flatMap((page) => page.posts) ?? [];
7424
7425
7426 return (
7427 <>
7428 <Posts posts={ posts } />
7429 { hasNextPage && (
7430 <button onClick={ () => fetchNextPage() } disabled={ isFetchingNextPage }>
7431 { isFetchingNextPage ? 'Loading...' : 'Load more' }
7432 </button>
7433 ) }
7434 </>
7435 );
7436};
7437```
7438
7439## Suspense mode
7440
7441[Section titled “Suspense mode”](#suspense-mode)
7442
7443`useSuspenseQuery` allows you to use React Suspense for handling loading states, removing the need to check `isLoading` manually.
7444
7445### 1. The query factory remains the same
7446
7447[Section titled “1. The query factory remains the same”](#suspense-query-factory)
7448
7449`queryOptions` and `useSuspenseQuery` are compatible — no changes to the factory are needed.
7450
7451### 2. Usage in a component
7452
7453[Section titled “2. Usage in a component”](#suspense-usage)
7454
7455src/pages/post/ui/post.tsx
7456
7457```tsx
7458import { useSuspenseQuery } from '@tanstack/react-query';
7459import { postApi } from '@/shared/api/post';
7460
7461
7462interface PostProps {
7463 id: number;
7464}
7465
7466
7467// isLoading is no longer needed — the component only renders when data is ready
7468export const Post = ({ id }: PostProps) => {
7469 const { data: post } = useSuspenseQuery(postApi.POST_QUERIES.detail({ id }));
7470
7471
7472 return (
7473 <div>
7474 <h1>{ post.title }</h1>
7475 <p>{ post.body }</p>
7476 </div>
7477 );
7478};
7479```
7480
7481### 3. Wrapper in the `app` layer
7482
7483[Section titled “3. Wrapper in the app layer”](#suspense-app-wrapper)
7484
7485src/app/providers/suspense-provider.tsx
7486
7487```tsx
7488import { Suspense, type ReactNode } from 'react';
7489import { ErrorBoundary } from 'react-error-boundary';
7490
7491
7492interface SuspenseProviderProps {
7493 children: ReactNode;
7494}
7495
7496
7497export const SuspenseProvider = ({ children }: SuspenseProviderProps) => (
7498 <ErrorBoundary fallback={ <div>Something went wrong</div> }>
7499 <Suspense fallback={ <div>Loading...</div> }>
7500 { children }
7501 </Suspense>
7502 </ErrorBoundary>
7503);
7504```
7505
7506## `useMutationState`
7507
7508[Section titled “useMutationState”](#use-mutation-state)
7509
7510`useMutationState` allows you to read the state of mutations from any component without passing props — useful for global loading indicators or displaying the status of an operation.
7511
7512### 1. Storing mutation keys
7513
7514[Section titled “1. Storing mutation keys”](#storing-mutation-keys)
7515
7516By analogy with the query factory, mutation keys should be stored in one place alongside the factory:
7517
7518src/shared/api/post/post.queries.ts
7519
7520```ts
7521export const POST_MUTATIONS = {
7522 updateTitle: () => ['post', 'update-title'],
7523 create: () => ['post', 'create'],
7524};
7525```
7526
7527### 2. Naming mutations via `mutationKey`
7528
7529[Section titled “2. Naming mutations via mutationKey”](#naming-mutations)
7530
7531src/features/update-post/api/use-update-post-title.ts
7532
7533```tsx
7534import { POST_MUTATIONS } from '@/shared/api/post';
7535
7536
7537interface UpdatePostTitle {
7538 id: number;
7539 newTitle: string;
7540}
7541
7542
7543export const useUpdatePostTitle = () =>
7544 useMutation({
7545 mutationKey: POST_MUTATIONS.updateTitle(),
7546 mutationFn: ({ id, newTitle }: UpdatePostTitle) =>
7547 apiClient.patch(`/posts/${id}`, { title: newTitle }),
7548 });
7549```
7550
7551### 3. Reading state in another component
7552
7553[Section titled “3. Reading state in another component”](#reading-mutation-state)
7554
7555src/widgets/save-indicator/ui/save-indicator.tsx
7556
7557```tsx
7558import { useMutationState } from '@tanstack/react-query';
7559import { POST_MUTATIONS } from '@/shared/api/post';
7560
7561
7562export const SaveIndicator = () => {
7563 const isPending = useMutationState({
7564 filters: { mutationKey: POST_MUTATIONS.updateTitle(), status: 'pending' },
7565 select: mutation => mutation.state.status,
7566 }).length > 0;
7567
7568
7569 return isPending && (
7570 <span>Saving...</span>
7571 );
7572};
7573```
7574
7575## Organising `QueryProvider`
7576
7577[Section titled “Organising QueryProvider”](#query-provider)
7578
7579src/app/providers/query-provider.tsx
7580
7581```tsx
7582import { type ReactNode } from 'react';
7583import { QueryClient, QueryClientProvider, MutationCache, QueryCache } from '@tanstack/react-query';
7584import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
7585import { toast } from 'sonner';
7586
7587
7588interface QueryProviderProps {
7589 children: ReactNode;
7590 client: QueryClient;
7591}
7592
7593
7594const queryClient = new QueryClient({
7595 queryCache: new QueryCache({
7596 onError: error => {
7597 toast.error(error.message);
7598 },
7599 }),
7600 mutationCache: new MutationCache({
7601 onError: error => {
7602 toast.error(error.message);
7603 },
7604 }),
7605 defaultOptions: {
7606 queries: {
7607 staleTime: 5 * 60 * 1000,
7608 gcTime: 5 * 60 * 1000,
7609 },
7610 },
7611});
7612
7613
7614export const QueryProvider = ({ client, children }: QueryProviderProps) => {
7615 return (
7616 <QueryClientProvider client={ client }>
7617 { children }
7618 <ReactQueryDevtools />
7619 </QueryClientProvider>
7620 );
7621};
7622```
7623
7624## Code generation
7625
7626[Section titled “Code generation”](#code-generation)
7627
7628There are tools for automatic code generation that are less flexible than the manual approach described above. If your Swagger file is well-structured and you are using one of these tools, it may make sense to generate all the code in the `@/shared/api` directory.
7629
7630## Additional advice on API interaction
7631
7632[Section titled “Additional advice on API interaction”](#api-client)
7633
7634By using a custom API client class in the `shared` layer, you can standardise the configuration and work with the API across the project. This allows you to manage logging, headers, and the data exchange format (such as JSON or XML) from one place. This approach simplifies maintenance and development by making changes and updates to API interactions easier.
7635
7636src/shared/api/api-client.ts
7637
7638```tsx
7639import { API_URL } from "@/shared/config";
7640
7641
7642export class ApiClient {
7643 #baseUrl: string;
7644
7645
7646 constructor(url: string) {
7647 this.#baseUrl = url;
7648 }
7649
7650
7651 async handleResponse<TResult>(response: Response): Promise<TResult> {
7652 if (!response.ok) {
7653 throw new Error(`HTTP error! Status: ${ response.status }`);
7654 }
7655
7656
7657 try {
7658 return await response.json();
7659 } catch (error) {
7660 throw new Error("Error parsing JSON response");
7661 }
7662 }
7663
7664
7665 public async get<TResult = unknown>(endpoint: string, queryParams?: Record<string, string | number>): Promise<TResult> {
7666 const url = new URL(endpoint, this.#baseUrl);
7667
7668
7669 if (queryParams) {
7670 Object.entries(queryParams).forEach(([key, value]) => {
7671 url.searchParams.append(key, value.toString());
7672 });
7673 }
7674
7675
7676 const response = await fetch(url.toString(), {
7677 method: 'GET',
7678 headers: {
7679 'Content-Type': 'application/json',
7680 },
7681 });
7682
7683
7684 return this.handleResponse<TResult>(response);
7685 }
7686
7687
7688 public async post<TResult = unknown, TData = Record<string, unknown>>(endpoint: string, body: TData): Promise<TResult> {
7689 const response = await fetch(`${ this.#baseUrl }${ endpoint }`, {
7690 method: 'POST',
7691 headers: {
7692 'Content-Type': 'application/json',
7693 },
7694 body: JSON.stringify(body),
7695 });
7696
7697
7698 return this.handleResponse<TResult>(response);
7699 }
7700}
7701
7702
7703export const apiClient = new ApiClient(API_URL);
7704```
7705
7706## See also
7707
7708[Section titled “See also”](#see-also)
7709
7710* [(GitHub) Sample Project](https://github.com/ruslan4432013/fsd-react-query-example)
7711* [(CodeSandbox) Sample Project](https://codesandbox.io/p/github/ruslan4432013/fsd-react-query-example/main)
7712* [About the query factory](https://tkdodo.eu/blog/the-query-options-api)
7713
7714# Usage with SvelteKit
7715
7716It is possible to implement FSD in a SvelteKit project, but conflicts arise due to the differences between the structure requirements of a SvelteKit project and the principles of FSD:
7717
7718* Initially, SvelteKit offers a file structure inside the `src/routes` folder, while in FSD the routing must be part of the `app` layer.
7719* SvelteKit suggests putting everything not related to routing in the `src/lib` folder.
7720
7721## Let’s set up the config
7722
7723[Section titled “Let’s set up the config”](#lets-set-up-the-config)
7724
7725svelte.config.ts
7726
7727```ts
7728import adapter from '@sveltejs/adapter-auto';
7729import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
7730
7731
7732/** @type {import('@sveltejs/kit').Config}*/
7733const config = {
7734 preprocess: [vitePreprocess()],
7735 kit: {
7736 adapter: adapter(),
7737 files: {
7738 routes: 'src/app/routes', // move routing inside the app layer
7739 lib: 'src',
7740 appTemplate: 'src/app/index.html', // Move the application entry point inside the app layer
7741 assets: 'public'
7742 },
7743 alias: {
7744 '@/*': 'src/*' // Create an alias for the src directory
7745 }
7746 }
7747};
7748export default config;
7749```
7750
7751## Move file routing to `src/app`.
7752
7753[Section titled “Move file routing to src/app.”](#move-file-routing-to-srcapp)
7754
7755Let’s create an app layer, move the app’s entry point `index.html` into it, and create a routes folder. Thus, your file structure should look like this:
7756
7757* src
7758
7759 * app
7760
7761 * index.html
7762 * routes
7763
7764 * pages FSD Pages folder
7765
7766Now, you can create routes for pages within `app` and connect pages from `pages` to them.
7767
7768For example, to add a home page to your project, you need to do the following steps:
7769
7770* Add a page slice inside the `pages` layer
7771* Add the corresponding rooute to the `routes` folder from the `app` layer
7772* Align the page from the slice with the rooute
7773
7774To create a page slice, let’s use the [CLI](https://github.com/feature-sliced/cli):
7775
7776```shell
7777fsd pages home
7778```
7779
7780Create a `home-page.svelte` file inside the ui segment, access it using the Public API
7781
7782src/pages/home/index.ts
7783
7784```ts
7785export { default as HomePage } from './ui/home-page.svelte';
7786```
7787
7788Create a route for this page inside the `app` layer:
7789
7790* src
7791
7792 * app
7793
7794 * routes
7795
7796 * +page.svelte
7797
7798 * index.html
7799
7800 * pages
7801
7802 * home
7803
7804 * ui
7805
7806 * home-page.svelte
7807
7808 * index.ts
7809
7810Add your page component inside the `+page.svelte` file:
7811
7812src/app/routes/+page.svelte
7813
7814```html
7815<script>
7816 import { HomePage } from '@/pages/home';
7817</script>
7818
7819
7820
7821
7822<HomePage/>
7823```
7824
7825## See also.
7826
7827[Section titled “See also.”](#see-also)
7828
7829* [Documentation on changing directory config in SvelteKit](https://kit.svelte.dev/docs/configuration#files)
7830
7831# Docs for LLMs
7832
7833Caution
7834
7835These tools are meant to help AI agents and LLMs apply Feature-Sliced Design concepts more effectively. However, all final architectural decisions must be made by human developers.
7836
7837## llms.txt
7838
7839[Section titled “llms.txt”](#llmstxt)
7840
7841[llms.txt](https://llmstxt.org/) provides LLM-friendly information about a website. It helps large language models understand and use site content more accurately.
7842
7843This site offers the following files:
7844
7845* [llms.txt](/llms.txt) — lists available documentation sets
7846* [llms-small.txt](/llms-small.txt) — a compact version of the Feature-Sliced Design documentation, with non-essential content removed
7847* [llms-full.txt](/llms-full.txt) — the complete Feature-Sliced Design documentation
7848
7849All files are served from the site root (`/`) regardless of the current page path. In deployments with a non-root base URL (e.g. `/documentation/`), links are automatically adjusted with the correct prefix.
7850
7851## Skills
7852
7853[Section titled “Skills”](#skills)
7854
7855[Skills](https://vercel.com/docs/agent-resources/skills) are reusable instruction packages designed for AI coding agents (Codex, Claude Code, OpenCode, and others). Unlike static `llms.txt` files, a Skill can be installed directly into your project and activated when relevant.
7856
7857* [Feature-Sliced Design Skill](https://skills.sh/feature-sliced/skills/feature-sliced-design) — an official, community-driven Skill based on the official FSD documentation.
7858
7859You can install the Skill using `skills` CLI:
7860
7861```bash
7862npx skills add https://github.com/feature-sliced/skills --skill feature-sliced-design
7863```
7864
7865# Layers
7866
7867Layers are the first level of organisational hierarchy in Feature-Sliced Design. Their purpose is to separate code based on how much responsibility it needs and how many other modules in the app it depends on. Every layer carries special semantic meaning to help you determine how much responsibility you should allocate to your code.
7868
7869There are **7 layers** in total, arranged from most responsibility and dependency to least:
7870
7871![A file system tree, with a single root folder called src and then seven subfolders: app, processes, pages, widgets, features, entities, shared. The processes folder is slightly faded out.](/_astro/folders-graphic-light.BBZRGyjc_IOGPH.svg)![A file system tree, with a single root folder called src and then seven subfolders: app, processes, pages, widgets, features, entities, shared. The processes folder is slightly faded out.](/_astro/folders-graphic-dark.CvHbWg7G_IOGPH.svg)
7872
78731. App
78742. Processes (deprecated)
78753. Pages
78764. Widgets
78775. Features
78786. Entities
78797. Shared
7880
7881You don’t have to use every layer in your project — only add them if you think it brings value to your project. Typically, most frontend projects will have at least the Shared, Pages, and App layers.
7882
7883In practice, layers are folders with lowercase names (for example, `📁 shared`, `📁 pages`, `📁 app`). Adding new layers is *not recommended* because their semantics are standardized.
7884
7885## Import rule on layers
7886
7887[Section titled “Import rule on layers”](#import-rule-on-layers)
7888
7889Layers are made up of *slices* — highly cohesive groups of modules. Dependencies between slices are regulated by **the import rule on layers**:
7890
7891> *A module (file) in a slice can only import other slices when they are located on layers strictly below.*
7892
7893For example, the folder `📁 ~/features/aaa` is a slice with the name “aaa”. A file inside of it, `~/features/aaa/api/request.ts`, cannot import code from any file in `📁 ~/features/bbb`, but can import code from `📁 ~/entities` and `📁 ~/shared`, as well as any sibling code from `📁 ~/features/aaa`, for example, `~/features/aaa/lib/cache.ts`.
7894
7895Layers App and Shared are **exceptions** to this rule — they are both a layer and a slice at the same time. Slices partition code by business domain, and these two layers are exceptions because Shared does not have business domains, and App combines all business domains.
7896
7897In practice, this means that layers App and Shared are made up of segments, and segments can import each other freely.
7898
7899## Layer definitions
7900
7901[Section titled “Layer definitions”](#layer-definitions)
7902
7903This section describes the semantic meaning of each layer to create an intuition for what kind of code belongs there.
7904
7905### Shared
7906
7907[Section titled “Shared”](#shared)
7908
7909This layer forms a foundation for the rest of the app. It’s a place to create connections with the external world, for example, backends, third-party libraries, the environment. It is also a place to define your own highly contained libraries.
7910
7911This layer, like the App layer, *does not contain slices*. Slices are intended to divide the layer into business domains, but business domains do not exist in Shared. This means that all files in Shared can reference and import from each other.
7912
7913Here are the segments that you can typically find in this layer:
7914
7915* `📁 api` — the API client and potentially also functions to make requests to specific backend endpoints.
7916* `📁 ui` — the application’s UI kit. Components on this layer should not contain business logic, but it’s okay for them to be business-themed. For example, you can put the company logo and page layout here. Components with UI logic are also allowed (for example, autocomplete or a search bar).
7917* `📁 lib` — a collection of internal libraries. This folder should not be treated as helpers or utilities ([read here why these folders often turn into a dump](https://dev.to/sergeysova/why-utils-helpers-is-a-dump-45fo)). Instead, every library in this folder should have one area of focus, for example, dates, colors, text manipulation, etc. That area of focus should be documented in a README file. The developers in your team should know what can and cannot be added to these libraries.
7918* `📁 config` — environment variables, global feature flags and other global configuration for your app.
7919* `📁 routes` — route constants or patterns for matching routes.
7920* `📁 i18n` — setup code for translations, global translation strings.
7921
7922You are free to add more segments, but make sure that the name of these segments describes the purpose of the content, not its essence. For example, `components`, `hooks`, and `types` are bad segment names because they aren’t that helpful when you’re looking for code.
7923
7924### Entities
7925
7926[Section titled “Entities”](#entities)
7927
7928Slices on this layer represent concepts from the real world that the project is working with. Commonly, they are the terms that the business uses to describe the product. For example, a social network might work with business entities like User, Post, and Group.
7929
7930An entity slice might contain the data storage (`📁 model`), data validation schemas (`📁 model`), entity-related API request functions (`📁 api`), as well as the visual representation of this entity in the interface (`📁 ui`). The visual representation doesn’t have to produce a complete UI block — it is primarily meant to reuse the same appearance across several pages in the app, and different business logic may be attached to it through props or slots.
7931
7932#### Entity relationships
7933
7934[Section titled “Entity relationships”](#entity-relationships)
7935
7936Entities in FSD are slices, and by default, slices cannot know about each other. In real life, however, entities often interact with each other, and sometimes one entity owns or contains other entities. Because of that, the business logic of these interactions is preferably kept in higher layers, like Features or Pages.
7937
7938When one entity’s data object contains other data objects, usually it’s a good idea to make the connection between the entities explicit and side-step the slice isolation by making a cross-reference API with the `@x` notation. The reason is that connected entities need to be refactored together, so it’s best to make the connection impossible to miss.
7939
7940For example:
7941
7942entities/artist/model/artist.ts
7943
7944```ts
7945import type { Song } from "entities/song/@x/artist";
7946
7947
7948export interface Artist {
7949 name: string;
7950 songs: Array<Song>;
7951}
7952```
7953
7954entities/song/@x/artist.ts
7955
7956```ts
7957export type { Song } from "../model/song.ts";
7958```
7959
7960Learn more about the `@x` notation in the [Public API for cross-imports](/docs/reference/public-api#public-api-for-cross-imports) section.
7961
7962### Features
7963
7964[Section titled “Features”](#features)
7965
7966This layer is for the main interactions in your app, things that your users care to do. These interactions often involve business entities, because that’s what the app is about.
7967
7968A crucial principle for using the Features layer effectively is: **not everything needs to be a feature**. A good indicator that something needs to be a feature is the fact that it is reused on several pages.
7969
7970For example, if the app has several editors, and all of them have comments, then comments are a reused feature. Remember that slices are a mechanism for finding code quickly, and if there are too many features, the important ones are drowned out.
7971
7972Ideally, when you arrive in a new project, you would discover its functionality by looking through the pages and features. When deciding on what should be a feature, optimize for the experience of a newcomer to the project to quickly discover large important areas of code.
7973
7974A feature slice might contain the UI to perform the interaction like a form (`📁 ui`), the API calls needed to make the action (`📁 api`), validation and internal state (`📁 model`), feature flags (`📁 config`).
7975
7976### Widgets
7977
7978[Section titled “Widgets”](#widgets)
7979
7980The Widgets layer is intended for large self-sufficient blocks of UI. Widgets are most useful when they are reused across multiple pages, or when the page that they belong to has multiple large independent blocks, and this is one of them.
7981
7982If a block of UI makes up most of the interesting content on a page, and is never reused, it **should not be a widget**, and instead it should be placed directly inside that page.
7983
7984Tip
7985
7986If you’re using a nested routing system (like the router of [Remix](https://remix.run)), it may be helpful to use the Widgets layer in the same way as a flat routing system would use the Pages layer — to create full router blocks, complete with related data fetching, loading states, and error boundaries.
7987
7988In the same way, you can store page layouts on this layer.
7989
7990### Pages
7991
7992[Section titled “Pages”](#pages)
7993
7994Pages are what makes up websites and applications (also known as screens or activities). One page usually corresponds to one slice, however, if there are several very similar pages, they can be grouped into one slice, for example, registration and login forms.
7995
7996There’s no limit to how much code you can place in a page slice as long as your team still finds it easy to navigate. If a UI block on a page is not reused, it’s perfectly fine to keep it inside the page slice.
7997
7998In a page slice you can typically find the page’s UI as well as loading states and error boundaries (`📁 ui`) and the data fetching and mutating requests (`📁 api`). It’s not common for a page to have a dedicated data model, and tiny bits of state can be kept in the components themselves.
7999
8000### Processes
8001
8002[Section titled “Processes”](#processes)
8003
8004Caution
8005
8006This layer has been deprecated. The current version of the spec recommends avoiding it and moving its contents to `features` and `app` instead.
8007
8008Processes are escape hatches for multi-page interactions.
8009
8010This layer is deliberately left undefined. Most applications should not use this layer, and keep router-level and server-level logic on the App layer. Consider using this layer only when the App layer grows large enough to become unmaintainable and needs unloading.
8011
8012### App
8013
8014[Section titled “App”](#app)
8015
8016All kinds of app-wide matters, both in the technical sense (e.g., context providers) and in the business sense (e.g., analytics).
8017
8018This layer usually doesn’t contain slices, as well as Shared, instead having segments directly.
8019
8020Here are the segments that you can typically find in this layer:
8021
8022* `📁 routes` — the router configuration
8023* `📁 store` — global store configuration
8024* `📁 styles` — global styles
8025* `📁 entrypoint` — the entrypoint to the application code, framework-specific
8026
8027# Public API
8028
8029A public API is a *contract* between a group of modules, like a slice, and the code that uses it. It also acts as a gate, only allowing access to certain objects, and only through that public API.
8030
8031In practice, it’s usually implemented as an index file with re-exports:
8032
8033pages/auth/index.js
8034
8035```js
8036export { LoginPage } from "./ui/LoginPage";
8037export { RegisterPage } from "./ui/RegisterPage";
8038```
8039
8040## What makes a good public API?
8041
8042[Section titled “What makes a good public API?”](#what-makes-a-good-public-api)
8043
8044A good public API makes using and integrating into other code a slice convenient and reliable. It can be achieved by setting these three goals:
8045
80461. The rest of the application must be protected from structural changes to the slice, like a refactoring
80472. Significant changes in the behavior of the slice that break the previous expectations should cause changes in the public API
80483. Only the necessary parts of the slice should be exposed
8049
8050The last goal has some important practical implications. It may be tempting to create wildcard re-exports of everything, especially in early development of the slice, because any new objects you export from your files are also automatically exported from the slice:
8051
8052Bad practice, features/comments/index.js
8053
8054```js
8055// ❌ BAD CODE BELOW, DON'T DO THIS
8056export * from "./ui/Comment"; // 👎 don't try this at home
8057export * from "./model/comments"; // 💩 this is bad practice
8058```
8059
8060This hurts the discoverability of a slice because you can’t easily tell what the interface of this slice is. Not knowing the interface means that you have to dig deep into the code of a slice to understand how to integrate it. Another problem is that you might accidentally expose the module internals, which will make refactoring difficult if someone starts depending on them.
8061
8062## Public API for cross-imports
8063
8064[Section titled “Public API for cross-imports”](#public-api-for-cross-imports)
8065
8066Cross-imports are a situation when one slice imports from another slice on the same layer. Usually that is prohibited by the [import rule on layers](/docs/reference/layers#import-rule-on-layers), but often there are legitimate reasons to cross-import. For example, business entities often reference each other in the real world, and it’s best to reflect these relationships in the code instead of working around them.
8067
8068For this purpose, there’s a special kind of public API, also known as the `@x`-notation. If you have entities A and B, and entity B needs to import from entity A, then entity A can declare a separate public API just for entity B.
8069
8070* entities
8071
8072 * A
8073
8074 * @x
8075
8076 * B.ts a special public API just for code inside `entities/B/`
8077
8078 * index.ts the regular public API
8079
8080Then the code inside `entities/B/` can import from `entities/A/@x/B`:
8081
8082```ts
8083import type { EntityA } from "entities/A/@x/B";
8084```
8085
8086The notation `A/@x/B` is meant to be read as “A crossed with B”.
8087
8088Note
8089
8090Try to keep cross-imports to a minimum, and **only use this notation on the Entities layer**, where eliminating cross-imports is often unreasonable.
8091
8092## Environment-specific Public APIs
8093
8094[Section titled “Environment-specific Public APIs”](#environment-specific-public-apis)
8095
8096A slice’s public API should generally be represented by `index.ts`, and freely customizing it is not recommended.
8097
8098However, even modules within the same slice may need to run in different environments. If these modules are exported together from a single `index.ts`, the environment boundary may be broken, or the bundler may incorrectly include code that should not be bundled together. When this problem actually occurs, separate the public API by adding files that match the runtime environment. For an example, see [Server and client public APIs in Usage with Next.js](/docs/guides/tech/with-nextjs#server-and-client-public-apis).
8099
8100## Issues with index files
8101
8102[Section titled “Issues with index files”](#issues-with-index-files)
8103
8104Index files like `index.js`, also known as barrel files, are the most common way to define a public API. They are easy to make, but they are known to cause problems with certain bundlers and frameworks.
8105
8106### Circular imports
8107
8108[Section titled “Circular imports”](#circular-imports)
8109
8110Circular import is when two or more files import each other in a circle.
8111
8112![Three files importing each other in a circle](/_astro/circular-import-light.DFMtjA79_1OXPV0.svg)![Three files importing each other in a circle](/_astro/circular-import-dark.BUJjU6uF_1OXPV0.svg)
8113
8114Pictured above: three files, `fileA.js`, `fileB.js`, and `fileC.js`, importing each other in a circle.
8115
8116These situations are often difficult for bundlers to deal with, and in some cases they might even lead to runtime errors that might be difficult to debug.
8117
8118Circular imports can occur without index files, but having an index file presents a clear opportunity to accidentally create a circular import. It often happens when you have two objects exposed in the public API of a slice, for example, `HomePage` and `loadUserStatistics`, and the `HomePage` needs to access `loadUserStatistics`, but it does it like this:
8119
8120pages/home/ui/HomePage.jsx
8121
8122```jsx
8123import { loadUserStatistics } from "../"; // importing from pages/home/index.js
8124
8125
8126export function HomePage() { /* … */ }
8127```
8128
8129pages/home/index.js
8130
8131```js
8132export { HomePage } from "./ui/HomePage";
8133export { loadUserStatistics } from "./api/loadUserStatistics";
8134```
8135
8136This situation creates a circular import, because `index.js` imports `ui/HomePage.jsx`, but `ui/HomePage.jsx` imports `index.js`.
8137
8138To prevent this issue, consider these two principles. If you have two files, and one imports from the other:
8139
8140* When they are in the same slice, always use *relative* imports and write the full import path
8141* When they are in different slices, always use *absolute* imports, for example, with an alias
8142
8143### Large bundles and broken tree-shaking in Shared
8144
8145[Section titled “Large bundles and broken tree-shaking in Shared”](#large-bundles)
8146
8147Some bundlers might have a hard time tree-shaking (removing code that isn’t imported) when you have an index file that re-exports everything.
8148
8149Usually this isn’t a problem for public APIs, because the contents of a module are usually quite closely related, so you would rarely need to import one thing and tree-shake away the other. However, there are two very common cases when the normal rules of public API in FSD may lead to issues — `shared/ui` and `shared/lib`.
8150
8151These two folders are both collections of unrelated things that often aren’t all needed in one place. For example, `shared/ui` might have modules for every component in the UI library:
8152
8153* shared/
8154
8155 * ui/
8156
8157 * button/
8158
8159 * …
8160
8161 * text-field/
8162
8163 * …
8164
8165 * carousel/
8166
8167 * …
8168
8169 * accordion/
8170
8171 * …
8172
8173This problem is made worse when one of these modules has a heavy dependency, like a syntax highlighter or a drag’n’drop library. You don’t want to pull those into every page that uses something from `shared/ui`, for example, a button.
8174
8175If your bundles grow undesirably due to a single public API in `shared/ui` or `shared/lib`, it’s recommended to instead have a separate index file for each component or library:
8176
8177* shared/
8178
8179 * ui/
8180
8181 * button/
8182
8183 * index.js
8184
8185 * text-field/
8186
8187 * index.js
8188
8189Then the consumers of these components can import them directly like this:
8190
8191pages/sign-in/ui/SignInPage.jsx
8192
8193```js
8194import { Button } from '@/shared/ui/button';
8195import { TextField } from '@/shared/ui/text-field';
8196```
8197
8198### No real protection against side-stepping the public API
8199
8200[Section titled “No real protection against side-stepping the public API”](#no-real-protection-against-side-stepping-the-public-api)
8201
8202When you create an index file for a slice, you don’t actually forbid anyone from not using it and importing directly. This is especially a problem for auto-imports, because there are several places from which an object can be imported, so the IDE has to decide that for you. Sometimes it might choose to import directly, breaking the public API rule on slices.
8203
8204To catch these issues automatically, we recommend using [Steiger](https://github.com/feature-sliced/steiger), an architectural linter with a ruleset for Feature-Sliced Design.
8205
8206### Worse performance of bundlers on large projects
8207
8208[Section titled “Worse performance of bundlers on large projects”](#worse-performance-of-bundlers-on-large-projects)
8209
8210Having a large amount of index files in a project can slow down the development server, as noted by TkDodo in [his article “Please Stop Using Barrel Files”](https://tkdodo.eu/blog/please-stop-using-barrel-files).
8211
8212There are several things you can do to tackle this issue:
8213
82141. The same advice as in [“Large bundles and broken tree-shaking in Shared” issue](#large-bundles) — have separate index files for each component/library in `shared/ui` and `shared/lib` instead of one big one
82152. Avoid having index files in segments on layers that have slices. For example, if you have an index for the feature “comments”, `📄 features/comments/index.js`, there’s no reason to have another index for the `ui` segment of that feature, `📄 features/comments/ui/index.js`.
82163. If you have a very big project, there’s a good chance that your application can be split into several big chunks. For example, Google Docs has very different responsibilities for the document editor and for the file browser. You can create a monorepo setup where each package is a separate FSD root, with its own set of layers. Some packages can only have the Shared and Entities layers, others might only have Pages and App, others still might include their own small Shared, but still use the big one from another package too.
8217
8218# Slice groups
8219
8220A slice group places related slices close together within the same layer, making the structure easier to navigate.
8221
8222It is not a required part of FSD. You can introduce slice groups selectively when the number of slices grows large enough that a flat structure becomes hard to browse.
8223
8224## Overview
8225
8226[Section titled “Overview”](#overview)
8227
8228It is a structure for making code easier to read and navigate, and does not affect dependency rules between slices.
8229
8230A slice group is not a slice. It does not have its own segments like `model`, `ui`, or `api`, nor does it have a public API such as `index.ts`. Shared code used across multiple slices should not be placed in a slice group either.
8231
8232![A slice group does not have segments or a public API](/_astro/slice-group-not-a-slice-light.ChbKf7T2_21YkdQ.svg)![A slice group does not have segments or a public API](/_astro/slice-group-not-a-slice-dark.B-BbpNUY_21YkdQ.svg)
8233
8234Each slice remains independent even when it belongs to a slice group. The isolation and dependency rules between slices do not change.
8235
8236## Why it is needed
8237
8238[Section titled “Why it is needed”](#why-it-is-needed)
8239
8240As the number of slices grows, a flat structure can make it hard to locate related code. When slices that share the same context are scattered across a layer, you end up scanning the entire layer multiple times.
8241
8242A slice group addresses this by bringing related slices closer together, making the structure easier to navigate.
8243
8244## When to consider introducing one
8245
8246[Section titled “When to consider introducing one”](#when-to-consider-introducing-one)
8247
8248Consider introducing a slice group when there is a grouping criterion that feels obvious at a glance, and splitting the structure actually makes it easier to read.
8249
8250* Several slices sharing the same business context are scattered across a layer.
8251* The slice names clearly suggest they belong to the same topic.
8252* The number of slices in a layer has grown to the point where it is hard to take in at a glance.
8253
8254Conversely, it may not be needed yet in the following cases.
8255
8256* Names alone are enough for quick navigation.
8257* There is no natural grouping criterion.
8258* Too few slices would end up in the group.
8259
8260## How to apply it
8261
8262[Section titled “How to apply it”](#how-to-apply-it)
8263
8264### entities
8265
8266[Section titled “entities”](#entities)
8267
8268You can group slices that are close from a domain model perspective.
8269
8270For example, suppose the `entities` layer has accumulated many payment-related slices.
8271
8272**Without a group:**
8273
8274* entities/
8275
8276 * invoice/
8277
8278 * model/
8279
8280 * …
8281
8282 * ui/
8283
8284 * …
8285
8286 * receipt/
8287
8288 * model/
8289
8290 * …
8291
8292 * ui/
8293
8294 * …
8295
8296 * transaction/
8297
8298 * model/
8299
8300 * …
8301
8302 * ui/
8303
8304 * …
8305
8306 * user/
8307
8308 * model/
8309
8310 * …
8311
8312 * ui/
8313
8314 * …
8315
8316 * product/
8317
8318 * model/
8319
8320 * …
8321
8322 * ui/
8323
8324 * …
8325
8326 * …
8327
8328To find the payment-related slices (`invoice`, `receipt`, `transaction`), you would need to scan the entire layer and pick them out one by one.
8329
8330**With a group:**
8331
8332* entities/
8333
8334 * payment/
8335
8336 * invoice/
8337
8338 * model/
8339
8340 * …
8341
8342 * ui/
8343
8344 * …
8345
8346 * receipt/
8347
8348 * model/
8349
8350 * …
8351
8352 * ui/
8353
8354 * …
8355
8356 * transaction/
8357
8358 * model/
8359
8360 * …
8361
8362 * ui/
8363
8364 * …
8365
8366 * user/
8367
8368 * model/
8369
8370 * …
8371
8372 * ui/
8373
8374 * …
8375
8376 * product/
8377
8378 * model/
8379
8380 * …
8381
8382 * ui/
8383
8384 * …
8385
8386 * …
8387
8388Now every payment-related slice can be found directly under `payment/`.
8389
8390Not every slice needs to belong to a group. A slice like `user/`, whose meaning is clear on its own, can remain ungrouped.
8391
8392### pages
8393
8394[Section titled “pages”](#pages)
8395
8396You can group related pages in the pages layer in a similar way. This is one possible example and does not represent the default structure for the pages layer.
8397
8398* pages/
8399
8400 * order/
8401
8402 * create/
8403
8404 * ui/
8405
8406 * …
8407
8408 * detail/
8409
8410 * ui/
8411
8412 * …
8413
8414 * list/
8415
8416 * ui/
8417
8418 * …
8419
8420 * customer/
8421
8422 * detail/
8423
8424 * ui/
8425
8426 * …
8427
8428 * list/
8429
8430 * ui/
8431
8432 * …
8433
8434 * settings/
8435
8436 * ui/
8437
8438 * …
8439
8440 * …
8441
8442This structure helps when there are multiple pages on the same topic, such as list, detail, create, and edit.
8443
8444### Can they be used in features as well?
8445
8446[Section titled “Can they be used in features as well?”](#can-they-be-used-in-features-as-well)
8447
8448Yes, slice groups can be applied to the features layer as well. However, a feature often spans multiple entities rather than being tied to a single domain. This makes it harder to find a natural grouping criterion compared to entities.
8449
8450If you create a group like `features/cart/` without a clear criterion, use cases like `add-to-cart` and `remove-from-cart` will end up there, but cart-related DTOs and mappers may start accumulating as well. At that point the group folder stops serving as a navigational structure and begins acting as a home for the entire cart domain, weakening the principle that features should be split by use case.
8451
8452To introduce a slice group in features, first check whether there are enough slices to warrant a group. If there are only two or three, a flat structure is likely sufficient. You should also check whether the group contains only feature slices, or whether code that belongs in entities has crept in as well.
8453
8454## References
8455
8456[Section titled “References”](#references)
8457
8458* [Russian Telegram FSD community — Discussion on structural grouping, slice groups, and layer extension](https://t.me/c/1216849846/1/2785)
8459* [Russian Telegram FSD community — Nested vs. flat structure, forms and boundaries of slice groups](https://t.me/c/1216849846/1/5051)
8460* [Russian Telegram FSD community — Why there are fewer grouping examples in features, and whether slice groups work in features](https://t.me/c/1216849846/1/80620)
8461* [KakaoPay — A case of applying slice grouping in the pages layer](https://tech.kakaopay.com/post/fsd/#2-slice-grouping-%ED%97%88%EC%9A%A9-%EB%B0%8F-pages-%EB%A0%88%EC%9D%B4%EC%96%B4-%EA%B5%AC%EC%84%B1)
8462
8463# Slices and segments
8464
8465## Slices
8466
8467[Section titled “Slices”](#slices)
8468
8469Slices are the second level in the organizational hierarchy of Feature-Sliced Design. Their main purpose is to group code by its meaning for the product, business, or just the application.
8470
8471The names of slices are not standardized because they are directly determined by the business domain of your application. For example, a photo gallery might have slices `photo`, `effects`, `gallery-page`. A social network would require different slices, for example, `post`, `comments`, `news-feed`.
8472
8473The layers Shared and App don’t contain slices. That is because Shared should contain no business logic at all, hence has no meaning for the product, and App should contain only code that concerns the entire application, so no splitting is necessary.
8474
8475### Zero coupling and high cohesion
8476
8477[Section titled “Zero coupling and high cohesion”](#zero-coupling-high-cohesion)
8478
8479Slices are meant to be independent and highly cohesive groups of code files. The graphic below might help to visualize the tricky concepts of *cohesion* and *coupling*:
8480
8481![Coupling and cohesion](/_astro/coupling-cohesion-light.CqnotwBT_ZdhsxN.svg)![Coupling and cohesion](/_astro/coupling-cohesion-dark.D-HXG9Ti_ZdhsxN.svg)
8482
8483Image inspired by <https://enterprisecraftsmanship.com/posts/cohesion-coupling-difference/>
8484
8485An ideal slice is independent from other slices on its layer (zero coupling) and contains most of the code related to its primary goal (high cohesion).
8486
8487The independence of slices is enforced by the [import rule on layers](/docs/reference/layers#import-rule-on-layers):
8488
8489> *A module (file) in a slice can only import other slices when they are located on layers strictly below.*
8490
8491### Public API rule on slices
8492
8493[Section titled “Public API rule on slices”](#public-api-rule-on-slices)
8494
8495Inside a slice, the code could be organized in any way that you want. That doesn’t pose any issues as long as the slice provides a good public API for other slices to use it. This is enforced with the **public API rule on slices**:
8496
8497> *Every slice (and segment on layers that don’t have slices) must contain a public API definition.*
8498>
8499> *Modules outside of this slice/segment can only reference the public API, not the internal file structure of the slice/segment.*
8500
8501Read more about the rationale of public APIs and the best practices on creating one in the [Public API reference](/docs/reference/public-api).
8502
8503### Slice groups
8504
8505[Section titled “Slice groups”](#slice-groups)
8506
8507Closely related slices can be structurally grouped in a folder, but they should exercise the same isolation rules as other slices — there should be **no code sharing** in that folder.
8508
8509![Features 'compose', 'like' and 'delete' grouped in a folder 'post'. In that folder there is also a file 'some-shared-code.ts' that is crossed out to imply that it's not allowed.](/_astro/graphic-nested-slices.Co_xBHAZ_1T6dkP.svg)
8510
8511## Segments
8512
8513[Section titled “Segments”](#segments)
8514
8515Segments are the third and final level in the organizational hierarchy, and their purpose is to group code by its technical nature.
8516
8517There a few standardized segment names:
8518
8519* `ui` — everything related to UI display: UI components, date formatters, styles, etc.
8520* `api` — backend interactions: request functions, data types, mappers, etc.
8521* `model` — the data model: schemas, interfaces, stores, and business logic.
8522* `lib` — library code that other modules on this slice need.
8523* `config` — configuration files and feature flags.
8524
8525See the [Layers page](/docs/reference/layers#layer-definitions) for examples of what each of these segments might be used for on different layers.
8526
8527You can also create custom segments. The most common places for custom segments are the App layer and the Shared layer, where slices don’t make sense.
8528
8529Make sure that the name of these segments describes the purpose of the content, not its essence. For example, `components`, `hooks`, and `types` are bad segment names because they aren’t that helpful when you’re looking for code.
No newline at end of file
Deleted web/openapi.json

Diff too large to display.

Deleted web/package.json
@@ -1,47 +0,0 @@
1{
2 "name": "ui",
3 "private": true,
4 "version": "0.0.0",
5 "type": "module",
6 "scripts": {
7 "dev": "vite",
8 "build": "tsc -b && vite build",
9 "lint": "oxlint && bun scripts/lint-nav.mjs && bun scripts/lint-i18n.mjs",
10 "lint:fsd": "steiger src",
11 "preview": "vite preview",
12 "gen:api": "alova gen -f"
13 },
14 "dependencies": {
15 "@alova/wormhole": "^1.5.2",
16 "@floating-ui/dom": "^1.8.0",
17 "@livekit/protocol": "1.50.4",
18 "@tailwindcss/vite": "^4.3.3",
19 "alova": "^3.5.5",
20 "clsx": "2.1.1",
21 "dompurify": "^3.4.15",
22 "emoji-picker-react": "^4.22.2",
23 "events": "^3.3.0",
24 "html-to-image": "^1.11.13",
25 "livekit-client": "2.22.3",
26 "loglevel": "^1.9.2",
27 "lucide-react": "^1.39.0",
28 "marked": "^18.0.13",
29 "react": "^19.2.8",
30 "react-dom": "^19.2.8",
31 "react-router-dom": "^7.18.3",
32 "rxjs": "7.8.2",
33 "tailwindcss": "^4.3.3",
34 "typed-emitter": "^2.1.0",
35 "usehooks-ts": "3.1.1"
36 },
37 "devDependencies": {
38 "@types/node": "^24.13.3",
39 "@types/react": "^19.2.18",
40 "@types/react-dom": "^19.2.4",
41 "@vitejs/plugin-react": "^6.1.0",
42 "oxlint": "^1.79.0",
43 "steiger": "^0.6.0",
44 "typescript": "~6.0.2",
45 "vite": "^8.2.2"
46 }
47}
No newline at end of file
Deleted web/README.md
@@ -1,69 +0,0 @@
1# ui
2
3Vite + React 19 + TypeScript + Tailwind v4 + Alova,项目结构遵循
4[Feature-Sliced Design v2.1](https://fsd.how)。
5
6## 命令
7
8```bash
9bun run dev # 启动开发服务器
10bun run build # tsc -b && vite build
11bun run lint # oxlint
12bun run lint:fsd # Steiger —— 官方 FSD 结构校验
13bun run gen:api # 依据 openapi.json 重新生成 src/shared/api
14```
15
16## 目录结构
17
18当前采用最小可用分层(`app` + `pages` + `shared`)。`features/`、`entities/` 只在
19「同一段代码被多处实际使用且边界稳定」时才创建,不要提前建空目录;
20`widgets/` 层不推荐采用。
21
22```text
23src/
24 app/ ← 应用初始化:入口、路由、全局样式、providers
25 index.tsx ← 入口(index.html 指向这里)
26 routes/router.tsx ← 路由表,唯一允许 import pages 的地方
27 shell/providers.tsx ← 全局 Provider 组合(Redux / QueryClient / theme…)
28 styles/index.css ← 全局样式(Tailwind 入口)
29 pages/ ← 路由级组合,页面自己拥有的逻辑都留在这里
30 home/
31 ui/home-page.tsx
32 index.ts ← 切片公共 API
33 not-found/
34 ui/not-found-page.tsx
35 index.ts
36 shared/ ← 无业务逻辑的基础设施,按 segment 划分、无 slice
37 ui/ ← UI kit(Button…),公共 API 在 ui/index.ts
38 api/ ← Alova 实例与 OpenAPI 生成代码(勿手改,见 gen:api)
39```
40
41## 约定
42
43- **导入方向**:`app → pages → widgets → features → entities → shared`,只能向下依赖,
44 同层 slice 之间禁止互相 import。
45- **公共 API**:外部只能通过 slice 的 `index.ts`(shared 则是每个 segment 的 `index.ts`)导入。
46
47 ```ts
48 import { Button } from '@/shared/ui' // ✅
49 import { Button } from '@/shared/ui/button' // ❌ 绕过公共 API
50 ```
51
52- **路径别名**:`@/app` `@/pages` `@/widgets` `@/features` `@/entities` `@/shared`
53 在 `tsconfig.app.json` 与 `vite.config.ts` 两处保持同步。
54- **非 auth 跳转一律走 react-router**:auth 流程之外的页面禁止 `<a href>` 与
55 `window.location` 整页跳转,只用 `Link` / `useNavigate`,保证 SPA 内最小化切换
56 (`scripts/lint-nav.mjs` 强制,已并入 `bun run lint`)。
57- **文件命名按业务域**,不用技术角色:`model/user.ts` 而不是 `model/types.ts`。
58- **shared 不含业务逻辑**;CRUD 与鉴权 token 属于 `shared/api` / `shared/auth`,
59 不要为此新建 `user` entity。
60- **Pages first**:拿不准放哪里就留在页面里,重复不是下沉的充分条件。
61
62新增页面:建 `pages/<name>/ui/<name>-page.tsx` + `index.ts`,再到
63`src/app/routes/router.tsx` 注册路由。
64
65## 注意(Windows + WSL)
66
67工作区通过 `\\wsl.localhost\...` 访问时,bun 无法回写 `bun.lock`,Windows 侧 node
68也解析不了部分包的入口(`vite build` 会报 plugin-react 入口错误)。
69安装依赖与运行 `dev` / `build` / `lint:fsd` 建议在 WSL 内执行。
Deleted web/skills-lock.json
@@ -1,29 +0,0 @@
1{
2 "version": 1,
3 "skills": {
4 "feature-sliced-design": {
5 "source": "feature-sliced/skills",
6 "sourceType": "github",
7 "skillPath": "feature-sliced-design/SKILL.md",
8 "computedHash": "f14c4d8f092ef8a6e9e0a2e5937a56884b9cdbd9b1f49c0618fad513d4a59dc8"
9 },
10 "vercel-composition-patterns": {
11 "source": "vercel-labs/agent-skills",
12 "sourceType": "github",
13 "skillPath": "skills/composition-patterns/SKILL.md",
14 "computedHash": "10eb4ec94577b79ae98c63ce3e7f35f8a6a06436a4011bff083b397de6960eeb"
15 },
16 "vercel-react-best-practices": {
17 "source": "vercel-labs/agent-skills",
18 "sourceType": "github",
19 "skillPath": "skills/react-best-practices/SKILL.md",
20 "computedHash": "6b526d013e28073246a36f99b529bc43745d30832ecfa8217b359c34f260ca6b"
21 },
22 "web-design-guidelines": {
23 "source": "vercel-labs/agent-skills",
24 "sourceType": "github",
25 "skillPath": "skills/web-design-guidelines/SKILL.md",
26 "computedHash": "d8e7d3afe37dcc8a97b99ffb5afdb4d0919ae0092ea8b68f44eb201f035e33ac"
27 }
28 }
29}
Modified Cargo.lock View Cargo.lock
@@ -8043,10 +8043,6 @@
80438043]
80448044
80458045[[package]]
8046name = "web"
8047version = "0.1.0"
8048
8049[[package]]
80508046name = "web-push-native"
80518047version = "0.5.0"
80528048source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -8108,7 +8104,6 @@
81088104 "user",
81098105 "utoipa",
81108106 "uuid",
8111 "web",
81128107]
81138108
81148109[[package]]
Modified Cargo.toml View Cargo.toml
@@ -1,8 +1,7 @@
11[workspace]
22members = [
33 "lib/*",
4 "app/*",
5 "web"
4 "app/*"
65]
76
87resolver = "3"
@@ -35,7 +34,6 @@
3534pulls = { path = "lib/pulls" }
3635social = { path = "lib/social" }
3736queue = { path = "lib/queue" }
38web = { path = "web" }
3937
4038
4139
Modified Dockerfile View Dockerfile
@@ -1,13 +1,34 @@
11# syntax=docker/dockerfile:1
22
3# Bun bundles the web SPA from inside web/build.rs. Take the binary from the
4# official image instead of running the network installer: the installer pulls
5# its archive from GitHub releases, which regularly times out here.
6FROM oven/bun:1-slim AS bun
3# ---------------------------------------------------------------------------
4# KenApi — GitDataAI 主业务后端(cargo workspace,二进制 apihost / migrate / openapi)
5#
6# docker build -t kenapi:local .
7# docker run --rm -p 3000:3000 \
8# -e GITDATA_DATABASE__URL=postgres://... kenapi:local
9#
10# 与仓库内其它镜像共用的约定:
11# * builder 只负责编译,runtime 只带二进制 + 运行期依赖;
12# * BuildKit cache mount 复用 cargo registry / git / target,重复构建不重编依赖;
13# * 非 root 运行(uid/gid 10001)、tini 作 PID 1、SIGTERM 优雅退出;
14# * 配置从 /app/config 读取,密钥只走环境变量或挂载文件,不烘进镜像层。
15# ---------------------------------------------------------------------------
16
17ARG RUST_IMAGE=rust:1-bookworm
18ARG RUNTIME_IMAGE=debian:bookworm-slim
19
20# ---------------------------------------------------------------------------
21# builder
22# ---------------------------------------------------------------------------
23FROM ${RUST_IMAGE} AS builder
724
8FROM rust:1-bookworm AS rust-deps
25# protoc:lib/kenauth 与 lib/kenshell 的 build.rs 都会编译 .proto。
26# libssl-dev / pkg-config:native-tls 链(reqwest 等处)需要 OpenSSL 头文件。
27ENV PROTOC_INCLUDE=/usr/include \
28 CARGO_TERM_COLOR=always
929
10RUN apt-get update && apt-get install -y --no-install-recommends \
30RUN apt-get update \
31 && apt-get install -y --no-install-recommends \
1132 pkg-config \
1233 libssl-dev \
1334 cmake \
@@ -17,52 +38,69 @@
1738 ca-certificates \
1839 unzip \
1940 protobuf-compiler \
41 libprotobuf-dev \
2042 && rm -rf /var/lib/apt/lists/*
2143
22COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
44WORKDIR /build
2345
24WORKDIR /build
46# 工具链由 rust-toolchain.toml 固定(nightly-2026-10-27);rustup 在这一层装好,
47# 后面所有 cargo 调用都用这个版本,不受基础镜像自带 stable 影响。
48COPY rust-toolchain.toml ./
49RUN rustup show > /dev/null
2550
2651COPY Cargo.toml Cargo.lock ./
27
28
29FROM rust-deps AS builder
3052COPY lib ./lib
3153COPY app ./app
32COPY web ./web
3354
34RUN --mount=type=cache,target=/usr/local/cargo/registry \
35 --mount=type=cache,target=/usr/local/cargo/git \
36 --mount=type=cache,target=/build/target \
37 rm -rf /build/target/release/build/web-* \
38 /build/target/release/deps/web-* 2>/dev/null; \
39 cargo build --release --workspace --bins && \
40 mkdir -p /out && \
41 for b in apihost migrate openapi; do \
42 cp "target/release/$b" /out/; \
43 done
55RUN --mount=type=cache,id=kenapi-cargo-registry,target=/usr/local/cargo/registry \
56 --mount=type=cache,id=kenapi-cargo-git,target=/usr/local/cargo/git \
57 --mount=type=cache,id=kenapi-target,target=/build/target \
58 cargo build --release --locked --workspace --bins \
59 && mkdir -p /out \
60 && cp target/release/apihost target/release/migrate target/release/openapi /out/ \
61 && strip /out/apihost /out/migrate /out/openapi
62
63# ---------------------------------------------------------------------------
64# runtime
65# ---------------------------------------------------------------------------
66FROM ${RUNTIME_IMAGE} AS runtime
4467
45########## runtime ##########
46FROM debian:bookworm-slim
68ARG APP_UID=10001
69ARG APP_GID=10001
4770
48# ca-certificates: outbound TLS; git: available for manual maintenance
49RUN apt-get update && apt-get install -y --no-install-recommends \
71# libssl3:native-tls 运行期依赖;git:仓库维护/排障用;
72# curl 只服务 HEALTHCHECK,tini 负责转发 SIGTERM 并回收子进程。
73RUN apt-get update \
74 && apt-get install -y --no-install-recommends \
5075 git \
5176 ca-certificates \
5277 libssl3 \
78 curl \
79 tini \
5380 && rm -rf /var/lib/apt/lists/* \
54 && useradd --system --create-home --uid 10001 gitdata \
81 && useradd --system --create-home --uid ${APP_UID} --user-group gitdata \
5582 && mkdir -p /opt/gitdata/repos /opt/gitdata/objects \
5683 && chown -R gitdata:gitdata /opt/gitdata
5784
5885WORKDIR /app
5986
87# 只带默认配置;config/local.toml 与各类密钥由部署方挂载或注入。
6088COPY --chown=gitdata:gitdata config/default.toml config/default.toml
6189COPY --from=builder /out/apihost /out/migrate /out/openapi /usr/local/bin/
6290
91LABEL org.opencontainers.image.title="KenApi" \
92 org.opencontainers.image.description="GitDataAI business API (apihost / migrate / openapi)" \
93 org.opencontainers.image.version="1.0.0"
94
95ENV RUST_BACKTRACE=0
96
6397USER gitdata
6498
65EXPOSE 3000
99EXPOSE 7051
100STOPSIGNAL SIGTERM
101
102HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
103 CMD curl -fsS "http://127.0.0.1:${KENAPI_HEALTH_PORT:-7051}/healthz" >/dev/null || exit 1
66104
105ENTRYPOINT ["/usr/bin/tini", "--"]
67106CMD ["apihost"]
68
Modified justfile View justfile
@@ -12,7 +12,7 @@
1212# CARGO_TARGET_DIR=/tmp/kenapi just up
1313# 日志 / pid / 本地 git 仓库仍固定在 target/dev,不随 CARGO_TARGET_DIR 搬家。
1414#
15# 依赖工具:cargo、bun、ssh-keygen;Windows 额外需要 WSL 的 Ubuntu 发行版,
15# 依赖工具:cargo、ssh-keygen;Windows 额外需要 WSL 的 Ubuntu 发行版,
1616# macOS/Linux 额外需要 docker + docker compose。
1717# 需要 just >= 1.21。
1818
@@ -48,7 +48,7 @@
4848migrate:
4949 @{{ invoke }}"{{ dev }}" migrate
5050
51# 构建全部后端二进制(web 前端由 build.rs 构建并内嵌)
51# 构建全部后端二进制
5252# 遵循 CARGO_TARGET_DIR;just up 从同一目录取二进制。
5353build:
5454 @cargo build --bins
@@ -57,7 +57,7 @@
5757build-release:
5858 @cargo build --release --bins
5959
60# 启动全部服务(后端 + web)
60# 启动全部服务(后端)
6161# 二进制目录 = $CARGO_TARGET_DIR/debug(未设置则 target/debug)。
6262up *svc='':
6363 @{{ invoke }}"{{ dev }}" up {{ svc }}
@@ -66,10 +66,6 @@
6666up-backend:
6767 @{{ invoke }}"{{ dev }}" up backends
6868
69# 仅启动前端 web(5173)
70up-frontend:
71 @{{ invoke }}"{{ dev }}" up frontends
72
7369# 停止全部服务,或指定:just down apihost
7470down *svc='':
7571 @{{ invoke }}"{{ dev }}" down {{ svc }}
@@ -78,7 +74,7 @@
7874restart *svc='':
7975 @{{ invoke }}"{{ dev }}" restart {{ svc }}
8076
81# 最近日志:just logs / just logs apihost / just logs "apihost web"
77# 最近日志:just logs / just logs apihost
8278logs *svc="apihost":
8379 @{{ invoke }}"{{ dev }}" logs {{ svc }}
8480
@@ -97,7 +93,6 @@
9793# URL 速查
9894urls:
9995 @echo " apihost http://127.0.0.1:4081 (Swagger: /api/docs)"
100 @echo " web http://127.0.0.1:5173"
10196
10297# 重新生成 openapi.json
10398openapi:
@@ -106,14 +101,6 @@
106101# 校验 openapi 是否与源码一致
107102openapi-check:
108103 @cargo run -p openapi -- --check
109
110# web 前端单独构建(cargo 构建时会自动执行)
111web-build:
112 @bun run --cwd web build
113
114# web 前端 lint(oxlint + FSD 导航守卫)
115web-lint:
116 @bun run --cwd web lint
117104
118105# cargo fmt --all
119106fmt: