From 0a53211389d988251ec71f3e4ad3efb1f3bb4890 Mon Sep 17 00:00:00 2001
From: aecsocket <43144841+aecsocket@users.noreply.github.com>
Date: Thu, 30 Jul 2026 05:40:52 +0100
Subject: [PATCH 01/82] fix: analytics event endpoint (#6938)
* fix: analytics event endpoint
* split admin-only routes into internal
* fix pr comment
---
.../src/pages/admin/analytics/events.vue | 8 +-
.../src/routes/internal/analytics_event.rs | 192 +++++++++++++++++
apps/labrinth/src/routes/internal/mod.rs | 17 +-
.../labrinth/src/routes/v3/analytics_event.rs | 193 +-----------------
apps/labrinth/src/routes/v3/mod.rs | 2 +
packages/api-client/src/modules/index.ts | 2 +
.../modules/labrinth/analytics/internal.ts | 51 +++++
.../src/modules/labrinth/analytics/v3.ts | 43 ----
.../api-client/src/modules/labrinth/index.ts | 1 +
.../api-client/src/modules/labrinth/types.ts | 18 +-
10 files changed, 276 insertions(+), 251 deletions(-)
create mode 100644 apps/labrinth/src/routes/internal/analytics_event.rs
create mode 100644 packages/api-client/src/modules/labrinth/analytics/internal.ts
diff --git a/apps/frontend/src/pages/admin/analytics/events.vue b/apps/frontend/src/pages/admin/analytics/events.vue
index bffebfaada..a471615ce9 100644
--- a/apps/frontend/src/pages/admin/analytics/events.vue
+++ b/apps/frontend/src/pages/admin/analytics/events.vue
@@ -485,9 +485,9 @@ async function saveEvent() {
const payload = buildEventPayload()
if (modalMode.value === 'edit' && editingEventId.value !== null) {
- await client.labrinth.analytics_v3.editEvent(editingEventId.value, payload)
+ await client.labrinth.analytics_internal.editEvent(editingEventId.value, payload)
} else {
- await client.labrinth.analytics_v3.createEvent(payload)
+ await client.labrinth.analytics_internal.createEvent(payload)
}
await queryClient.invalidateQueries({ queryKey: analyticsEventsQueryKey })
@@ -528,7 +528,7 @@ async function deleteEvent(eventId: Labrinth.Analytics.v3.AnalyticsEventId) {
setDeletingEvent(eventId, true)
try {
- await client.labrinth.analytics_v3.deleteEvent(eventId)
+ await client.labrinth.analytics_internal.deleteEvent(eventId)
await queryClient.invalidateQueries({ queryKey: analyticsEventsQueryKey })
addNotification({
title: 'Analytics event deleted',
@@ -581,7 +581,7 @@ function commitAnnouncementUrl() {
committedAnnouncementUrl.value = form.value.announcementUrl
}
-function buildEventPayload(): Labrinth.Analytics.v3.AnalyticsEventUpsert {
+function buildEventPayload(): Labrinth.Analytics.Internal.AnalyticsEventUpsert {
const selectedRange = getEventFormDateRange()
if (!selectedRange) {
throw new Error('Select a valid start and end date')
diff --git a/apps/labrinth/src/routes/internal/analytics_event.rs b/apps/labrinth/src/routes/internal/analytics_event.rs
new file mode 100644
index 0000000000..a743703da3
--- /dev/null
+++ b/apps/labrinth/src/routes/internal/analytics_event.rs
@@ -0,0 +1,192 @@
+use actix_web::{HttpRequest, delete, patch, post, web};
+use chrono::{DateTime, Utc};
+use eyre::eyre;
+use serde::{Deserialize, Serialize};
+use xredis::RedisPool;
+
+use crate::{
+ auth::get_user_from_headers,
+ database::{
+ PgPool,
+ models::{
+ DBAnalyticsEvent, DBAnalyticsEventId, generate_analytics_event_id,
+ },
+ },
+ models::{
+ ids::AnalyticsEventId,
+ pats::Scopes,
+ v3::analytics_event::{AnalyticsEvent, AnalyticsEventMeta},
+ },
+ queue::session::AuthQueue,
+ routes::ApiError,
+ util::error::Context,
+};
+
+pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
+ cfg.service(analytics_event_create)
+ .service(analytics_event_edit)
+ .service(analytics_event_delete);
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
+pub struct AnalyticsEventUpsert {
+ #[serde(flatten)]
+ pub meta: AnalyticsEventMeta,
+ pub starts: DateTime,
+ pub ends: DateTime,
+}
+
+/// Create an analytics event.
+#[utoipa::path(
+ context_path = "/analytics-event",
+ tag = "analytics events", responses((status = OK, body = AnalyticsEvent))
+)]
+#[post("")]
+pub async fn analytics_event_create(
+ req: HttpRequest,
+ event: web::Json,
+ pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+) -> Result, ApiError> {
+ let user = get_user_from_headers(
+ &req,
+ &**pool,
+ &redis,
+ &session_queue,
+ Scopes::empty(),
+ )
+ .await?
+ .1;
+
+ if !user.role.is_admin() {
+ return Err(ApiError::Auth(eyre!(
+ "you do not have permission to manage analytics events"
+ )));
+ }
+
+ let mut transaction = pool
+ .begin()
+ .await
+ .wrap_internal_err("failed to begin transaction")?;
+ let id = generate_analytics_event_id(&mut transaction)
+ .await
+ .wrap_internal_err("failed to generate analytics event ID")?;
+
+ let event = DBAnalyticsEvent {
+ id,
+ meta: event.meta.clone(),
+ starts: event.starts,
+ ends: event.ends,
+ };
+ event
+ .insert(&mut transaction)
+ .await
+ .wrap_internal_err("failed to insert analytics event")?;
+
+ transaction
+ .commit()
+ .await
+ .wrap_internal_err("failed to commit transaction")?;
+ DBAnalyticsEvent::clear_cache(&redis)
+ .await
+ .wrap_internal_err("failed to clear analytics event cache")?;
+
+ Ok(web::Json(event.into()))
+}
+
+/// Update an analytics event.
+#[utoipa::path(
+ context_path = "/analytics-event",
+ tag = "analytics events", responses((status = OK, body = AnalyticsEvent))
+)]
+#[patch("/{id}")]
+pub async fn analytics_event_edit(
+ req: HttpRequest,
+ id: web::Path<(AnalyticsEventId,)>,
+ event: web::Json,
+ pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+) -> Result, ApiError> {
+ let user = get_user_from_headers(
+ &req,
+ &**pool,
+ &redis,
+ &session_queue,
+ Scopes::empty(),
+ )
+ .await?
+ .1;
+
+ if !user.role.is_admin() {
+ return Err(ApiError::Auth(eyre!(
+ "you do not have permission to manage analytics events"
+ )));
+ }
+
+ let event = DBAnalyticsEvent {
+ id: DBAnalyticsEventId::from(id.into_inner().0),
+ meta: event.meta.clone(),
+ starts: event.starts,
+ ends: event.ends,
+ };
+
+ let updated = event
+ .update(&**pool)
+ .await
+ .wrap_internal_err("failed to update analytics event")?;
+ if !updated {
+ return Err(ApiError::NotFound);
+ }
+ DBAnalyticsEvent::clear_cache(&redis)
+ .await
+ .wrap_internal_err("failed to clear analytics event cache")?;
+
+ Ok(web::Json(event.into()))
+}
+
+/// Delete an analytics event.
+#[utoipa::path(
+ context_path = "/analytics-event",
+ tag = "analytics events", responses((status = NO_CONTENT))
+)]
+#[delete("/{id}")]
+pub async fn analytics_event_delete(
+ req: HttpRequest,
+ id: web::Path<(AnalyticsEventId,)>,
+ pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+) -> Result<(), ApiError> {
+ let user = get_user_from_headers(
+ &req,
+ &**pool,
+ &redis,
+ &session_queue,
+ Scopes::empty(),
+ )
+ .await?
+ .1;
+
+ if !user.role.is_admin() {
+ return Err(ApiError::Auth(eyre!(
+ "you do not have permission to manage analytics events"
+ )));
+ }
+
+ let deleted = DBAnalyticsEvent::remove(
+ DBAnalyticsEventId::from(id.into_inner().0),
+ &**pool,
+ )
+ .await
+ .wrap_internal_err("failed to delete analytics event")?;
+ if !deleted {
+ return Err(ApiError::NotFound);
+ }
+ DBAnalyticsEvent::clear_cache(&redis)
+ .await
+ .wrap_internal_err("failed to clear analytics event cache")?;
+
+ Ok(())
+}
diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs
index 272ac2de8b..1ad10523b1 100644
--- a/apps/labrinth/src/routes/internal/mod.rs
+++ b/apps/labrinth/src/routes/internal/mod.rs
@@ -1,5 +1,6 @@
pub mod admin;
pub mod affiliate;
+pub mod analytics_event;
pub mod attribution;
pub mod billing;
pub mod blocked_users;
@@ -35,6 +36,10 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.configure(flows::config)
.configure(pats::config)
.configure(oauth_clients::config)
+ .service(
+ web::scope("/analytics-event")
+ .configure(analytics_event::config),
+ )
.service(web::scope("/moderation").configure(moderation::config))
.service(web::scope("/affiliate").configure(affiliate::config))
.service(web::scope("/campaign").configure(campaign::config))
@@ -50,11 +55,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.configure(medal::config)
.configure(mural::config)
.configure(statuses::config),
- )
- .service(
- web::scope("/v3/analytics-event")
- .wrap(default_cors())
- .configure(super::v3::analytics_event::config),
);
}
@@ -180,10 +180,9 @@ pub fn config(cfg: &mut web::ServiceConfig) {
medal::redeem,
mural::get_bank_details,
statuses::ws_init,
- super::v3::analytics_event::analytics_events_get,
- super::v3::analytics_event::analytics_event_create,
- super::v3::analytics_event::analytics_event_edit,
- super::v3::analytics_event::analytics_event_delete,
+ analytics_event::analytics_event_create,
+ analytics_event::analytics_event_edit,
+ analytics_event::analytics_event_delete,
),
modifiers(&InternalPathModifier, &SecurityAddon)
)]
diff --git a/apps/labrinth/src/routes/v3/analytics_event.rs b/apps/labrinth/src/routes/v3/analytics_event.rs
index 9bbe2ef0a0..53142b411e 100644
--- a/apps/labrinth/src/routes/v3/analytics_event.rs
+++ b/apps/labrinth/src/routes/v3/analytics_event.rs
@@ -1,48 +1,22 @@
-use actix_web::{HttpRequest, delete, get, patch, post, web};
-use chrono::{DateTime, Utc};
-use eyre::eyre;
-use serde::{Deserialize, Serialize};
+use actix_web::{get, web};
use xredis::RedisPool;
use crate::{
- auth::get_user_from_headers,
- database::{
- PgPool,
- models::{
- DBAnalyticsEvent, DBAnalyticsEventId, generate_analytics_event_id,
- },
- },
- models::{
- ids::AnalyticsEventId,
- pats::Scopes,
- v3::analytics_event::{AnalyticsEvent, AnalyticsEventMeta},
- },
- queue::session::AuthQueue,
+ database::{PgPool, models::DBAnalyticsEvent},
+ models::v3::analytics_event::AnalyticsEvent,
routes::ApiError,
util::error::Context,
};
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
- cfg.service(analytics_events_get)
- .service(analytics_event_create)
- .service(analytics_event_edit)
- .service(analytics_event_delete);
+ cfg.service(analytics_events_get);
}
-#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
-pub struct AnalyticsEventUpsert {
- #[serde(flatten)]
- pub meta: AnalyticsEventMeta,
- pub starts: DateTime,
- pub ends: DateTime,
-}
-
-/// List analytics events.
+/// List analytics events.
#[utoipa::path(
- context_path = "/v3/analytics-event",
tag = "v3 analytics", responses((status = OK, body = Vec))
)]
-#[get("")]
+#[get("/analytics-event")]
pub async fn analytics_events_get(
pool: web::Data,
redis: web::Data,
@@ -56,158 +30,3 @@ pub async fn analytics_events_get(
Ok(web::Json(events))
}
-
-/// Create an analytics event.
-#[utoipa::path(
- context_path = "/v3/analytics-event",
- tag = "v3 analytics", responses((status = OK, body = AnalyticsEvent))
-)]
-#[post("")]
-pub async fn analytics_event_create(
- req: HttpRequest,
- event: web::Json,
- pool: web::Data,
- redis: web::Data,
- session_queue: web::Data,
-) -> Result, ApiError> {
- let user = get_user_from_headers(
- &req,
- &**pool,
- &redis,
- &session_queue,
- Scopes::empty(),
- )
- .await?
- .1;
-
- if !user.role.is_admin() {
- return Err(ApiError::Auth(eyre!(
- "you do not have permission to manage analytics events"
- )));
- }
-
- let mut transaction = pool
- .begin()
- .await
- .wrap_internal_err("failed to begin transaction")?;
- let id = generate_analytics_event_id(&mut transaction)
- .await
- .wrap_internal_err("failed to generate analytics event ID")?;
-
- let event = DBAnalyticsEvent {
- id,
- meta: event.meta.clone(),
- starts: event.starts,
- ends: event.ends,
- };
- event
- .insert(&mut transaction)
- .await
- .wrap_internal_err("failed to insert analytics event")?;
-
- transaction
- .commit()
- .await
- .wrap_internal_err("failed to commit transaction")?;
- DBAnalyticsEvent::clear_cache(&redis)
- .await
- .wrap_internal_err("failed to clear analytics event cache")?;
-
- Ok(web::Json(event.into()))
-}
-
-/// Update an analytics event.
-#[utoipa::path(
- context_path = "/v3/analytics-event",
- tag = "v3 analytics", responses((status = OK, body = AnalyticsEvent))
-)]
-#[patch("/{id}")]
-pub async fn analytics_event_edit(
- req: HttpRequest,
- id: web::Path<(AnalyticsEventId,)>,
- event: web::Json,
- pool: web::Data,
- redis: web::Data,
- session_queue: web::Data,
-) -> Result, ApiError> {
- let user = get_user_from_headers(
- &req,
- &**pool,
- &redis,
- &session_queue,
- Scopes::empty(),
- )
- .await?
- .1;
-
- if !user.role.is_admin() {
- return Err(ApiError::Auth(eyre!(
- "you do not have permission to manage analytics events"
- )));
- }
-
- let event = DBAnalyticsEvent {
- id: DBAnalyticsEventId::from(id.into_inner().0),
- meta: event.meta.clone(),
- starts: event.starts,
- ends: event.ends,
- };
-
- let updated = event
- .update(&**pool)
- .await
- .wrap_internal_err("failed to update analytics event")?;
- if !updated {
- return Err(ApiError::NotFound);
- }
- DBAnalyticsEvent::clear_cache(&redis)
- .await
- .wrap_internal_err("failed to clear analytics event cache")?;
-
- Ok(web::Json(event.into()))
-}
-
-/// Delete an analytics event.
-#[utoipa::path(
- context_path = "/v3/analytics-event",
- tag = "v3 analytics", responses((status = NO_CONTENT))
-)]
-#[delete("/{id}")]
-pub async fn analytics_event_delete(
- req: HttpRequest,
- id: web::Path<(AnalyticsEventId,)>,
- pool: web::Data,
- redis: web::Data,
- session_queue: web::Data,
-) -> Result<(), ApiError> {
- let user = get_user_from_headers(
- &req,
- &**pool,
- &redis,
- &session_queue,
- Scopes::empty(),
- )
- .await?
- .1;
-
- if !user.role.is_admin() {
- return Err(ApiError::Auth(eyre!(
- "you do not have permission to manage analytics events"
- )));
- }
-
- let deleted = DBAnalyticsEvent::remove(
- DBAnalyticsEventId::from(id.into_inner().0),
- &**pool,
- )
- .await
- .wrap_internal_err("failed to delete analytics event")?;
- if !deleted {
- return Err(ApiError::NotFound);
- }
- DBAnalyticsEvent::clear_cache(&redis)
- .await
- .wrap_internal_err("failed to clear analytics event cache")?;
-
- Ok(())
-}
diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs
index 919bffee4e..735daff22f 100644
--- a/apps/labrinth/src/routes/v3/mod.rs
+++ b/apps/labrinth/src/routes/v3/mod.rs
@@ -49,6 +49,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/v3")
.wrap(default_cors())
+ .configure(analytics_event::config)
.configure(limits::config)
.configure(collections::config)
.configure(images::config)
@@ -80,6 +81,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
description = include_str!("../../api_v3_description.md"),
),
paths(
+ analytics_event::analytics_events_get,
analytics_get::fetch_analytics,
analytics_get::facets::fetch_facets,
analytics_get::old::playtimes_get,
diff --git a/packages/api-client/src/modules/index.ts b/packages/api-client/src/modules/index.ts
index 9f8167bbc8..28b3c77e9a 100644
--- a/packages/api-client/src/modules/index.ts
+++ b/packages/api-client/src/modules/index.ts
@@ -19,6 +19,7 @@ import { KyrosLogsV1Module } from './kyros/logs/v1'
import { KyrosUploadSessionsV1Module } from './kyros/upload-sessions/v1'
import { LabrinthVersionsV2Module, LabrinthVersionsV3Module } from './labrinth'
import { LabrinthAffiliateInternalModule } from './labrinth/affiliate/internal'
+import { LabrinthAnalyticsInternalModule } from './labrinth/analytics/internal'
import { LabrinthAnalyticsV3Module } from './labrinth/analytics/v3'
import { LabrinthAttributionInternalModule } from './labrinth/attribution/internal'
import { LabrinthAuthInternalModule } from './labrinth/auth/internal'
@@ -97,6 +98,7 @@ export const MODULE_REGISTRY = {
kyros_logs_v1: KyrosLogsV1Module,
kyros_upload_sessions_v1: KyrosUploadSessionsV1Module,
labrinth_affiliate_internal: LabrinthAffiliateInternalModule,
+ labrinth_analytics_internal: LabrinthAnalyticsInternalModule,
labrinth_analytics_v3: LabrinthAnalyticsV3Module,
labrinth_auth_internal: LabrinthAuthInternalModule,
labrinth_auth_v2: LabrinthAuthV2Module,
diff --git a/packages/api-client/src/modules/labrinth/analytics/internal.ts b/packages/api-client/src/modules/labrinth/analytics/internal.ts
new file mode 100644
index 0000000000..b794327ef8
--- /dev/null
+++ b/packages/api-client/src/modules/labrinth/analytics/internal.ts
@@ -0,0 +1,51 @@
+import { AbstractModule } from '../../../core/abstract-module'
+import type { Labrinth } from '../types'
+
+export class LabrinthAnalyticsInternalModule extends AbstractModule {
+ public getModuleID(): string {
+ return 'labrinth_analytics_internal'
+ }
+
+ /**
+ * Create an analytics event.
+ * POST /_internal/analytics-event
+ */
+ public async createEvent(
+ data: Labrinth.Analytics.Internal.AnalyticsEventUpsert,
+ ): Promise {
+ return this.client.request('/analytics-event', {
+ api: 'labrinth',
+ version: 'internal',
+ method: 'POST',
+ body: data,
+ })
+ }
+
+ /**
+ * Edit an analytics event.
+ * PATCH /_internal/analytics-event/{id}
+ */
+ public async editEvent(
+ id: Labrinth.Analytics.v3.AnalyticsEventId,
+ data: Labrinth.Analytics.Internal.AnalyticsEventUpsert,
+ ): Promise {
+ return this.client.request(`/analytics-event/${id}`, {
+ api: 'labrinth',
+ version: 'internal',
+ method: 'PATCH',
+ body: data,
+ })
+ }
+
+ /**
+ * Delete an analytics event.
+ * DELETE /_internal/analytics-event/{id}
+ */
+ public async deleteEvent(id: Labrinth.Analytics.v3.AnalyticsEventId): Promise {
+ return this.client.request(`/analytics-event/${id}`, {
+ api: 'labrinth',
+ version: 'internal',
+ method: 'DELETE',
+ })
+ }
+}
diff --git a/packages/api-client/src/modules/labrinth/analytics/v3.ts b/packages/api-client/src/modules/labrinth/analytics/v3.ts
index ba93ca4ac8..6b7757e9cb 100644
--- a/packages/api-client/src/modules/labrinth/analytics/v3.ts
+++ b/packages/api-client/src/modules/labrinth/analytics/v3.ts
@@ -70,47 +70,4 @@ export class LabrinthAnalyticsV3Module extends AbstractModule {
method: 'GET',
})
}
-
- /**
- * Create an analytics event.
- * POST /v3/analytics-event
- */
- public async createEvent(
- data: Labrinth.Analytics.v3.AnalyticsEventUpsert,
- ): Promise {
- return this.client.request('/analytics-event', {
- api: 'labrinth',
- version: 3,
- method: 'POST',
- body: data,
- })
- }
-
- /**
- * Edit an analytics event.
- * PATCH /v3/analytics-event/{id}
- */
- public async editEvent(
- id: Labrinth.Analytics.v3.AnalyticsEventId,
- data: Labrinth.Analytics.v3.AnalyticsEventUpsert,
- ): Promise {
- return this.client.request(`/analytics-event/${id}`, {
- api: 'labrinth',
- version: 3,
- method: 'PATCH',
- body: data,
- })
- }
-
- /**
- * Delete an analytics event.
- * DELETE /v3/analytics-event/{id}
- */
- public async deleteEvent(id: Labrinth.Analytics.v3.AnalyticsEventId): Promise {
- return this.client.request(`/analytics-event/${id}`, {
- api: 'labrinth',
- version: 3,
- method: 'DELETE',
- })
- }
}
diff --git a/packages/api-client/src/modules/labrinth/index.ts b/packages/api-client/src/modules/labrinth/index.ts
index 38d23edd70..51cca73d32 100644
--- a/packages/api-client/src/modules/labrinth/index.ts
+++ b/packages/api-client/src/modules/labrinth/index.ts
@@ -1,3 +1,4 @@
+export * from './analytics/internal'
export * from './analytics/v3'
export * from './attribution/internal'
export * from './auth/internal'
diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts
index a5de5b8d8d..0e571a6291 100644
--- a/packages/api-client/src/modules/labrinth/types.ts
+++ b/packages/api-client/src/modules/labrinth/types.ts
@@ -463,6 +463,16 @@ export namespace Labrinth {
}
export namespace Analytics {
+ export namespace Internal {
+ export type AnalyticsEventUpsert = {
+ announcement_url: string | null
+ for_metric_kind: v3.AnalyticsEventMetricKind[] | null
+ title: string
+ ends: string
+ starts: string
+ }
+ }
+
export namespace v3 {
export type AnalyticsEventId = number
export type AnalyticsEventMetricKind = 'views' | 'revenue' | 'downloads' | 'playtime'
@@ -476,14 +486,6 @@ export namespace Labrinth {
starts: string
}
- export type AnalyticsEventUpsert = {
- announcement_url: string | null
- for_metric_kind: AnalyticsEventMetricKind[] | null
- title: string
- ends: string
- starts: string
- }
-
export type FetchRequest = {
time_range: TimeRange
return_metrics: ReturnMetrics
From 572b8027ffc9ceceb054791e96822400b9a0b693 Mon Sep 17 00:00:00 2001
From: "Michael H."
Date: Thu, 30 Jul 2026 19:23:10 +0200
Subject: [PATCH 02/82] fix: clickhouse migrations should not run normally
---
apps/labrinth/src/background_task.rs | 3 ++
apps/labrinth/src/clickhouse/mod.rs | 51 ++++++++++++++++------------
apps/labrinth/src/main.rs | 2 +-
apps/labrinth/src/test/mod.rs | 1 +
4 files changed, 34 insertions(+), 23 deletions(-)
diff --git a/apps/labrinth/src/background_task.rs b/apps/labrinth/src/background_task.rs
index 9a5150b3c5..3936356840 100644
--- a/apps/labrinth/src/background_task.rs
+++ b/apps/labrinth/src/background_task.rs
@@ -168,6 +168,9 @@ pub async fn update_bank_balances(pool: PgPool) -> eyre::Result<()> {
pub async fn run_migrations() -> eyre::Result<()> {
database::check_for_migrations().await?;
+ crate::clickhouse::run_migrations()
+ .await
+ .wrap_err("failed to run ClickHouse migrations")?;
Ok(())
}
diff --git a/apps/labrinth/src/clickhouse/mod.rs b/apps/labrinth/src/clickhouse/mod.rs
index d20353d6ef..e511bc0481 100644
--- a/apps/labrinth/src/clickhouse/mod.rs
+++ b/apps/labrinth/src/clickhouse/mod.rs
@@ -19,29 +19,36 @@ pub async fn init_client() -> clickhouse::error::Result {
pub async fn init_client_with_database(
database: &str,
) -> clickhouse::error::Result {
+ Ok(connect()?.with_database(database))
+}
+
+fn connect() -> clickhouse::error::Result {
+ let https_connector = HttpsConnectorBuilder::new()
+ .with_native_roots()?
+ .https_or_http()
+ .enable_all_versions()
+ .build();
+ let hyper_client =
+ hyper_util::client::legacy::Client::builder(TokioExecutor::new())
+ .build(https_connector);
+
+ Ok(clickhouse::Client::with_http_client(hyper_client)
+ .with_url(&ENV.CLICKHOUSE_URL)
+ .with_user(&ENV.CLICKHOUSE_USER)
+ .with_password(&ENV.CLICKHOUSE_PASSWORD)
+ .with_validation(false))
+}
+
+pub async fn run_migrations() -> clickhouse::error::Result<()> {
+ run_migrations_on_database(&ENV.CLICKHOUSE_DATABASE).await
+}
+
+pub async fn run_migrations_on_database(
+ database: &str,
+) -> clickhouse::error::Result<()> {
const MINECRAFT_JAVA_SERVER_PINGS: &str = server_ping::CLICKHOUSE_TABLE;
- let client = {
- let https_connector = HttpsConnectorBuilder::new()
- .with_native_roots()?
- .https_or_http()
- .enable_all_versions()
- .build();
- let hyper_client =
- hyper_util::client::legacy::Client::builder(TokioExecutor::new())
- .build(https_connector);
-
- clickhouse::Client::with_http_client(hyper_client)
- .with_url(&ENV.CLICKHOUSE_URL)
- .with_user(&ENV.CLICKHOUSE_USER)
- .with_password(&ENV.CLICKHOUSE_PASSWORD)
- .with_validation(false)
- };
-
- client
- .query(&format!("CREATE DATABASE IF NOT EXISTS {database}"))
- .execute()
- .await?;
+ let client = connect()?;
let clickhouse_replicated = ENV.CLICKHOUSE_REPLICATED;
let cluster_line = if clickhouse_replicated {
@@ -270,5 +277,5 @@ pub async fn init_client_with_database(
.execute()
.await?;
- Ok(client.with_database(database))
+ Ok(())
}
diff --git a/apps/labrinth/src/main.rs b/apps/labrinth/src/main.rs
index d788349117..b456b77a67 100644
--- a/apps/labrinth/src/main.rs
+++ b/apps/labrinth/src/main.rs
@@ -98,7 +98,7 @@ async fn app() -> std::io::Result<()> {
info!("Starting labrinth on {}", &ENV.BIND_ADDR);
if !args.no_migrations {
- database::check_for_migrations()
+ labrinth::background_task::run_migrations()
.await
.expect("An error occurred while running migrations.");
}
diff --git a/apps/labrinth/src/test/mod.rs b/apps/labrinth/src/test/mod.rs
index e81289e48d..7784392012 100644
--- a/apps/labrinth/src/test/mod.rs
+++ b/apps/labrinth/src/test/mod.rs
@@ -37,6 +37,7 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig {
let search_backend = db.search_backend.clone();
let file_host: Arc = Arc::new(file_hosting::MockHost::new());
let file_host = web::Data::::from(file_host);
+ clickhouse::run_migrations().await.unwrap();
let mut clickhouse = clickhouse::init_client().await.unwrap();
let stripe_client = stripe::Client::new(ENV.STRIPE_API_KEY.clone());
From c5ce5bc9b314b937d9e30ab65dba5be81ff3b4ec Mon Sep 17 00:00:00 2001
From: "Michael H."
Date: Thu, 30 Jul 2026 20:08:49 +0200
Subject: [PATCH 03/82] fix: create clickhouse db in tests only
---
apps/labrinth/src/clickhouse/mod.rs | 8 ++++++++
apps/labrinth/src/test/mod.rs | 3 +++
2 files changed, 11 insertions(+)
diff --git a/apps/labrinth/src/clickhouse/mod.rs b/apps/labrinth/src/clickhouse/mod.rs
index e511bc0481..1e3f92e73b 100644
--- a/apps/labrinth/src/clickhouse/mod.rs
+++ b/apps/labrinth/src/clickhouse/mod.rs
@@ -39,6 +39,14 @@ fn connect() -> clickhouse::error::Result {
.with_validation(false))
}
+#[cfg(feature = "test")]
+pub async fn create_database(database: &str) -> clickhouse::error::Result<()> {
+ connect()?
+ .query(&format!("CREATE DATABASE IF NOT EXISTS {database}"))
+ .execute()
+ .await
+}
+
pub async fn run_migrations() -> clickhouse::error::Result<()> {
run_migrations_on_database(&ENV.CLICKHOUSE_DATABASE).await
}
diff --git a/apps/labrinth/src/test/mod.rs b/apps/labrinth/src/test/mod.rs
index 7784392012..97fb991b87 100644
--- a/apps/labrinth/src/test/mod.rs
+++ b/apps/labrinth/src/test/mod.rs
@@ -37,6 +37,9 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig {
let search_backend = db.search_backend.clone();
let file_host: Arc = Arc::new(file_hosting::MockHost::new());
let file_host = web::Data::::from(file_host);
+ clickhouse::create_database(&ENV.CLICKHOUSE_DATABASE)
+ .await
+ .unwrap();
clickhouse::run_migrations().await.unwrap();
let mut clickhouse = clickhouse::init_client().await.unwrap();
From 7abe6f8c7318a41aa859f9f8b577d5d0d4d0e492 Mon Sep 17 00:00:00 2001
From: "Michael H."
Date: Thu, 30 Jul 2026 20:27:08 +0200
Subject: [PATCH 04/82] feat: sccache on windows
---
.github/workflows/theseus-build.yml | 2 --
1 file changed, 2 deletions(-)
diff --git a/.github/workflows/theseus-build.yml b/.github/workflows/theseus-build.yml
index abc1305f3d..6b83d5bfde 100644
--- a/.github/workflows/theseus-build.yml
+++ b/.github/workflows/theseus-build.yml
@@ -92,7 +92,6 @@ jobs:
run: corepack enable
- name: Set up caches
- if: contains(matrix.artifact-target-name, 'linux') || contains(matrix.artifact-target-name, 'darwin')
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
with:
cache: |
@@ -100,7 +99,6 @@ jobs:
pnpm
- name: Configure sccache
- if: contains(matrix.artifact-target-name, 'linux') || contains(matrix.artifact-target-name, 'darwin')
run: nsc cache sccache setup --cache_name default >> "$GITHUB_ENV"
- name: Generate tauri-dev.conf.json
From 295db3fb7ac113ba6b5a8622d2d325670193241e Mon Sep 17 00:00:00 2001
From: Prospector <6166773+Prospector@users.noreply.github.com>
Date: Thu, 30 Jul 2026 23:10:08 -0700
Subject: [PATCH 05/82] =?UTF-8?q?only=20refresh=20tokens=20and=20clear=20a?=
=?UTF-8?q?uth=20cookies=20on=20auth=20fails,=20not=20any=20ran=E2=80=A6?=
=?UTF-8?q?=20(#6947)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
only refresh tokens and clear auth cookies on auth fails, not any random error
---
apps/frontend/src/composables/auth.ts | 64 ++++++++++++++++++++-------
1 file changed, 48 insertions(+), 16 deletions(-)
diff --git a/apps/frontend/src/composables/auth.ts b/apps/frontend/src/composables/auth.ts
index 11f5f5c173..bffb9b72fc 100644
--- a/apps/frontend/src/composables/auth.ts
+++ b/apps/frontend/src/composables/auth.ts
@@ -30,6 +30,29 @@ const normalizeAuthToken = (value: unknown) => {
return ''
}
+const getErrorStatus = (error: unknown): number | undefined => {
+ if (!error || typeof error !== 'object') {
+ return undefined
+ }
+
+ const typedError = error as { statusCode?: unknown; status?: unknown }
+ const status = typedError.statusCode ?? typedError.status
+
+ return typeof status === 'number' ? status : undefined
+}
+
+// only when labrinth actually gives us an auth error
+const isAuthFailure = (error: unknown): boolean => {
+ const status = getErrorStatus(error)
+ return status === 401 || status === 403
+}
+
+const clearAuthCookie = (auth: AuthState, authCookie: { value: string | null }) => {
+ authCookie.value = null
+ auth.token = ''
+ auth.user = null
+}
+
const getQueryString = (value: QueryValue) => {
if (Array.isArray(value)) {
return value[0] ?? null
@@ -90,6 +113,7 @@ export const initAuth = async (oldToken: string | null | undefined = null) => {
}
const tokenStr = normalizeAuthToken(authCookie.value)
+ let shouldRefresh = false
if (authCookie.value != null && tokenStr === '') {
authCookie.value = null
@@ -111,12 +135,13 @@ export const initAuth = async (oldToken: string | null | undefined = null) => {
},
true,
)) as Labrinth.Users.v2.User
- } catch {
- /* empty */
+ } catch (error) {
+ // only refresh when the token was rejected. not on timeouts or other errors (think this was the cause of random logouts)
+ shouldRefresh = isAuthFailure(error)
}
}
- if (!auth.user && auth.token) {
+ if (!auth.user && auth.token && shouldRefresh) {
try {
const session = (await useBaseFetch(
'session/refresh',
@@ -132,22 +157,29 @@ export const initAuth = async (oldToken: string | null | undefined = null) => {
auth.token = normalizeAuthToken(session.session)
if (auth.token) {
authCookie.value = auth.token
- auth.user = (await useBaseFetch(
- 'user',
- {
- apiVersion: 3,
- headers: {
- Authorization: auth.token,
+ try {
+ auth.user = (await useBaseFetch(
+ 'user',
+ {
+ apiVersion: 3,
+ headers: {
+ Authorization: auth.token,
+ },
},
- },
- true,
- )) as Labrinth.Users.v2.User
+ true,
+ )) as Labrinth.Users.v2.User
+ } catch (error) {
+ if (isAuthFailure(error)) {
+ clearAuthCookie(auth, authCookie)
+ }
+ }
} else {
- authCookie.value = null
- auth.token = ''
+ clearAuthCookie(auth, authCookie)
+ }
+ } catch (error) {
+ if (isAuthFailure(error)) {
+ clearAuthCookie(auth, authCookie)
}
- } catch {
- authCookie.value = null
}
}
From 65a3ac4b34c14e44c9f351c40f92466714cdd1fe Mon Sep 17 00:00:00 2001
From: Prospector <6166773+Prospector@users.noreply.github.com>
Date: Thu, 30 Jul 2026 23:10:28 -0700
Subject: [PATCH 06/82] changelog
---
packages/blog/changelog.ts | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/packages/blog/changelog.ts b/packages/blog/changelog.ts
index bba5af2d91..979ffff830 100644
--- a/packages/blog/changelog.ts
+++ b/packages/blog/changelog.ts
@@ -10,6 +10,12 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
+ {
+ date: `2026-07-31T06:10:23+00:00`,
+ product: 'web',
+ body: `## Fixed
+- Fixed randomly getting signed out of Modrinth account due to random non-auth related errors.`,
+ },
{
date: `2026-07-29T21:32:06+00:00`,
product: 'app',
From 18b0b2848d688dfed2fee94a71ec8aabe5e4a824 Mon Sep 17 00:00:00 2001
From: "Michael H."
Date: Fri, 31 Jul 2026 13:10:58 +0200
Subject: [PATCH 07/82] feat: labrinth argo
---
.github/workflows/cmd-deploy.yml | 19 +++++
...labrinth-docker.yml => labrinth-build.yml} | 69 ++++++++-----------
.github/workflows/slash-cmds.yml | 18 +++++
apps/labrinth/Dockerfile | 2 +-
4 files changed, 67 insertions(+), 41 deletions(-)
create mode 100644 .github/workflows/cmd-deploy.yml
rename .github/workflows/{labrinth-docker.yml => labrinth-build.yml} (66%)
create mode 100644 .github/workflows/slash-cmds.yml
diff --git a/.github/workflows/cmd-deploy.yml b/.github/workflows/cmd-deploy.yml
new file mode 100644
index 0000000000..010359a2e2
--- /dev/null
+++ b/.github/workflows/cmd-deploy.yml
@@ -0,0 +1,19 @@
+name: Deploy Command
+run-name: Deploy PR #${{ github.event.client_payload.github.payload.issue.number }}
+
+on:
+ repository_dispatch:
+ types: [deploy-command]
+
+jobs:
+ deploy:
+ uses: SparkUniverse/workflows/.github/workflows/deploy-command.yaml@main
+ secrets:
+ ARGOCD_DEPLOY_KEY: ${{ secrets.ARGOCD_DEPLOY_KEY }}
+ CMD_DISPATCH_GH_TOKEN: ${{ secrets.SLASH_CMD_GH_TOKEN }}
+ with:
+ application-set: labrinth
+ build-workflow: labrinth-build.yml
+ branch: ${{ github.event.client_payload.pull_request.head.ref }}
+ issue-number: ${{ github.event.client_payload.github.payload.issue.number }}
+ head-sha: ${{ github.event.client_payload.pull_request.head.sha }}
diff --git a/.github/workflows/labrinth-docker.yml b/.github/workflows/labrinth-build.yml
similarity index 66%
rename from .github/workflows/labrinth-docker.yml
rename to .github/workflows/labrinth-build.yml
index ce8d8effae..5aba8298a3 100644
--- a/.github/workflows/labrinth-docker.yml
+++ b/.github/workflows/labrinth-build.yml
@@ -1,18 +1,18 @@
-name: docker-build
+name: Labrinth Build
on:
push:
branches:
- 'main'
paths:
- - .github/workflows/labrinth-docker.yml
+ - .github/workflows/labrinth-build.yml
- 'apps/labrinth/**'
- Cargo.toml
- Cargo.lock
pull_request:
types: [opened, synchronize]
paths:
- - .github/workflows/labrinth-docker.yml
+ - .github/workflows/labrinth-build.yml
- 'apps/labrinth/**'
- Cargo.toml
- Cargo.lock
@@ -69,7 +69,8 @@ jobs:
echo "skip=false" >> $GITHUB_OUTPUT
fi
- docker:
+ build:
+ name: Build Labrinth
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'namespace-profile-modrinth-labrinth' || 'ubuntu-latest' }}
needs: [skip-if-clean]
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
@@ -120,42 +121,30 @@ jobs:
cp -r apps/labrinth/migrations apps/labrinth/docker-stage/migrations
cp -r apps/labrinth/assets apps/labrinth/docker-stage/assets
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
-
- - name: Generate Docker image metadata
- id: docker-meta
- uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
- env:
- # GitHub Packages requires annotations metadata in at least the index descriptor to show them
- # up properly in its UI it seems, but it's not clear about it, because the docs refer to the
- # image manifest only. See:
- # https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#adding-a-description-to-multi-arch-images
- DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
+ - name: Upload Docker context
+ if: needs.skip-if-clean.outputs.internal == 'true'
+ uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3
with:
- images: ghcr.io/modrinth/labrinth
- labels: |
- org.opencontainers.image.title=labrinth
- org.opencontainers.image.description=Modrinth API
- org.opencontainers.image.licenses=AGPL-3.0-only
- annotations: |
- org.opencontainers.image.title=labrinth
- org.opencontainers.image.description=Modrinth API
- org.opencontainers.image.licenses=AGPL-3.0-only
+ name: labrinth-docker-context
+ retention-days: 1
+ path: apps/labrinth/docker-stage
- - name: Login to GitHub Packages
- uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
+ docker-build:
+ needs: [skip-if-clean, build]
+ if: ${{ needs.skip-if-clean.outputs.internal == 'true' }}
+ uses: SparkUniverse/workflows/.github/workflows/docker-build.yaml@main
+ with:
+ image-name: labrinth
+ dockerfile-path: apps/labrinth/Dockerfile
+ artifacts-name: labrinth-docker-context
- - name: Build and push
- uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
- with:
- context: ./apps/labrinth/docker-stage
- file: ./apps/labrinth/Dockerfile
- push: true
- tags: ${{ steps.docker-meta.outputs.tags }}
- labels: ${{ steps.docker-meta.outputs.labels }}
- annotations: ${{ steps.docker-meta.outputs.annotations }}
+ deploy:
+ needs: docker-build
+ if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/prod' }}
+ uses: SparkUniverse/workflows/.github/workflows/argo-update.yaml@main
+ secrets:
+ ARGOCD_DEPLOY_KEY: ${{ secrets.ARGOCD_DEPLOY_KEY }}
+ with:
+ application-set: labrinth
+ branch: ${{ github.ref == 'refs/heads/prod' && 'main' || 'develop' }}
+ environment-name: ${{ github.ref == 'refs/heads/prod' && 'production' || 'staging' }}
diff --git a/.github/workflows/slash-cmds.yml b/.github/workflows/slash-cmds.yml
new file mode 100644
index 0000000000..65a6d8df2e
--- /dev/null
+++ b/.github/workflows/slash-cmds.yml
@@ -0,0 +1,18 @@
+name: Slash Command Dispatch
+
+on:
+ issue_comment:
+ types: [created]
+
+jobs:
+ dispatch-command:
+ if: ${{ github.event.sender.type == 'User' && contains(fromJSON('["OWNER", "MEMBER"]'), github.event.comment.author_association) }}
+ runs-on: namespace-profile-tiny-arm64
+ steps:
+ - name: Slash Command Dispatch
+ uses: peter-evans/slash-command-dispatch@9bdcd7914ec1b75590b790b844aa3b8eee7c683a # v5.0.2
+ with:
+ token: ${{ secrets.SLASH_CMD_GH_TOKEN }}
+ issue-type: pull-request
+ commands: |
+ deploy
diff --git a/apps/labrinth/Dockerfile b/apps/labrinth/Dockerfile
index 328c4137d4..6d2923265c 100644
--- a/apps/labrinth/Dockerfile
+++ b/apps/labrinth/Dockerfile
@@ -11,7 +11,7 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates dumb-init curl \
&& rm -rf /var/lib/apt/lists/*
-COPY labrinth /labrinth/labrinth
+COPY --chmod=0755 labrinth /labrinth/labrinth
COPY migrations /labrinth/migrations
COPY assets /labrinth/assets
From 919b235621efce7278192c09f503f158ab19db43 Mon Sep 17 00:00:00 2001
From: "Michael H."
Date: Fri, 31 Jul 2026 13:17:26 +0200
Subject: [PATCH 08/82] chore: address action issues
---
.github/merge-queue-ci-skipper/action.yml | 32 +++++++++++++++--------
.github/workflows/cmd-deploy.yml | 4 +++
.github/workflows/slash-cmds.yml | 2 ++
3 files changed, 27 insertions(+), 11 deletions(-)
diff --git a/.github/merge-queue-ci-skipper/action.yml b/.github/merge-queue-ci-skipper/action.yml
index 3a2474a6dd..18783bb80e 100644
--- a/.github/merge-queue-ci-skipper/action.yml
+++ b/.github/merge-queue-ci-skipper/action.yml
@@ -34,13 +34,13 @@ runs:
using: 'composite'
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0
- name: Extract PR Number and Commit ID
id: extract-pr-info
- uses: actions/github-script@v7
+ uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
const githubRef = process.env.GITHUB_REF;
@@ -90,20 +90,26 @@ runs:
- name: Print PR Branch
if: env.CAN_SKIP_CHECKS != 'false'
shell: bash
+ env:
+ PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
run: |
- echo "PR Branch: ${{ steps.get-pr-branch.outputs.prBranch }}"
+ echo "PR Branch: $PR_BRANCH"
- name: Check if PR branch contains the Merge Queue target commit ID
if: env.CAN_SKIP_CHECKS != 'false'
shell: bash
+ env:
+ PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
+ COMMIT_ID: ${{ steps.extract-pr-info.outputs.commitId }}
+ TARGET_BRANCH: ${{ steps.extract-pr-info.outputs.targetBranchName }}
run: |
# Get the branch name from previous steps
- branch_name="origin/${{ steps.get-pr-branch.outputs.prBranch }}"
- commit_id="${{ steps.extract-pr-info.outputs.commitId }}"
+ branch_name="origin/$PR_BRANCH"
+ commit_id="$COMMIT_ID"
# Check if the branch history contains the commit
- if git branch -r --contains "$commit_id" | grep -q "$branch_name"; then
- echo "Branch '$branch_name' contains commit '$commit_id'. It is up to date with ${{ steps.extract-pr-info.outputs.targetBranchName }}."
+ if git branch -r --contains "$commit_id" | grep -qF "$branch_name"; then
+ echo "Branch '$branch_name' contains commit '$commit_id'. It is up to date with $TARGET_BRANCH."
else
echo "Branch '$branch_name' does not contain commit '$commit_id'. It is outdated. Setting CAN_SKIP_CHECKS to false."
echo "CAN_SKIP_CHECKS=false" >> "$GITHUB_ENV"
@@ -112,8 +118,10 @@ runs:
- name: Compare PR Branch with Current Branch
if: env.CAN_SKIP_CHECKS != 'false'
shell: bash
+ env:
+ PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
run: |
- if git diff --quiet "origin/${{ steps.get-pr-branch.outputs.prBranch }}"; then
+ if git diff --quiet "origin/$PR_BRANCH"; then
echo "No differences found. PR branch is identical with this merge queue branch."
else
echo "Differences detected. PR branch has been updated after PR was added to merge queue. Setting CAN_SKIP_CHECKS to false."
@@ -122,9 +130,11 @@ runs:
- name: Compute/publish skip result
id: passed-checks
- uses: actions/github-script@v7
+ uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
env:
SECRET: ${{ inputs.secret }}
+ PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
+ TARGET_BRANCH: ${{ steps.extract-pr-info.outputs.targetBranchName }}
with:
github-token: ${{ inputs.secret != '' && inputs.secret || github.token }}
script: |
@@ -144,7 +154,7 @@ runs:
const { data: branchProtection } = await github.rest.repos.getBranchProtection({
owner: context.repo.owner,
repo: context.repo.repo,
- branch: "${{ steps.extract-pr-info.outputs.targetBranchName }}",
+ branch: process.env.TARGET_BRANCH,
});
const requiredCheckNames = branchProtection.required_status_checks.contexts;
console.log(`requiredCheckNames = ${requiredCheckNames}`);
@@ -152,7 +162,7 @@ runs:
const { data: checks } = await github.rest.checks.listForRef({
owner: context.repo.owner,
repo: context.repo.repo,
- ref: "refs/heads/${{ steps.get-pr-branch.outputs.prBranch }}",
+ ref: `refs/heads/${process.env.PR_BRANCH}`,
});
console.log(`checks.check_runs = ${checks.check_runs.map(check => `${check.status},${check.conclusion},${check.name};`)}`);
diff --git a/.github/workflows/cmd-deploy.yml b/.github/workflows/cmd-deploy.yml
index 010359a2e2..bde79ac0f1 100644
--- a/.github/workflows/cmd-deploy.yml
+++ b/.github/workflows/cmd-deploy.yml
@@ -5,6 +5,10 @@ on:
repository_dispatch:
types: [deploy-command]
+permissions:
+ contents: read
+ actions: read
+
jobs:
deploy:
uses: SparkUniverse/workflows/.github/workflows/deploy-command.yaml@main
diff --git a/.github/workflows/slash-cmds.yml b/.github/workflows/slash-cmds.yml
index 65a6d8df2e..e35babeddf 100644
--- a/.github/workflows/slash-cmds.yml
+++ b/.github/workflows/slash-cmds.yml
@@ -4,6 +4,8 @@ on:
issue_comment:
types: [created]
+permissions: {}
+
jobs:
dispatch-command:
if: ${{ github.event.sender.type == 'User' && contains(fromJSON('["OWNER", "MEMBER"]'), github.event.comment.author_association) }}
From a53c9c3771bb44ce65e24515c5df890f97ac3963 Mon Sep 17 00:00:00 2001
From: "Michael H."
Date: Fri, 31 Jul 2026 13:21:35 +0200
Subject: [PATCH 09/82] fix: only deploy if internal
---
.github/workflows/cmd-deploy.yml | 1 +
.github/workflows/labrinth-build.yml | 4 ++--
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/cmd-deploy.yml b/.github/workflows/cmd-deploy.yml
index bde79ac0f1..182f2bd5aa 100644
--- a/.github/workflows/cmd-deploy.yml
+++ b/.github/workflows/cmd-deploy.yml
@@ -11,6 +11,7 @@ permissions:
jobs:
deploy:
+ if: ${{ github.event.client_payload.pull_request.head.repo.full_name == github.repository }}
uses: SparkUniverse/workflows/.github/workflows/deploy-command.yaml@main
secrets:
ARGOCD_DEPLOY_KEY: ${{ secrets.ARGOCD_DEPLOY_KEY }}
diff --git a/.github/workflows/labrinth-build.yml b/.github/workflows/labrinth-build.yml
index 5aba8298a3..966ec16f80 100644
--- a/.github/workflows/labrinth-build.yml
+++ b/.github/workflows/labrinth-build.yml
@@ -139,8 +139,8 @@ jobs:
artifacts-name: labrinth-docker-context
deploy:
- needs: docker-build
- if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/prod' }}
+ needs: [skip-if-clean, docker-build]
+ if: ${{ needs.skip-if-clean.outputs.internal == 'true' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/prod') }}
uses: SparkUniverse/workflows/.github/workflows/argo-update.yaml@main
secrets:
ARGOCD_DEPLOY_KEY: ${{ secrets.ARGOCD_DEPLOY_KEY }}
From 8b753a52ad5ca2a820bc4189e207728e405d7870 Mon Sep 17 00:00:00 2001
From: "Michael H."
Date: Fri, 31 Jul 2026 14:45:16 +0200
Subject: [PATCH 10/82] fix: build labrinth on prod again
---
.github/workflows/labrinth-build.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/labrinth-build.yml b/.github/workflows/labrinth-build.yml
index 966ec16f80..5fdc1ce080 100644
--- a/.github/workflows/labrinth-build.yml
+++ b/.github/workflows/labrinth-build.yml
@@ -4,6 +4,7 @@ on:
push:
branches:
- 'main'
+ - 'prod'
paths:
- .github/workflows/labrinth-build.yml
- 'apps/labrinth/**'
From 1e49d7da7a141e5d9e5790e0264003824b56705c Mon Sep 17 00:00:00 2001
From: ThatGravyBoat
Date: Sun, 2 Aug 2026 19:53:03 -0230
Subject: [PATCH 11/82] fix: missing auth providers in account details (#6965)
* fix: missing auth providers in account details
This was missing due to a bad merge in #6889 not merging in #6897 correctly
* chore: run intl:extract
---
.../layouts/shared/user-profile/layout.vue | 112 +++++++++++++++++-
packages/ui/src/locales/en-US/index.json | 15 +++
2 files changed, 125 insertions(+), 2 deletions(-)
diff --git a/packages/ui/src/layouts/shared/user-profile/layout.vue b/packages/ui/src/layouts/shared/user-profile/layout.vue
index be4f1644b9..9c3b008a6b 100644
--- a/packages/ui/src/layouts/shared/user-profile/layout.vue
+++ b/packages/ui/src/layouts/shared/user-profile/layout.vue
@@ -111,7 +111,33 @@
{{ formatMessage(messages.authProvidersLabel) }}
- {{ user.auth_providers?.join(', ') || '—' }}
+
+ -
+ {{ authProviderNames[provider] ?? provider }}
+
+ ({{ user.discord_id }})
+
+
+ (
+
+ )
+
+
+ ({{ user.steam_id }})
+
+
+
@@ -460,7 +486,13 @@ import ProjectCardList from '#ui/components/project/ProjectCardList.vue'
import UserBadges from '#ui/components/user/UserBadges.vue'
import UserPageHeader from '#ui/components/user/UserPageHeader.vue'
import { defineMessages, useVIntl } from '#ui/composables'
-import { injectAuth, injectNotificationManager, injectPageContext, injectTags } from '#ui/providers'
+import {
+ injectAuth,
+ injectModrinthClient,
+ injectNotificationManager,
+ injectPageContext,
+ injectTags,
+} from '#ui/providers'
import { commonMessages, getProjectTypeTitleMessage } from '#ui/utils'
import { blockedUsersQueryKey, injectUserProfile } from './providers'
@@ -520,6 +552,7 @@ const auth = injectAuth()
const tags = injectTags(null)
const pageContext = injectPageContext()
const notificationManager = injectNotificationManager()
+const client = injectModrinthClient()
const queryClient = useQueryClient()
const route = useRoute()
const router = useRouter()
@@ -567,6 +600,26 @@ const messages = defineMessages({
id: 'profile.details.label.auth-providers',
defaultMessage: 'Auth providers',
},
+ viewGithubProfileLabel: {
+ id: 'profile.details.label.view-github-profile',
+ defaultMessage: 'View profile',
+ },
+ loadingGithubProfileLabel: {
+ id: 'profile.details.label.loading-github-profile',
+ defaultMessage: 'Loading...',
+ },
+ githubProfileErrorTitle: {
+ id: 'profile.details.error.github-profile-title',
+ defaultMessage: 'Unable to open GitHub profile',
+ },
+ githubProfileErrorMessage: {
+ id: 'profile.details.error.github-profile-message',
+ defaultMessage: 'The GitHub profile could not be retrieved. Please try again.',
+ },
+ githubPopupBlockedMessage: {
+ id: 'profile.details.error.github-popup-blocked',
+ defaultMessage: 'Allow pop-ups for Modrinth, then try again.',
+ },
paymentMethodsLabel: {
id: 'profile.details.label.payment-methods',
defaultMessage: 'Payment methods',
@@ -853,6 +906,17 @@ const showCollectionsEmptyState = computed(
const normalizedSiteUrl = computed(() => props.siteUrl.replace(/\/$/, ''))
const editProfileLink = computed(() => props.editProfileLink ?? linkTarget('/settings/profile'))
+const authProviderNames = {
+ github: 'GitHub',
+ discord: 'Discord',
+ microsoft: 'Microsoft',
+ gitlab: 'GitLab',
+ google: 'Google',
+ steam: 'Steam',
+ paypal: 'PayPal',
+}
+const isLoadingGithubProfile = ref(false)
+
function externalUrl(path: string): string {
return `${normalizedSiteUrl.value}${path.startsWith('/') ? path : `/${path}`}`
}
@@ -896,6 +960,50 @@ async function copyPermalink(): Promise {
}
}
+async function openGithubProfile() {
+ const githubId = user.value?.github_id
+ if (!githubId || isLoadingGithubProfile.value) return
+
+ const profileWindow = window.open('about:blank', '_blank')
+ if (!profileWindow) {
+ notificationManager.addNotification({
+ type: 'error',
+ title: formatMessage(messages.githubProfileErrorTitle),
+ text: formatMessage(messages.githubPopupBlockedMessage),
+ })
+ return
+ }
+
+ profileWindow.opener = null
+ isLoadingGithubProfile.value = true
+
+ try {
+ const githubUser = await client.request<{ login?: string }>(`/${githubId}`, {
+ api: 'https://api.github.com',
+ version: 'user',
+ method: 'GET',
+ headers: { 'Content-Type': '' },
+ skipAuth: true,
+ })
+
+ if (!githubUser?.login) {
+ throw new Error('GitHub user response did not include a login')
+ }
+
+ profileWindow.location.replace(`https://github.com/${encodeURIComponent(githubUser.login)}`)
+ } catch (error) {
+ profileWindow.close()
+ console.error('Failed to retrieve GitHub profile:', error)
+ notificationManager.addNotification({
+ type: 'error',
+ title: formatMessage(messages.githubProfileErrorTitle),
+ text: formatMessage(messages.githubProfileErrorMessage),
+ })
+ } finally {
+ isLoadingGithubProfile.value = false
+ }
+}
+
function reportProfile(): void {
if (!user.value) return
const reportPath = `/report?item=user&itemID=${encodeURIComponent(user.value.id)}`
diff --git a/packages/ui/src/locales/en-US/index.json b/packages/ui/src/locales/en-US/index.json
index e1c3941001..f842c47c3d 100644
--- a/packages/ui/src/locales/en-US/index.json
+++ b/packages/ui/src/locales/en-US/index.json
@@ -2921,6 +2921,15 @@
"profile.collection.projects-count": {
"defaultMessage": "{count, plural, one {# project} other {# projects}}"
},
+ "profile.details.error.github-popup-blocked": {
+ "defaultMessage": "Allow pop-ups for Modrinth, then try again."
+ },
+ "profile.details.error.github-profile-message": {
+ "defaultMessage": "The GitHub profile could not be retrieved. Please try again."
+ },
+ "profile.details.error.github-profile-title": {
+ "defaultMessage": "Unable to open GitHub profile"
+ },
"profile.details.label.auth-providers": {
"defaultMessage": "Auth providers"
},
@@ -2933,9 +2942,15 @@
"profile.details.label.has-totp": {
"defaultMessage": "Has TOTP"
},
+ "profile.details.label.loading-github-profile": {
+ "defaultMessage": "Loading..."
+ },
"profile.details.label.payment-methods": {
"defaultMessage": "Payment methods"
},
+ "profile.details.label.view-github-profile": {
+ "defaultMessage": "View profile"
+ },
"profile.details.title": {
"defaultMessage": "User details"
},
From ae13d37edc2487fd0ebfd0f9c9aa096a63a99386 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?coco=20=F0=9F=90=BE?=
<44563370+cocoelacanth@users.noreply.github.com>
Date: Sun, 2 Aug 2026 16:49:19 -0700
Subject: [PATCH 12/82] docs: corrections to GetLatestVersionFromHash and
GetLatestVersionsFromHashes documentation (#6966)
correct version_files documentation
---
apps/docs/public/openapi.yaml | 16 ++++++++++++++--
1 file changed, 14 insertions(+), 2 deletions(-)
diff --git a/apps/docs/public/openapi.yaml b/apps/docs/public/openapi.yaml
index be4c40ede2..0e098712ea 100644
--- a/apps/docs/public/openapi.yaml
+++ b/apps/docs/public/openapi.yaml
@@ -563,12 +563,18 @@ components:
type: array
items:
type: string
- example: [fabric]
+ example: [fabric]
game_versions:
type: array
items:
type: string
example: ['1.18', 1.18.1]
+ version_types:
+ type: array
+ items:
+ type: string
+ enum: [release, alpha, beta]
+ example: [release]
required:
- loaders
- game_versions
@@ -612,6 +618,12 @@ components:
items:
type: string
example: ['1.18', 1.18.1]
+ version_types:
+ type: array
+ items:
+ type: string
+ enum: [release, alpha, beta]
+ example: [release]
required:
- loaders
- game_versions
@@ -2865,7 +2877,7 @@ paths:
$ref: '#/components/schemas/HashList'
/version_files/update:
post:
- summary: Latest versions of multiple project from hashes, loader(s), and game version(s)
+ summary: Latest versions of multiple projects from hashes, loader(s), and game version(s)
description: This is the same as [`/version_file/{hash}/update`](#operation/getLatestVersionFromHash) except it accepts multiple hashes.
operationId: getLatestVersionsFromHashes
tags:
From 779edf03337114da11a2521776bbf364f4f51848 Mon Sep 17 00:00:00 2001
From: Prospector <6166773+Prospector@users.noreply.github.com>
Date: Mon, 3 Aug 2026 00:00:54 -0700
Subject: [PATCH 13/82] Fix server ping race condition before protocol version
is available (#6935)
* fix race condition with server ping lookup before protocol resolved
* fix refresh button on worlds page
---
apps/app-frontend/src/helpers/worlds.ts | 16 ++++--
.../app-frontend/src/locales/en-US/index.json | 3 ++
.../src/pages/instance/Worlds.vue | 54 ++++++++++++++++---
3 files changed, 62 insertions(+), 11 deletions(-)
diff --git a/apps/app-frontend/src/helpers/worlds.ts b/apps/app-frontend/src/helpers/worlds.ts
index 3b5285f51b..5eb3385497 100644
--- a/apps/app-frontend/src/helpers/worlds.ts
+++ b/apps/app-frontend/src/helpers/worlds.ts
@@ -435,11 +435,12 @@ export async function refreshServerData(
}
}
-export function refreshServers(
+export async function refreshServers(
worlds: World[],
serverData: Record,
protocolVersion: ProtocolVersion | null,
-) {
+ ping = true,
+): Promise {
const servers = worlds.filter(isServerWorld)
servers.forEach((server) => {
if (!serverData[server.address]) {
@@ -451,9 +452,14 @@ export function refreshServers(
}
})
- // noinspection ES6MissingAwait - handled by refreshServerData
- Object.keys(serverData).forEach((address) =>
- refreshServerData(serverData[address], protocolVersion, address),
+ if (!ping) {
+ return
+ }
+
+ await Promise.all(
+ Object.keys(serverData).map((address) =>
+ refreshServerData(serverData[address], protocolVersion, address),
+ ),
)
}
diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json
index 91ff2a64ff..475e1027ca 100644
--- a/apps/app-frontend/src/locales/en-US/index.json
+++ b/apps/app-frontend/src/locales/en-US/index.json
@@ -590,6 +590,9 @@
"app.instance.worlds.no-worlds-heading": {
"message": "No servers or worlds added"
},
+ "app.instance.worlds.refreshing": {
+ "message": "Refreshing..."
+ },
"app.instance.worlds.remove-server-modal.remove-button": {
"message": "Remove server"
},
diff --git a/apps/app-frontend/src/pages/instance/Worlds.vue b/apps/app-frontend/src/pages/instance/Worlds.vue
index 1ddcb5e943..4a5bd6192d 100644
--- a/apps/app-frontend/src/pages/instance/Worlds.vue
+++ b/apps/app-frontend/src/pages/instance/Worlds.vue
@@ -75,7 +75,11 @@
@@ -242,6 +246,10 @@ const messages = defineMessages({
id: 'app.instance.worlds.filter-offline',
defaultMessage: 'Offline',
},
+ refreshingButton: {
+ id: 'app.instance.worlds.refreshing',
+ defaultMessage: 'Refreshing...',
+ },
})
const { formatMessage } = useVIntl()
@@ -328,7 +336,7 @@ const isLinux = platform() === 'linux'
const linuxRefreshCount = ref(0)
const protocolVersion = ref(null)
-
+const protocolVersionReady = ref(false)
const gameVersions = ref([])
const supportsServerQuickPlay = computed(() =>
hasServerQuickPlaySupport(gameVersions.value, instance.value.game_version),
@@ -342,8 +350,16 @@ watch(
(data) => {
if (data) {
worlds.value = [...data]
- refreshServers(worlds.value, serverData.value, protocolVersion.value)
hadNoWorlds.value = worlds.value.length === 0
+ // Manual refresh handles its own server pings to avoid double-pinging
+ if (!refreshingAll.value) {
+ void refreshServers(
+ worlds.value,
+ serverData.value,
+ protocolVersion.value,
+ protocolVersionReady.value,
+ )
+ }
}
},
{ immediate: true },
@@ -443,9 +459,14 @@ async function initWorldsTab() {
unlistenInstance = _unlistenInstance
protocolVersion.value = resolvedProtocolVersion
gameVersions.value = resolvedGameVersions
+ protocolVersionReady.value = true
+
+ if (worlds.value.length > 0) {
+ refreshServers(worlds.value, serverData.value, protocolVersion.value)
+ }
}
-await initWorldsTab()
+void initWorldsTab()
async function refreshServer(address: string) {
if (!serverData.value[address]) {
@@ -453,6 +474,7 @@ async function refreshServer(address: string) {
refreshing: true,
}
}
+ if (!protocolVersionReady.value) return
await refreshServerData(serverData.value[address], protocolVersion.value, address)
}
@@ -463,8 +485,28 @@ async function refreshAllWorlds() {
}
refreshingAll.value = true
- await queryClient.invalidateQueries({ queryKey: ['worlds', instance.value.id] })
- refreshingAll.value = false
+ try {
+ // Show loading on server rows immediately while the list refreshes
+ for (const world of worlds.value) {
+ if (world.type === 'server') {
+ if (!serverData.value[world.address]) {
+ serverData.value[world.address] = { refreshing: true }
+ } else {
+ serverData.value[world.address].refreshing = true
+ }
+ }
+ }
+
+ await queryClient.invalidateQueries({ queryKey: ['worlds', instance.value.id] })
+ await refreshServers(
+ worlds.value,
+ serverData.value,
+ protocolVersion.value,
+ protocolVersionReady.value,
+ )
+ } finally {
+ refreshingAll.value = false
+ }
}
async function addServer(server: ServerWorld) {
From 2e43f6a42bd10cbc31cc3ccc704e2b11674b4ef3 Mon Sep 17 00:00:00 2001
From: Prospector <6166773+Prospector@users.noreply.github.com>
Date: Mon, 3 Aug 2026 00:03:45 -0700
Subject: [PATCH 14/82] collapse offline friends by default, save collapse
value (#6937)
---
.../src/components/ui/friends/FriendsList.vue | 38 +++++++++++++++++--
.../components/ui/friends/FriendsSection.vue | 7 ++++
apps/app-frontend/src/store/theme.ts | 4 ++
packages/app-lib/src/state/settings.rs | 4 ++
4 files changed, 50 insertions(+), 3 deletions(-)
diff --git a/apps/app-frontend/src/components/ui/friends/FriendsList.vue b/apps/app-frontend/src/components/ui/friends/FriendsList.vue
index 71c8c1c8fe..1b954325ac 100644
--- a/apps/app-frontend/src/components/ui/friends/FriendsList.vue
+++ b/apps/app-frontend/src/components/ui/friends/FriendsList.vue
@@ -17,17 +17,40 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { useFriends } from '@/composables/use-friends'
import type { FriendWithUserData } from '@/helpers/friends.ts'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
+import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
+import { useTheming } from '@/store/state'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const formatRelativeTime = useRelativeTime()
+const themeStore = useTheming()
const props = defineProps<{
credentials: ModrinthCredentials | null
signIn: () => void
}>()
+type FriendsSectionCollapsedFlag =
+ | 'friends_active_collapsed'
+ | 'friends_online_collapsed'
+ | 'friends_offline_collapsed'
+ | 'friends_pending_collapsed'
+
+function isFriendsSectionCollapsed(flag: FriendsSectionCollapsedFlag) {
+ return themeStore.getFeatureFlag(flag)
+}
+
+function setFriendsSectionCollapsed(flag: FriendsSectionCollapsedFlag, collapsed: boolean) {
+ themeStore.featureFlags[flag] = collapsed
+ getSettings()
+ .then((settings) => {
+ settings.feature_flags[flag] = collapsed
+ return setSettings(settings)
+ })
+ .catch(handleError)
+}
+
const userCredentials = computed(() => props.credentials)
const {
friends: userFriends,
@@ -331,33 +354,42 @@ const messages = defineMessages({
{{ formatMessage(messages.noFriendsMatch, { query: search }) }}
diff --git a/apps/app-frontend/src/components/ui/friends/FriendsSection.vue b/apps/app-frontend/src/components/ui/friends/FriendsSection.vue
index f9efeb79e6..ec453e6c38 100644
--- a/apps/app-frontend/src/components/ui/friends/FriendsSection.vue
+++ b/apps/app-frontend/src/components/ui/friends/FriendsSection.vue
@@ -31,6 +31,11 @@ const props = withDefaults(
},
)
+const emit = defineEmits<{
+ onOpen: []
+ onClose: []
+}>()
+
function createContextMenuOptions(friend: FriendWithUserData) {
if (friend.accepted) {
return [
@@ -112,6 +117,8 @@ const messages = defineMessages({
? ''
: ' cursor-pointer hover:brightness-[--hover-brightness] active:scale-[0.98] transition-all')
"
+ @on-open="emit('onOpen')"
+ @on-close="emit('onClose')"
>
diff --git a/apps/app-frontend/src/store/theme.ts b/apps/app-frontend/src/store/theme.ts
index 4b61ebff78..00d47dc5f3 100644
--- a/apps/app-frontend/src/store/theme.ts
+++ b/apps/app-frontend/src/store/theme.ts
@@ -18,6 +18,10 @@ export const DEFAULT_FEATURE_FLAGS = {
advanced_filters_collapsed: true,
always_show_copy_details: false,
hide_installed_modpacks: false,
+ friends_active_collapsed: false,
+ friends_online_collapsed: false,
+ friends_offline_collapsed: true,
+ friends_pending_collapsed: true,
}
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'retro', 'system'] as const
diff --git a/packages/app-lib/src/state/settings.rs b/packages/app-lib/src/state/settings.rs
index 304cc43e4d..2e831754a5 100644
--- a/packages/app-lib/src/state/settings.rs
+++ b/packages/app-lib/src/state/settings.rs
@@ -66,6 +66,10 @@ pub enum FeatureFlag {
AdvancedFiltersCollapsed,
AlwaysShowCopyDetails,
HideInstalledModpacks,
+ FriendsActiveCollapsed,
+ FriendsOnlineCollapsed,
+ FriendsOfflineCollapsed,
+ FriendsPendingCollapsed,
}
impl Settings {
From c3249ee51db9d389e30d0b25df98971a64d24b37 Mon Sep 17 00:00:00 2001
From: aecsocket <43144841+aecsocket@users.noreply.github.com>
Date: Tue, 4 Aug 2026 02:14:07 +0900
Subject: [PATCH 15/82] feat: ElasticSearch backend (#6903)
* (do not merge) search test branch
* perf
* fix up search pagination
* more parity and perf work
* expand what parity does
* fix author query
* approach parity even without explicit parity enabled
* remove old parity code
* fix shear
* fix
* fmt
---
apps/labrinth/.env.docker-compose | 8 +-
apps/labrinth/.env.local | 4 +-
apps/labrinth/src/env.rs | 5 +
.../search/backend/elasticsearch/filter.rs | 390 ++++++
.../src/search/backend/elasticsearch/mod.rs | 1202 +++++++++++++++++
apps/labrinth/src/search/backend/mod.rs | 2 +
apps/labrinth/src/search/indexing.rs | 2 +-
apps/labrinth/src/search/mod.rs | 6 +
docker-compose.yml | 100 +-
9 files changed, 1711 insertions(+), 8 deletions(-)
create mode 100644 apps/labrinth/src/search/backend/elasticsearch/filter.rs
create mode 100644 apps/labrinth/src/search/backend/elasticsearch/mod.rs
diff --git a/apps/labrinth/.env.docker-compose b/apps/labrinth/.env.docker-compose
index 81bd6934a9..bff0a03574 100644
--- a/apps/labrinth/.env.docker-compose
+++ b/apps/labrinth/.env.docker-compose
@@ -17,14 +17,14 @@ DATABASE_URL=postgresql://labrinth:labrinth@labrinth-postgres/labrinth
DATABASE_MIN_CONNECTIONS=0
DATABASE_MAX_CONNECTIONS=16
-SEARCH_BACKEND=typesense
+SEARCH_BACKEND=elasticsearch
MEILISEARCH_READ_ADDR=http://localhost:7700
MEILISEARCH_WRITE_ADDRS=http://localhost:7700
MEILISEARCH_KEY=modrinth
-ELASTICSEARCH_URL=http://localhost:9200
+ELASTICSEARCH_URL=http://elasticsearch0:9200
ELASTICSEARCH_INDEX_PREFIX=labrinth
-ELASTICSEARCH_USERNAME=elastic
-ELASTICSEARCH_PASSWORD=elastic
+ELASTICSEARCH_USERNAME=
+ELASTICSEARCH_PASSWORD=
SEARCH_INDEX_CHUNK_SIZE=5000
SEARCH_INCREMENTAL_INDEX_BATCH_DELAY_SECONDS=5
SEARCH_INCREMENTAL_INDEX_BATCH_MAX_SIZE=1000
diff --git a/apps/labrinth/.env.local b/apps/labrinth/.env.local
index ddc5096de8..79220889d5 100644
--- a/apps/labrinth/.env.local
+++ b/apps/labrinth/.env.local
@@ -17,7 +17,7 @@ DATABASE_URL=postgresql://labrinth:labrinth@localhost/labrinth
DATABASE_MIN_CONNECTIONS=0
DATABASE_MAX_CONNECTIONS=16
-SEARCH_BACKEND=typesense
+SEARCH_BACKEND=elasticsearch
# Meilisearch configuration
MEILISEARCH_READ_ADDR=http://localhost:7700
@@ -32,7 +32,7 @@ ELASTICSEARCH_INDEX_PREFIX=labrinth
# MEILISEARCH_READ_ADDR=http://localhost:7710
# MEILISEARCH_WRITE_ADDRS=http://localhost:7700,http://localhost:7701
-SEARCH_BACKEND=typesense
+SEARCH_BACKEND=elasticsearch
MEILISEARCH_KEY=modrinth
MEILISEARCH_META_NAMESPACE=
diff --git a/apps/labrinth/src/env.rs b/apps/labrinth/src/env.rs
index 20ce98f546..88978d006a 100644
--- a/apps/labrinth/src/env.rs
+++ b/apps/labrinth/src/env.rs
@@ -237,6 +237,11 @@ vars! {
SEARCH_TYPESENSE_DEFAULT_BUCKETING: Json =
Json(crate::search::backend::typesense::Bucketing::Buckets(5));
SEARCH_TYPESENSE_DEFAULT_MAX_CANDIDATES: usize = 24usize;
+ ELASTICSEARCH_URL: String = "http://localhost:9200";
+ ELASTICSEARCH_INDEX_PREFIX: String = "labrinth";
+ ELASTICSEARCH_USERNAME: String = "";
+ ELASTICSEARCH_PASSWORD: String = "";
+ ELASTICSEARCH_BULK_BATCH_SIZE: usize = 1000usize;
// storage
STORAGE_BACKEND: crate::file_hosting::FileHostKind = crate::file_hosting::FileHostKind::Local;
diff --git a/apps/labrinth/src/search/backend/elasticsearch/filter.rs b/apps/labrinth/src/search/backend/elasticsearch/filter.rs
new file mode 100644
index 0000000000..06e0016740
--- /dev/null
+++ b/apps/labrinth/src/search/backend/elasticsearch/filter.rs
@@ -0,0 +1,390 @@
+use eyre::{Result, eyre};
+use serde_json::{Value, json};
+
+use crate::search::filter::{
+ FilterComparison, FilterCondition, FilterExpr, FilterLiteral,
+ FilterPredicate,
+};
+use crate::search::indexing::normalize_for_search;
+
+const MAX_DNF_CLAUSES: usize = 64;
+const MAX_FILTER_DEPTH: usize = 64;
+const MAX_FILTER_NODES: usize = 1024;
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum FilterScope {
+ Project,
+ Version,
+ Mixed,
+}
+
+pub(super) struct ElasticsearchFilter {
+ pub query: Value,
+ pub has_version_filter: bool,
+}
+
+pub(super) fn serialize_filter(
+ filter: &FilterExpr,
+) -> Result {
+ let (nodes, depth) = filter_complexity(filter);
+ if nodes > MAX_FILTER_NODES {
+ return Err(eyre!("search filter has too many expressions"));
+ }
+ if depth > MAX_FILTER_DEPTH {
+ return Err(eyre!("search filter is nested too deeply"));
+ }
+
+ let mut inner_hits_index = 0;
+ let query = plan(filter, &mut inner_hits_index)?;
+ Ok(ElasticsearchFilter {
+ query,
+ has_version_filter: inner_hits_index != 0,
+ })
+}
+
+fn plan(filter: &FilterExpr, inner_hits_index: &mut usize) -> Result {
+ match filter_scope(filter) {
+ FilterScope::Project => lower(filter),
+ FilterScope::Version => version_query(lower(filter)?, inner_hits_index),
+ FilterScope::Mixed => plan_mixed(filter, inner_hits_index),
+ }
+}
+
+fn plan_mixed(
+ filter: &FilterExpr,
+ inner_hits_index: &mut usize,
+) -> Result {
+ match filter {
+ FilterExpr::Or(expressions) => expressions
+ .iter()
+ .map(|expression| plan(expression, inner_hits_index))
+ .collect::>>()
+ .map(or_query),
+ FilterExpr::And(expressions)
+ if expressions.iter().all(|expression| {
+ filter_scope(expression) != FilterScope::Mixed
+ }) =>
+ {
+ plan_partitioned_and(expressions, inner_hits_index)
+ }
+ _ => {
+ let clauses = to_dnf(filter)?;
+ clauses
+ .into_iter()
+ .map(|clause| plan_clause(clause, inner_hits_index))
+ .collect::>>()
+ .map(or_query)
+ }
+ }
+}
+
+fn plan_partitioned_and(
+ expressions: &[FilterExpr],
+ inner_hits_index: &mut usize,
+) -> Result {
+ let mut project = Vec::new();
+ let mut version = Vec::new();
+ for expression in expressions {
+ match filter_scope(expression) {
+ FilterScope::Project => project.push(lower(expression)?),
+ FilterScope::Version => version.push(lower(expression)?),
+ FilterScope::Mixed => {
+ return Err(eyre!("could not partition mixed search filter"));
+ }
+ }
+ }
+ if !version.is_empty() {
+ project.push(version_query(and_query(version), inner_hits_index)?);
+ }
+ Ok(and_query(project))
+}
+
+fn plan_clause(
+ predicates: Vec<&FilterPredicate>,
+ inner_hits_index: &mut usize,
+) -> Result {
+ let mut project = Vec::new();
+ let mut version = Vec::new();
+ for predicate in predicates {
+ let query = predicate_query(predicate)?;
+ if is_version_filter_field(predicate.field.as_str()) {
+ version.push(query);
+ } else {
+ project.push(query);
+ }
+ }
+ if !version.is_empty() {
+ project.push(version_query(and_query(version), inner_hits_index)?);
+ }
+ Ok(and_query(project))
+}
+
+fn lower(filter: &FilterExpr) -> Result {
+ match filter {
+ FilterExpr::And(expressions) => expressions
+ .iter()
+ .map(lower)
+ .collect::>>()
+ .map(and_query),
+ FilterExpr::Or(expressions) => expressions
+ .iter()
+ .map(lower)
+ .collect::>>()
+ .map(or_query),
+ FilterExpr::Predicate(predicate) => predicate_query(predicate),
+ FilterExpr::Not(_) => {
+ Err(eyre!("search filter contains an unnormalized negation"))
+ }
+ }
+}
+
+fn predicate_query(predicate: &FilterPredicate) -> Result {
+ let source_field = predicate.field.as_str();
+ let field = exact_field(source_field);
+ match &predicate.condition {
+ FilterCondition::Compare { comparison, value } => {
+ let value = literal_value(source_field, value)?;
+ Ok(match comparison {
+ FilterComparison::Equal => {
+ json!({"term": {(field): {"value": value}}})
+ }
+ FilterComparison::NotEqual => not_query(json!({
+ "term": {(field): {"value": value}}
+ })),
+ FilterComparison::GreaterThan => {
+ json!({"range": {(field): {"gt": value}}})
+ }
+ FilterComparison::GreaterThanOrEqual => {
+ json!({"range": {(field): {"gte": value}}})
+ }
+ FilterComparison::LessThan => {
+ json!({"range": {(field): {"lt": value}}})
+ }
+ FilterComparison::LessThanOrEqual => {
+ json!({"range": {(field): {"lte": value}}})
+ }
+ })
+ }
+ FilterCondition::In { values, negated } => {
+ let values = values
+ .iter()
+ .map(|value| literal_value(source_field, value))
+ .collect::>>()?;
+ let query = json!({"terms": {(field): values}});
+ Ok(if *negated { not_query(query) } else { query })
+ }
+ FilterCondition::Exists { negated } => {
+ let query = json!({"exists": {"field": field}});
+ Ok(if *negated { not_query(query) } else { query })
+ }
+ }
+}
+
+fn literal_value(field: &str, literal: &FilterLiteral) -> Result {
+ match literal {
+ FilterLiteral::String(value) if field == "author" => {
+ Ok(Value::String(normalize_for_search(value)))
+ }
+ FilterLiteral::String(value) => Ok(Value::String(value.clone())),
+ FilterLiteral::Number(value) => serde_json::from_str(value)
+ .map_err(|error| eyre!("invalid numeric filter literal: {error}")),
+ FilterLiteral::Bool(value) => Ok(Value::Bool(*value)),
+ }
+}
+
+fn exact_field(field: &str) -> &str {
+ match field {
+ "name" => "name.keyword",
+ "author" => "indexed_author.keyword",
+ "summary" => "summary.keyword",
+ "slug" => "slug.keyword",
+ _ => field,
+ }
+}
+
+fn version_query(query: Value, inner_hits_index: &mut usize) -> Result {
+ let name = format!("matching_versions_{}", *inner_hits_index);
+ *inner_hits_index += 1;
+ Ok(json!({
+ "has_child": {
+ "type": "version",
+ "score_mode": "none",
+ "query": query,
+ "inner_hits": {
+ "name": name,
+ "size": 1,
+ "_source": ["version_id", "version_published_timestamp"],
+ "sort": [
+ {"version_published_timestamp": {"order": "desc"}},
+ {"version_id": {"order": "desc"}}
+ ]
+ }
+ }
+ }))
+}
+
+fn and_query(queries: Vec) -> Value {
+ match queries.len() {
+ 0 => json!({"match_all": {}}),
+ 1 => queries.into_iter().next().unwrap_or_default(),
+ _ => json!({"bool": {"filter": queries}}),
+ }
+}
+
+fn or_query(queries: Vec) -> Value {
+ match queries.len() {
+ 0 => json!({"match_none": {}}),
+ 1 => queries.into_iter().next().unwrap_or_default(),
+ _ => json!({
+ "bool": {
+ "should": queries,
+ "minimum_should_match": 1
+ }
+ }),
+ }
+}
+
+fn not_query(query: Value) -> Value {
+ json!({
+ "bool": {
+ "must": [{"match_all": {}}],
+ "must_not": [query]
+ }
+ })
+}
+
+fn filter_scope(filter: &FilterExpr) -> FilterScope {
+ match filter {
+ FilterExpr::Predicate(predicate) => {
+ if is_version_filter_field(predicate.field.as_str()) {
+ FilterScope::Version
+ } else {
+ FilterScope::Project
+ }
+ }
+ FilterExpr::And(expressions) | FilterExpr::Or(expressions) => {
+ let mut scopes = expressions.iter().map(filter_scope);
+ let Some(first) = scopes.next() else {
+ return FilterScope::Project;
+ };
+ if scopes.all(|scope| scope == first) {
+ first
+ } else {
+ FilterScope::Mixed
+ }
+ }
+ FilterExpr::Not(expression) => filter_scope(expression),
+ }
+}
+
+fn is_version_filter_field(field: &str) -> bool {
+ matches!(
+ field,
+ "categories"
+ | "project_types"
+ | "environment"
+ | "game_versions"
+ | "client_side"
+ | "server_side"
+ )
+}
+
+fn to_dnf(filter: &FilterExpr) -> Result>> {
+ match filter {
+ FilterExpr::Predicate(predicate) => Ok(vec![vec![predicate]]),
+ FilterExpr::Or(expressions) => {
+ let mut clauses = Vec::new();
+ for expression in expressions.iter() {
+ clauses.extend(to_dnf(expression)?);
+ if clauses.len() > MAX_DNF_CLAUSES {
+ return Err(eyre!(
+ "search filter has too many boolean clauses"
+ ));
+ }
+ }
+ Ok(clauses)
+ }
+ FilterExpr::And(expressions) => {
+ let mut clauses = vec![Vec::new()];
+ for expression in expressions.iter() {
+ let right = to_dnf(expression)?;
+ if clauses.len().saturating_mul(right.len()) > MAX_DNF_CLAUSES {
+ return Err(eyre!(
+ "search filter has too many boolean clauses"
+ ));
+ }
+ clauses = clauses
+ .into_iter()
+ .flat_map(|left| {
+ right.iter().map(move |right| {
+ let mut clause = left.clone();
+ clause.extend(right);
+ clause
+ })
+ })
+ .collect();
+ }
+ Ok(clauses)
+ }
+ FilterExpr::Not(_) => {
+ Err(eyre!("search filter contains an unnormalized negation"))
+ }
+ }
+}
+
+fn filter_complexity(filter: &FilterExpr) -> (usize, usize) {
+ match filter {
+ FilterExpr::Predicate(_) => (1, 1),
+ FilterExpr::And(expressions) | FilterExpr::Or(expressions) => {
+ expressions.iter().map(filter_complexity).fold(
+ (1, 1),
+ |(nodes, depth), (child_nodes, child_depth)| {
+ (nodes + child_nodes, depth.max(child_depth + 1))
+ },
+ )
+ }
+ FilterExpr::Not(expression) => {
+ let (nodes, depth) = filter_complexity(expression);
+ (nodes + 1, depth + 1)
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::serialize_filter;
+ use crate::search::filter::{normalize, parse_expression};
+ use serde_json::Value;
+
+ fn serialize(input: &str) -> Value {
+ let filter = normalize(parse_expression(input).unwrap());
+ serialize_filter(&filter).unwrap().query
+ }
+
+ #[test]
+ fn correlated_version_filters_use_one_join() {
+ let query = serialize("categories = fabric AND game_versions = 1.21");
+ assert_eq!(query.to_string().matches("has_child").count(), 1);
+ }
+
+ #[test]
+ fn project_filters_do_not_use_a_join() {
+ let query = serialize("license = MIT");
+ assert_eq!(query.to_string().matches("has_child").count(), 0);
+ assert_eq!(query["term"]["license"]["value"], "MIT");
+ }
+
+ #[test]
+ fn author_filters_use_the_normalized_exact_field() {
+ let query = serialize("author = User");
+ assert_eq!(query["term"]["indexed_author.keyword"]["value"], "user");
+ }
+
+ #[test]
+ fn mixed_boolean_filters_preserve_version_correlation() {
+ let query = serialize(
+ "(license = MIT OR categories = fabric) AND game_versions = 1.21",
+ );
+ assert_eq!(query.to_string().matches("has_child").count(), 2);
+ }
+}
diff --git a/apps/labrinth/src/search/backend/elasticsearch/mod.rs b/apps/labrinth/src/search/backend/elasticsearch/mod.rs
new file mode 100644
index 0000000000..7e41cd93f9
--- /dev/null
+++ b/apps/labrinth/src/search/backend/elasticsearch/mod.rs
@@ -0,0 +1,1202 @@
+//! Search implementation backed by an Elasticsearch cluster.
+//!
+//! Projects and versions share an index and use an Elasticsearch join field.
+//! This keeps version filters correlated without duplicating every version
+//! into its project document.
+
+use async_trait::async_trait;
+use eyre::{Result, eyre};
+use itertools::Itertools;
+use reqwest::{Method, Response, StatusCode};
+use serde::Serialize;
+use serde_json::{Map, Value, json};
+use tracing::{debug, info, warn};
+use xredis::RedisPool;
+
+use crate::database::PgPool;
+use crate::env::ENV;
+use crate::routes::ApiError;
+use crate::search::backend::{
+ SearchIndex, combined_search_filters, parse_search_index,
+ parse_search_request,
+};
+use crate::search::filter::{
+ FilterExpr, from_legacy_v2_facets_json, normalize, parse_expression,
+};
+use crate::search::indexing::index_local;
+use crate::search::{
+ ResultSearchProject, SearchBackend, SearchIndexUpdate, SearchRequest,
+ SearchResults, TasksCancelFilter, UploadSearchProject, UploadSearchVersion,
+};
+use crate::util::error::Context;
+
+use self::filter::{ElasticsearchFilter, serialize_filter};
+
+mod filter;
+
+const DELETE_FILTER_ID_BATCH_SIZE: usize = 1024;
+const MAX_RESULT_WINDOW: usize = 10_000;
+const MAX_CACHED_HITS: usize = 250;
+
+#[derive(Debug, Clone)]
+pub struct ElasticsearchConfig {
+ pub url: String,
+ pub username: String,
+ pub password: String,
+ pub index_prefix: String,
+ pub meta_namespace: String,
+ pub index_chunk_size: i64,
+ pub bulk_batch_size: usize,
+}
+
+impl ElasticsearchConfig {
+ pub fn new(meta_namespace: Option) -> Self {
+ Self {
+ url: ENV.ELASTICSEARCH_URL.clone(),
+ username: ENV.ELASTICSEARCH_USERNAME.clone(),
+ password: ENV.ELASTICSEARCH_PASSWORD.clone(),
+ index_prefix: ENV.ELASTICSEARCH_INDEX_PREFIX.clone(),
+ meta_namespace: meta_namespace.unwrap_or_default(),
+ index_chunk_size: ENV.SEARCH_INDEX_CHUNK_SIZE,
+ bulk_batch_size: ENV.ELASTICSEARCH_BULK_BATCH_SIZE,
+ }
+ }
+
+ fn alias_name(&self) -> String {
+ if self.meta_namespace.is_empty() {
+ format!("{}_projects", self.index_prefix)
+ } else {
+ format!("{}_{}_projects", self.meta_namespace, self.index_prefix)
+ }
+ }
+
+ fn next_index_name(&self, alias: &str, use_alt: bool) -> String {
+ if use_alt {
+ format!("{alias}__alt")
+ } else {
+ format!("{alias}__current")
+ }
+ }
+}
+
+struct ElasticsearchClient {
+ client: reqwest::Client,
+ base_url: String,
+ username: String,
+ password: String,
+}
+
+impl ElasticsearchClient {
+ fn new(config: &ElasticsearchConfig) -> Self {
+ Self {
+ client: reqwest::Client::new(),
+ base_url: config.url.trim_end_matches('/').to_string(),
+ username: config.username.clone(),
+ password: config.password.clone(),
+ }
+ }
+
+ fn request(&self, method: Method, path: &str) -> reqwest::RequestBuilder {
+ let request = self
+ .client
+ .request(method, format!("{}{}", self.base_url, path));
+ if self.username.is_empty() {
+ request
+ } else {
+ request.basic_auth(&self.username, Some(&self.password))
+ }
+ }
+
+ async fn get_alias_target(&self, alias: &str) -> Result
-
-
- {
- if (disabled) {
- event.preventDefault()
- return
- }
- if (action) {
- action(event)
- }
- }
- "
- >
-
-
-
-
- {
- if (disabled) {
- event.preventDefault()
- return
- }
- if (action) {
- action(event)
- }
- }
- "
- >
-
-
-
-
-
-
-
-
-
-
-
diff --git a/packages/ui/src/components/base/ButtonStyled.vue b/packages/ui/src/components/base/ButtonStyled.vue
deleted file mode 100644
index f546054c16..0000000000
--- a/packages/ui/src/components/base/ButtonStyled.vue
+++ /dev/null
@@ -1,397 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/packages/ui/src/components/base/Card.vue b/packages/ui/src/components/base/Card.vue
index f52f4533b0..c481f17eb0 100644
--- a/packages/ui/src/components/base/Card.vue
+++ b/packages/ui/src/components/base/Card.vue
@@ -2,7 +2,7 @@
import { DropdownIcon } from '@modrinth/assets'
import { reactive } from 'vue'
-import ButtonStyled from './ButtonStyled.vue'
+import { IconButton } from '#ui/components/base/buttons'
const props = defineProps({
collapsible: {
@@ -33,11 +33,9 @@ function toggleCollapsed() {
diff --git a/packages/ui/src/components/base/Chips.vue b/packages/ui/src/components/base/Chips.vue
index e1359c469e..e7daddc930 100644
--- a/packages/ui/src/components/base/Chips.vue
+++ b/packages/ui/src/components/base/Chips.vue
@@ -15,7 +15,11 @@
}"
@click="toggleItem(item)"
>
-
+
{{ formatLabel(item) }}
@@ -24,7 +28,7 @@
-
-
-
-
- {{ primaryAction.label }}
-
-
-
-
-
-
-
- {{ action.label }}
-
-
-
-
-
-
-
-
-
diff --git a/packages/ui/src/components/base/ManySelect.vue b/packages/ui/src/components/base/ManySelect.vue
index ed495af309..b3c927d039 100644
--- a/packages/ui/src/components/base/ManySelect.vue
+++ b/packages/ui/src/components/base/ManySelect.vue
@@ -1,23 +1,19 @@
-
- {
- searchQuery = ''
- }
- "
- >
+
+
-
+
+
+
{{ getOptionLabel(option) }}
{{ getOptionLabel(option) }}
-
-
-
+
+
+
-
-
diff --git a/packages/ui/src/components/base/Pagination.vue b/packages/ui/src/components/base/Pagination.vue
index ba1eae2cda..f7a6e08cb5 100644
--- a/packages/ui/src/components/base/Pagination.vue
+++ b/packages/ui/src/components/base/Pagination.vue
@@ -1,18 +1,20 @@
-
-
+
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/packages/ui/src/components/base/TimeFramePicker.vue b/packages/ui/src/components/base/TimeFramePicker.vue
index 0db65f9e14..2be4e72e73 100644
--- a/packages/ui/src/components/base/TimeFramePicker.vue
+++ b/packages/ui/src/components/base/TimeFramePicker.vue
@@ -5,6 +5,9 @@
:display-value="selectedTimeframeLabel"
:max-height="maxHeight"
:trigger-class="triggerClass"
+ :trigger-type="triggerType"
+ :trigger-size="triggerSize"
+ :trigger-interaction="triggerInteraction"
:dropdown-min-width="timeframeDropdownMinWidth"
:outside-click-ignore="timeframeDropdownOutsideClickIgnore"
:dropdown-class="
@@ -103,16 +106,18 @@
-
-
- {{ formatMessage(messages.cancel) }}
-
-
-
-
- {{ formatMessage(messages.apply) }}
-
-
+
+ {{ formatMessage(messages.cancel) }}
+
+
+ {{ formatMessage(messages.apply) }}
+
@@ -196,8 +201,14 @@
import { MinusIcon, PlusIcon } from '@modrinth/assets'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import {
+ Button,
+ type ButtonInteraction,
+ type ButtonSize,
+ type ButtonType,
+} from '#ui/components/base/buttons'
+
import { defineMessages, useVIntl } from '../../composables/i18n'
-import ButtonStyled from './ButtonStyled.vue'
import Combobox, { type ComboboxOption } from './Combobox.vue'
import DatePicker from './DatePicker.vue'
@@ -391,11 +402,17 @@ const props = withDefaults(
nowTimestamp?: number
maxHeight?: number
triggerClass?: string
+ triggerType?: ButtonType
+ triggerSize?: ButtonSize
+ triggerInteraction?: ButtonInteraction
dropdownMinWidth?: string | number
customRangeDropdownMinWidth?: string | number
}>(),
{
maxHeight: TIMEFRAME_DROPDOWN_MAX_HEIGHT,
+ triggerType: 'base',
+ triggerSize: 'md',
+ triggerInteraction: 'surface',
dropdownMinWidth: TIMEFRAME_DROPDOWN_MIN_WIDTH,
customRangeDropdownMinWidth: CUSTOM_RANGE_DROPDOWN_MIN_WIDTH,
},
diff --git a/packages/ui/src/components/base/UnsavedChangesPopup.vue b/packages/ui/src/components/base/UnsavedChangesPopup.vue
index 4606042c86..c997b965a5 100644
--- a/packages/ui/src/components/base/UnsavedChangesPopup.vue
+++ b/packages/ui/src/components/base/UnsavedChangesPopup.vue
@@ -3,9 +3,10 @@ import { HistoryIcon, SaveIcon, SpinnerIcon } from '@modrinth/assets'
import { isEqual } from 'es-toolkit'
import { type Component, computed, ref } from 'vue'
+import { Button } from '#ui/components/base/buttons'
+
import { defineMessage, type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { commonMessages } from '../../utils'
-import ButtonStyled from './ButtonStyled.vue'
import FloatingActionBar from './FloatingActionBar.vue'
const { formatMessage } = useVIntl()
@@ -62,18 +63,14 @@ defineExpose({ nudge })
{{ localizeIfPossible(text) }}
-
- emit('reset', e)">
- {{ formatMessage(commonMessages.resetButton) }}
-
-
-
- emit('save', e)">
-
-
- {{ localizeIfPossible(saving ? savingLabel : saveLabel) }}
-
-
+ emit('reset', e)">
+ {{ formatMessage(commonMessages.resetButton) }}
+
+ emit('save', e)">
+
+
+ {{ localizeIfPossible(saving ? savingLabel : saveLabel) }}
+
diff --git a/packages/ui/src/components/base/buttons/Button.vue b/packages/ui/src/components/base/buttons/Button.vue
new file mode 100644
index 0000000000..6d9f4e2b44
--- /dev/null
+++ b/packages/ui/src/components/base/buttons/Button.vue
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/base/buttons/ButtonFrame.vue b/packages/ui/src/components/base/buttons/ButtonFrame.vue
new file mode 100644
index 0000000000..81d0c09f8d
--- /dev/null
+++ b/packages/ui/src/components/base/buttons/ButtonFrame.vue
@@ -0,0 +1,165 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/base/buttons/ButtonGroup.vue b/packages/ui/src/components/base/buttons/ButtonGroup.vue
new file mode 100644
index 0000000000..04af5b1261
--- /dev/null
+++ b/packages/ui/src/components/base/buttons/ButtonGroup.vue
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/base/buttons/ButtonLink.vue b/packages/ui/src/components/base/buttons/ButtonLink.vue
new file mode 100644
index 0000000000..cb11c7c6c6
--- /dev/null
+++ b/packages/ui/src/components/base/buttons/ButtonLink.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/base/buttons/FileButton.vue b/packages/ui/src/components/base/buttons/FileButton.vue
new file mode 100644
index 0000000000..0c6c09b2ee
--- /dev/null
+++ b/packages/ui/src/components/base/buttons/FileButton.vue
@@ -0,0 +1,85 @@
+
+
+
+
+
+ {{ props.prompt }}
+
+
+
diff --git a/packages/ui/src/components/base/buttons/IconButton.vue b/packages/ui/src/components/base/buttons/IconButton.vue
new file mode 100644
index 0000000000..6fe601fb2b
--- /dev/null
+++ b/packages/ui/src/components/base/buttons/IconButton.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/base/buttons/SplitButton.vue b/packages/ui/src/components/base/buttons/SplitButton.vue
new file mode 100644
index 0000000000..40badebad8
--- /dev/null
+++ b/packages/ui/src/components/base/buttons/SplitButton.vue
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/base/buttons/TeleportOverflowMenu.vue b/packages/ui/src/components/base/buttons/TeleportOverflowMenu.vue
new file mode 100644
index 0000000000..f219a88910
--- /dev/null
+++ b/packages/ui/src/components/base/buttons/TeleportOverflowMenu.vue
@@ -0,0 +1,469 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/base/buttons/TeleportPopoutMenu.vue b/packages/ui/src/components/base/buttons/TeleportPopoutMenu.vue
new file mode 100644
index 0000000000..16574bfec9
--- /dev/null
+++ b/packages/ui/src/components/base/buttons/TeleportPopoutMenu.vue
@@ -0,0 +1,149 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/base/buttons/index.ts b/packages/ui/src/components/base/buttons/index.ts
new file mode 100644
index 0000000000..cbfc192984
--- /dev/null
+++ b/packages/ui/src/components/base/buttons/index.ts
@@ -0,0 +1,23 @@
+export { default as Button } from './Button.vue'
+export { default as ButtonGroup } from './ButtonGroup.vue'
+export { default as ButtonLink } from './ButtonLink.vue'
+export { default as FileButton } from './FileButton.vue'
+export { default as IconButton } from './IconButton.vue'
+export { default as SplitButton } from './SplitButton.vue'
+export { default as TeleportOverflowMenu } from './TeleportOverflowMenu.vue'
+export { default as TeleportPopoutMenu } from './TeleportPopoutMenu.vue'
+export type {
+ ButtonColor,
+ ButtonElementHandle,
+ ButtonInteraction,
+ ButtonLinkDestination,
+ ButtonNativeType,
+ ButtonSize,
+ ButtonType,
+ ButtonVisualProps,
+ OverflowMenuAction,
+ OverflowMenuDivider,
+ OverflowMenuLink,
+ OverflowMenuOption,
+ TeleportPlacement,
+} from './types'
diff --git a/packages/ui/src/components/base/buttons/types.ts b/packages/ui/src/components/base/buttons/types.ts
new file mode 100644
index 0000000000..f50eb35dcd
--- /dev/null
+++ b/packages/ui/src/components/base/buttons/types.ts
@@ -0,0 +1,105 @@
+import type { Component } from 'vue'
+import type { RouteLocationRaw } from 'vue-router'
+
+import type { AnchoredTeleportPlacement } from '../../../utils/use-anchored-teleport'
+
+export type ButtonType = 'base' | 'colored' | 'outlined' | 'quiet'
+
+export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
+
+export type ButtonInteraction = 'surface' | 'filled' | 'none'
+
+// TODO: Standardized color string enum props across @modrinth/ui
+export type ButtonColor =
+ | 'brand'
+ | 'red'
+ | 'orange'
+ | 'green'
+ | 'blue'
+ | 'purple'
+ | 'medal_promotion'
+
+export type ButtonVisualProps = {
+ size?: ButtonSize
+ interaction?: ButtonInteraction
+} & (
+ | {
+ type?: 'base'
+ color?: never
+ }
+ | {
+ type: 'outlined'
+ color?: ButtonColor
+ }
+ | {
+ type: 'colored'
+ color?: ButtonColor
+ }
+ | {
+ type: 'quiet'
+ color?: ButtonColor
+ }
+)
+
+export type ButtonNativeType = 'button' | 'submit' | 'reset'
+
+export interface ButtonProps {
+ type?: ButtonType
+ color?: ButtonColor
+ size?: ButtonSize
+ interaction?: ButtonInteraction
+ nativeType?: ButtonNativeType
+ disabled?: boolean
+ loading?: boolean
+}
+
+export type ButtonLinkDestination =
+ | {
+ to: RouteLocationRaw
+ href?: never
+ }
+ | {
+ href: string
+ to?: never
+ }
+
+export type TeleportPlacement = AnchoredTeleportPlacement
+
+export interface OverflowMenuItemBase {
+ id: string
+ label: string
+ icon?: Component
+ shown?: boolean
+ disabled?: boolean
+ tooltip?: string
+ remainOpen?: boolean
+ tone?: 'default' | ButtonColor
+ hoverFilled?: boolean
+ hoverFilledOnly?: boolean
+}
+
+export interface OverflowMenuAction extends OverflowMenuItemBase {
+ type?: 'action'
+ action: (event: MouseEvent) => void
+}
+
+export interface OverflowMenuLink extends OverflowMenuItemBase {
+ type: 'link'
+ to?: RouteLocationRaw
+ href?: string
+ target?: string
+ rel?: string
+ download?: string | boolean
+}
+
+export interface OverflowMenuDivider {
+ type: 'divider'
+ id?: string
+ shown?: boolean
+}
+
+export type OverflowMenuOption = OverflowMenuAction | OverflowMenuLink | OverflowMenuDivider
+
+export interface ButtonElementHandle {
+ element: HTMLElement | null
+}
diff --git a/packages/ui/src/components/base/index.ts b/packages/ui/src/components/base/index.ts
index 1373522c33..08b54ef241 100644
--- a/packages/ui/src/components/base/index.ts
+++ b/packages/ui/src/components/base/index.ts
@@ -9,8 +9,27 @@ export { default as Badge } from './Badge.vue'
export { default as BaseTerminal } from './BaseTerminal.vue'
export { default as BigOptionButton } from './BigOptionButton.vue'
export { default as BulletDivider } from './BulletDivider.vue'
-export { default as Button } from './Button.vue'
-export { default as ButtonStyled } from './ButtonStyled.vue'
+export { default as Button } from './buttons/Button.vue'
+export { default as ButtonGroup } from './buttons/ButtonGroup.vue'
+export { default as ButtonLink } from './buttons/ButtonLink.vue'
+export { default as FileButton } from './buttons/FileButton.vue'
+export { default as IconButton } from './buttons/IconButton.vue'
+export { default as SplitButton } from './buttons/SplitButton.vue'
+export { default as TeleportOverflowMenu } from './buttons/TeleportOverflowMenu.vue'
+export { default as TeleportPopoutMenu } from './buttons/TeleportPopoutMenu.vue'
+export type {
+ ButtonColor,
+ ButtonInteraction,
+ ButtonNativeType,
+ ButtonSize,
+ ButtonType,
+ ButtonVisualProps,
+ OverflowMenuAction,
+ OverflowMenuDivider,
+ OverflowMenuLink,
+ OverflowMenuOption,
+ TeleportPlacement,
+} from './buttons/types'
export { default as Card } from './Card.vue'
export { default as Checkbox } from './Checkbox.vue'
export { default as Chips } from './Chips.vue'
@@ -46,8 +65,6 @@ export { default as HorizontalRule } from './HorizontalRule.vue'
export { default as I18nDebugPanel } from './I18nDebugPanel.vue'
export { default as IconSelect } from './IconSelect.vue'
export { default as IntlFormatted } from './IntlFormatted.vue'
-export type { JoinedButtonAction } from './JoinedButtons.vue'
-export { default as JoinedButtons } from './JoinedButtons.vue'
export { default as LoadingBar } from './LoadingBar.vue'
export { default as LoadingIndicator } from './LoadingIndicator.vue'
export { default as ManySelect } from './ManySelect.vue'
@@ -62,8 +79,6 @@ export type { MaybeCtxFn, StageButtonConfig, StageConfigInput } from './MultiSta
export { default as MultiStageModal, resolveCtxFn } from './MultiStageModal.vue'
export { default as NavTabs } from './NavTabs.vue'
export { default as OptionGroup } from './OptionGroup.vue'
-export type { Option as OverflowMenuOption } from './OverflowMenu.vue'
-export { default as OverflowMenu } from './OverflowMenu.vue'
export { default as Page } from './Page.vue'
export { default as PageHeader } from './page-header/index.vue'
export { default as PageHeaderMetadata } from './page-header/metadata/index.vue'
@@ -106,11 +121,6 @@ export type { TabsTab, TabsValue } from './Tabs.vue'
export { default as Tabs } from './Tabs.vue'
export { default as TagItem } from './TagItem.vue'
export { default as TagTagItem } from './TagTagItem.vue'
-export type {
- Item as TeleportOverflowMenuItem,
- Option as TeleportOverflowMenuOption,
-} from './TeleportOverflowMenu.vue'
-export { default as TeleportOverflowMenu } from './TeleportOverflowMenu.vue'
export type {
TimeFrameLastUnit,
TimeFrameLastUnitOption,
diff --git a/packages/ui/src/components/billing/AddPaymentMethodModal.vue b/packages/ui/src/components/billing/AddPaymentMethodModal.vue
index 8e1361adb6..a05eaf4f20 100644
--- a/packages/ui/src/components/billing/AddPaymentMethodModal.vue
+++ b/packages/ui/src/components/billing/AddPaymentMethodModal.vue
@@ -3,9 +3,11 @@ import { PlusIcon, XIcon } from '@modrinth/assets'
import type Stripe from 'stripe'
import { nextTick, ref, useTemplateRef } from 'vue'
+import { Button } from '#ui/components/base/buttons'
+
import { defineMessages, useVIntl } from '../../composables/i18n'
import { commonMessages } from '../../utils'
-import { ButtonStyled, NewModal } from '../index'
+import { NewModal } from '../index'
import type { AddPaymentMethodProps } from './AddPaymentMethod.vue'
import AddPaymentMethod from './AddPaymentMethod.vue'
@@ -57,18 +59,14 @@ defineExpose({
@stop-loading="loading = false"
/>
diff --git a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue
index bdae9d75ed..14c1eacd8a 100644
--- a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue
+++ b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue
@@ -12,12 +12,12 @@ import { useQueryClient } from '@tanstack/vue-query'
import type Stripe from 'stripe'
import { computed, nextTick, onBeforeUnmount, ref, toRef, useTemplateRef, watch } from 'vue'
+import { Button } from '#ui/components/base/buttons'
import { injectNotificationManager } from '#ui/providers/web-notifications.ts'
import { defineMessage, type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { useStripe } from '../../composables/stripe'
import { commonMessages } from '../../utils'
-import { ButtonStyled } from '../index'
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
import NewModal from '../modal/NewModal.vue'
import PlanSelector from './ServersPurchase0Plan.vue'
@@ -555,51 +555,50 @@ function goToBreadcrumbStep(id: string) {
-
-
- {{ formatMessage(commonMessages.backButton) }}
-
-
-
- {{ formatMessage(commonMessages.cancelButton) }}
-
-
-
- {
- if (props.onFinalizeNoPaymentChange) {
- try {
- await props.onFinalizeNoPaymentChange()
- } catch (e) {
- return
- }
+
+ {{ formatMessage(commonMessages.backButton) }}
+
+
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+ {
+ if (props.onFinalizeNoPaymentChange) {
+ try {
+ await props.onFinalizeNoPaymentChange()
+ } catch (e) {
+ return
}
- modal?.hide()
- })()
- : setStep(nextStep)
- "
- >
-
- Confirm Change
-
-
-
- Subscribe
-
-
+ }
+ modal?.hide()
+ })()
+ : setStep(nextStep)
+ "
+ >
+
+ Confirm Change
- {{ formatMessage(commonMessages.nextButton) }}
+
+
+ Subscribe
-
-
+
+
+ {{ formatMessage(commonMessages.nextButton) }}
+
+
diff --git a/packages/ui/src/components/billing/ResubscribeModal.vue b/packages/ui/src/components/billing/ResubscribeModal.vue
index 298416af3b..084f3a0a35 100644
--- a/packages/ui/src/components/billing/ResubscribeModal.vue
+++ b/packages/ui/src/components/billing/ResubscribeModal.vue
@@ -78,18 +78,14 @@
-
-
-
- {{ formatMessage(messages.cancelButton) }}
-
-
-
-
-
- {{ formatMessage(messages.resubscribeButton) }}
-
-
+
+
+ {{ formatMessage(messages.cancelButton) }}
+
+
+
+ {{ formatMessage(messages.resubscribeButton) }}
+
@@ -100,12 +96,13 @@ import type { Labrinth } from '@modrinth/api-client'
import { RotateCounterClockwiseIcon, XIcon } from '@modrinth/assets'
import { computed, ref, useTemplateRef } from 'vue'
+import { Button } from '#ui/components/base/buttons'
import { injectNotificationManager } from '#ui/providers/web-notifications.ts'
import { useFormatDateTime, useFormatPrice } from '../../composables'
import { defineMessages, useVIntl } from '../../composables/i18n'
import IntlFormatted from '../base/IntlFormatted.vue'
-import { ButtonStyled, NewModal } from '../index'
+import { NewModal } from '../index'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
diff --git a/packages/ui/src/components/billing/ServersGuestPlanModal.vue b/packages/ui/src/components/billing/ServersGuestPlanModal.vue
index f99f2ecc52..f1322c88ac 100644
--- a/packages/ui/src/components/billing/ServersGuestPlanModal.vue
+++ b/packages/ui/src/components/billing/ServersGuestPlanModal.vue
@@ -3,7 +3,8 @@ import type { Labrinth } from '@modrinth/api-client'
import { ChevronRightIcon, ExternalIcon, XIcon } from '@modrinth/assets'
import { computed, ref, useTemplateRef } from 'vue'
-import ButtonStyled from '../base/ButtonStyled.vue'
+import { Button, IconButton } from '#ui/components/base/buttons'
+
import NewModal from '../modal/NewModal.vue'
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
import PlanSelector from './ServersPurchase0Plan.vue'
@@ -143,11 +144,9 @@ defineExpose({
class="absolute inset-x-0 bottom-0 -m-px z-30 rounded-2xl border border-solid border-surface-5 bg-bg-raised p-6 shadow-2xl"
>
-
-
-
-
-
+
+
+
@@ -155,12 +154,10 @@ defineExpose({
Sign in to continue your purchase
You need a Modrinth account to add your billing details.
-
-
- Sign in or create an account
-
-
-
+
+ Sign in or create an account
+
+
diff --git a/packages/ui/src/components/billing/ServersPurchase0Plan.vue b/packages/ui/src/components/billing/ServersPurchase0Plan.vue
index 456b324aea..03ae8aaf7b 100644
--- a/packages/ui/src/components/billing/ServersPurchase0Plan.vue
+++ b/packages/ui/src/components/billing/ServersPurchase0Plan.vue
@@ -3,10 +3,11 @@ import type { Labrinth } from '@modrinth/api-client'
import { RightArrowIcon } from '@modrinth/assets'
import { computed } from 'vue'
+import { Button } from '#ui/components/base/buttons'
+
import { useFormatPrice } from '../../composables'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils'
-import ButtonStyled from '../base/ButtonStyled.vue'
import OptionGroup from '../base/OptionGroup.vue'
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
import ServersSpecs from './ServersSpecs.vue'
@@ -218,19 +219,19 @@ function selectCustom() {
-
-
- {{
- existingPlan?.id === plansByRam.small.id
- ? formatMessage(messages.yourCurrentPlan)
- : formatMessage(messages.selectPlan)
- }}
-
-
+
+ {{
+ existingPlan?.id === plansByRam.small.id
+ ? formatMessage(messages.yourCurrentPlan)
+ : formatMessage(messages.selectPlan)
+ }}
+
-
-
- {{
- existingPlan?.id === plansByRam.medium.id
- ? formatMessage(messages.yourCurrentPlan)
- : formatMessage(messages.selectPlan)
- }}
-
-
+
+ {{
+ existingPlan?.id === plansByRam.medium.id
+ ? formatMessage(messages.yourCurrentPlan)
+ : formatMessage(messages.selectPlan)
+ }}
+
-
-
- {{
- existingPlan?.id === plansByRam.large.id
- ? formatMessage(messages.yourCurrentPlan)
- : formatMessage(messages.selectPlan)
- }}
-
-
+
+ {{
+ existingPlan?.id === plansByRam.large.id
+ ? formatMessage(messages.yourCurrentPlan)
+ : formatMessage(messages.selectPlan)
+ }}
+
-
-
- {{ formatMessage(messages.getStarted) }}
-
-
+
+ {{ formatMessage(messages.getStarted) }}
+
Starting at {{ formatPrice(customStartingPrice, currency, true) }}/mo
diff --git a/packages/ui/src/components/billing/ServersPurchase3Review.vue b/packages/ui/src/components/billing/ServersPurchase3Review.vue
index bfe6096c8d..ce76cbc1af 100644
--- a/packages/ui/src/components/billing/ServersPurchase3Review.vue
+++ b/packages/ui/src/components/billing/ServersPurchase3Review.vue
@@ -15,11 +15,12 @@ import dayjs from 'dayjs'
import type Stripe from 'stripe'
import { computed } from 'vue'
+import { Button } from '#ui/components/base/buttons'
+
import { useFormatPrice } from '../../composables'
import { useVIntl } from '../../composables/i18n'
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils'
import { regionOverrides } from '../../utils/regions'
-import ButtonStyled from '../base/ButtonStyled.vue'
import Checkbox from '../base/Checkbox.vue'
import TagItem from '../base/TagItem.vue'
import ModrinthServersIcon from '../servers/ModrinthServersIcon.vue'
@@ -323,12 +324,10 @@ function setInterval(newInterval: ServerBillingInterval) {
No payment method selected
-
-
- Change
- Select payment method
-
-
+
+ Change
+ Select payment method
+
diff --git a/packages/ui/src/components/chart/Chart.vue b/packages/ui/src/components/chart/Chart.vue
index 43c11e1fb5..72d42bc67a 100644
--- a/packages/ui/src/components/chart/Chart.vue
+++ b/packages/ui/src/components/chart/Chart.vue
@@ -4,7 +4,7 @@ import dayjs from 'dayjs'
import { defineAsyncComponent, onMounted, ref } from 'vue'
import { useFormatNumber } from '../../composables/index.ts'
-import Button from '../base/Button.vue'
+import { IconButton } from '../base/buttons'
import Checkbox from '../base/Checkbox.vue'
const VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
@@ -231,12 +231,16 @@ defineExpose({
diff --git a/packages/ui/src/components/external_files/AddFilesToAttributionGroupModal.vue b/packages/ui/src/components/external_files/AddFilesToAttributionGroupModal.vue
index fc94cf5af1..effcec44fa 100644
--- a/packages/ui/src/components/external_files/AddFilesToAttributionGroupModal.vue
+++ b/packages/ui/src/components/external_files/AddFilesToAttributionGroupModal.vue
@@ -3,7 +3,8 @@ import type { Labrinth } from '@modrinth/api-client'
import { CheckIcon, PlusIcon, SearchIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { computed, nextTick, ref } from 'vue'
-import { ButtonStyled, NewModal, StyledInput } from '#ui/components'
+import { NewModal, StyledInput } from '#ui/components'
+import { Button } from '#ui/components/base/buttons'
import { commonMessages } from '#ui/utils'
import { defineMessages, useVIntl } from '../../composables/i18n'
@@ -263,23 +264,21 @@ defineExpose({ show, hide })
{{ formatMessage(messages.addFilesModalSelectedCount, { count: selectedFileCount }) }}
-
-
-
- {{ formatMessage(commonMessages.cancelButton) }}
-
-
-
-
-
-
- {{ formatMessage(messages.addFilesModalConfirm, { count: selectedFileCount }) }}
-
-
+
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
+
+
+ {{ formatMessage(messages.addFilesModalConfirm, { count: selectedFileCount }) }}
+
diff --git a/packages/ui/src/components/external_files/AddToExistingExternalProjectModal.vue b/packages/ui/src/components/external_files/AddToExistingExternalProjectModal.vue
index 9ad61e3b1a..85ccadcae4 100644
--- a/packages/ui/src/components/external_files/AddToExistingExternalProjectModal.vue
+++ b/packages/ui/src/components/external_files/AddToExistingExternalProjectModal.vue
@@ -4,7 +4,8 @@ import { PlusIcon, SearchIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { useMutation } from '@tanstack/vue-query'
import { computed, ref, useTemplateRef } from 'vue'
-import { Accordion, ButtonStyled, NewModal, StyledInput } from '#ui/components'
+import { Accordion, NewModal, StyledInput } from '#ui/components'
+import { Button } from '#ui/components/base/buttons'
import { injectModrinthClient, injectNotificationManager } from '../../providers'
import AttributionGroupFilePicker from './AttributionGroupFilePicker.vue'
@@ -228,12 +229,15 @@ defineExpose({ show, hide })
wrapper-class="flex-1 min-w-[12rem]"
:disabled="addFilesMutation.isPending.value"
/>
-
-
-
- Search
-
-
+
+
+ Search
+
-
-
- {{ selectedProjectId === project.id ? 'Selected' : 'Select' }}
-
-
+
+ {{ selectedProjectId === project.id ? 'Selected' : 'Select' }}
+
@@ -306,22 +308,29 @@ defineExpose({ show, hide })
/>
-
-
-
- Cancel
-
-
-
-
-
-
- Add files to entry
-
-
+
+
+ Cancel
+
+
+
+
+ Add files to entry
+
diff --git a/packages/ui/src/components/external_files/AddToGlobalPermissionsDatabaseModal.vue b/packages/ui/src/components/external_files/AddToGlobalPermissionsDatabaseModal.vue
index c3a55d80d0..e36d1785d9 100644
--- a/packages/ui/src/components/external_files/AddToGlobalPermissionsDatabaseModal.vue
+++ b/packages/ui/src/components/external_files/AddToGlobalPermissionsDatabaseModal.vue
@@ -4,14 +4,8 @@ import { PlusIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { useMutation } from '@tanstack/vue-query'
import { computed, ref, useTemplateRef } from 'vue'
-import {
- Accordion,
- ButtonStyled,
- Combobox,
- type ComboboxOption,
- NewModal,
- StyledInput,
-} from '#ui/components'
+import { Accordion, Combobox, type ComboboxOption, NewModal, StyledInput } from '#ui/components'
+import { Button } from '#ui/components/base/buttons'
import { injectModrinthClient, injectNotificationManager } from '../../providers'
import AttributionGroupFilePicker from './AttributionGroupFilePicker.vue'
@@ -223,22 +217,26 @@ defineExpose({ show, hide })
-
-
-
- Cancel
-
-
-
-
-
-
- Add to global database
-
-
+
+
+ Cancel
+
+
+
+
+ Add to global database
+
diff --git a/packages/ui/src/components/external_files/AttributionEditor.vue b/packages/ui/src/components/external_files/AttributionEditor.vue
index 3694793a02..82e99ad7cb 100644
--- a/packages/ui/src/components/external_files/AttributionEditor.vue
+++ b/packages/ui/src/components/external_files/AttributionEditor.vue
@@ -14,8 +14,9 @@ import { builtinLicenses } from '@modrinth/utils'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue'
-import { ButtonStyled, Chips, Combobox, type ComboboxOption, StyledInput } from '#ui/components'
+import { Chips, Combobox, type ComboboxOption, StyledInput } from '#ui/components'
import { FileInput } from '#ui/components/base'
+import { Button, IconButton } from '#ui/components/base/buttons'
import { commonMessages } from '#ui/utils'
import { defineMessage, defineMessages, useVIntl } from '../../composables/i18n'
@@ -577,15 +578,14 @@ function cancelEditing() {
class="flex w-full object-contain bg-surface-3"
/>
-
-
-
-
-
+
+
+
@@ -633,29 +633,29 @@ function cancelEditing() {
-
-
- {{ formatMessage(commonMessages.cancelButton) }}
-
-
-
-
-
-
- {{ formatMessage(commonMessages.savingButton) }}
-
-
- {{ formatMessage(messages.saveAttribution) }}
-
- {{ formatMessage(messages.addAttribution) }}
-
-
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
+
+
+ {{ formatMessage(commonMessages.savingButton) }}
+
+
+ {{ formatMessage(messages.saveAttribution) }}
+
+ {{ formatMessage(messages.addAttribution) }}
+
diff --git a/packages/ui/src/components/external_files/ExternalProjectLookupCard.vue b/packages/ui/src/components/external_files/ExternalProjectLookupCard.vue
index 4bf6a34a92..2467b5d3cf 100644
--- a/packages/ui/src/components/external_files/ExternalProjectLookupCard.vue
+++ b/packages/ui/src/components/external_files/ExternalProjectLookupCard.vue
@@ -9,7 +9,8 @@ import {
import { Menu } from 'floating-vue'
import { computed } from 'vue'
-import { ButtonStyled, CopyCode } from '#ui/components'
+import { CopyCode } from '#ui/components'
+import { IconButton } from '#ui/components/base/buttons'
import ExternalProjectLicenseStateTag from './ExternalProjectLicenseStateTag.vue'
import type { ExternalLicenseStatus } from './types.ts'
@@ -58,11 +59,16 @@ async function copyProjectLink() {
Project link
-
-
-
-
-
+
+
+
diff --git a/packages/ui/src/components/external_files/ExternalProjectPermissionsCard.vue b/packages/ui/src/components/external_files/ExternalProjectPermissionsCard.vue
index bb26531235..9dc39af921 100644
--- a/packages/ui/src/components/external_files/ExternalProjectPermissionsCard.vue
+++ b/packages/ui/src/components/external_files/ExternalProjectPermissionsCard.vue
@@ -18,8 +18,9 @@ import { renderString } from '@modrinth/utils'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { computed, ref, useTemplateRef, watch } from 'vue'
-import { ButtonStyled, Collapsible, ConfirmModal, OverflowMenu } from '#ui/components'
+import { Collapsible, ConfirmModal } from '#ui/components'
import type { OverflowMenuOption } from '#ui/components/base'
+import { Button, IconButton, TeleportOverflowMenu } from '#ui/components/base/buttons'
import { commonMessages } from '#ui/utils'
import { defineMessage, defineMessages, useVIntl } from '../../composables/i18n'
@@ -498,6 +499,7 @@ const visibleQuickReplies = computed(() => {
(reply) =>
({
id: reply.label,
+ label: reply.label,
action: () => handleQuickReply(reply),
}) as OverflowMenuOption,
)
@@ -577,28 +579,27 @@ const visibleQuickReplies = computed(() => {
-
-
-
-
-
-
+
+
+
+
-
-
- {{ formatMessage(messages.addFilesToGroup) }}
-
-
+
+ {{ formatMessage(messages.addFilesToGroup) }}
+
@@ -644,11 +645,9 @@ const visibleQuickReplies = computed(() => {
"
#actions
>
-
-
- {{ formatMessage(commonMessages.editButton) }}
-
-
+
+ {{ formatMessage(commonMessages.editButton) }}
+
-
-
- Reply presets
-
-
-
-
-
-
-
- Approve
-
-
-
-
-
-
- Reject: Insufficient proof
-
-
-
-
-
-
- Reject: Not allowed
-
-
-
-
-
- {{ formatMessage(commonMessages.cancelButton) }}
-
-
+
+ Reply presets
+
+
+
+
+
+ Approve
+
+
+
+
+ Reject: Insufficient proof
+
+
+
+
+ Reject: Not allowed
+
+
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
-
-
- Add files to database...
-
-
-
-
- Add to existing entry...
-
-
+
+ Add files to database...
+
+
+ Add to existing entry...
+
@@ -808,12 +809,10 @@ const visibleQuickReplies = computed(() => {
"
class="ml-auto"
>
-
-
-
- {{ formatMessage(commonMessages.editButton) }}
-
-
+
+
+ {{ formatMessage(commonMessages.editButton) }}
+
@@ -835,16 +834,19 @@ const visibleQuickReplies = computed(() => {
/>
-
-
-
-
- {{ formatMessage(messages.removeGroup) }}
-
-
+
+
+
+ {{ formatMessage(messages.removeGroup) }}
+
diff --git a/packages/ui/src/components/flows/creation-flow-modal/components/CustomSetupStage.vue b/packages/ui/src/components/flows/creation-flow-modal/components/CustomSetupStage.vue
index 2d20d1a1a0..9899a8d370 100644
--- a/packages/ui/src/components/flows/creation-flow-modal/components/CustomSetupStage.vue
+++ b/packages/ui/src/components/flows/creation-flow-modal/components/CustomSetupStage.vue
@@ -4,18 +4,14 @@
-
-
-
- {{ formatMessage(messages.selectIcon) }}
-
-
-
-
-
- {{ formatMessage(messages.removeIcon) }}
-
-
+
+
+ {{ formatMessage(messages.selectIcon) }}
+
+
+
+ {{ formatMessage(messages.removeIcon) }}
+
@@ -153,11 +149,11 @@ import { EyeIcon, EyeOffIcon, UploadIcon, XIcon } from '@modrinth/assets'
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref, watch } from 'vue'
+import { Button } from '#ui/components/base/buttons'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { injectFilePicker, injectModrinthClient, injectTags } from '../../../../providers'
import Avatar from '../../../base/Avatar.vue'
-import ButtonStyled from '../../../base/ButtonStyled.vue'
import Chips from '../../../base/Chips.vue'
import Collapsible from '../../../base/Collapsible.vue'
import Combobox, { type ComboboxOption } from '../../../base/Combobox.vue'
diff --git a/packages/ui/src/components/flows/creation-flow-modal/components/ImportInstanceStage.vue b/packages/ui/src/components/flows/creation-flow-modal/components/ImportInstanceStage.vue
index 6351399bd3..3102c9bf78 100644
--- a/packages/ui/src/components/flows/creation-flow-modal/components/ImportInstanceStage.vue
+++ b/packages/ui/src/components/flows/creation-flow-modal/components/ImportInstanceStage.vue
@@ -5,13 +5,14 @@
{{
formatMessage(messages.launcherInstancesTitle)
}}
- {{ formatMessage(messages.clearAll) }}
- {{ formatMessage(messages.clearAll) }}
-
@@ -76,28 +77,22 @@
-
-
- {{ formatMessage(messages.addLauncherPath) }}
-
-
+
+ {{ formatMessage(messages.addLauncherPath) }}
+
-
-
-
-
-
+
+
+
-
-
- {{ formatMessage(messages.add) }}
-
-
+
+ {{ formatMessage(messages.add) }}
+
@@ -108,9 +103,10 @@ import { ChevronRightIcon, FolderSearchIcon, SearchIcon } from '@modrinth/assets
import { defineMessages, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref, watch } from 'vue'
+import { Button, IconButton } from '#ui/components/base/buttons'
+
import { injectInstanceImport, injectNotificationManager } from '../../../../providers'
import type { ImportableLauncher } from '../../../../providers/instance-import'
-import ButtonStyled from '../../../base/ButtonStyled.vue'
import Checkbox from '../../../base/Checkbox.vue'
import Collapsible from '../../../base/Collapsible.vue'
import StyledInput from '../../../base/StyledInput.vue'
diff --git a/packages/ui/src/components/flows/creation-flow-modal/components/ModpackStage.vue b/packages/ui/src/components/flows/creation-flow-modal/components/ModpackStage.vue
index e0860dd303..8fc5605aa3 100644
--- a/packages/ui/src/components/flows/creation-flow-modal/components/ModpackStage.vue
+++ b/packages/ui/src/components/flows/creation-flow-modal/components/ModpackStage.vue
@@ -30,28 +30,27 @@
-
-
-
- {{ formatMessage(messages.importModpack) }}
-
-
-
-
-
- {{ formatMessage(messages.browseModpacks) }}
-
-
+
+
+ {{ formatMessage(messages.importModpack) }}
+
+
+
+ {{ formatMessage(messages.browseModpacks) }}
+
@@ -61,10 +60,10 @@ import { CompassIcon, ImportIcon, RightArrowIcon } from '@modrinth/assets'
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { defineAsyncComponent, h, onMounted, ref, watch } from 'vue'
+import { Button } from '#ui/components/base/buttons'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { injectFilePicker } from '../../../../providers'
-import ButtonStyled from '../../../base/ButtonStyled.vue'
import Combobox from '../../../base/Combobox.vue'
import { injectCreationFlowContext } from '../creation-flow-context'
diff --git a/packages/ui/src/components/modal/ConfirmLeaveModal.vue b/packages/ui/src/components/modal/ConfirmLeaveModal.vue
index cba7b8468c..b49b7c0665 100644
--- a/packages/ui/src/components/modal/ConfirmLeaveModal.vue
+++ b/packages/ui/src/components/modal/ConfirmLeaveModal.vue
@@ -8,18 +8,14 @@
-
-
-
- {{ localizeIfPossible(stayLabel) }}
-
-
-
-
-
- {{ localizeIfPossible(leaveLabel) }}
-
-
+
+
+ {{ localizeIfPossible(stayLabel) }}
+
+
+
+ {{ localizeIfPossible(leaveLabel) }}
+
@@ -30,7 +26,7 @@ import { RightArrowIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
-import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
+import { Button } from '#ui/components/base/buttons'
import { defineMessage, type MessageDescriptor, useVIntl } from '#ui/composables/i18n'
import NewModal from './NewModal.vue'
diff --git a/packages/ui/src/components/modal/ConfirmModal.vue b/packages/ui/src/components/modal/ConfirmModal.vue
index 87a091324e..157b909d20 100644
--- a/packages/ui/src/components/modal/ConfirmModal.vue
+++ b/packages/ui/src/components/modal/ConfirmModal.vue
@@ -31,18 +31,19 @@
wrapper-class="max-w-[20rem]"
/>
-
-
-
- Cancel
-
-
-
-
-
- {{ proceedLabel }}
-
-
+
+
+ Cancel
+
+
+
+ {{ proceedLabel }}
+
@@ -53,7 +54,8 @@ import { TrashIcon, XIcon } from '@modrinth/assets'
import { renderString } from '@modrinth/utils'
import { computed, ref } from 'vue'
-import ButtonStyled from '../base/ButtonStyled.vue'
+import { Button } from '#ui/components/base/buttons'
+
import StyledInput from '../base/StyledInput.vue'
import NewModal from './NewModal.vue'
diff --git a/packages/ui/src/components/modal/NewModal.vue b/packages/ui/src/components/modal/NewModal.vue
index 803b831078..c5cb846bcd 100644
--- a/packages/ui/src/components/modal/NewModal.vue
+++ b/packages/ui/src/components/modal/NewModal.vue
@@ -43,33 +43,28 @@
-
-
-
-
-
+
+
+
-
-
-
-
-
+
+
@@ -116,8 +114,10 @@ import { CheckIcon, DownloadIcon, XIcon } from '@modrinth/assets'
import { commonMessages } from '@modrinth/ui'
import { computed, nextTick, onUnmounted, ref } from 'vue'
+import { Button, ButtonLink } from '#ui/components/base/buttons'
+
import { defineMessages, useVIntl } from '../../composables/i18n'
-import { Avatar, ButtonStyled } from '../base'
+import { Avatar } from '../base'
import ServerOnlinePlayers from '../project/server/ServerOnlinePlayers.vue'
import ServerRegion from '../project/server/ServerRegion.vue'
diff --git a/packages/ui/src/components/modal/ShareModal.vue b/packages/ui/src/components/modal/ShareModal.vue
index db9c485c1b..70cf15203c 100644
--- a/packages/ui/src/components/modal/ShareModal.vue
+++ b/packages/ui/src/components/modal/ShareModal.vue
@@ -12,9 +12,10 @@ import {
import QrcodeVue from 'qrcode.vue'
import { computed, nextTick, ref } from 'vue'
+import { ButtonLink, IconButton } from '#ui/components/base/buttons'
import { injectNotificationManager } from '#ui/providers'
-import { ButtonStyled, NewModal, StyledInput } from '../index'
+import { NewModal, StyledInput } from '../index'
const props = defineProps({
header: {
@@ -156,16 +157,15 @@ defineExpose({
-
-
-
-
-
+
+
+
-
-
-
-
-
+
+
+
-
-
- Open in new tab
-
-
-
+
+ Open in new tab
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/modal/UnknownFileWarningModal.vue b/packages/ui/src/components/modal/UnknownFileWarningModal.vue
index 247ff96c04..cd4d6055b4 100644
--- a/packages/ui/src/components/modal/UnknownFileWarningModal.vue
+++ b/packages/ui/src/components/modal/UnknownFileWarningModal.vue
@@ -86,17 +86,13 @@
/>
-
-
- {{ formatMessage(messages.installAnyway) }}
-
-
-
-
-
- {{ formatMessage(messages.dontInstall) }}
-
-
+
+ {{ formatMessage(messages.installAnyway) }}
+
+
+
+ {{ formatMessage(messages.dontInstall) }}
+
@@ -106,10 +102,11 @@
import { BanIcon } from '@modrinth/assets'
import { computed, nextTick, ref, useTemplateRef } from 'vue'
+import { Button } from '#ui/components/base/buttons'
+
import { defineMessages, useVIntl } from '../../composables/i18n'
import { useScrollIndicator } from '../../composables/scroll-indicator'
import Admonition from '../base/Admonition.vue'
-import ButtonStyled from '../base/ButtonStyled.vue'
import Checkbox from '../base/Checkbox.vue'
import Table, { type TableColumn } from '../base/Table.vue'
import NewModal from './NewModal.vue'
diff --git a/packages/ui/src/components/nav/NotificationPanel.vue b/packages/ui/src/components/nav/NotificationPanel.vue
index 95626d907e..0f0a592d53 100644
--- a/packages/ui/src/components/nav/NotificationPanel.vue
+++ b/packages/ui/src/components/nav/NotificationPanel.vue
@@ -59,22 +59,27 @@
x{{ item.count }}
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
- {{ button.label }}
-
-
+
+ {{ button.label }}
+
@@ -121,6 +132,7 @@ import {
} from '@modrinth/assets'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
+import { Button, IconButton } from '#ui/components/base/buttons'
import { useModalStack } from '#ui/composables/modal-stack.ts'
import {
@@ -128,7 +140,6 @@ import {
type WebNotification,
type WebNotificationButton,
} from '../../providers'
-import ButtonStyled from '../base/ButtonStyled.vue'
const notificationManager = injectNotificationManager()
const notifications = computed(() => notificationManager.getNotifications())
diff --git a/packages/ui/src/components/nav/PopupNotificationPanel.vue b/packages/ui/src/components/nav/PopupNotificationPanel.vue
index 49c55ccd58..da57f93c20 100644
--- a/packages/ui/src/components/nav/PopupNotificationPanel.vue
+++ b/packages/ui/src/components/nav/PopupNotificationPanel.vue
@@ -95,11 +95,15 @@
-
-
-
-
-
+
+
+
{{ item.text }}
@@ -142,16 +146,28 @@
full-width
/>
-
-
-
- {{ btn.label }}
-
-
+
+ {{ btn.label }}
+
@@ -171,6 +187,8 @@ import {
} from '@modrinth/assets'
import { computed, ref } from 'vue'
+import { Button, IconButton } from '#ui/components/base/buttons'
+
import { useModalStack } from '../../composables/modal-stack'
import {
injectPopupNotificationManager,
@@ -178,7 +196,6 @@ import {
type PopupNotificationButton,
type PopupNotificationProgressItem,
} from '../../providers'
-import ButtonStyled from '../base/ButtonStyled.vue'
import ProgressBar from '../base/ProgressBar.vue'
import NotificationToast from '../notifications/NotificationToast.vue'
diff --git a/packages/ui/src/components/notifications/NotificationToast.vue b/packages/ui/src/components/notifications/NotificationToast.vue
index 185fee06d7..82169e0044 100644
--- a/packages/ui/src/components/notifications/NotificationToast.vue
+++ b/packages/ui/src/components/notifications/NotificationToast.vue
@@ -49,31 +49,33 @@
-
-
-
-
-
+
+
+
-
-
-
-
- Accept
-
-
-
-
-
- Decline
-
-
+
+
+
+ Accept
+
+
+
+ Decline
+
@@ -96,16 +98,17 @@
{{ entityLabel }}
-
-
-
-
-
+
+
+
-
- Launch game
-
-
- Instance
-
+ Launch game
+ Instance
{{ progressLabel }}
@@ -145,16 +144,28 @@
v-if="type === 'instance-download' && actions?.length"
class="col-start-1 col-end-3 row-start-3 mt-2 flex min-w-0 flex-wrap items-center gap-2"
>
-
-
-
- {{ action.label }}
-
-
+
+ {{ action.label }}
+
@@ -182,11 +193,12 @@
import { CheckIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { computed, ref } from 'vue'
+import { Button, IconButton } from '#ui/components/base/buttons'
+
import { useFormatBytes, useFormatNumber } from '../../composables'
import type { PopupNotificationButton, PopupNotificationProgressType } from '../../providers'
import { truncatedTooltip } from '../../utils/truncate'
import Avatar from '../base/Avatar.vue'
-import ButtonStyled from '../base/ButtonStyled.vue'
type NotificationToastType =
| 'friend-request'
diff --git a/packages/ui/src/components/page/NormalPage.vue b/packages/ui/src/components/page/NormalPage.vue
index ce3f0e1689..3acc18993e 100644
--- a/packages/ui/src/components/page/NormalPage.vue
+++ b/packages/ui/src/components/page/NormalPage.vue
@@ -3,6 +3,7 @@ import { injectPageContext } from '@modrinth/ui'
defineProps<{
sidebar?: 'right' | 'left'
+ fullWidth?: boolean
}>()
const { hierarchicalSidebarAvailable } = injectPageContext()
@@ -12,6 +13,7 @@ const { hierarchicalSidebarAvailable } = injectPageContext()
:class="{
'ui-normal-page--sidebar-left': sidebar === 'left' && !hierarchicalSidebarAvailable,
'ui-normal-page--sidebar-right': sidebar === 'right' && !hierarchicalSidebarAvailable,
+ 'ui-normal-page--full-width': fullWidth,
}"
>
-
-
-
-
-
+
+
+
-
-
diff --git a/packages/ui/src/layouts/shared/server-settings/pages/installation.vue b/packages/ui/src/layouts/shared/server-settings/pages/installation.vue
index 3d943bd823..21275870a7 100644
--- a/packages/ui/src/layouts/shared/server-settings/pages/installation.vue
+++ b/packages/ui/src/layouts/shared/server-settings/pages/installation.vue
@@ -22,17 +22,16 @@
formatMessage(messages.resetServerTitle)
}}
-
-
-
- {{ formatMessage(commonMessages.resetServerButton) }}
-
-
+
+
+ {{ formatMessage(commonMessages.resetServerButton) }}
+
{{ formatMessage(messages.resetServerDescription) }}
@@ -59,17 +58,16 @@
{{ formatMessage(messages.supportOptionsTitle) }}
-
-
-
- {{ formatMessage(messages.resetToOnboardingButton) }}
-
-
+
+
+ {{ formatMessage(messages.resetToOnboardingButton) }}
+
@@ -79,7 +77,6 @@
import type { Archon } from '@modrinth/api-client'
import { RotateCounterClockwiseIcon } from '@modrinth/assets'
import {
- ButtonStyled,
commonMessages,
ConfirmModal,
defineMessages,
@@ -103,6 +100,7 @@ import {
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref, useTemplateRef, watch } from 'vue'
+import { Button } from '#ui/components/base/buttons'
import { injectFilePicker } from '#ui/providers/file-picker'
const debug = useDebugLogger('LoaderPage')
diff --git a/packages/ui/src/layouts/shared/server-settings/pages/network.vue b/packages/ui/src/layouts/shared/server-settings/pages/network.vue
index dd8091b289..9a91d08000 100644
--- a/packages/ui/src/layouts/shared/server-settings/pages/network.vue
+++ b/packages/ui/src/layouts/shared/server-settings/pages/network.vue
@@ -16,18 +16,16 @@
placeholder="e.g. Secondary allocation"
/>
-
- Cancel
-
-
-
- Update allocation
-
-
+ Cancel
+
+ Update allocation
+
@@ -61,9 +59,14 @@
allocationsError?.message ?? 'Unknown error'
}}
- refetchAllocations()">
- Retry
-
+ refetchAllocations()"
+ >Retry
@@ -83,16 +86,16 @@
placeholder="e.g. Secondary allocation"
/>
-
-
-
- Create allocation
-
-
+
+
+ Create allocation
+
@@ -105,30 +108,33 @@
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
@@ -153,16 +159,14 @@
:placeholder="exampleDomain"
/>
-
-
-
- Export
-
-
+
+
+ Export
+
@@ -223,8 +227,9 @@ import {
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref } from 'vue'
-import { ButtonStyled, ConfirmModal, NewModal, StyledInput, Table, TagItem } from '#ui/components'
+import { ConfirmModal, NewModal, StyledInput, Table, TagItem } from '#ui/components'
import type { TableColumn } from '#ui/components/base'
+import { Button, IconButton } from '#ui/components/base/buttons'
import { useServerPermissions } from '#ui/composables/server-permissions'
import {
injectModrinthClient,
diff --git a/packages/ui/src/layouts/shared/user-profile/layout.vue b/packages/ui/src/layouts/shared/user-profile/layout.vue
index 9c3b008a6b..608994520e 100644
--- a/packages/ui/src/layouts/shared/user-profile/layout.vue
+++ b/packages/ui/src/layouts/shared/user-profile/layout.vue
@@ -13,19 +13,26 @@
-
-
-
- {{ formatMessage(commonMessages.cancelButton) }}
-
-
-
-
-
-
- {{ formatMessage(messages.blockButton) }}
-
-
+
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
+
+
+ {{ formatMessage(messages.blockButton) }}
+
@@ -42,28 +49,26 @@
:placeholder="formatMessage(messages.selectRolePlaceholder)"
/>
-
-
-
- {{ formatMessage(commonMessages.cancelButton) }}
-
-
-
-
-
-
- {{ formatMessage(messages.savingLabel) }}
-
-
-
- {{ formatMessage(commonMessages.saveChangesButton) }}
-
-
-
+
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
+
+
+ {{ formatMessage(messages.savingLabel) }}
+
+
+
+ {{ formatMessage(commonMessages.saveChangesButton) }}
+
+
@@ -192,7 +197,7 @@
-
+
+ >
+
+
+
+
-
-
- {{ formatMessage(messages.createProjectButton) }}
-
-
+
+ {{ formatMessage(messages.createProjectButton) }}
+
@@ -380,11 +387,9 @@
"
>
-
-
- {{ formatMessage(messages.createCollectionButton) }}
-
-
+
+ {{ formatMessage(messages.createCollectionButton) }}
+
@@ -437,11 +442,9 @@
:description="formatMessage(messages.userLoadErrorDescription)"
>
-
-
- {{ formatMessage(commonMessages.retryButton) }}
-
-
+
+ {{ formatMessage(commonMessages.retryButton) }}
+
@@ -473,7 +476,7 @@ import { useRoute, useRouter } from 'vue-router'
import Admonition from '#ui/components/base/Admonition.vue'
import AutoLink from '#ui/components/base/AutoLink.vue'
import Avatar from '#ui/components/base/Avatar.vue'
-import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
+import { Button } from '#ui/components/base/buttons'
import Combobox from '#ui/components/base/Combobox.vue'
import EmptyState from '#ui/components/base/EmptyState.vue'
import IntlFormatted from '#ui/components/base/IntlFormatted.vue'
diff --git a/packages/ui/src/layouts/wrapped/AccountProfileSettings.vue b/packages/ui/src/layouts/wrapped/AccountProfileSettings.vue
index 1cd295cdcc..f38537a444 100644
--- a/packages/ui/src/layouts/wrapped/AccountProfileSettings.vue
+++ b/packages/ui/src/layouts/wrapped/AccountProfileSettings.vue
@@ -15,12 +15,10 @@
-
-
-
- {{ formatMessage(commonMessages.signInButton) }}
-
-
+
+
+ {{ formatMessage(commonMessages.signInButton) }}
+
@@ -56,30 +54,33 @@
-
-
-
-
-
-
-
-
- {{ formatMessage(commonMessages.removeImageButton) }}
-
-
-
-
-
- {{ formatMessage(commonMessages.resetButton) }}
-
-
+
+
+
+
+
+ {{ formatMessage(commonMessages.removeImageButton) }}
+
+
+
+ {{ formatMessage(commonMessages.resetButton) }}
+
@@ -135,9 +136,8 @@ import { computed, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import { RouterLink } from 'vue-router'
import Avatar from '#ui/components/base/Avatar.vue'
-import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
+import { Button, FileButton } from '#ui/components/base/buttons'
import EmptyState from '#ui/components/base/EmptyState.vue'
-import FileInput from '#ui/components/base/FileInput.vue'
import IntlFormatted from '#ui/components/base/IntlFormatted.vue'
import StyledInput from '#ui/components/base/StyledInput.vue'
import { defineMessages, useVIntl } from '#ui/composables'
diff --git a/packages/ui/src/layouts/wrapped/AccountSocialSettings.vue b/packages/ui/src/layouts/wrapped/AccountSocialSettings.vue
index 6d2f4f5d8c..240744fa31 100644
--- a/packages/ui/src/layouts/wrapped/AccountSocialSettings.vue
+++ b/packages/ui/src/layouts/wrapped/AccountSocialSettings.vue
@@ -15,12 +15,10 @@
-
-
-
- {{ formatMessage(commonMessages.signInButton) }}
-
-
+
+
+ {{ formatMessage(commonMessages.signInButton) }}
+
@@ -112,11 +110,9 @@
{{ formatMessage(messages.loadError) }}
-
-
- {{ formatMessage(commonMessages.retryButton) }}
-
-
+
+ {{ formatMessage(commonMessages.retryButton) }}
+
{{ formatMessage(messages.noBlockedUsers) }}
@@ -145,25 +141,24 @@
-
-
-
- {{ formatMessage(messages.unblockButton) }}
-
-
+
+
+ {{ formatMessage(messages.unblockButton) }}
+
@@ -195,7 +190,7 @@ import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import Avatar from '#ui/components/base/Avatar.vue'
-import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
+import { Button } from '#ui/components/base/buttons'
import Chips from '#ui/components/base/Chips.vue'
import EmptyState from '#ui/components/base/EmptyState.vue'
import Table, { type TableColumn } from '#ui/components/base/Table.vue'
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/access/access.vue b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/access/access.vue
index 96cc63944a..b85f8d8fe3 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/access/access.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/access/access.vue
@@ -14,23 +14,25 @@
v-model="roleFilter"
:options="roleFilterOptions"
:display-value="selectedRoleFilterLabel"
- trigger-class="min-w-[225px] !h-10 !min-h-10 !py-0"
+ trigger-size="lg"
+ trigger-class="min-w-[225px]"
>
-
-
-
- {{ formatMessage(messages.inviteFriends) }}
-
-
+
+
+ {{ formatMessage(messages.inviteFriends) }}
+
@@ -113,7 +115,7 @@ import { FilterIcon, SearchIcon, UserPlusIcon } from '@modrinth/assets'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue'
-import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
+import { Button } from '#ui/components/base/buttons'
import Combobox, { type ComboboxOption } from '#ui/components/base/Combobox.vue'
import DropdownFilterBar from '#ui/components/base/DropdownFilterBar.vue'
import StyledInput from '#ui/components/base/StyledInput.vue'
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/onboarding.vue b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/onboarding.vue
index bcdebf6392..f231ba7956 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/onboarding.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/onboarding.vue
@@ -42,22 +42,22 @@
-
-
-
- {{ formatMessage(messages.uploadingProgress, { percent: uploadPercent }) }}
-
-
-
-
- {{ formatMessage(messages.setupServerButton) }}
-
-
+
+
+ {{ formatMessage(messages.uploadingProgress, { percent: uploadPercent }) }}
+
+
+ {{ formatMessage(messages.setupServerButton) }}
+
{{ error.message }}
-
- Retry
-
+ Retry
@@ -71,16 +71,16 @@
{{ formatMessage(commonMessages.allProjectType) }}
-
-
-
- {{ formatMessage(messages.createBackup) }}
-
-
+
+
+ {{ formatMessage(messages.createBackup) }}
+
@@ -95,17 +95,17 @@
:description="formatMessage(messages.emptyDescription)"
>
-
-
-
- {{ formatMessage(messages.createBackup) }}
-
-
+
+
+ {{ formatMessage(messages.createBackup) }}
+
-
-
- {{ formatMessage(messages.clearFilters) }}
-
-
+
+ {{ formatMessage(messages.clearFilters) }}
+
@@ -198,30 +196,30 @@
}}
-
-
- {{ formatMessage(commonMessages.clearButton) }}
-
-
+
+ {{ formatMessage(commonMessages.clearButton) }}
+
-
-
-
- {{ formatMessage(commonMessages.deleteLabel) }}
-
-
+
+
+ {{ formatMessage(commonMessages.deleteLabel) }}
+
@@ -273,7 +271,7 @@ import type { Component } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
-import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
+import { Button } from '#ui/components/base/buttons'
import Checkbox from '#ui/components/base/Checkbox.vue'
import EmptyState from '#ui/components/base/EmptyState.vue'
import FilterPills, { type FilterPillOption } from '#ui/components/base/FilterPills.vue'
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/index.vue b/packages/ui/src/layouts/wrapped/hosting/manage/index.vue
index d22ac2dac4..f85d23337c 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/index.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/index.vue
@@ -75,14 +75,17 @@
-
- {{
- formatMessage(messages.contactSupportButton)
- }}
-
- router.go(0)">
- {{ formatMessage(messages.reloadButton) }}
-
+ {{ formatMessage(messages.contactSupportButton) }}
+ router.go(0)">{{
+ formatMessage(messages.reloadButton)
+ }}
@@ -106,12 +109,10 @@
:placeholder="formatMessage(messages.searchPlaceholder, { count: filteredData.length })"
wrapper-class="w-full md:w-72"
/>
-
-
-
- {{ formatMessage(messages.newServerButton) }}
-
-
+
+
+ {{ formatMessage(messages.newServerButton) }}
+
@@ -231,8 +232,6 @@
import type { Archon, Labrinth } from '@modrinth/api-client'
import { HammerIcon, LoaderCircleIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
import {
- AutoLink,
- ButtonStyled,
CopyCode,
defineMessages,
injectAuth,
@@ -255,6 +254,7 @@ import type Stripe from 'stripe'
import { type ComponentPublicInstance, computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
+import { Button, ButtonLink } from '#ui/components/base/buttons'
import ServersUpgradeModalWrapper from '#ui/components/billing/ServersUpgradeModalWrapper.vue'
import type { ServerListingOwner } from '#ui/components/servers/access'
import MedalServerListing from '#ui/components/servers/marketing/MedalServerListing.vue'
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/root.vue b/packages/ui/src/layouts/wrapped/hosting/manage/root.vue
index c781570242..dbf56d1b69 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/root.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/root.vue
@@ -181,31 +181,30 @@
:auto-hide="false"
placement="bottom-end"
>
-
-
-
-
-
+
+
+
{{ formatMessage(settingsHintMessages.title) }}
-
-
-
-
-
+
+
+
{{ formatMessage(settingsHintMessages.description) }}
@@ -213,15 +212,15 @@
-
-
-
-
-
+
+
+
@@ -284,13 +283,11 @@
If you're stuck, please contact Modrinth Support with the information below:
-
-
-
-
- Copy Debug Info
-
-
+
+
+
+ Copy Debug Info
+
An internal error occurred while installing your server. Don't fret — try
@@ -310,25 +307,23 @@
v-if="errorTitle === 'Installation error'"
class="mt-2 flex flex-col gap-4 sm:flex-row"
>
-
- Open Installation Log
-
-
-
-
-
- Copy Debug Info
-
-
-
-
-
- Change Loader
-
-
+ Open Installation Log
+
+
+
+ Copy Debug Info
+
+
+
+ Change Loader
+
@@ -430,7 +425,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import Avatar from '#ui/components/base/Avatar.vue'
-import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
+import { Button, IconButton, TeleportOverflowMenu } from '#ui/components/base/buttons'
import ErrorInformationCard from '#ui/components/base/ErrorInformationCard.vue'
import NavTabs from '#ui/components/base/NavTabs.vue'
import PageHeader from '#ui/components/base/page-header/index.vue'
@@ -438,7 +433,6 @@ import PageHeaderMetadata from '#ui/components/base/page-header/metadata/index.v
import PageHeaderMetadataItem from '#ui/components/base/page-header/metadata/page-header-metadata-item.vue'
import PageHeaderActions from '#ui/components/base/page-header/page-header-actions.vue'
import ServerNotice from '#ui/components/base/ServerNotice.vue'
-import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
import ServerPanelAdmonitions from '#ui/components/servers/admonitions/ServerPanelAdmonitions.vue'
import LoaderIcon from '#ui/components/servers/icons/LoaderIcon.vue'
diff --git a/packages/ui/src/stories/add-stories.md b/packages/ui/src/stories/add-stories.md
index 19f83c3ba9..2132d4f14c 100644
--- a/packages/ui/src/stories/add-stories.md
+++ b/packages/ui/src/stories/add-stories.md
@@ -153,16 +153,14 @@ For components that need user interaction to show:
```typescript
export const Default: Story = {
render: () => ({
- components: { NewModal, ButtonStyled },
+ components: { Button, NewModal },
setup() {
const modalRef = ref | null>(null)
return { modalRef }
},
template: /* html */ `
-
- Open Modal
-
+
Open Modal
Modal content
@@ -199,10 +197,10 @@ Components should use relative imports, not the package alias:
```typescript
// ❌ BAD - Causes circular dependency in Storybook
-import { ButtonStyled } from '@modrinth/ui'
+import { Button } from '@modrinth/ui'
// ✅ GOOD - Use relative imports
-import ButtonStyled from '../base/ButtonStyled.vue'
+import Button from '../components/base/buttons/Button.vue'
```
### 2. Object/Array Prop Defaults Must Be Factory Functions
diff --git a/packages/ui/src/stories/base/Admonition.stories.ts b/packages/ui/src/stories/base/Admonition.stories.ts
index 413e2a1b6d..bcd6894084 100644
--- a/packages/ui/src/stories/base/Admonition.stories.ts
+++ b/packages/ui/src/stories/base/Admonition.stories.ts
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import Admonition from '../../components/base/Admonition.vue'
-import ButtonStyled from '../../components/base/ButtonStyled.vue'
+import { Button } from '../../components/base/buttons'
const meta = {
title: 'Base/Admonition',
@@ -78,7 +78,7 @@ export const HeaderWithTimestamp: Story = {
export const WithTopRightActions: Story = {
render: () => ({
- components: { Admonition, ButtonStyled },
+ components: { Admonition, Button },
template: /*html*/ `
Uploading server files...
-
- Cancel
-
+ Cancel
Something went wrong while extracting the archive.
-
- Retry
-
+ Retry
@@ -115,7 +111,7 @@ export const WithTopRightActions: Story = {
export const WithProgressBar: Story = {
render: () => ({
- components: { Admonition, ButtonStyled },
+ components: { Admonition, Button },
template: /*html*/ `
128 KB / 1.2 MB (45%)
-
- Cancel
-
+ Cancel
24 MB extracted — config/settings.yml
-
- Cancel
-
+ Cancel
({
- components: { Button },
- setup() {
- return { args }
- },
- template: /*html*/ `
- Click me
- `,
- }),
-} satisfies Meta
-
-export default meta
-type Story = StoryObj
-
-export const Default: Story = {}
-
-export const Primary: Story = {
- args: {
- color: 'primary',
- },
-}
-
-export const Danger: Story = {
- args: {
- color: 'danger',
- },
-}
-
-export const AllColors: Story = {
- render: () => ({
- components: { Button },
- template: /*html*/ `
-
- Default
- Primary
- Danger
- Red
- Orange
- Green
- Blue
- Purple
-
- `,
- }),
-}
-
-export const Large: Story = {
- args: {
- large: true,
- },
-}
-
-export const Outline: Story = {
- args: {
- outline: true,
- },
-}
-
-export const Transparent: Story = {
- args: {
- transparent: true,
- },
-}
-
-export const Disabled: Story = {
- args: {
- disabled: true,
- },
-}
-
-export const AsLink: Story = {
- args: {
- link: 'https://modrinth.com',
- external: true,
- },
-}
diff --git a/packages/ui/src/stories/base/ButtonStyled.stories.ts b/packages/ui/src/stories/base/ButtonStyled.stories.ts
deleted file mode 100644
index 852ceab118..0000000000
--- a/packages/ui/src/stories/base/ButtonStyled.stories.ts
+++ /dev/null
@@ -1,226 +0,0 @@
-import { DownloadIcon, HeartIcon, SettingsIcon } from '@modrinth/assets'
-import type { Meta, StoryObj } from '@storybook/vue3-vite'
-
-import ButtonStyled from '../../components/base/ButtonStyled.vue'
-
-const colors = ['standard', 'brand', 'red', 'orange', 'green', 'blue', 'purple'] as const
-const types = [
- 'standard',
- 'outlined',
- 'transparent',
- 'highlight',
- 'highlight-colored-text',
- 'chip',
-] as const
-const sizes = ['small', 'standard', 'large'] as const
-
-const meta = {
- title: 'Base/ButtonStyled',
- component: ButtonStyled,
- argTypes: {
- color: {
- control: 'select',
- options: [...colors, 'medal-promo'],
- },
- size: {
- control: 'select',
- options: [...sizes],
- },
- type: {
- control: 'select',
- options: [...types],
- },
- circular: { control: 'boolean' },
- colorFill: {
- control: 'select',
- options: ['auto', 'background', 'text', 'none'],
- },
- hoverColorFill: {
- control: 'select',
- options: ['auto', 'background', 'text', 'none'],
- },
- highlighted: { control: 'boolean' },
- highlightedStyle: {
- control: 'select',
- options: ['main-nav-primary', 'main-nav-secondary'],
- },
- },
- args: {
- color: 'standard',
- size: 'standard',
- type: 'standard',
- circular: false,
- colorFill: 'auto',
- hoverColorFill: 'auto',
- highlighted: false,
- highlightedStyle: 'main-nav-primary',
- },
- render: (args) => ({
- components: { ButtonStyled, DownloadIcon },
- setup() {
- return { args }
- },
- template: /*html*/ `
-
- Button
-
- `,
- }),
-} satisfies Meta
-
-export default meta
-type Story = StoryObj
-
-export const Default: Story = {
- args: {
- type: 'standard',
- },
-}
-
-export const AllVariants: Story = {
- render: () => ({
- components: { ButtonStyled },
- setup() {
- return { colors, types }
- },
- template: /*html*/ `
-
-
-
-
- | Color / Type |
- {{ type }} |
-
-
-
-
- | {{ color }} |
-
-
- Button
-
- |
-
-
-
-
- `,
- }),
-}
-
-export const AllVariantsHighlighted: Story = {
- render: () => ({
- components: { ButtonStyled },
- setup() {
- return { colors, types }
- },
- template: /*html*/ `
-
-
-
-
- | Color / Type |
- {{ type }} |
-
-
-
-
- | {{ color }} |
-
-
- Button
-
- |
-
-
-
-
- `,
- }),
-}
-
-export const Sizes: Story = {
- render: () => ({
- components: { ButtonStyled },
- setup() {
- return { sizes, types }
- },
- template: /*html*/ `
-
-
-
-
- | Size / Type |
- {{ type }} |
-
-
-
-
- | {{ size }} |
-
-
- Button
-
- |
-
-
-
-
- `,
- }),
-}
-
-export const WithIcons: Story = {
- render: () => ({
- components: { ButtonStyled, DownloadIcon, HeartIcon, SettingsIcon },
- setup() {
- return { types }
- },
- template: /*html*/ `
-
-
-
-
- | Variant |
- {{ type }} |
-
-
-
-
- | Icon + text |
-
-
- Download
-
- |
-
-
- | Icon only |
-
-
-
-
- |
-
-
-
-
- `,
- }),
-}
-
-export const Disabled: Story = {
- render: () => ({
- components: { ButtonStyled },
- setup() {
- return { types }
- },
- template: /*html*/ `
-
-
- {{ type }}
-
-
- `,
- }),
-}
diff --git a/packages/ui/src/stories/base/EmptyState.stories.ts b/packages/ui/src/stories/base/EmptyState.stories.ts
index f698148b9b..8fa5be7f23 100644
--- a/packages/ui/src/stories/base/EmptyState.stories.ts
+++ b/packages/ui/src/stories/base/EmptyState.stories.ts
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
-import ButtonStyled from '../../components/base/ButtonStyled.vue'
+import { Button } from '../../components/base/buttons'
import EmptyState from '../../components/base/EmptyState.vue'
const meta = {
@@ -42,7 +42,7 @@ export const Default: Story = {
export const WithActions: StoryObj = {
render: () => ({
- components: { EmptyState, ButtonStyled },
+ components: { EmptyState, Button },
template: /*html*/ `
-
- Create backup
-
+ Create backup
`,
diff --git a/packages/ui/src/stories/base/JoinedButtons.stories.ts b/packages/ui/src/stories/base/JoinedButtons.stories.ts
deleted file mode 100644
index c007e3631d..0000000000
--- a/packages/ui/src/stories/base/JoinedButtons.stories.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-import { PlayIcon, SlashIcon, StopCircleIcon, UpdatedIcon } from '@modrinth/assets'
-import type { Meta, StoryObj } from '@storybook/vue3-vite'
-
-import JoinedButtons from '../../components/base/JoinedButtons.vue'
-
-const meta = {
- title: 'Base/JoinedButtons',
- component: JoinedButtons,
- argTypes: {
- color: {
- control: 'select',
- options: ['standard', 'brand', 'red', 'orange', 'green', 'blue', 'purple'],
- },
- size: {
- control: 'select',
- options: ['small', 'standard', 'large'],
- },
- disabled: { control: 'boolean' },
- primaryDisabled: { control: 'boolean' },
- dropdownDisabled: { control: 'boolean' },
- primaryMuted: { control: 'boolean' },
- },
-} satisfies Meta
-
-export default meta
-type Story = StoryObj
-
-export const Start: Story = {
- args: {
- color: 'brand',
- size: 'large',
- actions: [
- {
- id: 'start',
- label: 'Start',
- icon: PlayIcon,
- action: () => console.log('Start'),
- },
- ],
- },
-}
-
-export const StopWithKill: Story = {
- args: {
- color: 'red',
- size: 'large',
- actions: [
- {
- id: 'stop',
- label: 'Stop',
- icon: StopCircleIcon,
- action: () => console.log('Stop'),
- },
- {
- id: 'kill_server',
- label: 'Kill server',
- icon: SlashIcon,
- action: () => console.log('Kill'),
- },
- ],
- },
-}
-
-export const Stopping: Story = {
- args: {
- color: 'red',
- size: 'large',
- primaryDisabled: true,
- primaryMuted: true,
- actions: [
- {
- id: 'stop',
- label: 'Stopping',
- icon: StopCircleIcon,
- action: () => console.log('Stop'),
- },
- {
- id: 'kill_server',
- label: 'Kill server',
- icon: SlashIcon,
- action: () => console.log('Kill'),
- },
- ],
- },
-}
-
-export const Restart: Story = {
- args: {
- color: 'orange',
- size: 'large',
- actions: [
- {
- id: 'restart',
- label: 'Restart',
- icon: UpdatedIcon,
- action: () => console.log('Restart'),
- },
- ],
- },
-}
-
-export const Disabled: Story = {
- args: {
- color: 'red',
- size: 'large',
- disabled: true,
- actions: [
- {
- id: 'stop',
- label: 'Stop',
- icon: StopCircleIcon,
- action: () => console.log('Stop'),
- },
- {
- id: 'kill_server',
- label: 'Kill server',
- icon: SlashIcon,
- action: () => console.log('Kill'),
- },
- ],
- },
-}
diff --git a/packages/ui/src/stories/base/OverflowMenu.stories.ts b/packages/ui/src/stories/base/OverflowMenu.stories.ts
deleted file mode 100644
index 0adec75cf2..0000000000
--- a/packages/ui/src/stories/base/OverflowMenu.stories.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import { MoreHorizontalIcon } from '@modrinth/assets'
-import type { Meta, StoryObj } from '@storybook/vue3-vite'
-
-import ButtonStyled from '../../components/base/ButtonStyled.vue'
-import OverflowMenu from '../../components/base/OverflowMenu.vue'
-
-const meta = {
- title: 'Base/OverflowMenu',
- component: OverflowMenu,
- render: (args) => ({
- components: { OverflowMenu, MoreHorizontalIcon, ButtonStyled },
- setup() {
- return { args }
- },
- template: /*html*/ `
-
-
-
- Edit
- Delete
- Share
-
-
- `,
- }),
-} satisfies Meta
-
-export default meta
-type Story = StoryObj
-
-export const Default: Story = {
- args: {
- options: [
- { id: 'edit', action: () => console.log('Edit clicked') },
- { id: 'share', action: () => console.log('Share clicked') },
- { divider: true },
- { id: 'delete', action: () => console.log('Delete clicked'), color: 'danger' },
- ],
- },
-}
-
-export const WithDifferentPlacements: StoryObj = {
- render: () => ({
- components: { OverflowMenu, MoreHorizontalIcon, ButtonStyled },
- template: /*html*/ `
-
-
- bottom-end (default)
-
-
-
- Edit
- Delete
-
-
-
-
- bottom-start
-
-
-
- Edit
- Delete
-
-
-
-
- `,
- }),
-}
diff --git a/packages/ui/src/stories/base/PageHeader.stories.ts b/packages/ui/src/stories/base/PageHeader.stories.ts
index 73ad628820..d2e569ae6b 100644
--- a/packages/ui/src/stories/base/PageHeader.stories.ts
+++ b/packages/ui/src/stories/base/PageHeader.stories.ts
@@ -20,9 +20,13 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
import AutoLink from '../../components/base/AutoLink.vue'
import Avatar from '../../components/base/Avatar.vue'
-import ButtonStyled from '../../components/base/ButtonStyled.vue'
+import {
+ Button,
+ IconButton,
+ SplitButton,
+ TeleportOverflowMenu,
+} from '../../components/base/buttons'
import FormattedTag from '../../components/base/FormattedTag.vue'
-import JoinedButtons from '../../components/base/JoinedButtons.vue'
import PageHeader from '../../components/base/page-header/index.vue'
import PageHeaderMetadata from '../../components/base/page-header/metadata/index.vue'
import PageHeaderMetadataItem from '../../components/base/page-header/metadata/page-header-metadata-item.vue'
@@ -32,7 +36,6 @@ import PageHeaderMetadataTimeItem from '../../components/base/page-header/metada
import PageHeaderActions from '../../components/base/page-header/page-header-actions.vue'
import PageHeaderBadgeItem from '../../components/base/page-header/page-header-badge-item.vue'
import TagItem from '../../components/base/TagItem.vue'
-import TeleportOverflowMenu from '../../components/base/TeleportOverflowMenu.vue'
import LoaderIcon from '../../components/servers/icons/LoaderIcon.vue'
import ServerIcon from '../../components/servers/icons/ServerIcon.vue'
@@ -87,9 +90,10 @@ const pageHeaderIcons = {
const pageHeaderComponents = {
AutoLink,
Avatar,
- ButtonStyled,
+ Button,
FormattedTag,
- JoinedButtons,
+ IconButton,
+ SplitButton,
PageHeader,
PageHeaderActions,
PageHeaderBadgeItem,
@@ -111,7 +115,7 @@ const meta = {
},
decorators: [
(story) => ({
- components: { story },
+ components: { story, TeleportOverflowMenu },
template: '
',
}),
],
@@ -157,17 +161,13 @@ export const ProjectHeader: Story = {
-
-
-
- Download
-
-
-
-
-
-
-
+
+
+ Download
+
+
+
+
@@ -207,12 +207,10 @@ export const CreatorHeader: Story = {
-
-
-
- Follow
-
-
+
+
+ Follow
+
@@ -222,10 +220,7 @@ export const CreatorHeader: Story = {
export const AppInstanceHeader: Story = {
render: () => ({
- components: {
- ...pageHeaderComponents,
- LoaderIcon,
- },
+ components: { ...pageHeaderComponents, LoaderIcon, TeleportOverflowMenu },
setup() {
return {
...pageHeaderIcons,
@@ -252,22 +247,16 @@ export const AppInstanceHeader: Story = {
-
-
-
- Play
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+ Play
+
+
+
+
+
+
+
@@ -277,10 +266,7 @@ export const AppInstanceHeader: Story = {
export const BrowseHeader: Story = {
render: () => ({
- components: {
- ...pageHeaderComponents,
- LoaderIcon,
- },
+ components: { ...pageHeaderComponents, LoaderIcon, TeleportOverflowMenu },
setup() {
return {
...pageHeaderIcons,
@@ -291,11 +277,9 @@ export const BrowseHeader: Story = {
template: `
-
-
-
-
-
+
+
+
@@ -317,10 +301,7 @@ export const BrowseHeader: Story = {
export const ServerPanelRootHeader: Story = {
render: () => ({
- components: {
- ...pageHeaderComponents,
- ServerIcon,
- },
+ components: { ...pageHeaderComponents, ServerIcon, TeleportOverflowMenu },
setup() {
return {
...pageHeaderIcons,
@@ -346,17 +327,13 @@ export const ServerPanelRootHeader: Story = {
-
-
-
- Start server
-
-
-
-
-
-
-
+
+
+ Start server
+
+
+
+
@@ -366,10 +343,7 @@ export const ServerPanelRootHeader: Story = {
export const ServerPanelInstanceHeader: Story = {
render: () => ({
- components: {
- ...pageHeaderComponents,
- LoaderIcon,
- },
+ components: { ...pageHeaderComponents, LoaderIcon, TeleportOverflowMenu },
setup() {
return {
...pageHeaderIcons,
@@ -381,11 +355,9 @@ export const ServerPanelInstanceHeader: Story = {
template: `
-
-
-
-
-
+
+
+
@@ -400,18 +372,24 @@ export const ServerPanelInstanceHeader: Story = {
-
-
-
- Start instance
-
-
-
-
-
-
-
-
+
+
+ Start instance
+
+
+
+ Stop
+
+
+
+
diff --git a/packages/ui/src/stories/base/PopoutMenu.stories.ts b/packages/ui/src/stories/base/PopoutMenu.stories.ts
deleted file mode 100644
index 7e3d02a512..0000000000
--- a/packages/ui/src/stories/base/PopoutMenu.stories.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-import { SettingsIcon } from '@modrinth/assets'
-import type { Meta, StoryObj } from '@storybook/vue3-vite'
-
-import Button from '../../components/base/Button.vue'
-import ButtonStyled from '../../components/base/ButtonStyled.vue'
-import PopoutMenu from '../../components/base/PopoutMenu.vue'
-
-const meta = {
- title: 'Base/PopoutMenu',
- component: PopoutMenu,
- render: (args) => ({
- components: { PopoutMenu, Button, ButtonStyled, SettingsIcon },
- setup() {
- return { args }
- },
- template: /*html*/ `
-
-
-
-
-
- Option 1
- Option 2
- Option 3
-
-
-
-
- `,
- }),
-} satisfies Meta
-
-export default meta
-type Story = StoryObj
-
-export const Default: Story = {}
-
-export const WithTooltip: Story = {
- args: {
- tooltip: 'Click for more options',
- },
-}
-
-export const DifferentPlacements: StoryObj = {
- render: () => ({
- components: { PopoutMenu, Button, ButtonStyled, SettingsIcon },
- template: /*html*/ `
-
-
-
bottom-end (default)
-
-
-
-
-
- Option 1
- Option 2
-
-
-
-
-
-
-
bottom-start
-
-
-
-
-
- Option 1
- Option 2
-
-
-
-
-
-
-
top-end
-
-
-
-
-
- Option 1
- Option 2
-
-
-
-
-
-
- `,
- }),
-}
diff --git a/packages/ui/src/stories/base/StackedAdmonitions.stories.ts b/packages/ui/src/stories/base/StackedAdmonitions.stories.ts
index c0683ad7df..fbef68fb42 100644
--- a/packages/ui/src/stories/base/StackedAdmonitions.stories.ts
+++ b/packages/ui/src/stories/base/StackedAdmonitions.stories.ts
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import Admonition from '../../components/base/Admonition.vue'
-import ButtonStyled from '../../components/base/ButtonStyled.vue'
+import { Button } from '../../components/base/buttons'
import StackedAdmonitionsRaw, {
type StackedAdmonitionItem,
} from '../../components/base/StackedAdmonitions.vue'
@@ -383,7 +383,7 @@ interface RichItem extends StackedAdmonitionItem {
export const RichContent: Story = {
render: () => ({
- components: { StackedAdmonitions, Admonition, ButtonStyled },
+ components: { StackedAdmonitions, Admonition, Button },
setup() {
const items = ref([
{
@@ -429,12 +429,8 @@ export const RichContent: Story = {
>
{{ item.body }}
-
- Cancel
-
-
- Retry
-
+ Cancel
+ Retry
diff --git a/packages/ui/src/stories/base/Table.stories.ts b/packages/ui/src/stories/base/Table.stories.ts
index 157c1ffbcb..6448cbe7bd 100644
--- a/packages/ui/src/stories/base/Table.stories.ts
+++ b/packages/ui/src/stories/base/Table.stories.ts
@@ -3,8 +3,7 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { computed, ref } from 'vue'
import Badge from '../../components/base/Badge.vue'
-import ButtonStyled from '../../components/base/ButtonStyled.vue'
-import OverflowMenu from '../../components/base/OverflowMenu.vue'
+import { Button, TeleportOverflowMenu } from '../../components/base/buttons'
import Table from '../../components/base/Table.vue'
interface User {
@@ -54,7 +53,7 @@ export default meta
export const Default: StoryObj = {
args: {},
render: () => ({
- components: { Table },
+ components: { Table, TeleportOverflowMenu },
setup() {
const columns = [
{ key: 'name', label: 'Name' },
@@ -74,7 +73,7 @@ export const Default: StoryObj = {
export const HorizontalOverflow: StoryObj = {
args: {},
render: () => ({
- components: { Table },
+ components: { Table, TeleportOverflowMenu },
setup() {
const columns = [
{ key: 'name', label: 'Name' },
@@ -103,7 +102,7 @@ export const HorizontalOverflow: StoryObj = {
export const CustomClasses: StoryObj = {
args: {},
render: () => ({
- components: { Table },
+ components: { Table, TeleportOverflowMenu },
setup() {
const columns = [
{ key: 'name', label: 'Name', cellClass: '!overflow-visible py-3' },
@@ -129,7 +128,7 @@ export const CustomClasses: StoryObj = {
export const WithSelection: StoryObj = {
args: {},
render: () => ({
- components: { Table },
+ components: { Table, TeleportOverflowMenu },
setup() {
const columns = [
{ key: 'name', label: 'Name' },
@@ -160,7 +159,7 @@ export const WithSelection: StoryObj = {
export const WithSelectionData: StoryObj = {
args: {},
render: () => ({
- components: { Table },
+ components: { Table, TeleportOverflowMenu },
setup() {
const columns = [
{ key: 'name', label: 'Name' },
@@ -193,7 +192,7 @@ export const WithSelectionData: StoryObj = {
export const WithSelectionIds: StoryObj = {
args: {},
render: () => ({
- components: { Table },
+ components: { Table, TeleportOverflowMenu },
setup() {
const columns = [
{ key: 'name', label: 'Name' },
@@ -233,7 +232,7 @@ export const WithSorting: StoryObj = {
},
},
render: () => ({
- components: { Table },
+ components: { Table, TeleportOverflowMenu },
setup() {
const columns = [
{ key: 'name', label: 'Name', enableSorting: true },
@@ -269,7 +268,7 @@ export const WithSorting: StoryObj = {
export const WithColumnAlignment: StoryObj = {
args: {},
render: () => ({
- components: { Table },
+ components: { Table, TeleportOverflowMenu },
setup() {
const columns = [
{ key: 'name', label: 'Name', align: 'left' as const },
@@ -289,7 +288,7 @@ export const WithColumnAlignment: StoryObj = {
export const WithCustomCellSlots: StoryObj = {
args: {},
render: () => ({
- components: { Table, Badge },
+ components: { Table, Badge, TeleportOverflowMenu },
setup() {
const columns = [
{ key: 'name', label: 'Name' },
@@ -335,7 +334,7 @@ export const WithCustomCellSlots: StoryObj = {
export const WithCustomHeaderSlots: StoryObj = {
args: {},
render: () => ({
- components: { Table },
+ components: { Table, TeleportOverflowMenu },
setup() {
const columns = [
{ key: 'name', label: 'Name' },
@@ -365,7 +364,7 @@ export const WithCustomHeaderSlots: StoryObj = {
export const WithHeaderSlot: StoryObj = {
args: {},
render: () => ({
- components: { Table, ButtonStyled },
+ components: { Table, Button, TeleportOverflowMenu },
setup() {
const columns = [
{ key: 'name', label: 'Name' },
@@ -383,9 +382,7 @@ export const WithHeaderSlot: StoryObj = {
Team Members
-
- Invite member
-
+ Invite member
@@ -397,7 +394,7 @@ export const WithHeaderSlot: StoryObj = {
export const WithActionsColumn: StoryObj = {
args: {},
render: () => ({
- components: { Table, ButtonStyled, EditIcon, TrashIcon },
+ components: { Table, EditIcon, TrashIcon, Button, TeleportOverflowMenu },
setup() {
const columns = [
{ key: 'name', label: 'Name' },
@@ -421,18 +418,14 @@ export const WithActionsColumn: StoryObj = {
-
-
-
- Edit
-
-
-
-
-
- Delete
-
-
+
+
+ Edit
+
+
+
+ Delete
+
@@ -443,7 +436,7 @@ export const WithActionsColumn: StoryObj = {
export const WithLocalizedActionsColumn: StoryObj = {
args: {},
render: () => ({
- components: { Table, ButtonStyled, EditIcon, TrashIcon },
+ components: { Table, EditIcon, TrashIcon, Button, TeleportOverflowMenu },
setup() {
const columns = [
{ key: 'name', label: 'Nombre' },
@@ -467,18 +460,14 @@ export const WithLocalizedActionsColumn: StoryObj = {
-
-
-
- Editar
-
-
-
-
-
- Eliminar
-
-
+
+
+ Editar
+
+
+
+ Eliminar
+
@@ -489,7 +478,7 @@ export const WithLocalizedActionsColumn: StoryObj = {
export const FullFeatured: StoryObj = {
args: {},
render: () => ({
- components: { Table, Badge, ButtonStyled, EditIcon, TrashIcon },
+ components: { Table, Badge, EditIcon, TrashIcon, Button, TeleportOverflowMenu },
setup() {
const columns = [
{ key: 'name', label: 'Name', enableSorting: true },
@@ -565,18 +554,14 @@ export const FullFeatured: StoryObj = {
-
-
-
- Edit
-
-
-
-
-
- Delete
-
-
+
+
+ Edit
+
+
+
+ Delete
+
@@ -592,7 +577,7 @@ export const FullFeatured: StoryObj = {
export const VirtualizedLargeData: StoryObj = {
args: {},
render: () => ({
- components: { Table, Badge },
+ components: { Table, Badge, TeleportOverflowMenu },
setup() {
const columns = [
{ key: 'name', label: 'Name', enableSorting: true },
@@ -710,7 +695,15 @@ export const VirtualizedLargeData: StoryObj = {
export const WithOverflowMenu: StoryObj = {
args: {},
render: () => ({
- components: { Table, Badge, ButtonStyled, OverflowMenu, MoreVerticalIcon, EditIcon, TrashIcon },
+ components: {
+ Table,
+ Badge,
+ MoreVerticalIcon,
+ EditIcon,
+ TrashIcon,
+ Button,
+ TeleportOverflowMenu,
+ },
setup() {
const columns = [
{ key: 'name', label: 'Name' },
@@ -737,17 +730,19 @@ export const WithOverflowMenu: StoryObj = {
const getMenuOptions = (row: User) => [
{
id: 'edit',
+ label: 'Edit',
action: () => alert(`Edit user: ${row.name}`),
},
{
id: 'duplicate',
+ label: 'Duplicate',
action: () => alert(`Duplicate user: ${row.name}`),
},
- { divider: true },
+ { type: 'divider' },
{
id: 'delete',
- color: 'red' as const,
- hoverFilled: true,
+ label: 'Delete',
+ tone: 'red',
action: () => alert(`Delete user: ${row.name}`),
},
]
@@ -766,26 +761,23 @@ export const WithOverflowMenu: StoryObj = {
-
-
-
-
-
- Edit
-
-
-
- Duplicate
-
-
-
- Delete
-
-
-
+
+
+
+ Edit
+
+
+
+ Duplicate
+
+
+
+ Delete
+
+
@@ -796,7 +788,7 @@ export const WithOverflowMenu: StoryObj = {
export const EmptyState: StoryObj = {
args: {},
render: () => ({
- components: { Table },
+ components: { Table, TeleportOverflowMenu },
setup() {
const columns = [
{ key: 'name', label: 'Name' },
diff --git a/packages/ui/src/stories/buttons/Button.stories.ts b/packages/ui/src/stories/buttons/Button.stories.ts
new file mode 100644
index 0000000000..85bfe95332
--- /dev/null
+++ b/packages/ui/src/stories/buttons/Button.stories.ts
@@ -0,0 +1,213 @@
+import { DownloadIcon, ExternalIcon, HeartIcon, SettingsIcon } from '@modrinth/assets'
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+
+import Button from '../../components/base/buttons/Button.vue'
+import ButtonLink from '../../components/base/buttons/ButtonLink.vue'
+import IconButton from '../../components/base/buttons/IconButton.vue'
+
+const types = ['base', 'colored', 'outlined', 'quiet'] as const
+const sizes = ['sm', 'md', 'lg', 'xl'] as const
+const colors = ['brand', 'red', 'orange', 'green', 'blue', 'purple', 'medal_promotion'] as const
+const sizeColumns = [
+ { value: 'sm', label: 'Small' },
+ { value: 'md', label: 'Medium' },
+ { value: 'lg', label: 'Large' },
+ { value: 'xl', label: 'Extra large' },
+] as const
+const typeRows = [
+ { label: 'Base', type: 'base' },
+ { label: 'Outlined', type: 'outlined' },
+ { label: 'Quiet', type: 'quiet' },
+ ...colors.map((color) => ({
+ label: `Colored / ${color.charAt(0).toUpperCase()}${color.slice(1)}`,
+ type: 'colored' as const,
+ color,
+ })),
+ ...colors.map((color) => ({
+ label: `Outlined / ${color.charAt(0).toUpperCase()}${color.slice(1)}`,
+ type: 'outlined' as const,
+ color,
+ })),
+ ...colors.map((color) => ({
+ label: `Quiet / ${color.charAt(0).toUpperCase()}${color.slice(1)}`,
+ type: 'quiet' as const,
+ color,
+ })),
+]
+
+const meta = {
+ title: 'Buttons/Button',
+ component: Button,
+ argTypes: {
+ type: {
+ control: 'select',
+ options: types,
+ },
+ size: {
+ control: 'select',
+ options: sizes,
+ },
+ color: {
+ control: 'select',
+ options: colors,
+ },
+ nativeType: {
+ control: 'select',
+ options: ['button', 'submit', 'reset'],
+ },
+ disabled: { control: 'boolean' },
+ loading: { control: 'boolean' },
+ },
+ args: {
+ type: 'base',
+ size: 'md',
+ color: 'brand',
+ nativeType: 'button',
+ disabled: false,
+ loading: false,
+ },
+ render: (args) => ({
+ components: { Button, DownloadIcon },
+ setup() {
+ return { args }
+ },
+ template: /*html*/ `
+
+
+ Download
+
+ `,
+ }),
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Playground: Story = {}
+
+export const AllTypes: Story = {
+ render: () => ({
+ components: { Button, DownloadIcon },
+ setup() {
+ return { sizeColumns, typeRows }
+ },
+ template: /*html*/ `
+
+
+
+ {{ size.label }}
+
+
+
+ {{ row.label }}
+
+ Button
+
+
+
+ `,
+ }),
+}
+
+export const Quiet: Story = {
+ render: () => ({
+ components: { Button, DownloadIcon, IconButton, SettingsIcon },
+ template: /*html*/ `
+
+ Quiet
+ Quiet destructive
+
+
+ `,
+ }),
+}
+
+export const Sizes: Story = {
+ render: () => ({
+ components: { Button, DownloadIcon, IconButton },
+ setup() {
+ return { sizes }
+ },
+ template: /*html*/ `
+
+
+ {{ size }}
+
+
+
+ `,
+ }),
+}
+
+export const Colors: Story = {
+ render: () => ({
+ components: { Button },
+ setup() {
+ return { colors }
+ },
+ template: /*html*/ `
+
+
+ {{ color }}
+
+
+ `,
+ }),
+}
+
+export const Content: Story = {
+ render: () => ({
+ components: { Button, DownloadIcon, SettingsIcon },
+ template: /*html*/ `
+
+ Text only
+ Leading icon
+ Trailing icon
+ Full width
+ Continue with a deliberately long translated action label
+
+ `,
+ }),
+}
+
+export const InteractionStates: Story = {
+ render: () => ({
+ components: { Button },
+ template: /*html*/ `
+
+ Enabled
+ Disabled
+ Loading
+ Colored
+ Colored disabled
+ Outlined
+ Quiet
+
+ `,
+ }),
+}
+
+export const LinksAndIconButton: Story = {
+ render: () => ({
+ components: { ButtonLink, ExternalIcon, HeartIcon, IconButton },
+ template: /*html*/ `
+
+ Internal link
+
+ Modrinth
+
+ Disabled link
+
+
+
+
+
+ `,
+ }),
+}
diff --git a/packages/ui/src/stories/buttons/ButtonGroup.stories.ts b/packages/ui/src/stories/buttons/ButtonGroup.stories.ts
new file mode 100644
index 0000000000..3f234148c4
--- /dev/null
+++ b/packages/ui/src/stories/buttons/ButtonGroup.stories.ts
@@ -0,0 +1,82 @@
+import { PlayIcon, SettingsIcon, StopCircleIcon, TrashIcon } from '@modrinth/assets'
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+
+import Button from '../../components/base/buttons/Button.vue'
+import ButtonGroup from '../../components/base/buttons/ButtonGroup.vue'
+import SplitButton from '../../components/base/buttons/SplitButton.vue'
+import type { OverflowMenuOption } from '../../components/base/buttons/types'
+
+const splitOptions: OverflowMenuOption[] = [
+ {
+ id: 'settings',
+ label: 'Server settings',
+ icon: SettingsIcon,
+ action: () => undefined,
+ },
+ { type: 'divider' },
+ {
+ id: 'delete',
+ label: 'Delete server',
+ icon: TrashIcon,
+ tone: 'red',
+ action: () => undefined,
+ },
+]
+
+const meta = {
+ title: 'Buttons/Button Group',
+ component: ButtonGroup,
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Joined: Story = {
+ render: () => ({
+ components: { Button, ButtonGroup },
+ template: /*html*/ `
+
+ Previous
+ Next
+
+ `,
+ }),
+}
+
+export const Split: Story = {
+ render: () => ({
+ components: { PlayIcon, SplitButton },
+ setup() {
+ return { splitOptions }
+ },
+ template: /*html*/ `
+
+ Start server
+
+ `,
+ }),
+}
+
+export const IndependentDisabledStates: Story = {
+ render: () => ({
+ components: { SplitButton, StopCircleIcon },
+ setup() {
+ return { splitOptions }
+ },
+ template: /*html*/ `
+
+
+ Primary disabled
+
+
+ Menu disabled
+
+
+ `,
+ }),
+}
diff --git a/packages/ui/src/stories/buttons/FileButton.stories.ts b/packages/ui/src/stories/buttons/FileButton.stories.ts
new file mode 100644
index 0000000000..2f07b27acc
--- /dev/null
+++ b/packages/ui/src/stories/buttons/FileButton.stories.ts
@@ -0,0 +1,61 @@
+import { UploadIcon } from '@modrinth/assets'
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+
+import FileButton from '../../components/base/buttons/FileButton.vue'
+
+const meta = {
+ title: 'Buttons/File Button',
+ component: FileButton,
+ argTypes: {
+ type: {
+ control: 'select',
+ options: ['base', 'colored', 'outlined', 'quiet'],
+ },
+ size: {
+ control: 'select',
+ options: ['sm', 'md', 'lg', 'xl'],
+ },
+ color: {
+ control: 'select',
+ options: ['brand', 'red', 'orange', 'green', 'blue', 'purple', 'medal_promotion'],
+ },
+ },
+ args: {
+ prompt: 'Select file',
+ type: 'base',
+ size: 'md',
+ multiple: false,
+ disabled: false,
+ },
+ render: (args) => ({
+ components: { FileButton, UploadIcon },
+ setup() {
+ return { args }
+ },
+ template: /*html*/ `
+
+
+
+ `,
+ }),
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {}
+
+export const MultipleImages: Story = {
+ args: {
+ prompt: 'Select images',
+ accept: 'image/*',
+ multiple: true,
+ type: 'colored',
+ },
+}
+
+export const Disabled: Story = {
+ args: {
+ disabled: true,
+ },
+}
diff --git a/packages/ui/src/stories/buttons/TeleportOverflowMenu.stories.ts b/packages/ui/src/stories/buttons/TeleportOverflowMenu.stories.ts
new file mode 100644
index 0000000000..5ed21392f1
--- /dev/null
+++ b/packages/ui/src/stories/buttons/TeleportOverflowMenu.stories.ts
@@ -0,0 +1,104 @@
+import {
+ DownloadIcon,
+ ExternalIcon,
+ MoreVerticalIcon,
+ SettingsIcon,
+ TrashIcon,
+} from '@modrinth/assets'
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+
+import TeleportOverflowMenu from '../../components/base/buttons/TeleportOverflowMenu.vue'
+import type { OverflowMenuOption } from '../../components/base/buttons/types'
+
+const options: OverflowMenuOption[] = [
+ {
+ id: 'download',
+ label: 'Download',
+ icon: DownloadIcon,
+ action: () => undefined,
+ },
+ {
+ id: 'settings',
+ label: 'Project settings',
+ icon: SettingsIcon,
+ type: 'link',
+ to: '/settings',
+ },
+ {
+ id: 'website',
+ label: 'Open website',
+ icon: ExternalIcon,
+ type: 'link',
+ href: 'https://modrinth.com',
+ target: '_blank',
+ },
+ {
+ id: 'unavailable',
+ label: 'Unavailable action',
+ disabled: true,
+ tooltip: 'This action is currently unavailable',
+ action: () => undefined,
+ },
+ { type: 'divider' },
+ {
+ id: 'delete',
+ label: 'Delete project',
+ icon: TrashIcon,
+ tone: 'red',
+ action: () => undefined,
+ },
+]
+
+const meta = {
+ title: 'Buttons/Teleport Overflow Menu',
+ component: TeleportOverflowMenu,
+ args: {
+ label: 'More actions',
+ options,
+ type: 'base',
+ size: 'md',
+ placement: 'bottom-end',
+ disabled: false,
+ hoverable: false,
+ },
+ render: (args) => ({
+ components: { MoreVerticalIcon, TeleportOverflowMenu },
+ setup() {
+ return { args }
+ },
+ template: /*html*/ `
+
+
+
+ `,
+ }),
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {}
+
+export const ColoredTrigger: Story = {
+ args: {
+ type: 'colored',
+ },
+}
+
+export const OutlinedTrigger: Story = {
+ args: {
+ type: 'outlined',
+ },
+}
+
+export const QuietTrigger: Story = {
+ args: {
+ type: 'quiet',
+ },
+}
+
+export const Hoverable: Story = {
+ args: {
+ hoverable: true,
+ },
+}
diff --git a/packages/ui/src/stories/buttons/TeleportPopoutMenu.stories.ts b/packages/ui/src/stories/buttons/TeleportPopoutMenu.stories.ts
new file mode 100644
index 0000000000..d6b183197c
--- /dev/null
+++ b/packages/ui/src/stories/buttons/TeleportPopoutMenu.stories.ts
@@ -0,0 +1,50 @@
+import { SettingsIcon } from '@modrinth/assets'
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+
+import Button from '../../components/base/buttons/Button.vue'
+import TeleportPopoutMenu from '../../components/base/buttons/TeleportPopoutMenu.vue'
+
+const meta = {
+ title: 'Buttons/Teleport Popout Menu',
+ component: TeleportPopoutMenu,
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const ArbitraryContent: Story = {
+ render: () => ({
+ components: { Button, SettingsIcon, TeleportPopoutMenu },
+ template: /*html*/ `
+
+
+ Configure
+
+
+
+
+
+ Apply
+
+
+
+ `,
+ }),
+}
+
+export const IconTrigger: Story = {
+ render: () => ({
+ components: { SettingsIcon, TeleportPopoutMenu },
+ template: /*html*/ `
+
+
+
+ Arbitrary teleported content can live here.
+
+
+ `,
+ }),
+}
diff --git a/packages/ui/src/stories/instances/ContentCardTable.stories.ts b/packages/ui/src/stories/instances/ContentCardTable.stories.ts
index 486a4c909e..e7a90ebbea 100644
--- a/packages/ui/src/stories/instances/ContentCardTable.stories.ts
+++ b/packages/ui/src/stories/instances/ContentCardTable.stories.ts
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { fn } from 'storybook/test'
import { onMounted, onUnmounted, ref } from 'vue'
-import ButtonStyled from '../../components/base/ButtonStyled.vue'
+import { Button, IconButton } from '../../components/base/buttons'
import ContentCardTable from '../../layouts/shared/content-tab/components/ContentCardTable.vue'
import type { ContentCardTableItem } from '../../layouts/shared/content-tab/types'
@@ -539,7 +539,7 @@ export const InteractiveActions: Story = {
export const WithCustomItemButtons: Story = {
render: () => ({
- components: { ContentCardTable, ButtonStyled, EyeIcon, FolderOpenIcon, DownloadIcon },
+ components: { ContentCardTable, EyeIcon, FolderOpenIcon, DownloadIcon, Button, IconButton },
setup() {
return { items: sampleItems }
},
@@ -551,23 +551,17 @@ export const WithCustomItemButtons: Story = {
@delete="(id) => console.log('Delete', id)"
>
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
`,
@@ -582,15 +576,13 @@ export const WithEmptyState: Story = {
export const WithCustomEmptyState: Story = {
render: () => ({
- components: { ContentCardTable, ButtonStyled },
+ components: { ContentCardTable, Button, IconButton },
template: /*html*/ `
No mods installed
-
- Browse mods
-
+ Browse mods
@@ -781,7 +773,7 @@ export const WithOverflowMenu: Story = {
export const BulkActionsDemo: Story = {
render: () => ({
- components: { ContentCardTable, ButtonStyled },
+ components: { ContentCardTable, Button, IconButton },
setup() {
const items = ref([
{ ...sodiumItem, enabled: true },
@@ -825,15 +817,9 @@ export const BulkActionsDemo: Story = {
{{ selectedIds.length }} selected
-
- Enable
-
-
- Disable
-
-
- Delete
-
+ Enable
+ Disable
+ Delete
export const InstanceDependency: Story = {
render: () => ({
- components: { ButtonStyled, ContentDependencyWarningModal },
+ components: { ContentDependencyWarningModal, Button },
setup() {
const modalRef = ref | null>(null)
const deleted = ref(false)
@@ -186,9 +186,7 @@ export const InstanceDependency: Story = {
},
template: /* html */ `
-
- Delete dependency
-
+
Delete dependency
Dependency deletion confirmed
({
- components: { ButtonStyled, ContentDependencyWarningModal },
+ components: { ContentDependencyWarningModal, Button },
setup() {
const modalRef = ref | null>(null)
const deleted = ref(false)
@@ -227,9 +225,7 @@ export const ServerDependency: Story = {
},
template: /* html */ `
-
- Delete server dependency
-
+
Delete server dependency
Server dependency deletion confirmed
({
- components: { ButtonStyled, ContentDependencyWarningModal },
+ components: { ContentDependencyWarningModal, Button },
setup() {
const modalRef = ref | null>(null)
const deleted = ref(false)
@@ -267,9 +263,7 @@ export const BulkDependencies: Story = {
},
template: /* html */ `
-
- Delete selected dependencies
-
+
Delete selected dependencies
Bulk dependency deletion confirmed
export const ModExample: Story = {
render: (args) => ({
- components: { ContentUpdaterModal, ButtonStyled },
+ components: { ContentUpdaterModal, Button },
setup() {
const modalRef = ref | null>(null)
const openModal = () => modalRef.value?.show()
@@ -277,9 +277,7 @@ export const ModExample: Story = {
},
template: /*html*/ `
-
- Update Sodium
-
+
Update Sodium
({
- components: { ContentUpdaterModal, ButtonStyled },
+ components: { ContentUpdaterModal, Button },
setup() {
const modalRef = ref | null>(null)
const openModal = () => modalRef.value?.show()
@@ -318,9 +316,7 @@ export const ModpackExample: Story = {
},
template: /*html*/ `
-
- Update Cobblemon Modpack
-
+
Update Cobblemon Modpack
({
- components: { ContentUpdaterModal, ButtonStyled },
+ components: { ContentUpdaterModal, Button },
setup() {
const modalRef = ref | null>(null)
const openModal = () => modalRef.value?.show()
@@ -355,9 +351,7 @@ export const WithIncompatibleVersions: Story = {
},
template: /*html*/ `
-
- Update (Shows Incompatible)
-
+
Update (Shows Incompatible)
({
- components: { ContentUpdaterModal, ButtonStyled },
+ components: { ContentUpdaterModal, Button },
setup() {
const modalRef = ref | null>(null)
const openModal = () => modalRef.value?.show()
@@ -389,9 +383,7 @@ export const AllVersionTypes: Story = {
},
template: /*html*/ `
-
- View All Version Types
-
+
View All Version Types
export const Default: Story = {
render: () => ({
- components: { ModpackContentModal, ButtonStyled },
+ components: { ModpackContentModal, Button },
setup() {
const modalRef = ref | null>(null)
const openModal = () => modalRef.value?.show(mixedModpackContent)
@@ -417,9 +417,7 @@ export const Default: Story = {
},
template: /*html*/ `
-
- View Modpack Content (Mixed)
-
+
View Modpack Content (Mixed)
({
- components: { ModpackContentModal, ButtonStyled },
+ components: { ModpackContentModal, Button },
setup() {
const modalRef = ref | null>(null)
const openModal = () => modalRef.value?.show(modsOnlyContent)
@@ -440,9 +438,7 @@ export const ModsOnly: Story = {
},
template: /*html*/ `
-
- View Modpack Content (Mods Only)
-
+ View Modpack Content (Mods Only)
`,
@@ -455,7 +451,7 @@ export const ModsOnly: Story = {
export const LoadingState: Story = {
render: () => ({
- components: { ModpackContentModal, ButtonStyled },
+ components: { ModpackContentModal, Button },
setup() {
const modalRef = ref | null>(null)
const openModal = () => {
@@ -469,9 +465,7 @@ export const LoadingState: Story = {
},
template: /*html*/ `
-
- View Content (With Loading)
-
+
View Content (With Loading)
({
- components: { ModpackContentModal, ButtonStyled },
+ components: { ModpackContentModal, Button },
setup() {
const modalRef = ref | null>(null)
const openModal = () => modalRef.value?.show([])
@@ -496,9 +490,7 @@ export const EmptyContent: Story = {
},
template: /*html*/ `
-
- View Empty Modpack
-
+ View Empty Modpack
`,
@@ -511,7 +503,7 @@ export const EmptyContent: Story = {
export const LargeModpack: Story = {
render: () => ({
- components: { ModpackContentModal, ButtonStyled },
+ components: { ModpackContentModal, Button },
setup() {
const modalRef = ref | null>(null)
const openModal = () => modalRef.value?.show(largeModpackContent)
@@ -519,9 +511,7 @@ export const LargeModpack: Story = {
},
template: /*html*/ `
-
- View Large Modpack (47 items)
-
+
View Large Modpack (47 items)
({
- components: { ModpackContentModal, ButtonStyled },
+ components: { ModpackContentModal, Button },
setup() {
const modalRef = ref | null>(null)
const openModal = () => modalRef.value?.show(mixedModpackContent)
@@ -549,9 +539,7 @@ export const SearchDemo: Story = {
Click the button and try searching for "sodium", "shader", or "faithful" to test the search functionality.
-
- Test Search
-
+ Test Search
`,
@@ -564,7 +552,7 @@ export const SearchDemo: Story = {
export const FilterDemo: Story = {
render: () => ({
- components: { ModpackContentModal, ButtonStyled },
+ components: { ModpackContentModal, Button },
setup() {
const modalRef = ref | null>(null)
const openModal = () => modalRef.value?.show(mixedModpackContent)
@@ -575,9 +563,7 @@ export const FilterDemo: Story = {
Click the button and try the filter chips (Mods, Shaders, Resource Packs) to filter content by type.
-
- Test Filters
-
+ Test Filters
`,
@@ -590,7 +576,7 @@ export const FilterDemo: Story = {
export const MixedOwnerTypes: Story = {
render: () => ({
- components: { ModpackContentModal, ButtonStyled },
+ components: { ModpackContentModal, Button },
setup() {
const modalRef = ref | null>(null)
// Mix of user and organization owners
@@ -608,9 +594,7 @@ export const MixedOwnerTypes: Story = {
Shows content with different owner types: users (circular avatar) and organizations (rounded + icon).
-
- View Mixed Owners
-
+ View Mixed Owners
`,
diff --git a/packages/ui/src/stories/modal/ConfirmLeaveModal.stories.ts b/packages/ui/src/stories/modal/ConfirmLeaveModal.stories.ts
index 9509d27ac1..ac33eb8886 100644
--- a/packages/ui/src/stories/modal/ConfirmLeaveModal.stories.ts
+++ b/packages/ui/src/stories/modal/ConfirmLeaveModal.stories.ts
@@ -1,7 +1,7 @@
import type { StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
-import ButtonStyled from '../../components/base/ButtonStyled.vue'
+import { Button } from '../../components/base/buttons'
import ConfirmLeaveModal from '../../components/modal/ConfirmLeaveModal.vue'
const meta = {
@@ -14,7 +14,7 @@ type Story = StoryObj
export const Default: Story = {
render: () => ({
- components: { ConfirmLeaveModal, ButtonStyled },
+ components: { ConfirmLeaveModal, Button },
setup() {
const modalRef = ref | null>(null)
const result = ref('')
@@ -27,9 +27,7 @@ export const Default: Story = {
},
template: /* html */ `
-
- Trigger Leave Confirmation
-
+
Trigger Leave Confirmation
{{ result }}
@@ -39,7 +37,7 @@ export const Default: Story = {
export const CustomMessages: Story = {
render: () => ({
- components: { ConfirmLeaveModal, ButtonStyled },
+ components: { ConfirmLeaveModal, Button },
setup() {
const modalRef = ref | null>(null)
const openModal = () => modalRef.value?.prompt()
@@ -47,9 +45,7 @@ export const CustomMessages: Story = {
},
template: /* html */ `
-
- Discard Draft?
-
+
Discard Draft?
({
- components: { ConfirmLeaveModal, ButtonStyled },
+ components: { ConfirmLeaveModal, Button },
setup() {
const modalRef = ref | null>(null)
const openModal = () => modalRef.value?.prompt()
@@ -73,9 +69,7 @@ export const WarningAdmonition: Story = {
},
template: /* html */ `
-
- Open Warning Variant
-
+
Open Warning Variant
export const Default: Story = {
render: () => ({
- components: { NewModal, ButtonStyled },
+ components: { NewModal, Button },
setup() {
const modalRef = ref | null>(null)
const openModal = () => modalRef.value?.show()
@@ -22,9 +22,7 @@ export const Default: Story = {
},
template: `
-
- Open Modal
-
+
Open Modal
This is the modal content.
You can put any content here.
@@ -36,7 +34,7 @@ export const Default: Story = {
export const WithActions: Story = {
render: () => ({
- components: { NewModal, ButtonStyled },
+ components: { NewModal, Button },
setup() {
const modalRef = ref | null>(null)
const openModal = () => modalRef.value?.show()
@@ -44,19 +42,13 @@ export const WithActions: Story = {
},
template: `
-
- Open Modal with Actions
-
+
Open Modal with Actions
Are you sure you want to proceed with this action?
-
- Cancel
-
-
- Confirm
-
+ Cancel
+ Confirm
@@ -67,7 +59,7 @@ export const WithActions: Story = {
export const DangerFade: Story = {
render: () => ({
- components: { NewModal, ButtonStyled },
+ components: { NewModal, Button },
setup() {
const modalRef = ref
| null>(null)
const openModal = () => modalRef.value?.show()
@@ -75,19 +67,13 @@ export const DangerFade: Story = {
},
template: `
-
- Open Danger Modal
-
+
Open Danger Modal
Are you sure you want to delete this item? This action cannot be undone.
-
- Cancel
-
-
- Delete
-
+ Cancel
+ Delete
@@ -98,7 +84,7 @@ export const DangerFade: Story = {
export const WarningFade: Story = {
render: () => ({
- components: { NewModal, ButtonStyled },
+ components: { NewModal, Button },
setup() {
const modalRef = ref
| null>(null)
const openModal = () => modalRef.value?.show()
@@ -106,19 +92,13 @@ export const WarningFade: Story = {
},
template: `
-
- Open Warning Modal
-
+
Open Warning Modal
This action may have unintended consequences. Please review before proceeding.
-
- Cancel
-
-
- Proceed
-
+ Cancel
+ Proceed
@@ -129,7 +109,7 @@ export const WarningFade: Story = {
export const Scrollable: Story = {
render: () => ({
- components: { NewModal, ButtonStyled },
+ components: { NewModal, Button },
setup() {
const modalRef = ref
| null>(null)
const openModal = () => modalRef.value?.show()
@@ -137,9 +117,7 @@ export const Scrollable: Story = {
},
template: `
-
- Open Scrollable Modal
-
+
Open Scrollable Modal
@@ -148,9 +126,7 @@ export const Scrollable: Story = {
-
- Close
-
+ Close
@@ -161,7 +137,7 @@ export const Scrollable: Story = {
export const MergedHeader: Story = {
render: () => ({
- components: { NewModal, ButtonStyled },
+ components: { NewModal, Button },
setup() {
const modalRef = ref
| null>(null)
const openModal = () => modalRef.value?.show()
@@ -169,9 +145,7 @@ export const MergedHeader: Story = {
},
template: `
-
- Open Modal (Merged Header)
-
+
Open Modal (Merged Header)
Custom Header Area
@@ -185,7 +159,7 @@ export const MergedHeader: Story = {
export const NotClosable: Story = {
render: () => ({
- components: { NewModal, ButtonStyled },
+ components: { NewModal, Button },
setup() {
const modalRef = ref
| null>(null)
const openModal = () => modalRef.value?.show()
@@ -193,17 +167,13 @@ export const NotClosable: Story = {
},
template: `
-
- Open Non-Closable Modal
-
+
Open Non-Closable Modal
This modal cannot be closed by clicking outside or pressing escape.
Only the action button can close it.
-
- I understand, close
-
+ I understand, close
@@ -214,7 +184,7 @@ export const NotClosable: Story = {
export const NoPadding: Story = {
render: () => ({
- components: { NewModal, ButtonStyled },
+ components: { NewModal, Button },
setup() {
const modalRef = ref
| null>(null)
const openModal = () => modalRef.value?.show()
@@ -222,16 +192,12 @@ export const NoPadding: Story = {
},
template: `
-
- Open Modal (No Padding)
-
+
Open Modal (No Padding)
This modal has no default padding on the content area.
-
- Close
-
+ Close
diff --git a/packages/ui/src/stories/modal/ShareModal.stories.ts b/packages/ui/src/stories/modal/ShareModal.stories.ts
index 10b07701f4..28c81af2ae 100644
--- a/packages/ui/src/stories/modal/ShareModal.stories.ts
+++ b/packages/ui/src/stories/modal/ShareModal.stories.ts
@@ -1,7 +1,7 @@
import type { StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
-import ButtonStyled from '../../components/base/ButtonStyled.vue'
+import { Button } from '../../components/base/buttons'
import ShareModal from '../../components/modal/ShareModal.vue'
const meta = {
@@ -20,7 +20,7 @@ export const LinkShare: Story = {
link: true,
},
render: (args) => ({
- components: { ShareModal, ButtonStyled },
+ components: { ShareModal, Button },
setup() {
const modalRef = ref
| null>(null)
const openModal = () => {
@@ -30,9 +30,7 @@ export const LinkShare: Story = {
},
template: `
-
- Open Link Share Modal
-
+ Open Link Share Modal
`,
@@ -47,7 +45,7 @@ export const TextShare: Story = {
link: false,
},
render: (args) => ({
- components: { ShareModal, ButtonStyled },
+ components: { ShareModal, Button },
setup() {
const modalRef = ref | null>(null)
const openModal = () => {
@@ -57,9 +55,7 @@ export const TextShare: Story = {
},
template: `
-
- Open Text Share Modal
-
+ Open Text Share Modal
`,
diff --git a/packages/ui/src/stories/modal/TabbedModal.stories.ts b/packages/ui/src/stories/modal/TabbedModal.stories.ts
index aee894f212..5d85e192ef 100644
--- a/packages/ui/src/stories/modal/TabbedModal.stories.ts
+++ b/packages/ui/src/stories/modal/TabbedModal.stories.ts
@@ -14,7 +14,7 @@ import {
import type { StoryObj } from '@storybook/vue3-vite'
import { defineComponent, h, ref } from 'vue'
-import ButtonStyled from '../../components/base/ButtonStyled.vue'
+import { Button } from '../../components/base/buttons'
import UnsavedChangesPopup from '../../components/base/UnsavedChangesPopup.vue'
import TabbedModal from '../../components/modal/TabbedModal.vue'
@@ -42,7 +42,7 @@ export default meta
export const Default: StoryObj = {
render: () => ({
- components: { TabbedModal, ButtonStyled },
+ components: { TabbedModal, Button },
setup() {
const modalRef = ref | null>(null)
const tabs = [
@@ -66,9 +66,7 @@ export const Default: StoryObj = {
},
template: /* html */ `
-
- Open Tabbed Modal
-
+ Open Tabbed Modal
`,
@@ -77,7 +75,7 @@ export const Default: StoryObj = {
export const WithTitleSlot: StoryObj = {
render: () => ({
- components: { TabbedModal, ButtonStyled, SettingsIcon },
+ components: { TabbedModal, SettingsIcon, Button },
setup() {
const modalRef = ref | null>(null)
const tabs = [
@@ -96,9 +94,7 @@ export const WithTitleSlot: StoryObj = {
},
template: /* html */ `
-
- Open with Title Slot
-
+ Open with Title Slot