Merge commit '5ab12634954dabea006912efcdeb4d30992858fd' into feature-clean

This commit is contained in:
2024-11-16 19:22:11 +03:00
157 changed files with 21219 additions and 1463 deletions

4
.gitignore vendored
View File

@@ -56,4 +56,8 @@ generated
# app testing dir
app-playground-data/*
# soley because i need the PORT to be 3002 due to WSL stuff
.env
apps/frontend/.env
.astro

5
.idea/daedalus.iml generated
View File

@@ -7,6 +7,11 @@
<sourceFolder url="file://$MODULE_DIR$/daedalus_client_new/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/apps/daedalus_client/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/daedalus/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/apps/app-playground/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/apps/app/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/packages/app-lib/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />

1167
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -402,7 +402,14 @@ await getBranches()
The memory allocated to each instance when it is ran.
</span>
</label>
<Slider id="max-memory" v-model="settings.memory.maximum" :min="8" :max="maxMemory" :step="64" unit="mb" />
<Slider
id="max-memory"
v-model="settings.memory.maximum"
:min="8"
:max="maxMemory"
:step="64"
unit="MB"
/>
</div>
</Card>
<Card>

View File

@@ -2,9 +2,7 @@
"identifier": "plugins",
"description": "",
"local": true,
"windows": [
"main"
],
"windows": ["main"],
"permissions": [
"dialog:allow-open",
"dialog:allow-confirm",

View File

@@ -84,7 +84,7 @@
}
},
"permissions": {
"description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ```",
"description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionEntry"

View File

@@ -84,7 +84,7 @@
}
},
"permissions": {
"description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ```",
"description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionEntry"

File diff suppressed because it is too large Load Diff

View File

@@ -16,18 +16,27 @@ serde_json = "1.0"
serde-xml-rs = "0.6.0"
lazy_static = "1.4.0"
thiserror = "1.0"
reqwest = { version = "0.12.5", features = ["stream", "json", "rustls-tls"] }
reqwest = { version = "0.12.5", default-features = false, features = [
"stream",
"json",
"rustls-tls-native-roots",
] }
async_zip = { version = "0.0.17", features = ["full"] }
semver = "1.0"
chrono = { version = "0.4", features = ["serde"] }
bytes = "1.6.0"
rust-s3 = "0.34.0"
rust-s3 = { version = "0.33.0", default-features = false, features = [
"fail-on-err",
"tags",
"tokio-rustls-tls",
"reqwest",
] }
dashmap = "5.5.3"
sha1_smol = { version = "1.0.0", features = ["std"] }
indexmap = { version = "2.2.6", features = ["serde"]}
indexmap = { version = "2.2.6", features = ["serde"] }
itertools = "0.13.0"
tracing-error = "0.2.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-futures = { version = "0.2.5", features = ["futures", "tokio"] }
tracing-futures = { version = "0.2.5", features = ["futures", "tokio"] }

View File

@@ -749,6 +749,7 @@ async fn fetch(
let manifest = daedalus::modded::Manifest {
game_versions: forge_versions
.into_iter()
.sorted_by(|a, b| b.game_version.cmp(&a.game_version))
.rev()
.chunk_by(|x| x.game_version.clone())
.into_iter()

View File

@@ -1,5 +1,5 @@
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import starlight from '@astrojs/starlight'
import { defineConfig } from 'astro/config'
import starlightOpenAPI, { openAPISidebarGroups } from 'starlight-openapi'
// https://astro.build/config
@@ -8,15 +8,16 @@ export default defineConfig({
integrations: [
starlight({
title: 'Modrinth Documentation',
favicon: '/favicon.ico',
editLink: {
baseUrl: 'https://github.com/modrinth/code/edit/main/apps/docs/',
},
social: {
github: 'https://github.com/modrinth/code',
discord: 'https://discord.modrinth.com',
'x.com': 'https://x.com/modrinth',
mastodon: 'https://floss.social/@modrinth',
threads: 'https://threads.net/@modrinth',
github: 'https://github.com/modrinth/code',
discord: 'https://discord.modrinth.com',
'x.com': 'https://x.com/modrinth',
mastodon: 'https://floss.social/@modrinth',
threads: 'https://threads.net/@modrinth',
},
logo: {
light: './src/assets/light-logo.svg',
@@ -36,16 +37,16 @@ export default defineConfig({
label: 'Modrinth API',
schema: './public/openapi.yaml',
},
])
]),
],
sidebar: [
{
label: 'Contributing to Modrinth',
autogenerate: { directory: 'contributing' },
label: 'Contributing to Modrinth',
autogenerate: { directory: 'contributing' },
},
// Add the generated sidebar group to the sidebar.
...openAPISidebarGroups,
],
}),
],
});
})

View File

@@ -339,8 +339,8 @@ components:
type: array
items:
type: string
description: The mod loaders that this version supports
example: ["fabric", "forge"]
description: The mod loaders that this version supports. In case of resource packs, use "minecraft"
example: ["fabric", "forge", "minecraft"]
featured:
type: boolean
description: Whether the version is featured or not

View File

@@ -1,4 +1,7 @@
module.exports = {
root: true,
extends: ["../../packages/eslint-config-custom/nuxt.js"],
rules: {
"import/no-unresolved": "off",
},
};

View File

@@ -176,7 +176,6 @@ export default defineNuxtConfig({
$fetch(`${API_URL}projects_random?count=60`, headers),
$fetch(`${API_URL}search?limit=3&query=leave&index=relevance`, headers),
$fetch(`${API_URL}search?limit=3&query=&index=updated`, headers),
// TODO: dehardcode
$fetch(`${API_URL.replace("/v2/", "/_internal/")}billing/products`, headers),
]);
@@ -321,8 +320,10 @@ export default defineNuxtConfig({
apiBaseUrl: process.env.BASE_URL ?? globalThis.BASE_URL ?? getApiUrl(),
// @ts-ignore
rateLimitKey: process.env.RATE_LIMIT_IGNORE_KEY ?? globalThis.RATE_LIMIT_IGNORE_KEY,
pyroBaseUrl: process.env.PYRO_BASE_URL,
public: {
apiBaseUrl: getApiUrl(),
pyroBaseUrl: process.env.PYRO_BASE_URL,
siteUrl: getDomain(),
production: isProduction(),
featureFlagOverrides: getFeatureFlagOverrides(),
@@ -361,7 +362,7 @@ export default defineNuxtConfig({
},
},
},
modules: ["@vintl/nuxt", "@nuxtjs/turnstile"],
modules: ["@vintl/nuxt", "@nuxtjs/turnstile", "@pinia/nuxt"],
vintl: {
defaultLocale: "en-US",
locales: [
@@ -462,6 +463,7 @@ function getDomain() {
return "https://modrinth.com";
}
} else {
return "http://localhost:3000";
const port = process.env.PORT || 3000;
return `http://localhost:${port}`;
}
}

View File

@@ -16,6 +16,7 @@
"@formatjs/cli": "^6.2.12",
"@nuxt/devtools": "^1.3.3",
"@nuxtjs/turnstile": "^0.8.0",
"@types/dompurify": "^3.0.5",
"@types/node": "^20.1.0",
"@vintl/compact-number": "^2.0.5",
"@vintl/how-ago": "^3.0.1",
@@ -38,8 +39,13 @@
"@modrinth/assets": "workspace:*",
"@modrinth/ui": "workspace:*",
"@modrinth/utils": "workspace:*",
"@pinia/nuxt": "^0.5.1",
"@vintl/vintl": "^4.4.1",
"@vueuse/core": "^11.1.0",
"ace-builds": "^1.36.2",
"ansi-to-html": "^0.7.2",
"dayjs": "^1.11.7",
"dompurify": "^3.1.7",
"floating-vue": "2.0.0-beta.20",
"fuse.js": "^6.6.2",
"highlight.js": "^11.7.0",
@@ -48,9 +54,12 @@
"jszip": "^3.10.1",
"markdown-it": "14.1.0",
"pathe": "^1.1.2",
"pinia": "^2.1.7",
"qrcode.vue": "^3.4.0",
"semver": "^7.5.4",
"vue-multiselect": "3.0.0",
"vue-multiselect": "3.0.0-alpha.2",
"vue-typed-virtual-list": "^1.0.10",
"vue3-ace-editor": "^2.2.4",
"vue3-apexcharts": "^1.5.2",
"xss": "^1.0.14"
}

View File

@@ -0,0 +1,12 @@
<svg width="59" height="59" viewBox="0 0 59 59" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3318_3814)">
<rect x="0.414214" y="29.75" width="41.4871" height="41.4871" rx="6" transform="rotate(-45 0.414214 29.75)" fill="#601C2D" stroke="#FF496E" stroke-width="2"/>
<path d="M28.3854 24.14C27.7881 24.14 27.2747 23.9813 26.8454 23.664C26.4161 23.3373 26.0894 22.88 25.8654 22.292C25.6414 21.6947 25.5294 20.9993 25.5294 20.206C25.5294 18.8993 25.7954 17.91 26.3274 17.238C26.8594 16.5567 27.5687 16.216 28.4554 16.216C28.8194 16.216 29.1367 16.2673 29.4074 16.37C29.6874 16.4633 29.9301 16.594 30.1354 16.762C30.3407 16.93 30.5181 17.1213 30.6674 17.336H30.7234L30.9054 16.356H32.6694V21.578C32.6694 21.8953 32.7254 22.11 32.8374 22.222C32.9587 22.334 33.0941 22.39 33.2434 22.39C33.3087 22.39 33.3834 22.3807 33.4674 22.362C33.5514 22.3433 33.6121 22.3293 33.6494 22.32V23.958C33.6027 23.986 33.5234 24.014 33.4114 24.042C33.2994 24.07 33.1781 24.0933 33.0474 24.112C32.9261 24.1307 32.8141 24.14 32.7114 24.14C32.2447 24.14 31.8667 24.056 31.5774 23.888C31.2881 23.72 31.0641 23.4213 30.9054 22.992H30.7654C30.6254 23.1973 30.4434 23.3887 30.2194 23.566C29.9954 23.7433 29.7294 23.8833 29.4214 23.986C29.1134 24.0887 28.7681 24.14 28.3854 24.14ZM29.1554 22.502C29.5381 22.502 29.8414 22.4227 30.0654 22.264C30.2987 22.1053 30.4667 21.8627 30.5694 21.536C30.6721 21.2 30.7234 20.78 30.7234 20.276V20.192C30.7234 19.4453 30.6067 18.8807 30.3734 18.498C30.1494 18.106 29.7341 17.91 29.1274 17.91C28.6327 17.91 28.2641 18.106 28.0214 18.498C27.7787 18.89 27.6574 19.464 27.6574 20.22C27.6574 20.9667 27.7787 21.536 28.0214 21.928C28.2641 22.3107 28.6421 22.502 29.1554 22.502Z" fill="#FF496E"/>
<path d="M23.0278 31.9446C23.0136 31.7884 22.9504 31.6669 22.8382 31.5803C22.7274 31.4922 22.569 31.4482 22.363 31.4482C22.2267 31.4482 22.113 31.4659 22.0221 31.5014C21.9312 31.5369 21.863 31.5859 21.8176 31.6484C21.7721 31.7095 21.7487 31.7798 21.7472 31.8594C21.7444 31.9247 21.7572 31.9822 21.7856 32.032C21.8154 32.0817 21.858 32.1257 21.9134 32.1641C21.9703 32.201 22.0384 32.2337 22.118 32.2621C22.1975 32.2905 22.287 32.3153 22.3865 32.3366L22.7615 32.4219C22.9774 32.4688 23.1677 32.5312 23.3325 32.6094C23.4987 32.6875 23.6379 32.7805 23.7501 32.8885C23.8637 32.9964 23.9497 33.1207 24.0079 33.2614C24.0661 33.402 24.096 33.5597 24.0974 33.7344C24.096 34.0099 24.0264 34.2464 23.8886 34.4439C23.7508 34.6413 23.5526 34.7926 23.2941 34.8977C23.037 35.0028 22.7267 35.0554 22.363 35.0554C21.998 35.0554 21.6798 35.0007 21.4085 34.8913C21.1372 34.782 20.9262 34.6158 20.7757 34.3928C20.6251 34.1697 20.5477 33.8878 20.5434 33.5469H21.5534C21.5619 33.6875 21.5995 33.8047 21.6663 33.8984C21.733 33.9922 21.8247 34.0632 21.9411 34.1115C22.059 34.1598 22.1954 34.1839 22.3502 34.1839C22.4923 34.1839 22.613 34.1648 22.7124 34.1264C22.8133 34.0881 22.8907 34.0348 22.9447 33.9666C22.9987 33.8984 23.0264 33.8203 23.0278 33.7322C23.0264 33.6499 23.0008 33.5795 22.9511 33.5213C22.9014 33.4616 22.8247 33.4105 22.721 33.3679C22.6187 33.3239 22.488 33.2834 22.3289 33.2464L21.873 33.1399C21.4951 33.0533 21.1975 32.9134 20.9802 32.7202C20.7629 32.5256 20.6549 32.2628 20.6563 31.9318C20.6549 31.6619 20.7274 31.4254 20.8737 31.2223C21.02 31.0192 21.2224 30.8608 21.4809 30.7472C21.7394 30.6335 22.0342 30.5767 22.3651 30.5767C22.7032 30.5767 22.9965 30.6342 23.2451 30.7493C23.4951 30.8629 23.689 31.0227 23.8268 31.2287C23.9646 31.4347 24.0349 31.6733 24.0377 31.9446H23.0278ZM25.6847 30.6364V35H24.6301V30.6364H25.6847ZM30.0606 30.6364V35H29.1657L27.4292 32.4815H27.4015V35H26.3469V30.6364H27.2545L28.9719 33.1506H29.0081V30.6364H30.0606ZM34.6847 32.2173H33.6194C33.6052 32.108 33.5761 32.0092 33.532 31.9212C33.488 31.8331 33.4298 31.7578 33.3573 31.6953C33.2849 31.6328 33.199 31.5852 33.0995 31.5526C33.0015 31.5185 32.8928 31.5014 32.7735 31.5014C32.5619 31.5014 32.3794 31.5533 32.2259 31.657C32.074 31.7607 31.9568 31.9105 31.8744 32.1065C31.7934 32.3026 31.7529 32.5398 31.7529 32.8182C31.7529 33.108 31.7941 33.3509 31.8765 33.5469C31.9603 33.7415 32.0775 33.8885 32.2281 33.9879C32.3801 34.0859 32.5597 34.1349 32.7671 34.1349C32.8836 34.1349 32.9894 34.12 33.0846 34.0902C33.1812 34.0604 33.2657 34.017 33.3382 33.9602C33.412 33.902 33.4724 33.8317 33.5193 33.7493C33.5676 33.6655 33.6009 33.571 33.6194 33.4659L34.6847 33.4723C34.6663 33.6655 34.6102 33.8558 34.5164 34.0433C34.4241 34.2308 34.297 34.402 34.135 34.5568C33.9731 34.7102 33.7757 34.8324 33.5427 34.9233C33.3112 35.0142 33.0455 35.0597 32.7458 35.0597C32.3509 35.0597 31.9972 34.973 31.6847 34.7997C31.3737 34.625 31.1279 34.3707 30.9475 34.0369C30.7671 33.7031 30.6769 33.2969 30.6769 32.8182C30.6769 32.3381 30.7686 31.9311 30.9518 31.5973C31.135 31.2635 31.3829 31.0099 31.6954 30.8366C32.0079 30.6634 32.358 30.5767 32.7458 30.5767C33.01 30.5767 33.2544 30.6136 33.4788 30.6875C33.7032 30.7599 33.9007 30.8665 34.0711 31.0071C34.2416 31.1463 34.3801 31.3175 34.4866 31.5206C34.5931 31.7237 34.6592 31.956 34.6847 32.2173ZM35.2883 35V30.6364H38.3309V31.4929H36.343V32.3878H38.1753V33.2464H36.343V34.1435H38.3309V35H35.2883ZM20.2361 42H19.1025L20.5748 37.6364H21.979L23.4513 42H22.3177L21.2929 38.7358H21.2588L20.2361 42ZM20.0848 40.2827H22.4541V41.0838H20.0848V40.2827ZM23.9211 42V37.6364H24.9758V41.1435H26.7911V42H23.9211ZM27.3371 42V37.6364H29.1396C29.4664 37.6364 29.7483 37.7003 29.9855 37.8281C30.2242 37.9545 30.4081 38.1314 30.5374 38.3587C30.6666 38.5845 30.7313 38.8473 30.7313 39.147C30.7313 39.4482 30.6652 39.7116 30.5331 39.9375C30.4024 40.1619 30.2156 40.3359 29.9727 40.4595C29.7298 40.5831 29.4415 40.6449 29.1077 40.6449H27.9955V39.8139H28.9117C29.0708 39.8139 29.2036 39.7862 29.3101 39.7308C29.4181 39.6754 29.4997 39.598 29.5551 39.4986C29.6105 39.3977 29.6382 39.2805 29.6382 39.147C29.6382 39.0121 29.6105 38.8956 29.5551 38.7976C29.4997 38.6982 29.4181 38.6214 29.3101 38.5675C29.2021 38.5135 29.0693 38.4865 28.9117 38.4865H28.3918V42H27.3371ZM31.2512 42V37.6364H32.3058V39.3878H34.0253V37.6364H35.0779V42H34.0253V40.2464H32.3058V42H31.2512ZM36.6833 42H35.5498L37.0221 37.6364H38.4262L39.8985 42H38.765L37.7401 38.7358H37.7061L36.6833 42ZM36.532 40.2827H38.9014V41.0838H36.532V40.2827Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_3318_3814">
<rect width="59" height="59" rx="12" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@@ -0,0 +1,12 @@
<svg width="59" height="59" viewBox="0 0 59 59" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3318_3833)">
<rect x="0.414214" y="29.75" width="41.4871" height="41.4871" rx="6" transform="rotate(-45 0.414214 29.75)" fill="#1C3360" stroke="#4F9CFF" stroke-width="2"/>
<path d="M26.1623 27.36V16.608C26.1623 15.852 26.2976 15.2313 26.5683 14.746C26.8483 14.2513 27.2403 13.8873 27.7443 13.654C28.2576 13.4113 28.8596 13.29 29.5503 13.29C30.2223 13.29 30.7963 13.3927 31.2723 13.598C31.7483 13.794 32.1123 14.0927 32.3643 14.494C32.6256 14.886 32.7563 15.3807 32.7563 15.978C32.7563 16.426 32.677 16.8133 32.5183 17.14C32.369 17.4667 32.1543 17.728 31.8743 17.924C31.6036 18.12 31.277 18.2553 30.8943 18.33V18.372C31.3796 18.4373 31.7996 18.5727 32.1543 18.778C32.5183 18.9833 32.7983 19.2727 32.9943 19.646C33.1996 20.01 33.3023 20.4767 33.3023 21.046C33.3023 21.6993 33.153 22.2593 32.8543 22.726C32.565 23.1833 32.173 23.5333 31.6783 23.776C31.193 24.0187 30.6516 24.14 30.0543 24.14C29.7556 24.14 29.5083 24.1213 29.3123 24.084C29.1163 24.056 28.9343 24.0093 28.7663 23.944C28.6076 23.8787 28.435 23.7993 28.2483 23.706V27.36H26.1623ZM29.6623 22.474C30.129 22.474 30.493 22.334 30.7543 22.054C31.0156 21.7647 31.1463 21.368 31.1463 20.864C31.1463 20.4813 31.067 20.1733 30.9083 19.94C30.759 19.6973 30.5676 19.5247 30.3343 19.422C30.101 19.31 29.8583 19.254 29.6063 19.254H28.9763V17.686H29.4803C29.7323 17.686 29.947 17.6253 30.1243 17.504C30.3016 17.3733 30.437 17.2007 30.5303 16.986C30.6236 16.762 30.6703 16.51 30.6703 16.23C30.6703 15.7913 30.5583 15.4647 30.3343 15.25C30.1103 15.026 29.8443 14.914 29.5363 14.914C29.275 14.914 29.0463 14.97 28.8503 15.082C28.6543 15.194 28.505 15.376 28.4023 15.628C28.2996 15.8707 28.2483 16.202 28.2483 16.622V21.998C28.4816 22.1473 28.7103 22.264 28.9343 22.348C29.1583 22.432 29.401 22.474 29.6623 22.474Z" fill="#4F9CFF"/>
<path d="M23.0278 31.9446C23.0136 31.7884 22.9504 31.6669 22.8382 31.5803C22.7274 31.4922 22.569 31.4482 22.363 31.4482C22.2267 31.4482 22.113 31.4659 22.0221 31.5014C21.9312 31.5369 21.863 31.5859 21.8176 31.6484C21.7721 31.7095 21.7487 31.7798 21.7472 31.8594C21.7444 31.9247 21.7572 31.9822 21.7856 32.032C21.8154 32.0817 21.858 32.1257 21.9134 32.1641C21.9703 32.201 22.0384 32.2337 22.118 32.2621C22.1975 32.2905 22.287 32.3153 22.3865 32.3366L22.7615 32.4219C22.9774 32.4688 23.1677 32.5312 23.3325 32.6094C23.4987 32.6875 23.6379 32.7805 23.7501 32.8885C23.8637 32.9964 23.9497 33.1207 24.0079 33.2614C24.0661 33.402 24.096 33.5597 24.0974 33.7344C24.096 34.0099 24.0264 34.2464 23.8886 34.4439C23.7508 34.6413 23.5526 34.7926 23.2941 34.8977C23.037 35.0028 22.7267 35.0554 22.363 35.0554C21.998 35.0554 21.6798 35.0007 21.4085 34.8913C21.1372 34.782 20.9262 34.6158 20.7757 34.3928C20.6251 34.1697 20.5477 33.8878 20.5434 33.5469H21.5534C21.5619 33.6875 21.5995 33.8047 21.6663 33.8984C21.733 33.9922 21.8247 34.0632 21.9411 34.1115C22.059 34.1598 22.1954 34.1839 22.3502 34.1839C22.4923 34.1839 22.613 34.1648 22.7124 34.1264C22.8133 34.0881 22.8907 34.0348 22.9447 33.9666C22.9987 33.8984 23.0264 33.8203 23.0278 33.7322C23.0264 33.6499 23.0008 33.5795 22.9511 33.5213C22.9014 33.4616 22.8247 33.4105 22.721 33.3679C22.6187 33.3239 22.488 33.2834 22.3289 33.2464L21.873 33.1399C21.4951 33.0533 21.1975 32.9134 20.9802 32.7202C20.7629 32.5256 20.6549 32.2628 20.6563 31.9318C20.6549 31.6619 20.7274 31.4254 20.8737 31.2223C21.02 31.0192 21.2224 30.8608 21.4809 30.7472C21.7394 30.6335 22.0342 30.5767 22.3651 30.5767C22.7032 30.5767 22.9965 30.6342 23.2451 30.7493C23.4951 30.8629 23.689 31.0227 23.8268 31.2287C23.9646 31.4347 24.0349 31.6733 24.0377 31.9446H23.0278ZM25.6847 30.6364V35H24.6301V30.6364H25.6847ZM30.0606 30.6364V35H29.1657L27.4292 32.4815H27.4015V35H26.3469V30.6364H27.2545L28.9719 33.1506H29.0081V30.6364H30.0606ZM34.6847 32.2173H33.6194C33.6052 32.108 33.5761 32.0092 33.532 31.9212C33.488 31.8331 33.4298 31.7578 33.3573 31.6953C33.2849 31.6328 33.199 31.5852 33.0995 31.5526C33.0015 31.5185 32.8928 31.5014 32.7735 31.5014C32.5619 31.5014 32.3794 31.5533 32.2259 31.657C32.074 31.7607 31.9568 31.9105 31.8744 32.1065C31.7934 32.3026 31.7529 32.5398 31.7529 32.8182C31.7529 33.108 31.7941 33.3509 31.8765 33.5469C31.9603 33.7415 32.0775 33.8885 32.2281 33.9879C32.3801 34.0859 32.5597 34.1349 32.7671 34.1349C32.8836 34.1349 32.9894 34.12 33.0846 34.0902C33.1812 34.0604 33.2657 34.017 33.3382 33.9602C33.412 33.902 33.4724 33.8317 33.5193 33.7493C33.5676 33.6655 33.6009 33.571 33.6194 33.4659L34.6847 33.4723C34.6663 33.6655 34.6102 33.8558 34.5164 34.0433C34.4241 34.2308 34.297 34.402 34.135 34.5568C33.9731 34.7102 33.7757 34.8324 33.5427 34.9233C33.3112 35.0142 33.0455 35.0597 32.7458 35.0597C32.3509 35.0597 31.9972 34.973 31.6847 34.7997C31.3737 34.625 31.1279 34.3707 30.9475 34.0369C30.7671 33.7031 30.6769 33.2969 30.6769 32.8182C30.6769 32.3381 30.7686 31.9311 30.9518 31.5973C31.135 31.2635 31.3829 31.0099 31.6954 30.8366C32.0079 30.6634 32.358 30.5767 32.7458 30.5767C33.01 30.5767 33.2544 30.6136 33.4788 30.6875C33.7032 30.7599 33.9007 30.8665 34.0711 31.0071C34.2416 31.1463 34.3801 31.3175 34.4866 31.5206C34.5931 31.7237 34.6592 31.956 34.6847 32.2173ZM35.2883 35V30.6364H38.3309V31.4929H36.343V32.3878H38.1753V33.2464H36.343V34.1435H38.3309V35H35.2883ZM21.9142 42V37.6364H23.7338C24.0605 37.6364 24.334 37.6825 24.5542 37.7749C24.7757 37.8672 24.9419 37.9964 25.0527 38.1626C25.165 38.3288 25.2211 38.5213 25.2211 38.7401C25.2211 38.9062 25.1863 39.0547 25.1167 39.1854C25.0471 39.3146 24.9512 39.4219 24.829 39.5071C24.7069 39.5923 24.5655 39.652 24.405 39.6861V39.7287C24.5811 39.7372 24.7438 39.7848 24.8929 39.8714C25.0435 39.9581 25.1642 40.0788 25.2551 40.2337C25.3461 40.3871 25.3915 40.5689 25.3915 40.7791C25.3915 41.0135 25.3319 41.223 25.2125 41.4077C25.0932 41.5909 24.9206 41.7358 24.6948 41.8423C24.4689 41.9474 24.1948 42 23.8723 42H21.9142ZM22.9689 41.1499H23.6209C23.8496 41.1499 24.0179 41.1065 24.1259 41.0199C24.2353 40.9332 24.29 40.8125 24.29 40.6577C24.29 40.5455 24.2637 40.4489 24.2111 40.3679C24.1586 40.2855 24.084 40.2223 23.9874 40.1783C23.8908 40.1328 23.775 40.1101 23.6401 40.1101H22.9689V41.1499ZM22.9689 39.4304H23.5527C23.6678 39.4304 23.7701 39.4112 23.8596 39.3729C23.949 39.3345 24.0186 39.2791 24.0684 39.2067C24.1195 39.1342 24.1451 39.0469 24.1451 38.9446C24.1451 38.7983 24.0932 38.6832 23.9895 38.5994C23.8858 38.5156 23.7459 38.4737 23.5698 38.4737H22.9689V39.4304ZM25.8986 42V37.6364H28.9412V38.4929H26.9533V39.3878H28.7857V40.2464H26.9533V41.1435H28.9412V42H25.8986ZM29.459 38.4929V37.6364H33.1472V38.4929H31.824V42H30.7843V38.4929H29.459ZM34.0613 42H32.9277L34.4 37.6364H35.8042L37.2765 42H36.1429L35.1181 38.7358H35.084L34.0613 42ZM33.91 40.2827H36.2793V41.0838H33.91V40.2827Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_3318_3833">
<rect width="59" height="59" rx="12" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

View File

@@ -119,7 +119,7 @@ export default {
}
svg {
color: var(--color-brand-inverted);
color: var(--color-accent-contrast, var(--color-brand-inverted));
stroke-width: 0.2rem;
height: 0.8rem;
width: 0.8rem;

View File

@@ -45,7 +45,7 @@ export default {
margin: 0;
padding: 0.25rem 0.5rem;
background-color: var(--color-code-bg);
width: min-content;
width: fit-content;
border-radius: 10px;
user-select: text;
transition:
@@ -55,7 +55,6 @@ export default {
outline 0.2s ease-in-out;
span {
max-width: 10rem;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -1,6 +1,7 @@
<template>
<nav
class="experimental-styles-within relative flex w-fit overflow-clip rounded-full bg-bg-raised p-1 text-sm font-bold"
ref="scrollContainer"
class="experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
>
<NuxtLink
v-for="(link, index) in filteredLinks"
@@ -18,7 +19,9 @@
<span class="text-nowrap">{{ link.label }}</span>
</NuxtLink>
<div
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${subpageSelected ? 'bg-button-bg' : 'bg-brand-highlight'}`"
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${
subpageSelected ? 'bg-button-bg' : 'bg-brand-highlight'
}`"
:style="{
left: sliderLeftPx,
top: sliderTopPx,
@@ -32,6 +35,8 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from "vue";
const route = useNativeRoute();
interface Tab {
@@ -47,12 +52,13 @@ const props = defineProps<{
query?: string;
}>();
const scrollContainer = ref<HTMLElement | null>(null);
const sliderLeft = ref(4);
const sliderTop = ref(4);
const sliderRight = ref(4);
const sliderBottom = ref(4);
const activeIndex = ref(-1);
const oldIndex = ref(-1);
const subpageSelected = ref(false);
const filteredLinks = computed(() =>
@@ -63,6 +69,8 @@ const sliderTopPx = computed(() => `${sliderTop.value}px`);
const sliderRightPx = computed(() => `${sliderRight.value}px`);
const sliderBottomPx = computed(() => `${sliderBottom.value}px`);
const tabLinkElements = ref();
function pickLink() {
let index = -1;
subpageSelected.value = false;
@@ -86,16 +94,13 @@ function pickLink() {
if (activeIndex.value !== -1) {
startAnimation();
} else {
oldIndex.value = -1;
sliderLeft.value = 0;
sliderRight.value = 0;
}
}
const tabLinkElements = ref();
function startAnimation() {
const el = tabLinkElements.value[activeIndex.value].$el;
const el = tabLinkElements.value[activeIndex.value]?.$el;
if (!el || !el.offsetParent) return;
@@ -141,21 +146,19 @@ function startAnimation() {
}
onMounted(() => {
window.addEventListener("resize", pickLink);
pickLink();
});
onUnmounted(() => {
window.removeEventListener("resize", pickLink);
});
watch(route, () => pickLink());
watch(
() => route.path,
() => pickLink(),
);
</script>
<style scoped>
.navtabs-transition {
/* Delay on opacity is to hide any jankiness as the page loads */
transition:
all 150ms cubic-bezier(0.4, 0, 0.2, 1) 0s,
all 150ms cubic-bezier(0.4, 0, 0.2, 1),
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
}
</style>

View File

@@ -571,6 +571,10 @@ function getMessages() {
gap: var(--spacing-card-sm);
}
.notification__actions .iconified-button.square-button svg {
margin-right: 0;
}
.unknown-type {
color: var(--color-red);
}

View File

@@ -384,6 +384,8 @@ const submitForReview = async () => {
}
.author-actions {
margin-top: var(--spacing-card-md);
&:empty {
display: none;
}

View File

@@ -134,7 +134,6 @@
:data="analytics.formattedData.value.revenue.chart.data"
:labels="analytics.formattedData.value.revenue.chart.labels"
is-money
suffix="<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><line x1='12' y1='2' x2='12' y2='22'></line><path d='M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6'></path></svg>"
:colors="
isUsingProjectColors
? analytics.formattedData.value.revenue.chart.colors
@@ -193,15 +192,20 @@
class="country-value"
>
<div class="country-flag-container">
<img
:src="
name.toLowerCase() === 'xx' || !name
? 'https://cdn.modrinth.com/placeholder-banner.svg'
: countryCodeToFlag(name)
"
alt="Hidden country"
class="country-flag"
/>
<template v-if="name.toLowerCase() === 'xx' || !name">
<img
src="https://cdn.modrinth.com/placeholder-banner.svg"
alt="Placeholder flag"
class="country-flag"
/>
</template>
<template v-else>
<img
:src="countryCodeToFlag(name)"
:alt="`${countryCodeToName(name)}'s flag`"
class="country-flag"
/>
</template>
</div>
<div class="country-text">
<strong class="country-name"
@@ -247,15 +251,20 @@
class="country-value"
>
<div class="country-flag-container">
<img
:src="
name.toLowerCase() === 'xx' || !name
? 'https://cdn.modrinth.com/placeholder-banner.svg'
: countryCodeToFlag(name)
"
alt="Hidden country"
class="country-flag"
/>
<template v-if="name.toLowerCase() === 'xx' || !name">
<img
src="https://cdn.modrinth.com/placeholder-banner.svg"
alt="Placeholder flag"
class="country-flag"
/>
</template>
<template v-else>
<img
:src="countryCodeToFlag(name)"
:alt="`${countryCodeToName(name)}'s flag`"
class="country-flag"
/>
</template>
</div>
<div class="country-text">
<strong class="country-name">

View File

@@ -0,0 +1,95 @@
<template>
<NewModal ref="modal" header="Creating backup" @show="focusInput">
<div class="flex flex-col gap-2 md:w-[600px]">
<div class="font-semibold text-contrast">Name</div>
<input
ref="input"
v-model="backupName"
type="text"
class="bg-bg-input w-full rounded-lg p-4"
placeholder="e.g. Before 1.21"
maxlength="64"
/>
<div class="flex items-center gap-2">
<InfoIcon class="hidden sm:block" />
<span class="text-sm text-secondary">
If left empty, the backup name will default to
<span class="font-semibold"> Backup #{{ newBackupAmount }}</span>
</span>
</div>
</div>
<div class="mb-1 mt-4 flex justify-start gap-4">
<ButtonStyled color="brand">
<button :disabled="isCreating" @click="createBackup">
<PlusIcon />
Create backup
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hideModal">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { ref, nextTick } from "vue";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { PlusIcon, XIcon, InfoIcon } from "@modrinth/assets";
import type { Server } from "~/composables/pyroServers";
const props = defineProps<{
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
}>();
const emit = defineEmits(["backupCreated"]);
const modal = ref<InstanceType<typeof NewModal>>();
const input = ref<HTMLInputElement>();
const isCreating = ref(false);
const backupError = ref<string | null>(null);
const backupName = ref("");
const newBackupAmount = computed(() =>
props.server.backups?.data?.length === undefined ? 1 : props.server.backups?.data?.length + 1,
);
const focusInput = () => {
nextTick(() => {
setTimeout(() => {
input.value?.focus();
}, 100);
});
};
const hideModal = () => {
modal.value?.hide();
backupName.value = "";
};
const createBackup = async () => {
if (!backupName.value.trim()) {
backupName.value = `Backup #${newBackupAmount.value}`;
}
isCreating.value = true;
try {
await props.server.backups?.create(backupName.value);
await props.server.refresh();
hideModal();
emit("backupCreated", { success: true, message: "Backup created successfully" });
} catch (error) {
backupError.value = error instanceof Error ? error.message : String(error);
emit("backupCreated", { success: false, message: backupError.value });
} finally {
isCreating.value = false;
}
};
defineExpose({
show: () => modal.value?.show(),
hide: hideModal,
});
</script>

View File

@@ -0,0 +1,86 @@
<template>
<NewModal ref="modal" danger header="Deleting backup">
<div class="flex flex-col gap-4">
<div class="relative flex w-full flex-col gap-2 rounded-2xl bg-[#0e0e0ea4] p-6">
<div class="text-2xl font-extrabold text-contrast">
{{ backupName }}
</div>
<div class="flex gap-2 font-semibold text-contrast">
<CalendarIcon />
{{ formattedDate }}
</div>
</div>
</div>
<div class="mb-1 mt-4 flex justify-end gap-4">
<ButtonStyled color="red">
<button :disabled="isDeleting" @click="deleteBackup">
<TrashIcon />
Delete backup
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button @click="hideModal">Cancel</button>
</ButtonStyled>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { TrashIcon, CalendarIcon } from "@modrinth/assets";
import type { Server } from "~/composables/pyroServers";
const props = defineProps<{
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
backupId: string;
backupName: string;
backupCreatedAt: string;
}>();
const emit = defineEmits(["backupDeleted"]);
const modal = ref<InstanceType<typeof NewModal>>();
const isDeleting = ref(false);
const backupError = ref<string | null>(null);
const formattedDate = computed(() => {
return new Date(props.backupCreatedAt).toLocaleString("en-US", {
month: "numeric",
day: "numeric",
year: "2-digit",
hour: "numeric",
minute: "numeric",
hour12: true,
});
});
const hideModal = () => {
modal.value?.hide();
};
const deleteBackup = async () => {
if (!props.backupId) {
emit("backupDeleted", { success: false, message: "No backup selected" });
return;
}
isDeleting.value = true;
try {
await props.server.backups?.delete(props.backupId);
await props.server.refresh();
hideModal();
emit("backupDeleted", { success: true, message: "Backup deleted successfully" });
} catch (error) {
backupError.value = error instanceof Error ? error.message : String(error);
emit("backupDeleted", { success: false, message: backupError.value });
} finally {
isDeleting.value = false;
}
};
defineExpose({
show: () => modal.value?.show(),
hide: hideModal,
});
</script>

View File

@@ -0,0 +1,86 @@
<template>
<NewModal ref="modal" header="Renaming backup" @show="focusInput">
<div class="flex flex-col gap-2 md:w-[600px]">
<div class="font-semibold text-contrast">Name</div>
<input
ref="input"
v-model="backupName"
type="text"
class="bg-bg-input w-full rounded-lg p-4"
placeholder="e.g. Before 1.21"
/>
</div>
<div class="mb-1 mt-4 flex justify-start gap-4">
<ButtonStyled color="brand">
<button :disabled="isRenaming" @click="renameBackup">
<SaveIcon />
Rename backup
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hideModal">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { ref, nextTick } from "vue";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { SaveIcon, XIcon } from "@modrinth/assets";
import type { Server } from "~/composables/pyroServers";
const props = defineProps<{
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
currentBackupId: string;
}>();
const emit = defineEmits(["backupRenamed"]);
const modal = ref<InstanceType<typeof NewModal>>();
const input = ref<HTMLInputElement>();
const backupName = ref("");
const isRenaming = ref(false);
const backupError = ref<string | null>(null);
const focusInput = () => {
nextTick(() => {
setTimeout(() => {
input.value?.focus();
}, 100);
});
};
const hideModal = () => {
backupName.value = "";
modal.value?.hide();
};
const renameBackup = async () => {
if (!backupName.value.trim() || !props.currentBackupId) {
emit("backupRenamed", { success: false, message: "Backup name cannot be empty" });
return;
}
isRenaming.value = true;
try {
await props.server.backups?.rename(props.currentBackupId, backupName.value);
await props.server.refresh();
hideModal();
emit("backupRenamed", { success: true, message: "Backup renamed successfully" });
} catch (error) {
backupError.value = error instanceof Error ? error.message : String(error);
emit("backupRenamed", { success: false, message: backupError.value });
} finally {
isRenaming.value = false;
}
};
defineExpose({
show: () => modal.value?.show(),
hide: hideModal,
});
</script>

View File

@@ -0,0 +1,82 @@
<template>
<NewModal ref="modal" header="Restoring backup">
<div class="flex flex-col gap-4">
<div class="relative flex w-full flex-col gap-2 rounded-2xl bg-bg p-6">
<div class="text-2xl font-extrabold text-contrast">
{{ backupName }}
</div>
<div class="flex gap-2 font-semibold text-contrast">
<CalendarIcon />
{{ formattedDate }}
</div>
</div>
</div>
<div class="mb-1 mt-4 flex justify-end gap-4">
<ButtonStyled color="brand">
<button :disabled="isRestoring" @click="restoreBackup">Restore backup</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button @click="hideModal">Cancel</button>
</ButtonStyled>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { CalendarIcon } from "@modrinth/assets";
import type { Server } from "~/composables/pyroServers";
const props = defineProps<{
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
backupId: string;
backupName: string;
backupCreatedAt: string;
}>();
const emit = defineEmits(["backupRestored"]);
const modal = ref<InstanceType<typeof NewModal>>();
const isRestoring = ref(false);
const backupError = ref<string | null>(null);
const formattedDate = computed(() => {
return new Date(props.backupCreatedAt).toLocaleString("en-US", {
month: "numeric",
day: "numeric",
year: "2-digit",
hour: "numeric",
minute: "numeric",
hour12: true,
});
});
const hideModal = () => {
modal.value?.hide();
};
const restoreBackup = async () => {
if (!props.backupId) {
emit("backupRestored", { success: false, message: "No backup selected" });
return;
}
isRestoring.value = true;
try {
await props.server.backups?.restore(props.backupId);
hideModal();
emit("backupRestored", { success: true, message: "Backup restored successfully" });
} catch (error) {
backupError.value = error instanceof Error ? error.message : String(error);
emit("backupRestored", { success: false, message: backupError.value });
} finally {
isRestoring.value = false;
}
};
defineExpose({
show: () => modal.value?.show(),
hide: hideModal,
});
</script>

View File

@@ -0,0 +1,201 @@
<template>
<NewModal ref="modal" header="Editing auto backup settings">
<div class="flex flex-col gap-4 md:w-[600px]">
<div class="flex flex-col gap-2">
<div class="font-semibold text-contrast">Auto backup</div>
<p class="m-0">
Automatically create a backup of your server every
<strong>{{ autoBackupInterval == 1 ? "hour" : `${autoBackupInterval} hours` }}</strong>
</p>
</div>
<div v-if="isLoadingSettings" class="py-2 text-sm text-secondary">Loading settings...</div>
<template v-else>
<input
id="auto-backup-toggle"
v-model="autoBackupEnabled"
class="switch stylized-toggle"
type="checkbox"
:disabled="isSaving"
/>
<div class="flex flex-col gap-2">
<div class="font-semibold text-contrast">Interval</div>
<p class="m-0">
The amount of hours between each backup. This will only backup your server if it has
been modified since the last backup.
</p>
</div>
<div class="flex items-center gap-2 text-contrast">
<div
class="flex w-fit items-center rounded-xl border border-solid border-button-border bg-table-alternateRow"
>
<button
class="rounded-l-xl p-3 text-secondary enabled:hover:text-contrast [&&]:bg-transparent enabled:[&&]:hover:bg-button-bg"
:disabled="!autoBackupEnabled || isSaving"
@click="autoBackupInterval = Math.max(autoBackupInterval - 1, 1)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="2" viewBox="-2 0 18 2">
<path
d="M18,12H6"
transform="translate(-5 -11)"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</button>
<input
id="auto-backup-interval"
v-model="autoBackupInterval"
class="w-16 !appearance-none text-center [&&]:bg-transparent [&&]:focus:shadow-none"
type="number"
style="-moz-appearance: textfield; appearance: none"
min="1"
max="24"
step="1"
:disabled="!autoBackupEnabled || isSaving"
/>
<button
class="rounded-r-xl p-3 text-secondary enabled:hover:text-contrast [&&]:bg-transparent enabled:[&&]:hover:bg-button-bg"
:disabled="!autoBackupEnabled || isSaving"
@click="autoBackupInterval = Math.min(autoBackupInterval + 1, 24)"
>
<PlusIcon />
</button>
</div>
{{ autoBackupInterval == 1 ? "hour" : "hours" }}
</div>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled color="brand">
<button :disabled="!hasChanges || isSaving" @click="saveSettings">
<SaveIcon class="h-5 w-5" />
{{ isSaving ? "Saving..." : "Save changes" }}
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="isSaving" @click="modal?.hide()">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</template>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { PlusIcon, XIcon, SaveIcon } from "@modrinth/assets";
import { ref, computed } from "vue";
import type { Server } from "~/composables/pyroServers";
const props = defineProps<{
server: Server<["backups"]>;
}>();
const modal = ref<InstanceType<typeof NewModal>>();
const initialSettings = ref<{ interval: number; enabled: boolean } | null>(null);
const autoBackupEnabled = ref(false);
const autoBackupInterval = ref(1);
const isLoadingSettings = ref(true);
const isSaving = ref(false);
const validatedBackupInterval = computed(() => {
const roundedValue = Math.round(autoBackupInterval.value);
if (roundedValue < 1) {
return 1;
} else if (roundedValue > 24) {
return 24;
}
return roundedValue;
});
const hasChanges = computed(() => {
if (!initialSettings.value) return false;
return (
autoBackupEnabled.value !== initialSettings.value.enabled ||
autoBackupInterval.value !== initialSettings.value.interval
);
});
const fetchSettings = async () => {
isLoadingSettings.value = true;
try {
const settings = await props.server.backups?.getAutoBackup();
initialSettings.value = settings as { interval: number; enabled: boolean };
autoBackupEnabled.value = settings?.enabled ?? false;
autoBackupInterval.value = settings?.interval || 1;
} catch (error) {
console.error("Error fetching backup settings:", error);
addNotification({
group: "server",
title: "Error",
text: "Failed to load backup settings",
type: "error",
});
} finally {
isLoadingSettings.value = false;
}
};
const saveSettings = async () => {
isSaving.value = true;
try {
await props.server.backups?.updateAutoBackup(
autoBackupEnabled.value ? "enable" : "disable",
autoBackupInterval.value,
);
initialSettings.value = {
enabled: autoBackupEnabled.value,
interval: autoBackupInterval.value,
};
addNotification({
group: "server",
title: "Success",
text: "Backup settings updated successfully",
type: "success",
});
modal.value?.hide();
} catch (error) {
console.error("Error saving backup settings:", error);
addNotification({
group: "server",
title: "Error",
text: "Failed to save backup settings",
type: "error",
});
} finally {
isSaving.value = false;
}
};
watch(autoBackupInterval, () => {
autoBackupInterval.value = validatedBackupInterval.value;
});
defineExpose({
show: async () => {
await fetchSettings();
modal.value?.show();
},
});
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
</style>

View File

@@ -0,0 +1,229 @@
<template>
<li
role="button"
data-pyro-file
:class="containerClasses"
tabindex="0"
@click="selectItem"
@contextmenu="openContextMenu"
@keydown="(e) => e.key === 'Enter' && selectItem()"
>
<div data-pyro-file-metadata class="flex w-full items-center gap-4 truncate">
<div
class="flex size-8 items-center justify-center rounded-full bg-bg-raised p-[6px] group-hover:bg-brand-highlight group-hover:text-brand group-focus:bg-brand-highlight group-focus:text-brand"
>
<component :is="iconComponent" class="size-6" />
</div>
<div class="flex w-full flex-col truncate">
<span
class="w-[98%] truncate font-bold group-hover:text-contrast group-focus:text-contrast"
>{{ name }}</span
>
<span class="text-xs text-secondary group-hover:text-primary">
{{ subText }}
</span>
</div>
</div>
<div data-pyro-file-actions class="flex w-fit flex-shrink-0 items-center gap-4 md:gap-12">
<span class="w-[160px] text-nowrap text-right font-mono text-sm text-secondary">{{
formattedDate
}}</span>
<ButtonStyled circular type="transparent">
<UiServersTeleportOverflowMenu :options="menuOptions" direction="left" position="bottom">
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
<template #rename> <EditIcon /> Rename </template>
<template #move> <RightArrowIcon /> Move </template>
<template #download> <DownloadIcon /> Download </template>
<template #delete> <TrashIcon /> Delete </template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
</div>
</li>
</template>
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
import {
MoreHorizontalIcon,
EditIcon,
DownloadIcon,
TrashIcon,
FolderOpenIcon,
FileIcon,
RightArrowIcon,
} from "@modrinth/assets";
import { computed, shallowRef, ref } from "vue";
import { useRouter, useRoute } from "vue-router";
import {
UiServersIconsCogFolderIcon,
UiServersIconsEarthIcon,
UiServersIconsCodeFileIcon,
UiServersIconsTextFileIcon,
UiServersIconsImageFileIcon,
} from "#components";
import PaletteIcon from "~/assets/icons/palette.svg?component";
interface FileItemProps {
name: string;
type: "directory" | "file";
size?: number;
count?: number;
modified: number;
path: string;
}
const props = defineProps<FileItemProps>();
const emit = defineEmits(["rename", "download", "delete", "move", "edit", "contextmenu"]);
const codeExtensions = Object.freeze([
"json",
"json5",
"jsonc",
"java",
"kt",
"kts",
"sh",
"bat",
"ps1",
"yml",
"yaml",
"toml",
"js",
"ts",
"py",
"rb",
"php",
"html",
"css",
"cpp",
"c",
"h",
"rs",
"go",
]);
const textExtensions = Object.freeze(["txt", "md", "log", "cfg", "conf", "properties", "ini"]);
const imageExtensions = Object.freeze(["png", "jpg", "jpeg", "gif", "svg", "webp"]);
const units = Object.freeze(["B", "KB", "MB", "GB", "TB", "PB", "EB"]);
const route = shallowRef(useRoute());
const router = useRouter();
const containerClasses = computed(() => [
"group m-0 p-0 focus:!outline-none flex w-full select-none items-center justify-between overflow-hidden border-0 border-b border-solid border-bg-raised p-3 last:border-none hover:bg-bg-raised focus:bg-bg-raised",
isEditableFile.value ? "cursor-pointer" : props.type === "directory" ? "cursor-pointer" : "",
]);
const fileExtension = computed(() => props.name.split(".").pop()?.toLowerCase() || "");
const menuOptions = computed(() => [
{
id: "rename",
action: () => emit("rename", { name: props.name, type: props.type, path: props.path }),
},
{
id: "move",
action: () => emit("move", { name: props.name, type: props.type, path: props.path }),
},
{
id: "download",
action: () => emit("download", { name: props.name, type: props.type, path: props.path }),
shown: props.type !== "directory",
},
{
id: "delete",
action: () => emit("delete", { name: props.name, type: props.type, path: props.path }),
color: "red" as const,
},
]);
const iconComponent = computed(() => {
if (props.type === "directory") {
if (props.name === "config") return UiServersIconsCogFolderIcon;
if (props.name === "world") return UiServersIconsEarthIcon;
if (props.name === "resourcepacks") return PaletteIcon;
return FolderOpenIcon;
}
const ext = fileExtension.value;
if (codeExtensions.includes(ext)) return UiServersIconsCodeFileIcon;
if (textExtensions.includes(ext)) return UiServersIconsTextFileIcon;
if (imageExtensions.includes(ext)) return UiServersIconsImageFileIcon;
return FileIcon;
});
const subText = computed(() => {
if (props.type === "directory") {
return `${props.count} ${props.count === 1 ? "item" : "items"}`;
}
return formattedSize.value;
});
const formattedDate = computed(() => {
const date = new Date(props.modified * 1000);
return `${date.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
year: "2-digit",
})}, ${date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "numeric",
hour12: true,
})}`;
});
const isEditableFile = computed(() => {
if (props.type === "file") {
const ext = fileExtension.value;
return (
!props.name.includes(".") ||
textExtensions.includes(ext) ||
codeExtensions.includes(ext) ||
imageExtensions.includes(ext)
);
}
return false;
});
const formattedSize = computed(() => {
if (props.size === undefined) return "";
const bytes = props.size;
if (bytes === 0) return "0 B";
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const size = (bytes / Math.pow(1024, exponent)).toFixed(2);
return `${size} ${units[exponent]}`;
});
const openContextMenu = (event: MouseEvent) => {
event.preventDefault();
emit("contextmenu", event.clientX, event.clientY);
};
const navigateToFolder = () => {
const currentPath = route.value.query.path?.toString() || "";
const newPath = currentPath.endsWith("/")
? `${currentPath}${props.name}`
: `${currentPath}/${props.name}`;
router.push({ query: { path: newPath, page: 1 } });
};
const isNavigating = ref(false);
const selectItem = () => {
if (isNavigating.value) return;
isNavigating.value = true;
if (props.type === "directory") {
navigateToFolder();
} else if (props.type === "file" && isEditableFile.value) {
emit("edit", { name: props.name, type: props.type, path: props.path });
}
setTimeout(() => {
isNavigating.value = false;
}, 500);
};
</script>

View File

@@ -0,0 +1,40 @@
<template>
<div class="flex h-full w-full items-center justify-center gap-6 p-20">
<FileIcon class="size-28" />
<div class="flex flex-col gap-2">
<h3 class="text-red-500 m-0 text-2xl font-bold">{{ title }}</h3>
<p class="m-0 text-sm text-secondary">
{{ message }}
</p>
<div class="flex gap-2">
<ButtonStyled>
<button size="sm" @click="$emit('refetch')">
<UiServersIconsLoadingIcon class="h-5 w-5" />
Try again
</button>
</ButtonStyled>
<ButtonStyled>
<button size="sm" @click="$emit('home')">
<HomeIcon class="h-5 w-5" />
Go to home folder
</button>
</ButtonStyled>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { FileIcon, HomeIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
defineProps<{
title: string;
message: string;
}>();
defineEmits<{
(e: "refetch"): void;
(e: "home"): void;
}>();
</script>

View File

@@ -0,0 +1,120 @@
<template>
<div ref="listContainer" data-pyro-files-virtual-list-root class="relative w-full">
<div
:style="{
position: 'relative',
minHeight: `${totalHeight}px`,
}"
data-pyro-files-virtual-height-watcher
>
<ul
class="list-none"
:style="{
position: 'absolute',
top: `${visibleTop}px`,
width: '100%',
margin: 0,
padding: 0,
}"
data-pyro-files-virtual-list
>
<UiServersFileItem
v-for="item in visibleItems"
:key="item.path"
:count="item.count"
:created="item.created"
:modified="item.modified"
:name="item.name"
:path="item.path"
:type="item.type"
:size="item.size"
@delete="$emit('delete', item)"
@rename="$emit('rename', item)"
@download="$emit('download', item)"
@move="$emit('move', item)"
@edit="$emit('edit', item)"
@contextmenu="(x, y) => $emit('contextmenu', item, x, y)"
/>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
const props = defineProps<{
items: any[];
}>();
const emit = defineEmits<{
(e: "delete", item: any): void;
(e: "rename", item: any): void;
(e: "download", item: any): void;
(e: "move", item: any): void;
(e: "edit", item: any): void;
(e: "contextmenu", item: any, x: number, y: number): void;
(e: "loadMore"): void;
}>();
const ITEM_HEIGHT = 61;
const BUFFER_SIZE = 5;
const listContainer = ref<HTMLElement | null>(null);
const windowScrollY = ref(0);
const windowHeight = ref(0);
const totalHeight = computed(() => props.items.length * ITEM_HEIGHT);
const visibleRange = computed(() => {
if (!listContainer.value) return { start: 0, end: 0 };
const containerTop = listContainer.value.getBoundingClientRect().top + window.scrollY;
const relativeScrollTop = Math.max(0, windowScrollY.value - containerTop);
const start = Math.floor(relativeScrollTop / ITEM_HEIGHT);
const visibleCount = Math.ceil(windowHeight.value / ITEM_HEIGHT);
return {
start: Math.max(0, start - BUFFER_SIZE),
end: Math.min(props.items.length, start + visibleCount + BUFFER_SIZE * 2),
};
});
const visibleTop = computed(() => {
return visibleRange.value.start * ITEM_HEIGHT;
});
const visibleItems = computed(() => {
return props.items.slice(visibleRange.value.start, visibleRange.value.end);
});
const handleScroll = () => {
windowScrollY.value = window.scrollY;
if (!listContainer.value) return;
const containerBottom = listContainer.value.getBoundingClientRect().bottom;
const remainingScroll = containerBottom - window.innerHeight;
if (remainingScroll < windowHeight.value * 0.2) {
emit("loadMore");
}
};
const handleResize = () => {
windowHeight.value = window.innerHeight;
};
onMounted(() => {
windowHeight.value = window.innerHeight;
window.addEventListener("scroll", handleScroll, { passive: true });
window.addEventListener("resize", handleResize, { passive: true });
handleScroll();
});
onUnmounted(() => {
window.removeEventListener("scroll", handleScroll);
window.removeEventListener("resize", handleResize);
});
</script>

View File

@@ -0,0 +1,231 @@
<template>
<div ref="pyroFilesSentinel" class="sentinel" data-pyro-files-sentinel />
<header
:class="[
'duration-20 h-26 top-0 flex select-none flex-col justify-between gap-2 bg-table-alternateRow p-3 transition-[border-radius] sm:h-12 sm:flex-row',
!isStuck ? 'rounded-t-2xl' : 'sticky top-0 z-20',
]"
data-pyro-files-state="browsing"
aria-label="File navigation"
>
<nav
aria-label="Breadcrumb navigation"
class="m-0 flex min-w-0 flex-shrink items-center p-0 text-contrast"
>
<ol class="m-0 flex min-w-0 flex-shrink list-none items-center p-0">
<li class="-ml-1 flex-shrink-0">
<ButtonStyled type="transparent">
<button
v-tooltip="'Back to home'"
type="button"
class="mr-2 grid h-12 w-10 place-content-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
@click="$emit('navigate', -1)"
>
<span
class="grid size-8 place-content-center rounded-full bg-button-bg p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
>
<HomeIcon class="h-5 w-5" />
<span class="sr-only">Home</span>
</span>
</button>
</ButtonStyled>
</li>
<li class="m-0 -ml-2 min-w-0 flex-shrink p-0">
<ol class="m-0 flex min-w-0 flex-shrink items-center overflow-hidden p-0">
<TransitionGroup
name="breadcrumb"
tag="span"
class="relative flex min-w-0 flex-shrink items-center"
>
<li
v-for="(segment, index) in breadcrumbSegments"
:key="`${segment || index}-group`"
class="relative flex min-w-0 flex-shrink items-center text-sm"
>
<div class="flex min-w-0 flex-shrink items-center">
<ButtonStyled type="transparent">
<button
class="cursor-pointer truncate focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
:aria-current="
index === breadcrumbSegments.length - 1 ? 'location' : undefined
"
:class="{
'!text-contrast': index === breadcrumbSegments.length - 1,
}"
@click="$emit('navigate', index)"
>
{{ segment || "" }}
</button>
</ButtonStyled>
<ChevronRightIcon
v-if="index < breadcrumbSegments.length - 1"
class="size-4 flex-shrink-0 text-secondary"
aria-hidden="true"
/>
</div>
</li>
</TransitionGroup>
</ol>
</li>
</ol>
</nav>
<div class="flex flex-shrink-0 items-center gap-1">
<div class="flex w-full flex-row-reverse sm:flex-row">
<ButtonStyled type="transparent">
<UiServersTeleportOverflowMenu
position="bottom"
direction="left"
aria-label="Sort files"
:options="[
{ id: 'normal', action: () => $emit('sort', 'default') },
{ id: 'modified', action: () => $emit('sort', 'modified') },
{ id: 'filesOnly', action: () => $emit('sort', 'filesOnly') },
{ id: 'foldersOnly', action: () => $emit('sort', 'foldersOnly') },
]"
>
<span class="hidden whitespace-pre text-sm font-medium sm:block">
{{ sortMethodLabel }}
</span>
<SortAscendingIcon aria-hidden="true" />
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
<template #normal> Alphabetical </template>
<template #modified> Date modified </template>
<template #filesOnly> Files only </template>
<template #foldersOnly> Folders only </template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
<div class="mx-1 w-full text-sm sm:w-40">
<label for="search-folder" class="sr-only">Search folder</label>
<div class="relative">
<SearchIcon
class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2"
aria-hidden="true"
/>
<input
id="search-folder"
:value="searchQuery"
type="search"
name="search"
autocomplete="off"
class="h-8 min-h-[unset] w-full border-[1px] border-solid border-button-bg bg-transparent py-2 pl-9"
placeholder="Search..."
@input="$emit('update:searchQuery', ($event.target as HTMLInputElement).value)"
/>
</div>
</div>
</div>
<ButtonStyled type="transparent">
<UiServersTeleportOverflowMenu
position="bottom"
direction="left"
aria-label="Create new..."
:options="[
{ id: 'file', action: () => $emit('create', 'file') },
{ id: 'directory', action: () => $emit('create', 'directory') },
{ id: 'upload', action: () => $emit('upload') },
]"
>
<PlusIcon aria-hidden="true" />
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
<template #file> <BoxIcon aria-hidden="true" /> New file </template>
<template #directory> <FolderOpenIcon aria-hidden="true" /> New folder </template>
<template #upload> <UploadIcon aria-hidden="true" /> Upload file </template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
</div>
</header>
</template>
<script setup lang="ts">
import {
BoxIcon,
PlusIcon,
UploadIcon,
DropdownIcon,
FolderOpenIcon,
SearchIcon,
SortAscendingIcon,
HomeIcon,
ChevronRightIcon,
} from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import { ref, computed } from "vue";
import { useIntersectionObserver } from "@vueuse/core";
const props = defineProps<{
breadcrumbSegments: string[];
searchQuery: string;
sortMethod: string;
}>();
defineEmits<{
(e: "navigate", index: number): void;
(e: "sort", method: string): void;
(e: "create", type: "file" | "directory"): void;
(e: "upload"): void;
(e: "update:searchQuery", value: string): void;
}>();
const pyroFilesSentinel = ref<HTMLElement | null>(null);
const isStuck = ref(false);
useIntersectionObserver(
pyroFilesSentinel,
([{ isIntersecting }]) => {
isStuck.value = !isIntersecting;
},
{ threshold: [0, 1] },
);
const sortMethodLabel = computed(() => {
switch (props.sortMethod) {
case "modified":
return "Date modified";
case "filesOnly":
return "Files only";
case "foldersOnly":
return "Folders only";
default:
return "Alphabetical";
}
});
</script>
<style scoped>
.sentinel {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
visibility: hidden;
}
.breadcrumb-move,
.breadcrumb-enter-active,
.breadcrumb-leave-active {
transition: all 0.2s ease;
}
.breadcrumb-enter-from {
opacity: 0;
transform: translateX(-10px) scale(0.9);
}
.breadcrumb-leave-to {
opacity: 0;
transform: translateX(-10px) scale(0.8);
filter: blur(4px);
}
.breadcrumb-leave-active {
position: relative;
pointer-events: none;
}
.breadcrumb-move {
z-index: 1;
}
</style>

View File

@@ -0,0 +1,106 @@
<template>
<div
class="fixed"
:style="{
transform: `translateY(${isAtBottom ? '-100%' : '0'})`,
top: `${y}px`,
left: `${x}px`,
}"
>
<Transition>
<div
v-if="item"
id="item-context-menu"
ref="ctxRef"
:style="{
border: '1px solid var(--color-button-bg)',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--color-raised-bg)',
padding: 'var(--gap-sm)',
boxShadow: 'var(--shadow-floating)',
gap: 'var(--gap-xs)',
width: 'max-content',
}"
class="flex h-fit w-fit select-none flex-col"
>
<button
class="btn btn-transparent flex !w-full items-center"
@click="$emit('rename', item)"
>
<EditIcon class="h-5 w-5" />
Rename
</button>
<button class="btn btn-transparent flex !w-full items-center" @click="$emit('move', item)">
<ArrowBigUpDashIcon class="h-5 w-5" />
Move
</button>
<button
v-if="item.type !== 'directory'"
class="btn btn-transparent flex !w-full items-center"
@click="$emit('download', item)"
>
<DownloadIcon class="h-5 w-5" />
Download
</button>
<button
class="btn btn-transparent btn-red flex !w-full items-center"
@click="$emit('delete', item)"
>
<TrashIcon class="h-5 w-5" />
Delete
</button>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { EditIcon, ArrowBigUpDashIcon, DownloadIcon, TrashIcon } from "@modrinth/assets";
interface FileItem {
type: string;
name: string;
[key: string]: any;
}
defineProps<{
item: FileItem | null;
x: number;
y: number;
isAtBottom: boolean;
}>();
const ctxRef = ref<HTMLElement | null>(null);
defineEmits<{
(e: "rename", item: FileItem): void;
(e: "move", item: FileItem): void;
(e: "download", item: FileItem): void;
(e: "delete", item: FileItem): void;
}>();
defineExpose({
ctxRef,
});
</script>
<style scoped>
#item-context-menu {
transition:
transform 0.1s ease,
opacity 0.1s ease;
transform-origin: top left;
}
#item-context-menu.v-enter-active,
#item-context-menu.v-leave-active {
transform: scale(1);
opacity: 1;
}
#item-context-menu.v-enter-from,
#item-context-menu.v-leave-to {
transform: scale(0.5);
opacity: 0;
}
</style>

View File

@@ -0,0 +1,95 @@
<template>
<NewModal ref="modal" :header="`Creating a ${type}`">
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-2">
<div class="font-semibold text-contrast">Name</div>
<input
ref="createInput"
v-model="itemName"
autofocus
type="text"
class="bg-bg-input w-full rounded-lg p-4"
:placeholder="`e.g. ${type === 'file' ? 'config.yml' : 'plugins'}`"
required
/>
<div v-if="submitted && error" class="text-red">{{ error }}</div>
</div>
<div class="flex justify-start gap-4">
<ButtonStyled color="brand">
<button :disabled="!!error" type="submit">
<PlusIcon class="h-5 w-5" />
Create
</button>
</ButtonStyled>
<ButtonStyled>
<button type="button" @click="hide">
<XIcon class="h-5 w-5" />
Cancel
</button>
</ButtonStyled>
</div>
</form>
</NewModal>
</template>
<script setup lang="ts">
import { PlusIcon, XIcon } from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { ref, computed, nextTick } from "vue";
const props = defineProps<{
type: "file" | "directory";
}>();
const emit = defineEmits<{
(e: "create", name: string): void;
}>();
const modal = ref<typeof NewModal>();
const createInput = ref<HTMLInputElement | null>(null);
const itemName = ref("");
const submitted = ref(false);
const error = computed(() => {
if (!itemName.value) {
return "Name is required.";
}
if (props.type === "file") {
const validPattern = /^[a-zA-Z0-9-_.\s]+$/;
if (!validPattern.test(itemName.value)) {
return "Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.";
}
} else {
const validPattern = /^[a-zA-Z0-9-_\s]+$/;
if (!validPattern.test(itemName.value)) {
return "Name must contain only alphanumeric characters, dashes, underscores, or spaces.";
}
}
return "";
});
const handleSubmit = () => {
submitted.value = true;
if (!error.value) {
emit("create", itemName.value);
hide();
}
};
const show = () => {
itemName.value = "";
submitted.value = false;
modal.value?.show();
nextTick(() => {
setTimeout(() => {
createInput.value?.focus();
}, 100);
});
};
const hide = () => {
modal.value?.hide();
};
defineExpose({ show, hide });
</script>

View File

@@ -0,0 +1,77 @@
<template>
<NewModal ref="modal" danger :header="`Deleting ${item?.type}`">
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
<div
class="relative flex w-full items-center gap-2 rounded-2xl border border-solid border-[#cb224436] bg-[#f57b7b0e] p-6 shadow-md dark:border-0 dark:bg-[#0e0e0ea4]"
>
<div
class="flex h-9 w-9 items-center justify-center rounded-full bg-[#3f1818a4] p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
>
<FolderOpenIcon v-if="item?.type === 'directory'" class="h-5 w-5" />
<FileIcon v-else-if="item?.type === 'file'" class="h-5 w-5" />
</div>
<div class="flex flex-col">
<span class="font-bold group-hover:text-contrast">{{ item?.name }}</span>
<span
v-if="item?.type === 'directory'"
class="text-xs text-secondary group-hover:text-primary"
>
{{ item?.count }} items
</span>
<span v-else class="text-xs text-secondary group-hover:text-primary">
{{ ((item?.size ?? 0) / 1024 / 1024).toFixed(2) }} MB
</span>
</div>
</div>
<div class="flex justify-start gap-4">
<ButtonStyled color="red">
<button type="submit">
<TrashIcon class="h-5 w-5" />
Delete {{ item?.type }}
</button>
</ButtonStyled>
<ButtonStyled>
<button type="button" @click="hide">
<XIcon class="h-5 w-5" />
Cancel
</button>
</ButtonStyled>
</div>
</form>
</NewModal>
</template>
<script setup lang="ts">
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { FileIcon, FolderOpenIcon, TrashIcon, XIcon } from "@modrinth/assets";
defineProps<{
item: {
name: string;
type: string;
count?: number;
size?: number;
} | null;
}>();
const emit = defineEmits<{
(e: "delete"): void;
}>();
const modal = ref<typeof NewModal>();
const handleSubmit = () => {
emit("delete");
hide();
};
const show = () => {
modal.value?.show();
};
const hide = () => {
modal.value?.hide();
};
defineExpose({ show, hide });
</script>

View File

@@ -0,0 +1,140 @@
<template>
<header
data-pyro-files-state="editing"
class="flex h-12 select-none items-center justify-between rounded-t-2xl bg-table-alternateRow p-3"
aria-label="File editor navigation"
>
<nav
aria-label="Breadcrumb navigation"
class="m-0 flex list-none items-center p-0 text-contrast"
>
<ol class="m-0 flex list-none items-center p-0">
<li class="-ml-1">
<ButtonStyled type="transparent">
<button
v-tooltip="'Back to home'"
type="button"
class="grid h-12 w-10 place-content-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
@click="goHome"
>
<span
class="grid size-8 place-content-center rounded-full bg-button-bg p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
>
<HomeIcon class="h-5 w-5" />
<span class="sr-only">Home</span>
</span>
</button>
</ButtonStyled>
</li>
<li class="m-0 -ml-2 p-0">
<ol class="m-0 flex items-center p-0">
<li
v-for="(segment, index) in breadcrumbSegments"
:key="index"
class="flex items-center text-sm"
>
<ButtonStyled type="transparent">
<button
class="cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
:class="{ '!text-contrast': index === breadcrumbSegments.length - 1 }"
@click="$emit('navigate', index)"
>
{{ segment || "" }}
</button>
</ButtonStyled>
<ChevronRightIcon
v-if="index < breadcrumbSegments.length"
class="size-4 text-secondary"
aria-hidden="true"
/>
</li>
<li class="flex items-center px-3 text-sm">
<span class="font-semibold !text-contrast" aria-current="location">{{
fileName
}}</span>
</li>
</ol>
</li>
</ol>
</nav>
<div v-if="!isImage" class="flex gap-2">
<Button
v-if="isLogFile"
v-tooltip="'Share to mclo.gs'"
icon-only
transparent
aria-label="Share to mclo.gs"
@click="$emit('share')"
>
<ShareIcon />
</Button>
<ButtonStyled type="transparent">
<UiServersTeleportOverflowMenu
position="bottom"
direction="left"
aria-label="Save file"
:options="[
{ id: 'save', action: () => $emit('save') },
{ id: 'save-as', action: () => $emit('save-as') },
{ id: 'save&restart', action: () => $emit('save-restart') },
]"
>
<SaveIcon aria-hidden="true" />
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
<template #save> <SaveIcon aria-hidden="true" /> Save </template>
<template #save-as> <SaveIcon aria-hidden="true" /> Save as... </template>
<template #save&restart>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0V5.36l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389A5.5 5.5 0 0 1 13.89 6.11l.311.31h-2.432a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.219Z"
clip-rule="evenodd"
/>
</svg>
Save & restart
</template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
</div>
</header>
</template>
<script setup lang="ts">
import { DropdownIcon, SaveIcon, ShareIcon, HomeIcon, ChevronRightIcon } from "@modrinth/assets";
import { Button, ButtonStyled } from "@modrinth/ui";
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
const props = defineProps<{
breadcrumbSegments: string[];
fileName?: string;
isImage: boolean;
filePath?: string;
}>();
const isLogFile = computed(() => {
return props.filePath?.startsWith("logs") || props.filePath?.endsWith(".log");
});
const route = useRoute();
const router = useRouter();
const emit = defineEmits<{
(e: "cancel"): void;
(e: "save"): void;
(e: "save-as"): void;
(e: "save-restart"): void;
(e: "share"): void;
(e: "navigate", index: number): void;
}>();
const goHome = () => {
emit("cancel");
router.push({ path: "/servers/manage/" + route.params.id + "/files" });
};
</script>

View File

@@ -0,0 +1,159 @@
<template>
<div class="flex h-[calc(100vh-12rem)] w-full flex-col items-center bg-bg-raised">
<div
ref="container"
class="relative w-full flex-grow overflow-hidden bg-bg-raised"
@mousedown="startPan"
@mousemove="pan"
@mouseup="endPan"
@mouseleave="endPan"
@wheel.prevent="handleWheel"
>
<UiServersPyroLoading v-if="loading" />
<div v-if="error" class="flex h-full w-full flex-col items-center justify-center gap-8">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-12"
>
<path d="M4 13c3.5-2 8-2 10 2a5.5 5.5 0 0 1 8 5" />
<path
d="M5.15 17.89c5.52-1.52 8.65-6.89 7-12C11.55 4 11.5 2 13 2c3.22 0 5 5.5 5 8 0 6.5-4.2 12-10.49 12C5.11 22 2 22 2 20c0-1.5 1.14-1.55 3.15-2.11Z"
/>
</svg>
<p class="m-0">Invalid or empty image file.</p>
</div>
<img
v-show="!loading && !error"
ref="image"
:src="imageUrl"
class="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform"
:style="{
transform: `translate(-50%, -50%) scale(${scale}) translate(${translateX}px, ${translateY}px)`,
transition: isPanning ? 'none' : 'transform 0.3s ease-out',
}"
alt="Viewed image"
@load="onImageLoad"
@error="onImageError"
/>
</div>
<div
v-if="!error"
class="absolute bottom-0 mb-2 flex w-fit justify-center space-x-4 rounded-xl bg-bg p-2"
>
<Button icon-only transparent @click="zoomIn">
<ZoomInIcon />
</Button>
<Button icon-only transparent @click="resetZoom">
<HomeIcon />
</Button>
<Button icon-only transparent @click="zoomOut">
<ZoomOutIcon />
</Button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from "vue";
import { HomeIcon, ZoomInIcon, ZoomOutIcon } from "@modrinth/assets";
import { Button } from "@modrinth/ui";
const props = defineProps({
imageBlob: {
type: Blob,
required: true,
},
});
const container = ref(null);
const image = ref(null);
const scale = ref(1);
const translateX = ref(0);
const translateY = ref(0);
const isPanning = ref(false);
const startX = ref(0);
const startY = ref(0);
const imageUrl = ref("");
const loading = ref(true);
const error = ref(false);
const createImageUrl = (blob) => {
if (imageUrl.value) {
URL.revokeObjectURL(imageUrl.value);
}
imageUrl.value = URL.createObjectURL(blob);
};
watch(
() => props.imageBlob,
(newBlob) => {
if (newBlob) {
loading.value = true;
error.value = false;
createImageUrl(newBlob);
}
},
);
onMounted(() => {
if (props.imageBlob) {
createImageUrl(props.imageBlob);
}
});
const onImageLoad = () => {
loading.value = false;
resetZoom();
};
const onImageError = () => {
loading.value = false;
error.value = true;
};
const zoomIn = () => {
scale.value = Math.min(scale.value * 1.2, 5);
};
const zoomOut = () => {
scale.value = Math.max(scale.value / 1.2, 0.1);
};
const resetZoom = () => {
scale.value = 0.5;
translateX.value = 0;
translateY.value = 0;
};
const startPan = (e) => {
isPanning.value = true;
startX.value = e.clientX - translateX.value;
startY.value = e.clientY - translateY.value;
};
const pan = (e) => {
if (isPanning.value) {
translateX.value = e.clientX - startX.value;
translateY.value = e.clientY - startY.value;
}
};
const endPan = () => {
isPanning.value = false;
};
const handleWheel = (e) => {
const delta = (e.deltaY * -0.01) / 10;
const newScale = Math.max(0.1, Math.min(scale.value + delta, 5));
scale.value = newScale;
};
</script>

View File

@@ -0,0 +1,81 @@
<template>
<NewModal ref="modal" :header="`Moving ${item?.name}`">
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-2">
<input
ref="destinationInput"
v-model="destination"
autofocus
type="text"
class="bg-bg-input w-full rounded-lg p-4"
placeholder="e.g. mods/modname"
required
/>
</div>
<div class="flex items-center gap-2 text-nowrap">
New location:
<div class="w-full rounded-lg bg-table-alternateRow p-2 font-bold text-contrast">
<span class="text-secondary">/root</span>{{ newpath }}
</div>
</div>
<div class="flex justify-start gap-4">
<ButtonStyled color="brand">
<button type="submit">
<ArrowBigUpDashIcon class="h-5 w-5" />
Move
</button>
</ButtonStyled>
<ButtonStyled>
<button type="button" @click="hide">
<XIcon class="h-5 w-5" />
Cancel
</button>
</ButtonStyled>
</div>
</form>
</NewModal>
</template>
<script setup lang="ts">
import { ArrowBigUpDashIcon, XIcon } from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { ref, nextTick } from "vue";
const destinationInput = ref<HTMLInputElement | null>(null);
const props = defineProps<{
item: { name: string } | null;
currentPath: string;
}>();
const emit = defineEmits<{
(e: "move", destination: string): void;
}>();
const modal = ref<typeof NewModal>();
const destination = ref("");
const newpath = computed(() => {
return destination.value.replace("//", "/");
});
const handleSubmit = () => {
emit("move", destination.value);
hide();
};
const show = () => {
destination.value = props.currentPath;
modal.value?.show();
nextTick(() => {
setTimeout(() => {
destinationInput.value?.focus();
}, 100);
});
};
const hide = () => {
modal.value?.hide();
};
defineExpose({ show, hide });
</script>

View File

@@ -0,0 +1,94 @@
<template>
<NewModal ref="modal" :header="`Renaming ${item?.type}`">
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-2">
<div class="font-semibold text-contrast">Name</div>
<input
ref="renameInput"
v-model="itemName"
autofocus
type="text"
class="bg-bg-input w-full rounded-lg p-4"
required
/>
<div v-if="submitted && error" class="text-red">{{ error }}</div>
</div>
<div class="flex justify-start gap-4">
<ButtonStyled color="brand">
<button :disabled="!!error" type="submit">
<EditIcon class="h-5 w-5" />
Rename
</button>
</ButtonStyled>
<ButtonStyled>
<button type="button" @click="hide">
<XIcon class="h-5 w-5" />
Cancel
</button>
</ButtonStyled>
</div>
</form>
</NewModal>
</template>
<script setup lang="ts">
import { EditIcon, XIcon } from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { ref, computed, nextTick } from "vue";
const props = defineProps<{
item: { name: string; type: string } | null;
}>();
const emit = defineEmits<{
(e: "rename", newName: string): void;
}>();
const modal = ref<typeof NewModal>();
const renameInput = ref<HTMLInputElement | null>(null);
const itemName = ref("");
const submitted = ref(false);
const error = computed(() => {
if (!itemName.value) {
return "Name is required.";
}
if (props.item?.type === "file") {
const validPattern = /^[a-zA-Z0-9-_.\s]+$/;
if (!validPattern.test(itemName.value)) {
return "Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.";
}
} else {
const validPattern = /^[a-zA-Z0-9-_\s]+$/;
if (!validPattern.test(itemName.value)) {
return "Name must contain only alphanumeric characters, dashes, underscores, or spaces.";
}
}
return "";
});
const handleSubmit = () => {
submitted.value = true;
if (!error.value) {
emit("rename", itemName.value);
hide();
}
};
const show = (item: { name: string; type: string }) => {
itemName.value = item.name;
submitted.value = false;
modal.value?.show();
nextTick(() => {
setTimeout(() => {
renameInput.value?.focus();
}, 100);
});
};
const hide = () => {
modal.value?.hide();
};
defineExpose({ show, hide });
</script>

View File

@@ -0,0 +1,76 @@
<template>
<div
v-for="loader in loaders"
:key="loader.name"
class="group relative flex items-center justify-between rounded-2xl p-2 pr-2.5 hover:bg-bg"
>
<div class="flex items-center gap-4">
<div
class="grid size-10 place-content-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
:class="isCurrentLoader(loader.name) ? '[&&]:bg-bg-green' : ''"
>
<UiServersIconsLoaderIcon
:loader="loader.name"
class="[&&]:size-6"
:class="isCurrentLoader(loader.name) ? 'text-brand' : ''"
/>
</div>
<div class="flex flex-col gap-0.5">
<div class="flex flex-row items-center gap-2">
<h1 class="m-0 text-xl font-bold leading-none text-contrast">
{{ loader.displayName }}
</h1>
<span
v-if="isCurrentLoader(loader.name)"
class="hidden items-center gap-1 rounded-full bg-bg-green p-1 px-1.5 text-xs font-semibold text-brand sm:flex"
>
<CheckIcon class="h-4 w-4" />
Current
</span>
</div>
<p v-if="isCurrentLoader(loader.name)" class="m-0 text-xs text-secondary">
{{ data.loader_version }}
</p>
</div>
</div>
<ButtonStyled>
<button @click="selectLoader(loader.name)">
<DownloadIcon class="h-5 w-5" />
{{ isCurrentLoader(loader.name) ? "Reinstall" : "Install" }}
</button>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import { CheckIcon, DownloadIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
const props = defineProps<{
data: {
loader: string | null;
loader_version: string | null;
};
}>();
const emit = defineEmits<{
(e: "selectLoader", loader: string): void;
}>();
const loaders = [
{ name: "Vanilla" as const, displayName: "Vanilla" },
{ name: "Fabric" as const, displayName: "Fabric" },
{ name: "Quilt" as const, displayName: "Quilt" },
{ name: "Forge" as const, displayName: "Forge" },
{ name: "NeoForge" as const, displayName: "NeoForge" },
];
const isCurrentLoader = (loaderName: string) => {
return props.data.loader?.toLowerCase() === loaderName.toLowerCase();
};
const selectLoader = (loader: string) => {
emit("selectLoader", loader);
};
</script>

View File

@@ -0,0 +1,107 @@
<template>
<div class="parsed-log group relative w-full overflow-hidden px-6 py-1">
<div
ref="logContent"
class="log-content selectable whitespace-pre-wrap selection:bg-black selection:text-white dark:selection:bg-white dark:selection:text-black"
v-html="sanitizedLog"
></div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import Convert from "ansi-to-html";
import DOMPurify from "dompurify";
const props = defineProps<{
log: string;
index: number;
}>();
const logContent = ref<HTMLElement | null>(null);
const colors = {
30: "#101010",
31: "#EFA6A2",
32: "#80C990",
33: "#A69460",
34: "#A3B8EF",
35: "#E6A3DC",
36: "#50CACD",
37: "#808080",
90: "#454545",
91: "#E0AF85",
92: "#5ACCAF",
93: "#C8C874",
94: "#CCACED",
95: "#F2A1C2",
96: "#74C3E4",
97: "#C0C0C0",
};
const convert = new Convert({
fg: "#FFF",
bg: "#000",
newline: false,
escapeXML: true,
stream: false,
colors,
});
const urlRegex = /https?:\/\/[^\s]+/g;
const usernameRegex = /&lt;([^&]+)&gt;/g;
const sanitizedLog = computed(() => {
let html = convert.toHtml(props.log);
html = html.replace(
urlRegex,
(url) =>
`<a style="color:var(--color-link);text-decoration:underline;" href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`,
);
html = html.replace(
usernameRegex,
(_, username) => `<span class="minecraft-username">&lt;${username}&gt;</span>`,
);
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ["span", "a"],
ALLOWED_ATTR: ["style", "href", "target", "rel", "class"],
ADD_ATTR: ["target"],
RETURN_TRUSTED_TYPE: true,
USE_PROFILES: { html: true },
});
});
</script>
<style scoped>
.parsed-log:hover:not(.selected) {
border-radius: 0.5rem;
}
html.light-mode .parsed-log:hover:not(.selected) {
background-color: #ccc;
}
html.dark-mode .parsed-log:hover:not(.selected) {
background-color: #222;
}
html.oled-mode .parsed-log:hover:not(.selected) {
background-color: #222;
}
.minecraft-username {
font-weight: bold;
}
::v-deep(.log-content) {
user-select: none;
}
::v-deep(.log-content.selectable) {
user-select: text;
}
::v-deep(.log-content *) {
user-select: text;
}
</style>

View File

@@ -0,0 +1,660 @@
<template>
<div class="flex items-center justify-center">
<div class="w-full overflow-hidden">
<div class="mb-4">
<div
v-for="(line, lineIndex) in motd"
:key="lineIndex"
class="relative mb-2 rounded bg-button-bg p-2"
>
<div
class="font-minecraft text-white"
:contenteditable="true"
spellcheck="false"
@input="handleInput($event, lineIndex)"
@keydown.enter.prevent
@paste.prevent="handlePaste($event, lineIndex)"
@mouseup="handleSelection(lineIndex)"
v-html="renderLine(line)"
></div>
<div class="text-sm text-gray-400">
{{ motd[lineIndex].reduce((sum, segment) => sum + segment.text.length, 0) }}/45
characters
</div>
</div>
</div>
</div>
<transition name="fade">
<div
v-if="showPopup"
:style="{ top: `${popupY}px`, left: `${popupX}px` }"
class="fixed z-10 flex flex-col items-end gap-2 transition-all duration-300 ease-in-out"
>
<div class="rounded-xl border bg-table-alternateRow p-2 shadow-lg">
<div class="flex space-x-2">
<Button
v-for="style in styles"
:key="style.name"
icon-only
transparent
@click="applyStyle({ [style.name]: !currentStyle[style.name] })"
>
<component :is="style.icon" class="h-4 w-4" />
</Button>
<div class="relative overflow-y-scroll">
<Button icon-only transparent :class="colorPicker ?? 'hidden'" @click="pickColor">
<PaintBrushIcon />
</Button>
</div>
</div>
</div>
<div
v-if="colorPicker"
icon-only
class="w-fit overflow-y-auto rounded-xl p-2 [&&]:bg-table-alternateRow"
>
<div :class="colorPicker ? `grid grid-flow-col grid-rows-4 gap-2` : '[&&]:hidden'">
<button
v-for="format in sortedFormatCodes()"
:key="format.code"
class="rounded-full p-3"
:style="{ backgroundColor: format.color }"
:title="format.description"
@click="applyStyle({ color: format.color })"
></button>
</div>
</div>
</div>
</transition>
</div>
</template>
<script setup>
import {
ItalicIcon,
BoldIcon,
StrikethroughIcon,
UnderlineIcon,
PaintBrushIcon,
ChevronLeftIcon,
} from "@modrinth/assets";
import { Button } from "@modrinth/ui";
const props = defineProps({
server: {
type: Object,
required: true,
},
});
const formatCodes = [
{ code: "§f", color: "white", description: "White" },
{ code: "§7", color: "#AAAAAA", description: "Gray" },
{ code: "§8", color: "#555555", description: "Dark Gray" },
{ code: "§0", color: "#000000", description: "Black" },
{ code: "§9", color: "#5555FF", description: "Blue" },
{ code: "§1", color: "#0000AA", description: "Dark Blue" },
{ code: "§b", color: "#55FFFF", description: "Aqua" },
{ code: "§3", color: "#00AAAA", description: "Dark Aqua" },
{ code: "§a", color: "#55FF55", description: "Green" },
{ code: "§2", color: "#00AA00", description: "Dark Green" },
{ code: "§e", color: "#FFFF55", description: "Yellow" },
{ code: "§6", color: "#FFAA00", description: "Gold" },
{ code: "§c", color: "#FF5555", description: "Red" },
{ code: "§4", color: "#AA0000", description: "Dark Red" },
{ code: "§d", color: "#FF55FF", description: "Light Purple" },
{ code: "§5", color: "#AA00AA", description: "Dark Purple" },
];
const sortedFormatCodes = () => {
const colors = formatCodes;
if (colors[0].description === "White") {
colors.reverse();
}
return colors;
};
const minecraftEmojis = [
{ char: "☺", name: "SMILING FACE" },
{ char: "☹", name: "FROWNING FACE" },
{ char: "☠", name: "SKULL AND CROSSBONES" },
{ char: "❣", name: "HEART EXCLAMATION" },
{ char: "❤", name: "RED HEART" },
{ char: "✌", name: "VICTORY HAND" },
{ char: "☝", name: "INDEX POINTING UP" },
{ char: "✍", name: "WRITING HAND" },
{ char: "♨", name: "HOT SPRINGS" },
{ char: "✈", name: "AIRPLANE" },
{ char: "⌛", name: "HOURGLASS DONE" },
{ char: "⌚", name: "WATCH" },
{ char: "☀", name: "SUN" },
{ char: "☁", name: "CLOUD" },
{ char: "☂", name: "UMBRELLA" },
{ char: "❄", name: "SNOWFLAKE" },
{ char: "☃", name: "SNOWMAN" },
{ char: "☄", name: "COMET" },
{ char: "♠", name: "SPADE SUIT" },
{ char: "♥", name: "HEART SUIT" },
{ char: "♦", name: "DIAMOND SUIT" },
{ char: "♣", name: "CLUB SUIT" },
{ char: "♟", name: "CHESS PAWN" },
{ char: "☎", name: "TELEPHONE" },
{ char: "⌨", name: "KEYBOARD" },
{ char: "✉", name: "ENVELOPE" },
{ char: "✏", name: "PENCIL" },
{ char: "✒", name: "BLACK PEN" },
{ char: "✂", name: "SCISSORS" },
{ char: "☢", name: "RADIOACTIVE" },
{ char: "☣", name: "BIOHAZARD" },
{ char: "⬆", name: "UP ARROW" },
{ char: "⬇", name: "DOWN ARROW" },
{ char: "➡", name: "RIGHT ARROW" },
{ char: "⬅", name: "LEFT ARROW" },
{ char: "↗", name: "UP-RIGHT ARROW" },
{ char: "↘", name: "DOWN-RIGHT ARROW" },
{ char: "↙", name: "DOWN-LEFT ARROW" },
{ char: "↖", name: "UP-LEFT ARROW" },
{ char: "↕", name: "UP-DOWN ARROW" },
{ char: "↔", name: "LEFT-RIGHT ARROW" },
{ char: "↩", name: "RIGHT ARROW CURVING LEFT" },
{ char: "↪", name: "LEFT ARROW CURVING RIGHT" },
{ char: "✡", name: "STAR OF DAVID" },
{ char: "☸", name: "WHEEL OF DHARMA" },
{ char: "☯", name: "YIN YANG" },
{ char: "✝", name: "LATIN CROSS" },
{ char: "☦", name: "ORTHODOX CROSS" },
{ char: "☪", name: "STAR AND CRESCENT" },
{ char: "☮", name: "PEACE SYMBOL" },
{ char: "♈", name: "ARIES" },
{ char: "♉", name: "TAURUS" },
{ char: "♊", name: "GEMINI" },
{ char: "♋", name: "CANCER" },
{ char: "♌", name: "LEO" },
{ char: "♍", name: "VIRGO" },
{ char: "♎", name: "LIBRA" },
{ char: "♏", name: "SCORPIO" },
{ char: "♐", name: "SAGITTARIUS" },
{ char: "♑", name: "CAPRICORN" },
{ char: "♒", name: "AQUARIUS" },
{ char: "♓", name: "PISCES" },
{ char: "▶", name: "PLAY BUTTON" },
{ char: "◀", name: "REVERSE BUTTON" },
{ char: "♀", name: "FEMALE SIGN" },
{ char: "♂", name: "MALE SIGN" },
{ char: "✖", name: "MULTIPLY" },
{ char: "‼", name: "DOUBLE EXCLAMATION MARK" },
{ char: "〰", name: "WAVY DASH" },
{ char: "☑", name: "CHECK BOX WITH CHECK" },
{ char: "✔", name: "CHECK MARK" },
{ char: "✳", name: "EIGHT-SPOKED ASTERISK" },
{ char: "✴", name: "EIGHT-POINTED STAR" },
{ char: "❇", name: "SPARKLE" },
{ char: "©", name: "COPYRIGHT" },
{ char: "®", name: "REGISTERED" },
{ char: "™", name: "TRADE MARK" },
{ char: "Ⓜ", name: "CIRCLED M" },
{ char: "㊗", name: 'JAPANESE "CONGRATULATIONS" BUTTON' },
{ char: "㊙", name: 'JAPANESE "SECRET" BUTTON' },
{ char: "▪", name: "BLACK SMALL SQUARE" },
{ char: "▫", name: "WHITE SMALL SQUARE" },
{ char: "☷", name: "TRIGRAM FOR EARTH" },
{ char: "☵", name: "TRIGRAM FOR WATER" },
{ char: "☶", name: "TRIGRAM FOR MOUNTAIN" },
{ char: "☋", name: "DESCENDING NODE" },
{ char: "☌", name: "CONJUNCTION" },
{ char: "♜", name: "BLACK CHESS ROOK" },
{ char: "♕", name: "WHITE CHESS QUEEN" },
{ char: "♡", name: "WHITE HEART SUIT" },
{ char: "♬", name: "BEAMED SIXTEENTH NOTES" },
{ char: "☚", name: "BLACK LEFT POINTING INDEX" },
{ char: "♮", name: "MUSIC NATURAL SIGN" },
{ char: "♝", name: "BLACK CHESS BISHOP" },
{ char: "♯", name: "SHARP" },
{ char: "☴", name: "TRIGRAM FOR WIND" },
{ char: "♭", name: "FLAT" },
{ char: "☓", name: "SALTIRE" },
{ char: "☛", name: "BLACK RIGHT POINTING INDEX" },
{ char: "☭", name: "HAMMER AND SICKLE" },
{ char: "♢", name: "WHITE DIAMOND SUIT" },
{ char: "✐", name: "UPPER RIGHT PENCIL" },
{ char: "♖", name: "WHITE CHESS ROOK" },
{ char: "☈", name: "THUNDERSTORM" },
{ char: "☒", name: "BALLOT BOX WITH X" },
{ char: "★", name: "BLACK STAR" },
{ char: "♚", name: "BLACK CHESS KING" },
{ char: "♛", name: "BLACK CHESS QUEEN" },
{ char: "✎", name: "LOWER RIGHT PENCIL" },
{ char: "♪", name: "EIGHTH NOTE" },
{ char: "☰", name: "TRIGRAM FOR HEAVEN" },
{ char: "☽", name: "FIRST QUARTER MOON" },
{ char: "☡", name: "CAUTION SIGN" },
{ char: "☼", name: "WHITE SUN WITH RAYS" },
{ char: "♅", name: "URANUS" },
{ char: "☐", name: "BALLOT BOX" },
{ char: "☟", name: "WHITE DOWN POINTING INDEX" },
{ char: "❦", name: "FLORAL HEART" },
{ char: "☊", name: "ASCENDING NODE" },
{ char: "☍", name: "OPPOSITION" },
{ char: "☬", name: "ADI SHAKTI" },
{ char: "♧", name: "WHITE CLUB SUIT" },
{ char: "☫", name: "FARSI SYMBOL" },
{ char: "☱", name: "TRIGRAM FOR LAKE" },
{ char: "☾", name: "LAST QUARTER MOON" },
{ char: "☤", name: "CADUCEUS" },
{ char: "❧", name: "ROTATED FLORAL HEART BULLET" },
{ char: "♄", name: "SATURN" },
{ char: "♁", name: "EARTH" },
{ char: "♔", name: "WHITE CHESS KING" },
{ char: "❥", name: "ROTATED HEAVY BLACK HEART BULLET" },
{ char: "☥", name: "ANKH" },
{ char: "☻", name: "BLACK SMILING FACE" },
{ char: "♤", name: "WHITE SPADE SUIT" },
{ char: "♞", name: "BLACK CHESS KNIGHT" },
{ char: "♆", name: "NEPTUNE" },
{ char: "#", name: "HASH SIGN" },
{ char: "♃", name: "JUPITER" },
{ char: "♩", name: "QUARTER NOTE" },
{ char: "☇", name: "LIGHTNING" },
{ char: "☞", name: "WHITE RIGHT POINTING INDEX" },
{ char: "♫", name: "BEAMED EIGHTH NOTES" },
{ char: "☏", name: "WHITE TELEPHONE" },
{ char: "♘", name: "WHITE CHESS KNIGHT" },
{ char: "☧", name: "CHI RHO" },
{ char: "☉", name: "SUN" },
{ char: "♇", name: "PLUTO" },
{ char: "☩", name: "CROSS OF JERUSALEM" },
{ char: "♙", name: "WHITE CHESS PAWN" },
{ char: "☜", name: "WHITE LEFT POINTING INDEX" },
{ char: "☲", name: "TRIGRAM FOR FIRE" },
{ char: "☨", name: "CROSS OF LORRAINE" },
{ char: "♗", name: "WHITE CHESS BISHOP" },
{ char: "☳", name: "TRIGRAM FOR THUNDER" },
{ char: "⚔", name: "CROSSED SWORDS" },
{ char: "⚀", name: "DICE ONE" },
];
const rawMotd = ref(props.server.general?.motd ?? "");
const motd = computed(() => {
const lines = rawMotd.value.split("\n");
return lines.map((line) => {
const segments = [];
let currentSegment = { text: "", color: "White" };
let i = 0;
while (i < line.length) {
if (line[i] === "§") {
if (currentSegment.text) {
segments.push({ ...currentSegment });
currentSegment = { text: "", color: "White" };
}
const formatCode = line.substr(i, 2);
const format = formatCodes.find((f) => f.code === formatCode);
console.log(format);
console.log(formatCode);
if (format) {
currentSegment.color = format.color;
i += 2;
continue;
} else if (formatCode === "§l") {
currentSegment.bold = true;
i += 2;
continue;
} else if (formatCode === "§o") {
currentSegment.italic = true;
i += 2;
continue;
} else if (formatCode === "§n") {
currentSegment.underline = true;
i += 2;
continue;
} else if (formatCode === "§m") {
currentSegment.strikethrough = true;
i += 2;
continue;
}
}
currentSegment.text += line[i];
i++;
}
if (currentSegment.text) {
segments.push(currentSegment);
}
return segments;
});
});
const styles = [
{
name: "bold",
icon: BoldIcon,
},
{
name: "italic",
icon: ItalicIcon,
},
{
name: "underline",
icon: UnderlineIcon,
},
{
name: "strikethrough",
icon: StrikethroughIcon,
},
];
const showPopup = ref(false);
const popupX = ref(0);
const popupY = ref(0);
const currentLineIndex = ref(0);
const selectionStart = ref(0);
const selectionEnd = ref(0);
const colorPicker = ref(false);
const pickColor = () => {
colorPicker.value = !colorPicker.value;
};
const totalCharacters = computed(() => {
return motd.value.reduce((sum, line) => {
return Math.max(
sum,
line.reduce((lineSum, segment) => lineSum + segment.text.length, 0),
);
}, 0);
});
const minecraftFormat = computed(() => {
return motd.value
.map((line) => {
return line
.map((segment) => {
let format = getColorCode(segment.color);
if (segment.bold) format += "§l";
if (segment.italic) format += "§o";
if (segment.underline) format += "§n";
if (segment.strikethrough) format += "§m";
return format + segment.text;
})
.join("");
})
.join("\n");
});
const currentStyle = computed(() => {
const line = motd.value[currentLineIndex.value];
if (!line) return {};
let start = 0;
for (const segment of line) {
if (start + segment.text.length > selectionStart.value) {
return {
color: segment.color || "White",
bold: segment.bold || false,
italic: segment.italic || false,
underline: segment.underline || false,
strikethrough: segment.strikethrough || false,
};
}
start += segment.text.length;
}
return {};
});
function getColorCode(color) {
const format = formatCodes.find((f) => f.description === color);
return format ? format.code : "§f";
}
function renderLine(line) {
return line
.map((segment) => {
let style = `color: ${segment.color};`;
if (segment.bold) style += "font-weight: 900;";
if (segment.italic) style += "font-style: italic;";
if (segment.underline) style += "text-decoration: underline;";
if (segment.strikethrough) style += "text-decoration: line-through;";
return `<span style="${style}">${segment.text}</span>`;
})
.join("");
}
function handleSelection(lineIndex) {
const selection = window.getSelection();
if (selection.toString().length > 0) {
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
popupX.value = rect.left;
popupY.value = rect.bottom;
showPopup.value = true;
currentLineIndex.value = lineIndex;
const lineElement = document.querySelectorAll("[contenteditable]")[lineIndex];
const rangeClone = range.cloneRange();
rangeClone.selectNodeContents(lineElement);
rangeClone.setEnd(range.startContainer, range.startOffset);
selectionStart.value = rangeClone.toString().length;
selectionEnd.value = selectionStart.value + range.toString().length;
} else {
showPopup.value = false;
colorPicker.value = false;
}
}
function applyStyle(newStyle) {
const line = motd.value[currentLineIndex.value];
const newLine = [];
let currentPos = 0;
for (const segment of line) {
if (currentPos + segment.text.length <= selectionStart.value) {
newLine.push(segment);
} else if (currentPos >= selectionEnd.value) {
newLine.push(segment);
} else {
const beforeSelection = segment.text.slice(0, Math.max(0, selectionStart.value - currentPos));
const inSelection = segment.text.slice(
Math.max(0, selectionStart.value - currentPos),
Math.min(segment.text.length, selectionEnd.value - currentPos),
);
const afterSelection = segment.text.slice(
Math.min(segment.text.length, selectionEnd.value - currentPos),
);
console.log(beforeSelection);
console.log(inSelection);
console.log(afterSelection);
if (beforeSelection) newLine.push({ ...segment, text: beforeSelection });
if (inSelection) {
const mergedStyle = { ...segment, ...newStyle };
for (const key in newStyle) {
if (newStyle[key] === false) {
delete mergedStyle[key];
}
}
newLine.push({ ...mergedStyle, text: inSelection });
}
if (afterSelection) newLine.push({ ...segment, text: afterSelection });
}
currentPos += segment.text.length;
}
motd.value[currentLineIndex.value] = newLine;
showPopup.value = false;
colorPicker.value = false;
// Rerender the line to reflect the changes
nextTick(() => {
const lineElement = document.querySelectorAll("[contenteditable]")[currentLineIndex.value];
lineElement.innerHTML = renderLine(newLine);
});
}
function insertEmoji() {
const emoji = "☺";
if (totalCharacters.value + emoji.length <= 90) {
applyStyle({ text: emoji });
}
}
function handleInput(event, lineIndex) {
const newText = event.target.textContent;
const oldText = motd.value[lineIndex].reduce((acc, segment) => acc + segment.text, "");
const diff = newText.length - oldText.length;
if (newText.length <= 45) {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
const cursorOffset = getCursorOffset(event.target, range);
const newLine = [];
let currentPos = 0;
for (const segment of motd.value[lineIndex]) {
const segmentEnd = currentPos + segment.text.length;
const newSegmentText = newText.slice(currentPos, Math.min(segmentEnd, newText.length));
if (newSegmentText) {
newLine.push({ ...segment, text: newSegmentText });
}
currentPos = segmentEnd;
if (currentPos >= newText.length) break;
}
if (currentPos < newText.length) {
newLine.push({ text: newText.slice(currentPos), color: "White" });
}
motd.value[lineIndex] = newLine;
nextTick(() => {
const lineElement = event.target;
lineElement.innerHTML = renderLine(newLine);
const newRange = document.createRange();
const sel = window.getSelection();
const { node, offset } = getCursorNodeAndOffset(lineElement, cursorOffset);
if (node) {
newRange.setStart(node, offset);
newRange.collapse(true);
sel.removeAllRanges();
sel.addRange(newRange);
}
});
} else {
event.target.innerHTML = renderLine(motd.value[lineIndex]);
}
}
// Helper function to get cursor offset considering styled spans
function getCursorOffset(element, range) {
let offset = 0;
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false);
let node;
while ((node = walker.nextNode())) {
if (node === range.startContainer) {
return offset + range.startOffset;
}
offset += node.length;
}
return offset;
}
// Helper function to find the node and offset for cursor placement
function getCursorNodeAndOffset(element, targetOffset) {
let currentOffset = 0;
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false);
let node;
while ((node = walker.nextNode())) {
if (currentOffset + node.length >= targetOffset) {
return { node, offset: targetOffset - currentOffset };
}
currentOffset += node.length;
}
// If we've gone past the end, return the last possible position
const lastTextNode = element.lastChild?.lastChild;
return { node: lastTextNode, offset: lastTextNode?.length || 0 };
}
function handlePaste(event, lineIndex) {
event.preventDefault();
const pastedText = (event.clipboardData || window.clipboardData).getData("text");
const selection = window.getSelection();
const range = selection.getRangeAt(0);
const startOffset = range.startOffset;
const currentText = motd.value[lineIndex].reduce((acc, segment) => acc + segment.text, "");
const newText = currentText.slice(0, startOffset) + pastedText + currentText.slice(startOffset);
if (newText.length <= 45) {
// Preserve existing styles by matching new text with old segments
const newLine = [];
let currentPos = 0;
for (const segment of motd.value[lineIndex]) {
if (currentPos < startOffset) {
const segmentEnd = Math.min(currentPos + segment.text.length, startOffset);
newLine.push({ ...segment, text: newText.slice(currentPos, segmentEnd) });
currentPos = segmentEnd;
} else if (currentPos >= startOffset + pastedText.length) {
newLine.push({ ...segment, text: newText.slice(currentPos) });
break;
}
}
// Insert pasted text as a new segment
if (currentPos < startOffset + pastedText.length) {
newLine.push({
text: newText.slice(currentPos, startOffset + pastedText.length),
color: "White",
});
}
motd.value[lineIndex] = newLine;
nextTick(() => {
const lineElement = document.querySelectorAll("[contenteditable]")[lineIndex];
lineElement.innerHTML = renderLine(newLine);
const newRange = document.createRange();
const sel = window.getSelection();
newRange.setStart(lineElement.childNodes[0], startOffset + pastedText.length);
newRange.collapse(true);
sel.removeAllRanges();
sel.addRange(newRange);
});
}
}
</script>
<style scoped>
.minecraft-font {
font-family: "Minecraft", monospace;
font-size: 16px;
line-height: 1.5;
}
[contenteditable] {
outline: none;
}
</style>
<style scoped>
@font-face {
font-family: "Monocraft";
src: url("/Monocraft.ttf") format("truetype");
}
.font-minecraft {
font-family: "Monocraft", monospace;
}
.mcbg {
background: url("@/assets/images/servers/minecraft-background-dark.png") repeat center center;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease-in-out;
}
.fade-enter, .fade-leave-to /* .fade-leave-active in <2.1.8 */ {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:serif="http://www.serif.com/"
version="1.1"
viewBox="0 0 1793 199"
>
<g>
<g id="Layer_1">
<g id="green" fill="var(--color-brand)">
<path
d="M1184.1,166.6c-8,0-15.6-1-22.9-3.1s-13.1-4.6-17.4-7.6l8.5-16.9c4.3,2.7,9.4,5,15.3,6.8,5.9,1.8,11.9,2.7,17.8,2.7s12.1-.9,15.2-2.8c3.1-1.9,4.7-4.5,4.7-7.7s-1.1-4.6-3.2-6c-2.1-1.4-4.9-2.4-8.4-3.1-3.4-.7-7.3-1.4-11.5-2-4.2-.6-8.4-1.4-12.6-2.4-4.2-1-8-2.5-11.5-4.5-3.4-2-6.2-4.6-8.4-7.9-2.1-3.3-3.2-7.7-3.2-13.2s1.7-11.3,5.2-15.8c3.4-4.5,8.3-7.9,14.5-10.3,6.2-2.4,13.6-3.7,22.2-3.7s12.9.7,19.4,2.1c6.5,1.4,11.9,3.4,16.2,6.1l-8.5,16.9c-4.5-2.7-9.1-4.6-13.6-5.6-4.6-1-9.1-1.5-13.6-1.5-6.8,0-11.8,1-15,3-3.3,2-4.9,4.6-4.9,7.7s1.1,5,3.2,6.4c2.1,1.4,4.9,2.6,8.4,3.4,3.4.8,7.3,1.5,11.5,2,4.2.5,8.4,1.3,12.6,2.4,4.2,1.1,8,2.5,11.5,4.4,3.5,1.8,6.3,4.4,8.5,7.7,2.1,3.3,3.2,7.7,3.2,13s-1.8,11.1-5.3,15.5c-3.5,4.4-8.5,7.8-14.9,10.2-6.4,2.4-14.1,3.7-23,3.7Z"
/>
<path
d="M1291.1,166.6c-10.6,0-19.8-2.1-27.7-6.3-7.9-4.2-14-10-18.3-17.4-4.3-7.4-6.5-15.7-6.5-25.1s2.1-17.9,6.3-25.2c4.2-7.3,10-13,17.5-17.2,7.4-4.2,15.9-6.2,25.4-6.2s17.5,2,24.8,6.1c7.2,4,12.9,9.7,17.1,17.1,4.2,7.4,6.2,16,6.2,26s0,2,0,3.2c0,1.2-.2,2.3-.3,3.4h-79.3v-14.8h67.5l-8.7,4.6c.1-5.5-1-10.3-3.4-14.4-2.4-4.2-5.6-7.4-9.7-9.8-4.1-2.4-8.8-3.6-14.2-3.6s-10.2,1.2-14.3,3.6c-4.1,2.4-7.3,5.7-9.6,9.9-2.3,4.2-3.5,9.2-3.5,14.9v3.6c0,5.7,1.3,10.7,3.9,15.1,2.6,4.4,6.3,7.8,11,10.2,4.7,2.4,10.2,3.6,16.4,3.6s10.2-.8,14.4-2.5c4.3-1.7,8.1-4.3,11.4-7.8l11.9,13.7c-4.3,5-9.6,8.8-16.1,11.5-6.5,2.7-13.9,4-22.2,4Z"
/>
<path
d="M1357.2,165.3v-95.1h21.2v26.2l-2.5-7.7c2.8-6.4,7.3-11.3,13.4-14.6,6.1-3.3,13.7-5,22.9-5v21.2c-1-.2-1.8-.4-2.7-.4-.8,0-1.7,0-2.5,0-8.4,0-15.1,2.5-20.1,7.4-5,4.9-7.5,12.3-7.5,22v46.1h-22.3Z"
/>
<path d="M1460,165.3l-40.8-95.1h23.2l35.1,83.9h-11.4l36.3-83.9h21.4l-40.8,95.1h-23Z" />
<path
d="M1579.6,166.6c-10.6,0-19.8-2.1-27.7-6.3-7.9-4.2-14-10-18.3-17.4-4.3-7.4-6.5-15.7-6.5-25.1s2.1-17.9,6.3-25.2c4.2-7.3,10-13,17.5-17.2,7.4-4.2,15.9-6.2,25.4-6.2s17.5,2,24.8,6.1c7.2,4,12.9,9.7,17.1,17.1,4.2,7.4,6.2,16,6.2,26s0,2,0,3.2c0,1.2-.2,2.3-.3,3.4h-79.3v-14.8h67.5l-8.7,4.6c.1-5.5-1-10.3-3.4-14.4-2.4-4.2-5.6-7.4-9.7-9.8-4.1-2.4-8.8-3.6-14.2-3.6s-10.2,1.2-14.3,3.6c-4.1,2.4-7.3,5.7-9.6,9.9-2.3,4.2-3.5,9.2-3.5,14.9v3.6c0,5.7,1.3,10.7,3.9,15.1,2.6,4.4,6.3,7.8,11,10.2,4.7,2.4,10.2,3.6,16.4,3.6s10.2-.8,14.4-2.5c4.3-1.7,8.1-4.3,11.4-7.8l11.9,13.7c-4.3,5-9.6,8.8-16.1,11.5-6.5,2.7-13.9,4-22.2,4Z"
/>
<path
d="M1645.7,165.3v-95.1h21.2v26.2l-2.5-7.7c2.8-6.4,7.3-11.3,13.4-14.6,6.1-3.3,13.7-5,22.9-5v21.2c-1-.2-1.8-.4-2.7-.4-.8,0-1.7,0-2.5,0-8.4,0-15.1,2.5-20.1,7.4-5,4.9-7.5,12.3-7.5,22v46.1h-22.3Z"
/>
<path
d="M1749.9,166.6c-8,0-15.6-1-22.9-3.1s-13.1-4.6-17.4-7.6l8.5-16.9c4.3,2.7,9.4,5,15.3,6.8,5.9,1.8,11.9,2.7,17.8,2.7s12.1-.9,15.2-2.8c3.1-1.9,4.7-4.5,4.7-7.7s-1.1-4.6-3.2-6c-2.1-1.4-4.9-2.4-8.4-3.1-3.4-.7-7.3-1.4-11.5-2-4.2-.6-8.4-1.4-12.6-2.4-4.2-1-8-2.5-11.5-4.5-3.4-2-6.2-4.6-8.4-7.9-2.1-3.3-3.2-7.7-3.2-13.2s1.7-11.3,5.2-15.8c3.4-4.5,8.3-7.9,14.5-10.3,6.2-2.4,13.6-3.7,22.2-3.7s12.9.7,19.4,2.1c6.5,1.4,11.9,3.4,16.2,6.1l-8.5,16.9c-4.5-2.7-9.1-4.6-13.6-5.6-4.6-1-9.1-1.5-13.6-1.5-6.8,0-11.8,1-15,3-3.3,2-4.9,4.6-4.9,7.7s1.1,5,3.2,6.4c2.1,1.4,4.9,2.6,8.4,3.4,3.4.8,7.3,1.5,11.5,2,4.2.5,8.4,1.3,12.6,2.4,4.2,1.1,8,2.5,11.5,4.4,3.5,1.8,6.3,4.4,8.5,7.7,2.1,3.3,3.2,7.7,3.2,13s-1.8,11.1-5.3,15.5c-3.5,4.4-8.5,7.8-14.9,10.2-6.4,2.4-14.1,3.7-23,3.7Z"
/>
<g>
<path
d="M9.8,143l63.4-38.1-5.8-15.3,18.1-18.6,22.9-4.9,6.6,8.2-10.6,10.7-9.2,2.9-6.6,6.8,3.2,9,6.5,6.9,9.2-2.5,6.6-7.2,14.3-4.5,4.3,9.6-14.8,18.1-24.8,7.8-11.1-12.4-63.6,38.2c-3-3.9-6.5-9.4-8.8-14.7ZM192.8,65.4l-50.4,13.6c2.8,7.4,3.7,11.7,4.5,16.5l50.3-13.6c-.8-5.4-2.2-10.8-4.4-16.5Z"
fill-rule="evenodd"
/>
<path
d="M17.3,106.5c3.6,42.1,38.9,75.2,82,75.2s60.7-18.9,74-46.3l16.4,5.7c-15.8,34.1-50.3,57.9-90.4,57.9S3.6,158.2,0,106.5h17.3ZM.3,89.4C5.3,39.2,47.8,0,99.3,0s99.5,44.6,99.5,99.5-1.1,17.4-3.3,25.5l-16.3-5.7c1.6-6.5,2.4-13.1,2.4-19.8,0-45.4-36.9-82.3-82.3-82.3S22.6,48.7,17.6,89.4H.3Z"
fill-rule="evenodd"
/>
<path
d="M99,51.6c-26.4,0-47.9,21.5-47.9,48s21.5,48,48,48,2.7,0,4-.2l4.8,16.8c-2.9.4-5.8.6-8.8.6-36,0-65.2-29.2-65.2-65.2S63.1,34.4,99,34.4s1.8,0,2.7,0l-2.7,17.1ZM118.6,37.4c26.4,8.3,45.6,33,45.6,62.2s-16.4,50.2-39.8,60l-4.8-16.7c16.2-7.7,27.4-24.2,27.4-43.3s-13-38.1-31.1-44.9l2.7-17.2Z"
fill-rule="evenodd"
/>
</g>
</g>
<g id="black" fill="currentColor">
<path
d="M354.8,69.2c12,0,21.7,3.4,28.6,10.4,7,7.2,10.6,17.5,10.6,31.5v54.8h-22.4v-51.9c0-8.4-1.8-14.7-5.5-19-3.8-4.1-8.9-6.3-15.9-6.3s-13.6,2.5-18.1,7.3c-4.5,5-6.8,12.2-6.8,21.3v48.5h-22.4v-51.9c0-8.4-1.8-14.7-5.5-19-3.8-4.1-8.9-6.3-15.9-6.3s-13.6,2.5-18.1,7.3c-4.5,4.8-6.8,12-6.8,21.3v48.5h-22.4v-95.6h21.3v12.2c3.6-4.3,8.1-7.5,13.4-9.8,5.4-2.3,11.3-3.4,17.9-3.4s13.6,1.3,19.2,3.9c5.5,2.9,9.8,6.8,13.1,12,3.9-5,8.9-8.9,15.2-11.8,6.3-2.7,13.1-4.1,20.6-4.1ZM466,167.2c-9.7,0-18.4-2.1-26.1-6.3-7.6-4-13.8-10.1-18.1-17.5-4.5-7.3-6.6-15.7-6.6-25.2s2.1-17.9,6.6-25.2c4.3-7.4,10.6-13.4,18.1-17.4,7.7-4.1,16.5-6.3,26.1-6.3s18.6,2.1,26.3,6.3c7.7,4.1,13.8,10,18.3,17.4,4.3,7.3,6.4,15.7,6.4,25.2s-2.1,17.9-6.4,25.2c-4.5,7.5-10.6,13.4-18.3,17.5-7.7,4.1-16.5,6.3-26.3,6.3h0ZM466,148c8.2,0,15-2.7,20.4-8.2,5.4-5.5,8.1-12.7,8.1-21.7s-2.7-16.1-8.1-21.7c-5.4-5.5-12.2-8.2-20.4-8.2s-15,2.7-20.2,8.2c-5.4,5.5-8.1,12.7-8.1,21.7s2.7,16.1,8.1,21.7c5.2,5.5,12,8.2,20.2,8.2ZM631.5,33.1v132.8h-21.5v-12.3c-3.7,4.4-8.3,7.9-13.6,10.2-5.5,2.3-11.5,3.4-18.1,3.4s-17.4-2-24.7-6.1c-7.3-4.1-13.2-9.8-17.4-17.4-4.1-7.3-6.3-15.9-6.3-25.6s2.1-18.3,6.3-25.6c4.1-7.3,10-13.1,17.4-17.2,7.3-4.1,15.6-6.1,24.7-6.1s12.2,1.1,17.4,3.2c5.2,2.1,9.8,5.4,13.4,9.7v-49h22.4ZM581.1,148c5.4,0,10.2-1.3,14.5-3.8,4.3-2.3,7.7-5.9,10.2-10.4,2.5-4.5,3.8-9.8,3.8-15.7s-1.3-11.3-3.8-15.7c-2.5-4.5-5.9-8.1-10.2-10.6-4.3-2.3-9.1-3.6-14.5-3.6s-10.2,1.3-14.5,3.6c-4.3,2.5-7.7,6.1-10.2,10.6-2.5,4.5-3.8,9.8-3.8,15.7s1.3,11.3,3.8,15.7c2.5,4.5,5.9,8.1,10.2,10.4,4.3,2.5,9.1,3.8,14.5,3.8ZM681.6,84.3c6.4-10,17.7-15,34-15v21.3c-1.7-.3-3.4-.5-5.2-.5-8.8,0-15.6,2.5-20.4,7.5-4.8,5.2-7.3,12.5-7.3,22v46.4h-22.4v-95.6h21.3v14h0ZM734.1,70.3h22.4v95.6h-22.4v-95.6ZM745.4,54.6c-4.1,0-7.5-1.3-10.2-3.9-2.7-2.4-4.2-5.9-4.1-9.5,0-3.8,1.4-7,4.1-9.7,2.7-2.5,6.1-3.8,10.2-3.8s7.5,1.3,10.2,3.6c2.7,2.5,4.1,5.5,4.1,9.3s-1.3,7.2-3.9,9.8c-2.7,2.7-6.3,4.1-10.4,4.1ZM839.5,69.2c12,0,21.7,3.6,29,10.6,7.3,7,10.9,17.5,10.9,31.3v54.8h-22.4v-51.9c0-8.4-2-14.7-5.9-19-3.9-4.1-9.5-6.3-16.8-6.3s-14.7,2.5-19.5,7.3c-4.8,5-7.2,12.2-7.2,21.5v48.3h-22.4v-95.6h21.3v12.3c3.8-4.5,8.4-7.7,14-10,5.5-2.3,12-3.4,19-3.4ZM964.8,160.7c-2.8,2.2-6,3.9-9.5,4.8-3.9,1.1-7.9,1.6-12,1.6-10.6,0-18.6-2.7-24.3-8.2-5.7-5.5-8.6-13.4-8.6-24v-46h-15.7v-17.9h15.7v-21.8h22.4v21.8h25.6v17.9h-25.6v45.5c0,4.7,1.1,8.2,3.4,10.6,2.3,2.5,5.5,3.8,9.8,3.8s9.1-1.3,12.5-3.9l6.3,15.9ZM1036.9,69.2c12,0,21.7,3.6,29,10.6,7.3,7,10.9,17.5,10.9,31.3v54.8h-22.4v-51.9c0-8.4-2-14.7-5.9-19-3.9-4.1-9.5-6.3-16.8-6.3s-14.7,2.5-19.5,7.3c-4.8,5-7.2,12.2-7.2,21.5v48.3h-22.4V33.1h22.4v48.3c3.8-3.9,8.2-7,13.8-9.1,5.4-2,11.5-3,18.1-3Z"
/>
</g>
</g>
</g>
</svg>
</template>

View File

@@ -0,0 +1,31 @@
<template>
<ButtonStyled type="standard">
<button aria-label="Copy server IP" @click="copyText">
<CopyIcon />
Copy IP
</button>
</ButtonStyled>
</template>
<script setup lang="ts">
import { CopyIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
const props = defineProps<{
ip: string;
port: number;
subdomain?: string | null;
}>();
const copyText = () => {
const text = props.subdomain ? `${props.subdomain}.modrinth.gg` : `${props.ip}:${props.port}`;
navigator.clipboard.writeText(text);
addNotification({
group: "server",
title: `Copied IP`,
text: `Your server's IP has been copied to your clipboard`,
type: "success",
});
};
</script>

View File

@@ -0,0 +1,77 @@
<template>
<div
aria-hidden="true"
style="font-variant-numeric: tabular-nums"
class="pointer-events-none h-full w-full select-none"
>
<div class="flex flex-col gap-6">
<div class="flex flex-row items-center gap-6">
<div
class="relative max-h-[156px] min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
>
<div class="relative z-10 -ml-3 w-fit rounded-xl px-3 py-1">
<div class="-mb-0.5 mt-0.5 flex flex-row items-center gap-2">
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">0.00%</h2>
<h3 class="relative z-10 text-sm font-normal text-secondary">/ 100%</h3>
</div>
<h3 class="relative z-10 text-base font-normal text-secondary">CPU usage</h3>
</div>
<CPUIcon class="absolute right-10 top-10" />
</div>
<div
class="relative max-h-[156px] min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
>
<div class="relative z-10 -ml-3 w-fit rounded-xl px-3 py-1">
<div class="-mb-0.5 mt-0.5 flex flex-row items-center gap-2">
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">0.00%</h2>
<h3 class="relative z-10 text-sm font-normal text-secondary">/ 100%</h3>
</div>
<h3 class="relative z-10 text-base font-normal text-secondary">Memory usage</h3>
</div>
<DBIcon class="absolute right-10 top-10" />
</div>
<div
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8 transition-transform duration-100 hover:scale-105 active:scale-100"
>
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">0 Bytes</h2>
<h3 class="relative z-10 text-sm font-normal text-secondary">/ 0 Bytes</h3>
</div>
<h3 class="relative z-10 text-base font-normal text-secondary">Storage usage</h3>
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
</div>
</div>
<div
class="relative flex h-full w-full flex-col gap-3 overflow-hidden rounded-2xl bg-bg-raised p-8"
>
<div class="experimental-styles-within flex flex-row items-center">
<div class="flex flex-row items-center gap-4">
<h2 class="m-0 text-3xl font-extrabold text-contrast">Console</h2>
</div>
</div>
<div
class="console relative h-full min-h-[488px] w-full overflow-hidden rounded-xl bg-bg text-sm"
></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CPUIcon, DBIcon, FolderOpenIcon } from "@modrinth/assets";
</script>
<style scoped>
html.light-mode .console {
background: var(--color-bg);
}
html.dark-mode .console {
background: black;
}
html.oled-mode .console {
background: black;
}
</style>

View File

@@ -0,0 +1,312 @@
<template>
<div class="contents">
<NewModal ref="confirmActionModal" header="Confirming power action" @close="closePowerModal">
<div class="flex flex-col gap-4 md:w-[400px]">
<p class="m-0">Are you sure you want to {{ currentPendingAction }} the server?</p>
<UiCheckbox
v-model="powerDontAskAgainCheckbox"
label="Don't ask me again"
class="text-sm"
:disabled="!currentPendingAction"
/>
<div class="flex flex-row gap-4">
<ButtonStyled type="standard" color="brand" @click="confirmAction">
<button>
<CheckIcon class="h-5 w-5" />
{{ currentPendingActionFriendly }} server
</button>
</ButtonStyled>
<ButtonStyled @click="closePowerModal">
<button>
<XIcon class="h-5 w-5" />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<NewModal
ref="detailsModal"
:header="`All of ${props.serverName ? props.serverName : 'Server'} info`"
@close="closeDetailsModal"
>
<UiServersServerInfoLabels
:server-data="serverData"
:show-game-label="true"
:show-loader-label="true"
:uptime-seconds="uptimeSeconds"
:column="true"
class="mb-6 flex flex-col gap-2"
/>
<ButtonStyled type="standard" color="brand" @click="closeDetailsModal">
<button class="w-full">Close</button>
</ButtonStyled>
</NewModal>
<div class="flex flex-row items-center gap-2 rounded-lg">
<ButtonStyled v-if="isInstalling" type="standard" color="brand">
<button disabled class="flex-shrink-0">
<UiServersPanelSpinner class="size-5" /> Installing...
</button>
</ButtonStyled>
<div v-else class="contents">
<ButtonStyled v-if="showStopButton" type="transparent">
<button :disabled="!canTakeAction || disabled || isStopping" @click="stopServer">
<div class="flex gap-1">
<StopCircleIcon class="h-5 w-5" />
<span>{{ stopButtonText }}</span>
</div>
</button>
</ButtonStyled>
<ButtonStyled type="standard" color="brand">
<button :disabled="!canTakeAction || disabled || isStopping" @click="handleAction">
<div v-if="isStartingOrRestarting" class="grid place-content-center">
<UiServersIconsLoadingIcon />
</div>
<div v-else class="contents">
<component :is="showRestartIcon ? UpdatedIcon : PlayIcon" />
</div>
<span>
{{ actionButtonText }}
</span>
</button>
</ButtonStyled>
</div>
<!-- Dropdown options -->
<ButtonStyled circular type="transparent">
<UiServersTeleportOverflowMenu
:options="[
...(props.isInstalling ? [] : [{ id: 'kill', action: () => killServer() }]),
{ id: 'allServers', action: () => router.push('/servers/manage') },
{ id: 'details', action: () => showDetailsModal() },
]"
>
<MoreVerticalIcon aria-hidden="true" />
<template #kill>
<SlashIcon class="h-5 w-5" />
<span>Kill server</span>
</template>
<template #allServers>
<ServerIcon class="h-5 w-5" />
<span>All servers</span>
</template>
<template #details>
<InfoIcon class="h-5 w-5" />
<span>Details</span>
</template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import {
PlayIcon,
UpdatedIcon,
StopCircleIcon,
SlashIcon,
MoreVerticalIcon,
XIcon,
CheckIcon,
ServerIcon,
InfoIcon,
} from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { useRouter } from "vue-router";
import { useStorage } from "@vueuse/core";
const props = defineProps<{
isOnline: boolean;
isActioning: boolean;
isInstalling: boolean;
disabled: boolean;
serverName?: string;
serverData: object;
uptimeSeconds: number;
}>();
const router = useRouter();
const serverId = router.currentRoute.value.params.id;
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
powerDontAskAgain: false,
});
const emit = defineEmits<{
(e: "action", action: "start" | "restart" | "stop" | "kill"): void;
}>();
const confirmActionModal = ref<InstanceType<typeof NewModal> | null>(null);
const detailsModal = ref<InstanceType<typeof NewModal> | null>(null);
const ServerState = {
Stopped: "Stopped",
Starting: "Starting",
Running: "Running",
Stopping: "Stopping",
Restarting: "Restarting",
} as const;
type ServerStateType = (typeof ServerState)[keyof typeof ServerState];
const currentPendingAction = ref<string | null>(null);
const currentPendingState = ref<ServerStateType | null>(null);
const powerDontAskAgainCheckbox = ref(false);
const currentState = ref<ServerStateType>(
props.isOnline ? ServerState.Running : ServerState.Stopped,
);
const isStartingDelay = ref(false);
const showStopButton = computed(
() => currentState.value === ServerState.Running || currentState.value === ServerState.Stopping,
);
const showRestartIcon = computed(() => currentState.value === ServerState.Running);
const canTakeAction = computed(
() =>
!props.isActioning &&
!isStartingDelay.value &&
currentState.value !== ServerState.Starting &&
currentState.value !== ServerState.Stopping,
);
const isStartingOrRestarting = computed(
() =>
currentState.value === ServerState.Starting || currentState.value === ServerState.Restarting,
);
const isStopping = computed(() => currentState.value === ServerState.Stopping);
const actionButtonText = computed(() => {
switch (currentState.value) {
case ServerState.Starting:
return "Starting...";
case ServerState.Restarting:
return "Restarting...";
case ServerState.Running:
return "Restart";
case ServerState.Stopping:
return "Stopping...";
default:
return "Start";
}
});
const currentPendingActionFriendly = computed(() => {
switch (currentPendingAction.value) {
case "start":
return "Start";
case "restart":
return "Restart";
case "stop":
return "Stop";
case "kill":
return "Kill";
default:
return null;
}
});
const stopButtonText = computed(() =>
currentState.value === ServerState.Stopping ? "Stopping..." : "Stop",
);
const createPendingAction = () => {
if (!canTakeAction.value) return;
if (currentState.value === ServerState.Running) {
currentPendingAction.value = "restart";
currentPendingState.value = ServerState.Restarting;
showPowerModal();
} else {
runAction("start", ServerState.Starting);
}
};
const handleAction = () => {
createPendingAction();
};
const showPowerModal = () => {
if (userPreferences.value.powerDontAskAgain) {
runAction(
currentPendingAction.value as "start" | "restart" | "stop" | "kill",
currentPendingState.value!,
);
} else {
confirmActionModal.value?.show();
}
};
const confirmAction = () => {
if (powerDontAskAgainCheckbox.value) {
userPreferences.value.powerDontAskAgain = true;
}
runAction(
currentPendingAction.value as "start" | "restart" | "stop" | "kill",
currentPendingState.value!,
);
closePowerModal();
};
const runAction = (action: "start" | "restart" | "stop" | "kill", serverState: ServerStateType) => {
emit("action", action);
currentState.value = serverState;
if (action === "start") {
isStartingDelay.value = true;
setTimeout(() => {
isStartingDelay.value = false;
}, 5000);
}
};
const stopServer = () => {
if (!canTakeAction.value) return;
currentPendingAction.value = "stop";
currentPendingState.value = ServerState.Stopping;
showPowerModal();
};
const killServer = () => {
currentPendingAction.value = "kill";
currentPendingState.value = ServerState.Stopping;
showPowerModal();
};
const closePowerModal = () => {
confirmActionModal.value?.hide();
currentPendingAction.value = null;
powerDontAskAgainCheckbox.value = false;
};
const closeDetailsModal = () => {
detailsModal.value?.hide();
};
const showDetailsModal = () => {
detailsModal.value?.show();
};
watch(
() => props.isOnline,
(newValue) => {
if (newValue) {
currentState.value = ServerState.Running;
} else {
currentState.value = ServerState.Stopped;
}
},
);
watch(
() => router.currentRoute.value.fullPath,
() => {
closeDetailsModal();
},
);
</script>

View File

@@ -0,0 +1,66 @@
<template>
<div
:aria-label="`Server is ${getStatusText}`"
class="relative inline-flex select-none items-center"
@mouseenter="isExpanded = true"
@mouseleave="isExpanded = false"
>
<div
:class="`h-4 w-4 rounded-full transition-all duration-300 ease-in-out ${getStatusClass.main}`"
>
<div
:class="`absolute inline-flex h-4 w-4 animate-ping rounded-full ${getStatusClass.bg}`"
></div>
</div>
<div
:class="`absolute -left-4 flex w-auto items-center gap-2 rounded-full px-2 py-1 transition-all duration-150 ease-in-out ${getStatusClass.bg} ${
isExpanded ? 'translate-x-2 scale-100 opacity-100' : 'translate-x-0 scale-90 opacity-0'
}`"
>
<div class="h-3 w-3 rounded-full"></div>
<span
class="origin-left whitespace-nowrap text-sm font-semibold text-contrast transition-all duration-[200ms] ease-in-out"
:class="`${isExpanded ? 'translate-x-0 scale-100' : '-translate-x-1 scale-x-75'}`"
>
{{ getStatusText }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import type { ServerState } from "~/types/servers";
const props = defineProps<{
state: ServerState;
}>();
const isExpanded = ref(false);
const getStatusClass = computed(() => {
switch (props.state) {
case "running":
return { main: "bg-brand", bg: "bg-bg-green" };
case "stopped":
return { main: "", bg: "" };
case "crashed":
return { main: "bg-brand-red", bg: "bg-bg-red" };
default:
return { main: "", bg: "" };
}
});
const getStatusText = computed(() => {
switch (props.state) {
case "running":
return "Running";
case "stopped":
return "";
case "crashed":
return "Crashed";
default:
return "Unknown";
}
});
</script>

View File

@@ -0,0 +1,22 @@
<template>
<svg
class="h-5 w-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</template>

View File

@@ -0,0 +1,684 @@
<template>
<div
data-pyro-terminal
:class="[
'terminal-font console relative z-[1] flex h-full w-full flex-col items-center justify-between overflow-hidden rounded-t-xl px-1 text-sm transition-transform duration-300',
{ 'scale-fullscreen screen-fixed inset-0 z-50 !rounded-none': isFullScreen },
]"
tabindex="-1"
>
<div
v-if="cosmetics.advancedRendering"
class="progressive-gradient pointer-events-none absolute -bottom-6 left-0 z-[2] h-[10rem] w-full overflow-hidden rounded-xl"
:style="`--transparency: ${Math.max(0, lerp(100, 0, bottomThreshold * 8))}%`"
aria-hidden="true"
>
<div
v-for="i in progressiveBlurIterations"
:key="i"
aria-hidden="true"
class="absolute left-0 top-0 h-full w-full"
:style="getBlurStyle(i)"
/>
</div>
<div
v-else
class="pointer-events-none absolute bottom-0 left-0 right-0 z-[2] h-[196px] w-full"
:style="
bottomThreshold > 0
? { background: 'linear-gradient(transparent 30%, var(--console-bg) 70%)' }
: {}
"
></div>
<div
aria-hidden="true"
class="pointer-events-none absolute left-0 top-0 z-[60] h-full w-full"
:style="{
visibility: isFullScreen ? 'hidden' : 'visible',
}"
>
<div
aria-hidden="true"
class="absolute -bottom-2 -right-2 h-7 w-7"
:style="{
background: `radial-gradient(circle at 0% 0%, transparent 50%, var(--color-raised-bg) 52%)`,
}"
></div>
<div
aria-hidden="true"
class="absolute -bottom-2 -left-2 h-7 w-7"
:style="{
background: `radial-gradient(circle at 100% 0%, transparent 50%, var(--color-raised-bg) 52%)`,
}"
></div>
</div>
<div data-pyro-terminal-scroll-root class="relative h-full w-full">
<div
ref="scrollbarTrack"
data-pyro-terminal-scrollbar-track
class="absolute -right-1 bottom-16 top-4 z-[4] w-4"
@mousedown="handleTrackClick"
>
<div
data-pyro-terminal-scrollbar
class="flex h-full justify-center rounded-full transition-all"
:style="{ opacity: bottomThreshold > 0 ? '1' : '0.5' }"
>
<div
ref="scrollbarThumb"
data-pyro-terminal-scrollbar-thumb
class="absolute w-1.5 cursor-default rounded-full bg-button-bg"
:style="{
height: `${getThumbHeight()}px`,
transform: `translateY(${getThumbPosition()}px)`,
}"
@mousedown="startDragging"
></div>
</div>
</div>
<div
ref="scrollContainer"
data-pyro-terminal-root
class="scrollbar-none absolute left-0 top-0 h-full w-full select-text overflow-x-auto overflow-y-auto py-6 pb-[72px]"
@scroll="handleScrollEvent"
>
<div data-pyro-terminal-virtual-height-watcher :style="{ height: `${totalHeight}px` }">
<ul
class="m-0 list-none p-0"
data-pyro-terminal-virtual-list
:style="virtualListStyle"
aria-live="polite"
role="listbox"
>
<template v-for="(item, index) in visibleItems" :key="index">
<li>
<UiServersLogParser :log="item" :index="visibleStartIndex + index" />
</li>
</template>
</ul>
</div>
</div>
</div>
<div
class="absolute bottom-4 z-[3] w-full"
:style="{
filter: `drop-shadow(0 8px 12px rgba(0, 0, 0, ${lerp(0.1, 0.5, bottomThreshold)}))`,
}"
>
<slot />
</div>
<button
data-pyro-fullscreen
:label="isFullScreen ? 'Exit full screen' : 'Enter full screen'"
class="experimental-styles-within absolute right-4 top-4 z-[3] grid h-12 w-12 place-content-center rounded-lg border-[1px] border-solid border-button-border bg-bg-raised text-contrast transition-all duration-200 hover:scale-110 active:scale-95"
@click="toggleFullscreen"
>
<UiServersPanelTerminalMinimize v-if="isFullScreen" />
<UiServersPanelTerminalFullscreen v-else />
</button>
<Transition name="scroll-to-bottom">
<button
v-if="bottomThreshold > 0 && !isScrolledToBottom"
data-pyro-scrolltobottom
label="Scroll to bottom"
class="scroll-to-bottom-btn experimental-styles-within absolute bottom-[4.5rem] right-4 z-[3] grid h-12 w-12 place-content-center rounded-lg border-[1px] border-solid border-button-border bg-bg-raised text-contrast transition-all duration-200 hover:scale-110 active:scale-95"
@click="scrollToBottom"
>
<RightArrowIcon class="rotate-90" />
<span class="sr-only">Scroll to bottom</span>
</button>
</Transition>
</div>
</template>
<script setup lang="ts">
import { RightArrowIcon } from "@modrinth/assets";
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
const { $cosmetics } = useNuxtApp();
const cosmetics = $cosmetics;
const props = defineProps<{
consoleOutput: string[];
fullScreen: boolean;
}>();
const scrollContainer = ref<HTMLElement | null>(null);
const itemHeights = ref<number[]>([]);
const averageItemHeight = ref(36);
const bottomThreshold = ref(0);
const bufferSize = 5;
const cachedHeights = ref<Map<string, number>>(new Map());
const isAutoScrolling = ref(false);
const progressiveBlurIterations = ref(8);
const scrollTop = ref(0);
const clientHeight = ref(0);
const isFullScreen = ref(props.fullScreen);
const initial = ref(false);
const userHasScrolled = ref(false);
const isScrolledToBottom = ref(true);
const handleScrollEvent = () => {
handleListScroll();
};
const totalHeight = computed(
() =>
itemHeights.value.reduce((sum, height) => sum + height, 0) ||
props.consoleOutput.length * averageItemHeight.value,
);
watch(totalHeight, () => {
if (isScrolledToBottom.value) {
scrollToBottom();
}
if (!initial.value) {
initial.value = true;
}
});
const lerp = (start: number, end: number, t: number) => start * (1 - t) + end * t;
const getBlurStyle = (i: number) => {
const properBlurIteration = i + 1;
const blur = lerp(0, 2 ** (properBlurIteration - 3), bottomThreshold.value);
const singular = 100 / progressiveBlurIterations.value;
let mask = "linear-gradient(";
switch (i) {
case 0:
mask += `rgba(0, 0, 0, 0) 0%, rgb(0, 0, 0) ${singular}%`;
break;
case 1:
mask += `rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) ${singular}%, rgb(0, 0, 0) ${singular * 2}%`;
break;
case 2:
mask += `rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) ${singular}%, rgba(0, 0, 0, 0) ${singular * 2}%, rgb(0, 0, 0) ${singular * 3}%`;
break;
default:
mask += `rgba(0, 0, 0, 0) ${singular * (i - 3)}%, rgb(0, 0, 0) ${singular * (i + 1 - 3)}%, rgb(0, 0, 0) ${singular * (i + 2 - 3)}%, rgba(0, 0, 0, 0) ${singular * (i + 3 - 3)}%`;
break;
}
mask += `)`;
return {
backdropFilter: `blur(${blur}px)`,
mask,
position: "absolute" as any,
zIndex: progressiveBlurIterations.value - i,
};
};
const getItemOffset = (index: number) => {
return itemHeights.value.slice(0, index).reduce((sum, height) => sum + height, 0);
};
const visibleStartIndex = computed(() => {
let index = 0;
let offset = 0;
while (
index < props.consoleOutput.length &&
offset < scrollTop.value - bufferSize * averageItemHeight.value
) {
offset += itemHeights.value[index] || averageItemHeight.value;
index++;
}
return Math.max(0, index - 1);
});
const visibleEndIndex = computed(() => {
let index = visibleStartIndex.value;
let offset = getItemOffset(index);
while (
index < props.consoleOutput.length &&
offset < scrollTop.value + clientHeight.value + bufferSize * averageItemHeight.value
) {
offset += itemHeights.value[index] || averageItemHeight.value;
index++;
}
return Math.min(props.consoleOutput.length - 1, index);
});
const visibleItems = computed(() =>
props.consoleOutput.slice(visibleStartIndex.value, visibleEndIndex.value + 1),
);
const offsetY = computed(() => getItemOffset(visibleStartIndex.value));
const handleListScroll = () => {
if (!scrollContainer.value) return;
const container = scrollContainer.value;
scrollTop.value = container.scrollTop;
clientHeight.value = container.clientHeight;
const scrollHeight = container.scrollHeight;
const threshold = 32;
isScrolledToBottom.value = scrollHeight - scrollTop.value - clientHeight.value <= threshold;
if (!isScrolledToBottom.value && !isAutoScrolling.value) {
userHasScrolled.value = true;
}
bottomThreshold.value = Math.min(1, (scrollHeight - scrollTop.value - clientHeight.value) / 256);
};
const updateItemHeights = async () => {
if (!scrollContainer.value) return;
await nextTick();
const items =
scrollContainer.value?.querySelectorAll("[data-pyro-terminal-virtual-list] li") || [];
items.forEach((el, idx) => {
const index = visibleStartIndex.value + idx;
const height = el.getBoundingClientRect().height;
itemHeights.value[index] = height;
const content = props.consoleOutput[index];
if (content) {
cachedHeights.value.set(content, height);
}
});
};
const updateClientHeight = () => {
if (scrollContainer.value) {
clientHeight.value = scrollContainer.value.clientHeight;
}
};
const scrollToBottom = () => {
if (!scrollContainer.value) return;
isAutoScrolling.value = true;
const container = scrollContainer.value;
nextTick(() => {
const maxScroll = container.scrollHeight - container.clientHeight;
container.scrollTop = maxScroll;
setTimeout(() => {
if (container.scrollTop < maxScroll) {
container.scrollTop = maxScroll;
}
isAutoScrolling.value = false;
userHasScrolled.value = false;
isScrolledToBottom.value = true;
handleListScroll();
}, 50);
});
};
const scrollbarTrack = ref<HTMLElement | null>(null);
const scrollbarThumb = ref<HTMLElement | null>(null);
const isDragging = ref(false);
const startY = ref(0);
const startScrollTop = ref(0);
const getThumbHeight = () => {
if (!scrollContainer.value || !scrollbarTrack.value) return 30;
const contentHeight = scrollContainer.value.scrollHeight;
const viewportHeight = scrollContainer.value.clientHeight;
const trackHeight = scrollbarTrack.value.clientHeight;
const heightRatio = viewportHeight / contentHeight;
const minThumbHeight = Math.min(40, trackHeight / 2);
const proposedHeight = Math.max(heightRatio * trackHeight, minThumbHeight);
return Math.min(proposedHeight, trackHeight);
};
const getThumbPosition = () => {
if (!scrollContainer.value || !scrollbarTrack.value) return 0;
const contentHeight = scrollContainer.value.scrollHeight;
const viewportHeight = scrollContainer.value.clientHeight;
const trackHeight = scrollbarTrack.value.clientHeight;
const scrollProgress = scrollTop.value / (contentHeight - viewportHeight);
const thumbHeight = getThumbHeight();
const availableTrackSpace = trackHeight - thumbHeight;
return Math.max(0, Math.min(scrollProgress * availableTrackSpace, availableTrackSpace));
};
const startDragging = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (!scrollContainer.value || !scrollbarTrack.value) return;
isDragging.value = true;
startY.value = event.clientY;
startScrollTop.value = scrollContainer.value.scrollTop;
window.addEventListener("mousemove", handleDragging);
window.addEventListener("mouseup", stopDragging);
document.body.style.userSelect = "none";
document.body.style.pointerEvents = "none";
};
const handleDragging = (event: MouseEvent) => {
if (!isDragging.value || !scrollContainer.value || !scrollbarTrack.value) return;
const trackRect = scrollbarTrack.value.getBoundingClientRect();
const deltaY = event.clientY - startY.value;
const trackHeight = trackRect.height;
const contentHeight = scrollContainer.value.scrollHeight;
const viewportHeight = scrollContainer.value.clientHeight;
const maxScroll = contentHeight - viewportHeight;
const moveRatio = deltaY / trackHeight;
const scrollDelta = moveRatio * maxScroll;
const newScrollTop = Math.max(0, Math.min(startScrollTop.value + scrollDelta, maxScroll));
scrollContainer.value.scrollTop = newScrollTop;
};
const stopDragging = () => {
isDragging.value = false;
window.removeEventListener("mousemove", handleDragging);
window.removeEventListener("mouseup", stopDragging);
document.body.style.userSelect = "";
document.body.style.pointerEvents = "";
};
const handleTrackClick = (event: MouseEvent) => {
if (!scrollContainer.value || !scrollbarTrack.value || event.target === scrollbarThumb.value)
return;
const trackRect = scrollbarTrack.value.getBoundingClientRect();
const thumbHeight = getThumbHeight();
const clickOffset = event.clientY - trackRect.top;
const currentThumbPosition = getThumbPosition();
const thumbCenterPosition = currentThumbPosition + thumbHeight / 2;
const scrollAmount = clientHeight.value * (clickOffset < thumbCenterPosition ? -1 : 1);
const newScrollTop = Math.max(
0,
Math.min(
scrollContainer.value.scrollTop + scrollAmount,
scrollContainer.value.scrollHeight - scrollContainer.value.clientHeight,
),
);
scrollContainer.value.scrollTop = newScrollTop;
};
const enterFullScreen = () => {
isFullScreen.value = true;
document.body.style.overflow = "hidden";
nextTick(() => {
updateClientHeight();
updateItemHeights();
});
};
const exitFullScreen = () => {
isFullScreen.value = false;
document.body.style.overflow = "";
nextTick(() => {
updateClientHeight();
updateItemHeights();
});
};
const toggleFullscreen = () => {
if (isFullScreen.value) {
exitFullScreen();
} else {
enterFullScreen();
}
};
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape" && isFullScreen.value) {
exitFullScreen();
}
};
const initializeTerminal = async () => {
if (!scrollContainer.value) return;
updateClientHeight();
const initialHeights = props.consoleOutput.map(
(content) => cachedHeights.value.get(content) || averageItemHeight.value,
);
itemHeights.value = initialHeights;
await nextTick();
await updateItemHeights();
await nextTick();
const container = scrollContainer.value;
container.scrollTop = container.scrollHeight;
handleListScroll();
initial.value = true;
};
onMounted(async () => {
await initializeTerminal();
window.addEventListener("resize", updateClientHeight);
window.addEventListener("keydown", handleKeydown);
});
onUnmounted(() => {
window.removeEventListener("resize", updateClientHeight);
window.removeEventListener("keydown", handleKeydown);
stopDragging();
});
watch(
() => props.consoleOutput,
async (newOutput) => {
const newItemsCount = newOutput.length - itemHeights.value.length;
if (newItemsCount > 0) {
const shouldScroll = isScrolledToBottom.value || !userHasScrolled.value;
const newHeights = Array(newItemsCount)
.fill(0)
.map((_, i) => {
const index = itemHeights.value.length + i;
const content = newOutput[index];
return cachedHeights.value.get(content) || averageItemHeight.value;
});
itemHeights.value.push(...newHeights);
if (shouldScroll) {
await nextTick();
scrollToBottom();
await nextTick();
await updateItemHeights();
scrollToBottom();
}
}
},
{ deep: true },
);
const virtualListStyle = computed(() => ({
transform: `translateY(${offsetY.value}px)`,
}));
watch([visibleStartIndex, visibleEndIndex], updateItemHeights);
watch(
() => props.fullScreen,
(newValue) => {
isFullScreen.value = newValue;
nextTick(() => {
updateClientHeight();
updateItemHeights();
});
},
);
watch(isFullScreen, () => {
nextTick(() => {
updateClientHeight();
updateItemHeights();
});
});
watch(
itemHeights,
() => {
const totalHeight = itemHeights.value.reduce((sum, height) => sum + height, 0);
averageItemHeight.value = totalHeight / itemHeights.value.length || averageItemHeight.value;
},
{ deep: true },
);
</script>
<style scoped>
:root {
--console-bg: var(--color-bg);
}
.terminal-font {
font-family: var(--mono-font);
font-size: 1rem;
line-height: 1.5em;
}
html.light-mode .console {
--console-bg: var(--color-bg);
}
html.dark-mode .console {
--console-bg: black;
}
html.oled-mode .console {
--console-bg: black;
}
.console {
background: var(--console-bg);
}
.scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-none::-webkit-scrollbar {
display: none;
}
[data-pyro-terminal-root]::-webkit-scrollbar,
[data-pyro-terminal-root]::-webkit-scrollbar-thumb,
[data-pyro-terminal-root]::-webkit-scrollbar-track-piece,
[data-pyro-terminal-root]::-webkit-scrollbar-corner {
display: none;
}
.screen-fixed {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 50;
background: var(--color-bg);
}
@keyframes scaleUp {
from {
opacity: 0;
transform: scale(0.98);
}
to {
opacity: 1;
transform: scale(1);
}
}
.scale-fullscreen {
animation: scaleUp 190ms forwards;
}
.progressive-gradient {
background: linear-gradient(
to top,
color-mix(in srgb, var(--color-bg), transparent var(--transparency)) 0%,
rgba(0, 0, 0, 0) 100%
);
}
html.dark-mode .progressive-gradient {
background: linear-gradient(
to top,
color-mix(in srgb, black, transparent var(--transparency)) 0%,
rgba(0, 0, 0, 0) 100%
);
}
.scroll-to-bottom-enter-active,
.scroll-to-bottom-leave-active {
transition:
opacity 300ms ease,
transform 300ms ease;
}
.scroll-to-bottom-enter-from,
.scroll-to-bottom-leave-to {
opacity: 0;
transform: scale(0.4) translateY(2rem);
}
[data-pyro-terminal-selected="true"] {
border-radius: 0;
}
[data-pyro-terminal-selected="true"].first-selected {
border-top-left-radius: 0.5rem;
border-top-right-radius: 0.5rem;
overflow: hidden !important;
}
[data-pyro-terminal-selected="true"].last-selected {
border-bottom-left-radius: 0.5rem;
border-bottom-right-radius: 0.5rem;
overflow: hidden !important;
}
[data-pyro-terminal-root] {
will-change: transform;
backface-visibility: hidden;
transform: translateZ(0);
-webkit-font-smoothing: subpixel-antialiased;
}
[data-pyro-terminal-root] {
user-select: none;
}
[data-pyro-terminal-root] * {
user-select: text;
}
.selection-in-progress {
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,19 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-5"
>
<path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8" />
<path d="M3 16.2V21m0 0h4.8M3 21l6-6" />
<path d="M21 7.8V3m0 0h-4.8M21 3l-6 6" />
<path d="M3 7.8V3m0 0h4.8M3 3l6 6" />
</svg>
</template>

View File

@@ -0,0 +1,19 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-5"
>
<path d="m15 15 6 6m-6-6v4.8m0-4.8h4.8" />
<path d="M9 19.8V15m0 0H4.2M9 15l-6 6" />
<path d="M15 4.2V9m0 0h4.8M15 9l6-6" />
<path d="M9 4.2V9m0 0H4.2M9 9 3 3" />
</svg>
</template>

View File

@@ -0,0 +1,14 @@
<template>
<a
href="https://pyro.host"
target="_blank"
class="mx-auto mt-8 flex select-none flex-row items-center gap-2 hover:underline"
>
<PyroIcon class="size-4 text-secondary" />
<span class="text-sm text-secondary">Powered by Pyro</span>
</a>
</template>
<script setup lang="ts">
import { PyroIcon } from "@modrinth/assets";
</script>

View File

@@ -0,0 +1,167 @@
<template>
<div class="flex h-[400px] w-full max-w-xl flex-col overflow-hidden">
<div class="iconified-input mb-4 w-full">
<label class="hidden" for="search">Search</label>
<SearchIcon aria-hidden="true" />
<input
id="search"
v-model="queryFilter"
name="search"
type="search"
:placeholder="`Search ${props.type}s...`"
autocomplete="off"
@keyup.enter="resetList"
/>
</div>
<div class="flex h-full w-full flex-col">
<div
v-if="mods && mods.hits.length > 0"
ref="scrollContainer"
class="flex h-full w-full flex-col gap-2 overflow-y-scroll"
>
<div v-for="mod in mods.hits" :key="mod.title" class="rounded-lg px-2 py-2 hover:bg-bg">
<div class="flex cursor-pointer gap-2" @click="toggleMod(mod.project_id)">
<UiAvatar :src="mod.icon_url" class="!h-12 !min-h-12 !w-12 !min-w-12" />
<div class="flex flex-col gap-1">
<h1 class="m-0 text-2xl font-bold leading-none text-contrast">
{{ mod.title }}
</h1>
<span class="text-sm text-secondary">
{{ mod.description.substring(0, 100) }}
{{ mod.description.length > 100 ? "..." : "" }}
</span>
</div>
</div>
<div v-if="expandedMods[mod.project_id]" class="mt-2 flex items-center gap-2">
<DropdownSelect
id="version-select"
v-model="selectedVersions[mod.project_id]"
name="version-select"
:options="expandedMods[mod.project_id].versions"
placeholder="Select version..."
/>
<Button icon-only @click="emits('select', mod, selectedVersions[mod.project_id])">
<ChevronRightIcon />
</Button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ChevronRightIcon, SearchIcon } from "@modrinth/assets";
import { Button, DropdownSelect } from "@modrinth/ui";
import { useInfiniteScroll } from "@vueuse/core";
const emits = defineEmits(["select"]);
const props = defineProps<{
type: "mod" | "modpack" | "plugin" | "datapack";
isserver?: boolean;
}>();
const route = useNativeRoute();
const serverId = route.params.id as string;
const server = serverId ? await usePyroServer(serverId, ["general"]) : null;
const data = computed(() => (serverId ? server?.general : null));
const scrollContainer = ref<HTMLElement | null>(null);
const pages = ref(1);
const page = ref(0);
const queryFilter = ref("");
const facets = ref<any>([]);
if (props.isserver === false && props.type !== "modpack") {
facets.value.push(`["categories:${data.value?.loader?.toLocaleLowerCase()}"]`);
facets.value.push(`["versions:${data.value?.mc_version}"]`);
}
facets.value.push(`["project_type:${props.type}"]`);
const buildFacetString = (facets: string[]) => {
return "[" + facets.map((facet) => `${facet}`).join(",") + "]";
};
const mods = ref<any>({ hits: [] });
const modsStatus = ref("idle");
const loadMods = async () => {
modsStatus.value = "loading";
const newMods = (await useBaseFetch(
`search?query=${queryFilter.value}&facets=${buildFacetString(facets.value)}&index=relevance&limit=25&offset=${page.value * 25}`,
{},
false,
)) as any;
pages.value = newMods.total_hits;
mods.value.hits.push(...newMods.hits);
modsStatus.value = "success";
};
const versions = reactive<{ [key: string]: any[] }>({});
const getVersions = async (projectId: string) => {
if (!versions[projectId]) {
const allVersions = (await useBaseFetch(`project/${projectId}/version`, {}, false)) as any;
if (props.isserver === false && props.type !== "modpack") {
versions[projectId] = allVersions
.filter((x: any) => x.loaders.includes(data.value?.loader?.toLocaleLowerCase()))
.filter((x: any) => x.game_versions.includes(data.value?.mc_version))
.map((x: any) => x.version_number);
} else {
versions[projectId] = allVersions.map((x: any) => x.version_number);
}
}
return versions[projectId];
};
const selectedVersions = reactive<{ [key: string]: string }>({});
const expandedMods = reactive<{ [key: string]: { expanded: boolean; versions: any[] } }>({});
const toggleMod = async (modId: string) => {
if (!expandedMods[modId]) {
expandedMods[modId] = { expanded: false, versions: [] };
}
expandedMods[modId].expanded = !expandedMods[modId].expanded;
if (expandedMods[modId].expanded && expandedMods[modId].versions.length === 0) {
expandedMods[modId].versions = await getVersions(modId);
// Select the first version by default
if (expandedMods[modId].versions.length > 0) {
selectedVersions[modId] = expandedMods[modId].versions[0];
}
}
};
const loadMore = async () => {
page.value++;
await loadMods();
};
const { reset } = useInfiniteScroll(scrollContainer, async () => {
if (page.value <= pages.value) {
await loadMore();
console.log("loading more");
console.log(page.value);
console.log(pages.value);
}
});
const resetList = () => {
mods.value.hits = [];
Object.keys(expandedMods).forEach((key) => delete expandedMods[key]);
Object.keys(selectedVersions).forEach((key) => delete selectedVersions[key]);
page.value = 0;
loadMods();
reset();
};
onMounted(async () => {
await loadMods();
});
</script>

View File

@@ -0,0 +1,94 @@
<template>
<div class="flex h-[70vh] w-full flex-col items-center justify-center">
<PyroIcon class="pyro-logo-animation size-32 opacity-10" />
<p
class="text-sm transition"
:class="{ 'opacity-0': !showLoading, 'animate-pulse opacity-100': showLoading }"
>
Loading...
</p>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { PyroIcon } from "@modrinth/assets";
const showLoading = ref(false);
onMounted(() => {
setTimeout(() => {
showLoading.value = true;
}, 5000);
});
</script>
<style>
.page-enter-active,
.page-leave-active {
transition: all 0.1s;
}
.page-enter-from,
.page-leave-to {
opacity: 0;
}
@keyframes zoom-in {
0% {
transform: scale(0.5);
}
100% {
transform: scale(1);
}
}
.pyro-logo-animation {
animation: zoom-in 0.8s
linear(
0 0%,
0.01 0.8%,
0.04 1.6%,
0.161 3.3%,
0.816 9.4%,
1.046 11.9%,
1.189 14.4%,
1.231 15.7%,
1.254 17%,
1.259 17.8%,
1.257 18.6%,
1.236 20.45%,
1.194 22.3%,
1.057 27%,
0.999 29.4%,
0.955 32.1%,
0.942 33.5%,
0.935 34.9%,
0.933 36.65%,
0.939 38.4%,
1 47.3%,
1.011 49.95%,
1.017 52.6%,
1.016 56.4%,
1 65.2%,
0.996 70.2%,
1.001 87.2%,
1 100%
);
}
@keyframes fade-bg-in {
0% {
opacity: 0;
}
100% {
opacity: 0.6;
}
}
.bg-loading-animation {
animation: fade-bg-in 0.12s linear forwards;
}
</style>

View File

@@ -0,0 +1,60 @@
<template>
<div
class="flex h-full flex-col gap-4 py-6"
:class="
'flex h-full flex-col gap-4 py-6' +
(danger
? ' rounded-2xl border-2 border-solid border-[#cb2245] bg-[#fff5f6] dark:border-[#FF496E] dark:bg-[#270B11]'
: '')
"
>
<div class="mb-2 flex items-center justify-between gap-4 px-6">
<div class="flex w-full items-center gap-4">
<UiServersServerIcon v-if="data" :image="data.image" class="h-12 w-12 rounded-lg" />
<div class="text-2xl font-extrabold text-contrast">{{ props.header }}</div>
</div>
<button
:class="
'h-8 w-8 rounded-full bg-button-bg p-2 text-contrast hover:bg-button-bgActive' +
(danger ? 'hover:bg-[#ffffff20] [&&]:bg-[#ffffff10]' : '')
"
@click="$emit('modal')"
>
<XIcon class="h-4 w-4" />
</button>
</div>
<div
class="border-0 border-b border-solid"
:class="danger ? 'border-[#cb2245] dark:border-[#612d38]' : 'border-button-bg'"
></div>
<div class="mt-2 h-full w-full overflow-auto px-6">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import { XIcon } from "@modrinth/assets";
const emit = defineEmits(["modal"]);
const props = defineProps<{
header?: string;
data?: any;
danger?: boolean;
}>();
const onEscKeyRelease = (event: KeyboardEvent) => {
if (event.key === "Escape") {
emit("modal");
}
};
onMounted(() => {
document.body.addEventListener("keyup", onEscKeyRelease);
});
onBeforeUnmount(() => {
document.removeEventListener("keyup", onEscKeyRelease);
});
</script>

View File

@@ -0,0 +1,75 @@
<template>
<Transition name="save-banner">
<div
v-if="props.isVisible"
data-pyro-save-banner
class="fixed bottom-16 left-0 right-0 z-[6] mx-auto h-fit w-full max-w-4xl transition-all duration-300 sm:bottom-8"
>
<div class="mx-2 rounded-2xl border-2 border-solid border-button-border bg-bg-raised p-4">
<div class="flex flex-col items-center justify-between gap-2 md:flex-row">
<span class="font-bold text-contrast">Careful, you have unsaved changes!</span>
<div class="flex gap-2">
<ButtonStyled type="transparent" color="standard">
<button :disabled="props.isUpdating" @click="props.reset">Reset</button>
</ButtonStyled>
<ButtonStyled type="standard" :color="props.restart ? 'standard' : 'brand'">
<button :disabled="props.isUpdating" @click="props.save">
{{ props.isUpdating ? "Saving..." : "Save" }}
</button>
</ButtonStyled>
<ButtonStyled v-if="props.restart" type="standard" color="brand">
<button :disabled="props.isUpdating" @click="saveAndRestart">
{{ props.isUpdating ? "Saving..." : "Save & restart" }}
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
import type { Server } from "~/composables/pyroServers";
const props = defineProps<{
isUpdating: boolean;
restart?: boolean;
save: () => void;
reset: () => void;
isVisible: boolean;
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
}>();
const saveAndRestart = async () => {
props.save();
await props.server.general?.power("Restart");
};
</script>
<style scoped>
.save-banner-enter-active {
transition:
opacity 300ms,
transform 300ms;
}
.save-banner-leave-active {
transition:
opacity 200ms,
transform 200ms;
}
.save-banner-enter-from,
.save-banner-leave-to {
opacity: 0;
transform: translateY(100%) scale(0.98);
}
.save-banner-enter-to,
.save-banner-leave-from {
opacity: 1;
transform: none;
}
</style>

View File

@@ -0,0 +1,33 @@
<template>
<div
v-if="game"
v-tooltip="'Change server version'"
class="min-w-0 flex-none flex-row items-center gap-2 first:!flex"
>
<GameIcon aria-hidden="true" class="size-5 shrink-0" />
<NuxtLink
v-if="isLink"
:to="serverId ? `/servers/manage/${serverId}/options/loader` : ''"
class="min-w-0 truncate text-sm font-semibold"
:class="serverId ? 'hover:underline' : ''"
>
{{ game[0].toUpperCase() + game.slice(1) }} {{ mcVersion }}
</NuxtLink>
<div v-else class="min-w-0 truncate text-sm font-semibold">
{{ game[0].toUpperCase() + game.slice(1) }} {{ mcVersion }}
</div>
</div>
</template>
<script setup lang="ts">
import { GameIcon } from "@modrinth/assets";
defineProps<{
game: string;
mcVersion: string;
isLink?: boolean;
}>();
const route = useNativeRoute();
const serverId = route.params.id as string;
</script>

View File

@@ -0,0 +1,26 @@
<template>
<div
class="experimental-styles-within flex size-24 shrink-0 overflow-hidden rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
>
<client-only>
<img
v-if="image"
class="h-full w-full select-none object-fill"
alt="Server Icon"
:src="image"
/>
<img
v-else
class="h-full w-full select-none object-fill"
alt="Server Icon"
src="~/assets/images/servers/minecraft_server_icon.png"
/>
</client-only>
</div>
</template>
<script setup lang="ts">
defineProps<{
image: string | undefined;
}>();
</script>

View File

@@ -0,0 +1,41 @@
<template>
<div>
<UiServersServerGameLabel
v-if="showGameLabel"
:game="serverData.game!"
:mc-version="serverData.mc_version ?? ''"
:is-link="linked"
/>
<UiServersServerLoaderLabel
v-if="showLoaderLabel"
:loader="serverData.loader!"
:loader-version="serverData.loader_version ?? ''"
:no-separator="column"
:is-link="linked"
/>
<UiServersServerSubdomainLabel
v-if="serverData.net.domain"
:subdomain="serverData.net.domain"
:no-separator="column"
:is-link="linked"
/>
<UiServersServerUptimeLabel
v-if="uptimeSeconds"
:uptime-seconds="uptimeSeconds"
:no-separator="column"
/>
</div>
</template>
<script setup lang="ts">
interface ServerInfoLabelsProps {
serverData: Record<string, any>;
showGameLabel: boolean;
showLoaderLabel: boolean;
uptimeSeconds?: number;
column?: boolean;
linked?: boolean;
}
defineProps<ServerInfoLabelsProps>();
</script>

View File

@@ -0,0 +1,101 @@
<template>
<NuxtLink class="contents" :to="`/servers/manage/${props.server_id}`">
<div
class="flex cursor-pointer flex-row items-center overflow-x-hidden rounded-3xl bg-bg-raised p-4 transition-transform duration-100 active:scale-95"
data-pyro-server-listing
:data-pyro-server-listing-id="server_id"
>
<UiServersServerIcon :image="image" />
<div class="ml-8 flex flex-col gap-2.5">
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 text-xl font-bold text-contrast">{{ name }}</h2>
<ChevronRightIcon />
</div>
<div
v-if="projectData?.title"
class="m-0 flex flex-row items-center gap-2 text-sm font-medium text-secondary"
>
<UiAvatar
:src="iconUrl"
no-shadow
style="min-height: 20px; min-width: 20px; height: 20px; width: 20px"
alt="Server Icon"
/>
Using {{ projectData?.title || "Unknown" }}
</div>
<div v-else class="min-h-[20px]"></div>
<UiServersServerInfoLabels
:server-data="{ game, mc_version, loader, loader_version, net }"
:show-game-label="showGameLabel"
:show-loader-label="showLoaderLabel"
:show-subdomain-label="showSubdomainLabel"
:linked="false"
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
/>
</div>
</div>
</NuxtLink>
</template>
<script setup lang="ts">
import { ChevronRightIcon } from "@modrinth/assets";
import type { Project, Server } from "~/types/servers";
const props = defineProps<Partial<Server>>();
const showGameLabel = computed(() => !!props.game);
const showLoaderLabel = computed(() => !!props.loader);
const showSubdomainLabel = computed(() => !!props.net?.domain);
let projectData: Ref<Project | null>;
if (props.upstream) {
const { data } = await useAsyncData<Project>(
`server-project-${props.server_id}`,
async (): Promise<Project> => {
const result = await useBaseFetch(`project/${props.upstream?.project_id}`);
return result as Project;
},
);
projectData = data;
} else {
projectData = ref(null);
}
const image = ref<string | undefined>();
onMounted(async () => {
const auth = (await usePyroFetch(`servers/${props.server_id}/fs`)) as any;
try {
const fileData = await usePyroFetch(`/download?path=/server-icon-original.png`, {
override: auth,
});
if (fileData instanceof Blob) {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.src = URL.createObjectURL(fileData);
await new Promise<void>((resolve) => {
img.onload = () => {
canvas.width = 512;
canvas.height = 512;
ctx?.drawImage(img, 0, 0, 512, 512);
const dataURL = canvas.toDataURL("image/png");
image.value = dataURL;
resolve();
};
});
}
} catch (error) {
if (error instanceof PyroFetchError && error.statusCode === 404) {
image.value = undefined;
} else {
console.error(error);
}
}
});
const iconUrl = computed(() => projectData.value?.icon_url || undefined);
</script>

View File

@@ -0,0 +1,20 @@
<template>
<div class="flex flex-row items-center gap-8 overflow-x-hidden rounded-3xl bg-bg-raised p-4">
<div class="relative grid place-content-center">
<img
no-shadow
size="lg"
alt="Server Icon"
class="size-[96px] rounded-xl bg-bg-raised opacity-50"
src="~/assets/images/servers/minecraft_server_icon.png"
/>
<div class="absolute inset-0 grid place-content-center">
<UiServersIconsLoadingIcon class="size-8 animate-spin text-contrast" />
</div>
</div>
<div class="flex flex-col gap-4">
<h2 class="m-0 text-contrast">Your new server is being prepared.</h2>
<p class="m-0">It'll appear here once it's ready.</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,35 @@
<template>
<div
v-if="loader"
v-tooltip="'Change server loader'"
class="flex min-w-0 flex-row items-center gap-4 truncate"
>
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>
<div class="flex flex-row items-center gap-2">
<UiServersIconsLoaderIcon :loader="loader" class="flex shrink-0 [&&]:size-5" />
<NuxtLink
v-if="isLink"
:to="serverId ? `/servers/manage/${serverId}/options/loader` : ''"
class="min-w-0 text-sm font-semibold"
:class="serverId ? 'hover:underline' : ''"
>
{{ loader }} <span v-if="loaderVersion">{{ loaderVersion }}</span>
</NuxtLink>
<div v-else class="min-w-0 text-sm font-semibold">
{{ loader }} <span v-if="loaderVersion">{{ loaderVersion }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
noSeparator?: boolean;
loader: "Fabric" | "Quilt" | "Forge" | "NeoForge" | "Paper" | "Spigot" | "Bukkit" | "Vanilla";
loaderVersion: string;
isLink?: boolean;
}>();
const route = useNativeRoute();
const serverId = route.params.id as string;
</script>

View File

@@ -0,0 +1,19 @@
<template>
<div class="flex h-full flex-col items-center justify-center gap-8">
<img
src="https://cdn.modrinth.com/servers/excitement.webp"
alt=""
class="max-w-[360px]"
style="mask-image: radial-gradient(97% 77% at 50% 25%, #d9d9d9 0, hsla(0, 0%, 45%, 0) 100%)"
/>
<h1 class="m-0 text-contrast">You don't have any servers yet!</h1>
<p class="m-0">Modrinth Servers is a new way to play modded Minecraft with your friends.</p>
<ButtonStyled size="large" type="standard" color="brand">
<NuxtLink to="/servers#plan">Create a Server</NuxtLink>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
</script>

View File

@@ -0,0 +1,45 @@
<template>
<div class="static w-full grid-cols-1 md:relative md:flex">
<div class="static h-full flex-col pb-4 md:flex md:pb-0 md:pr-4">
<div class="z-10 flex select-none flex-col gap-2 rounded-2xl bg-bg-raised p-4 md:w-[16rem]">
<div v-for="link in navLinks" :key="link.label">
<NuxtLink
:to="link.href"
class="flex items-center gap-2 rounded-xl p-2 hover:bg-button-bg"
:class="{ 'bg-button-bg text-contrast': route.path === link.href }"
>
<div class="flex items-center gap-2 font-bold">
<component :is="link.icon" class="size-6" />
{{ link.label }}
</div>
<div class="flex-grow" />
<RightArrowIcon v-if="link.external" class="size-4" />
</NuxtLink>
</div>
</div>
</div>
<div class="h-full w-full">
<NuxtPage :route="props.route" :server="props.server" @reinstall="onReinstall" />
</div>
</div>
</template>
<script setup lang="ts">
import { RightArrowIcon } from "@modrinth/assets";
import type { RouteLocationNormalized } from "vue-router";
import type { Server } from "~/composables/pyroServers";
const emit = defineEmits(["reinstall"]);
const props = defineProps<{
navLinks: { label: string; href: string; icon: Component; external?: boolean }[];
route: RouteLocationNormalized;
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
}>();
const onReinstall = (...args: any[]) => {
emit("reinstall", ...args);
};
</script>

View File

@@ -0,0 +1,18 @@
<template>
<div class="flex flex-col gap-4">
<div
v-for="n in count"
:key="n"
class="relative h-[128px] w-full animate-pulse rounded-3xl bg-bg-raised p-4"
/>
</div>
</template>
<script setup lang="ts">
defineProps({
count: {
type: Number,
default: 3,
},
});
</script>

View File

@@ -0,0 +1,330 @@
<template>
<div
data-pyro-server-stats
style="font-variant-numeric: tabular-nums"
class="flex select-none flex-col items-center gap-6 md:flex-row"
>
<div
v-for="(metric, index) in metrics"
:key="index"
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
>
<div
class="relative z-10 -ml-3 w-fit rounded-xl px-3 py-1"
:style="{
backdropFilter: 'blur(6px)',
}"
>
<div class="-mb-0.5 mt-0.5 flex flex-row items-center gap-2">
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">
{{ metric.value }}
</h2>
<h3 class="relative z-10 text-sm font-normal text-secondary">/ {{ metric.max }}</h3>
</div>
<h3 class="relative z-10 flex items-center gap-2 text-base font-normal text-secondary">
{{ metric.title }}
<WarningIcon
v-tooltip="getPotentialWarning(metric)"
:style="{
color: 'var(--color-orange)',
width: '1.25rem',
height: '1.25rem',
display: getPotentialWarning(metric) ? 'block' : 'none',
}"
/>
</h3>
</div>
<component :is="metric.icon" class="absolute right-10 top-10 z-10" />
<ClientOnly>
<VueApexCharts
v-if="
metric.data.length && !(metric.title === 'Memory usage' && userPreferences.ramAsNumber)
"
ref="chart"
type="area"
height="142"
:options="generateOptions(metric)"
:series="[{ name: 'Chart', data: metric.data }]"
class="chart chart-animation absolute bottom-0 left-0 right-0 w-full"
/>
</ClientOnly>
</div>
<NuxtLink
:to="`/servers/manage/${serverId}/files`"
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8 transition-transform duration-100 hover:scale-105 active:scale-100"
>
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 -ml-0.5 mt-1 text-3xl font-extrabold text-contrast">
{{ formatBytes(animatedStorageUsage) }}
</h2>
<!-- <h3 class="relative z-10 text-sm font-normal text-secondary">
/ {{ formatBytes(props.data.current.storage_total_bytes) }}
</h3> -->
</div>
<h3 class="relative z-10 text-base font-normal text-secondary">Storage usage</h3>
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
</NuxtLink>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import { FolderOpenIcon, CPUIcon, DBIcon } from "@modrinth/assets";
import { useStorage } from "@vueuse/core";
import type { Stats } from "~/types/servers";
import WarningIcon from "~/assets/images/utils/issues.svg?component";
const route = useNativeRoute();
const serverId = route.params.id;
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
ramAsNumber: false,
autoRestart: false,
backupWhileRunning: false,
});
const VueApexCharts = defineAsyncComponent(() => import("vue3-apexcharts"));
const props = defineProps({
data: {
type: Object as PropType<Stats>,
required: true,
},
});
const lerp = (a: number, b: number) => {
return a + (b - a) * 0.5;
};
// I told you it would go into prod
const formatBytes = (bytes: number) => {
const units = ["Bytes", "KB", "MB", "GB", "TB"];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 2) {
value /= 1024;
unitIndex++;
}
return `${Math.round(value * 100) / 100} ${units[unitIndex]}`;
};
const animatedStorageUsage = ref(0);
const animateValue = (start: number, end: number, duration: number): void => {
let startTimestamp: number | null = null;
const step = (timestamp: number) => {
if (!startTimestamp) startTimestamp = timestamp;
const progress = Math.min((timestamp - startTimestamp) / duration, 1);
animatedStorageUsage.value = Math.floor(progress * (end - start) + start);
if (progress < 1) {
requestAnimationFrame(step);
}
};
requestAnimationFrame(step);
};
onMounted(() => {
animateValue(0, props.data.current.storage_usage_bytes, 250);
});
watch(
() => props.data.current.storage_usage_bytes,
(newValue, oldValue) => {
animateValue(oldValue, newValue, 250);
},
);
const metrics = ref([
{
title: "CPU usage",
value: "0%",
max: "100%",
icon: markRaw(CPUIcon),
data: [] as number[],
},
{
title: "Memory usage",
value: "0%",
max: userPreferences.value.ramAsNumber
? formatBytes(props.data.current.ram_total_bytes)
: "100%",
icon: markRaw(DBIcon),
data: [] as number[],
},
]);
const updateMetrics = () => {
console.log(props.data.current.ram_usage_bytes);
metrics.value = metrics.value.map((metric, index) => {
if (userPreferences.value.ramAsNumber && index === 1) {
return {
...metric,
value: formatBytes(props.data.current.ram_usage_bytes),
data: [...metric.data.slice(-10), props.data.current.ram_usage_bytes],
max: formatBytes(props.data.current.ram_total_bytes),
};
} else {
const currentValue =
index === 0
? props.data.current.cpu_percent
: Math.min(
(props.data.current.ram_usage_bytes / props.data.current.ram_total_bytes) * 100,
100,
);
const pastValue =
index === 0
? props.data.past.cpu_percent
: Math.min(
(props.data.past.ram_usage_bytes / props.data.past.ram_total_bytes) * 100,
100,
);
const newValue = lerp(currentValue, pastValue);
return {
...metric,
value: `${newValue.toFixed(2)}%`,
data: [...metric.data.slice(-10), newValue],
// data: [36, 36],
};
}
});
};
// aww, you gotta give em that rinth tuah, mod on that thang
const getPotentialWarning = (metric: (typeof metrics.value)[0]) => {
// make all words in the string lowercase, unless the word is in all caps
const split = metric.title.split(" ");
const title = split
.map((word) => {
if (word === word.toUpperCase()) {
return word;
}
return word.toLowerCase();
})
.join(" ");
let data = metric.data.at(-1) || 0;
if (userPreferences.value.ramAsNumber) {
data = (props.data.current.ram_usage_bytes / props.data.current.ram_total_bytes) * 100;
}
switch (true) {
case data >= 90:
return `Your server's ${title} is very high.`;
default:
return "";
}
};
const generateOptions = (metric: (typeof metrics.value)[0]) => {
let color = "var(--color-brand)";
let data = metric.data.at(-1) || 0;
if (userPreferences.value.ramAsNumber) {
data = (props.data.current.ram_usage_bytes / props.data.current.ram_total_bytes) * 100;
}
switch (true) {
case data >= 90:
color = "var(--color-red)";
break;
case data >= 80:
color = "var(--color-orange)";
break;
}
return {
chart: {
id: "stats",
fontFamily:
"Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif",
foreColor: "var(--color-base)",
toolbar: { show: false },
zoom: { enabled: false },
sparkline: { enabled: true },
animations: {
enabled: true,
easing: "linear",
dynamicAnimation: { speed: 1000 },
},
},
stroke: { curve: "smooth" },
fill: {
colors: [color],
type: "gradient",
opacity: 1,
gradient: {
shade: "light",
type: "vertical",
shadeIntensity: 0,
gradientToColors: [color],
inverseColors: true,
opacityFrom: 0.5,
opacityTo: 0,
stops: [0, 100],
colorStops: [],
},
},
grid: { show: false },
legend: { show: false },
colors: [color],
dataLabels: { enabled: false },
xaxis: {
type: "numeric",
lines: { show: false },
axisBorder: { show: false },
labels: { show: false },
},
yaxis: {
min: 0,
max: 100,
tickAmount: 5,
labels: { show: false },
axisBorder: { show: false },
axisTicks: { show: false },
},
tooltip: { enabled: false },
};
};
// watch(
// metrics,
// () => {
// console.log(metrics.value[0].data.at(-1));
// },
// {
// deep: true,
// immediate: true,
// },
// );
let interval: number;
onMounted(() => {
updateMetrics();
interval = window.setInterval(updateMetrics, 1000);
});
onUnmounted(() => {
if (interval) {
clearInterval(interval);
}
});
</script>
<style scoped>
@keyframes chart-enter-animation {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.chart-animation {
opacity: 0;
animation: chart-enter-animation 0.5s ease-out forwards;
animation-delay: 1s;
}
</style>

View File

@@ -0,0 +1,40 @@
<template>
<div
v-if="subdomain"
v-tooltip="'Copy subdomain'"
class="flex min-w-0 flex-row items-center gap-4 truncate hover:cursor-pointer"
>
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>
<div class="flex flex-row items-center gap-2">
<LinkIcon class="flex size-5 shrink-0" />
<div
class="flex min-w-0 text-sm font-semibold"
:class="serverId ? 'hover:underline' : ''"
@click="copySubdomain"
>
{{ subdomain }}.modrinth.gg
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { LinkIcon } from "@modrinth/assets";
const props = defineProps<{
subdomain: string;
noSeparator?: boolean;
}>();
const copySubdomain = () => {
navigator.clipboard.writeText(props.subdomain + ".modrinth.gg");
addNotification({
group: "servers",
title: "Subdomain copied",
text: "Your subdomain has been copied to your clipboard.",
type: "success",
});
};
const route = useNativeRoute();
const serverId = computed(() => route.params.id as string);
</script>

View File

@@ -0,0 +1,65 @@
<template>
<div
v-if="uptimeSeconds || uptimeSeconds !== 0"
v-tooltip="`Online for ${verboseUptime}`"
class="flex min-w-0 flex-row items-center gap-4"
data-pyro-uptime
>
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>
<div class="flex gap-2">
<UiServersTimer class="flex size-5 shrink-0" />
<time class="truncate text-sm font-semibold" :aria-label="verboseUptime">
{{ formattedUptime }}
</time>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps<{
uptimeSeconds: number;
noSeparator?: boolean;
}>();
const formattedUptime = computed(() => {
const days = Math.floor(props.uptimeSeconds / (24 * 3600));
const hours = Math.floor((props.uptimeSeconds % (24 * 3600)) / 3600);
const minutes = Math.floor((props.uptimeSeconds % 3600) / 60);
const seconds = props.uptimeSeconds % 60;
let formatted = "";
if (days > 0) {
formatted += `${days}d `;
}
if (hours > 0 || days > 0) {
formatted += `${hours}h `;
}
formatted += `${minutes}m ${seconds}s`;
return formatted.trim();
});
const verboseUptime = computed(() => {
const days = Math.floor(props.uptimeSeconds / (24 * 3600));
const hours = Math.floor((props.uptimeSeconds % (24 * 3600)) / 3600);
const minutes = Math.floor((props.uptimeSeconds % 3600) / 60);
const seconds = props.uptimeSeconds % 60;
let verbose = "";
if (days > 0) {
verbose += `${days} day${days > 1 ? "s" : ""} `;
}
if (hours > 0) {
verbose += `${hours} hour${hours > 1 ? "s" : ""} `;
}
if (minutes > 0) {
verbose += `${minutes} minute${minutes > 1 ? "s" : ""} `;
}
verbose += `${seconds} second${seconds > 1 ? "s" : ""}`;
return verbose.trim();
});
</script>

View File

@@ -0,0 +1,444 @@
<template>
<div
ref="dropdown"
data-pyro-dropdown
tabindex="0"
role="combobox"
:aria-expanded="dropdownVisible"
class="relative inline-block h-9 w-full max-w-80"
@focus="onFocus"
@blur="onBlur"
@mousedown.prevent
@keydown="handleKeyDown"
>
<div
data-pyro-dropdown-trigger
class="duration-50 flex h-full w-full cursor-pointer select-none items-center justify-between gap-4 rounded-xl bg-button-bg px-4 py-2 shadow-sm transition-all ease-in-out"
:class="triggerClasses"
@click="toggleDropdown"
>
<span>{{ selectedOption }}</span>
<DropdownIcon
class="transition-transform duration-200 ease-in-out"
:class="{ 'rotate-180': dropdownVisible }"
/>
</div>
<Teleport to="#teleports">
<transition
enter-active-class="transition-opacity duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="dropdownVisible"
ref="optionsContainer"
data-pyro-dropdown-options
class="experimental-styles-within fixed z-50 bg-button-bg shadow-lg"
:class="{
'rounded-b-xl': !isRenderingUp,
'rounded-t-xl': isRenderingUp,
}"
:style="positionStyle"
@keydown.stop="handleDropdownKeyDown"
>
<div
class="overflow-y-auto"
:style="{ height: `${virtualListHeight}px` }"
data-pyro-dropdown-options-virtual-scroller
@scroll="handleScroll"
>
<div :style="{ height: `${totalHeight}px`, position: 'relative' }">
<div
v-for="item in visibleOptions"
:key="item.index"
data-pyro-dropdown-option
:style="{
position: 'absolute',
top: 0,
transform: `translateY(${item.index * ITEM_HEIGHT}px)`,
width: '100%',
height: `${ITEM_HEIGHT}px`,
}"
>
<div
:ref="(el) => handleOptionRef(el as HTMLElement, item.index)"
role="option"
:tabindex="focusedOptionIndex === item.index ? 0 : -1"
class="hover:brightness-85 flex h-full cursor-pointer select-none items-center px-4 transition-colors duration-150 ease-in-out focus:border-none focus:outline-none"
:class="{
'bg-brand font-bold text-brand-inverted': selectedValue === item.option,
'bg-bg-raised': focusedOptionIndex === item.index,
}"
:aria-selected="selectedValue === item.option"
@click="selectOption(item.option, item.index)"
@mouseover="focusedOptionIndex = item.index"
@focus="focusedOptionIndex = item.index"
>
<input
:id="`${name}-${item.index}`"
v-model="radioValue"
type="radio"
:value="item.option"
:name="name"
class="hidden"
/>
<label :for="`${name}-${item.index}`" class="w-full cursor-pointer">
{{ displayName(item.option) }}
</label>
</div>
</div>
</div>
</div>
</div>
</transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { DropdownIcon } from "@modrinth/assets";
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from "vue";
import type { CSSProperties } from "vue";
const ITEM_HEIGHT = 44;
const BUFFER_ITEMS = 5;
type OptionValue = string | number | Record<string, any>;
interface Props {
options: OptionValue[];
name: string;
defaultValue?: OptionValue | null;
placeholder?: string | number | null;
modelValue?: OptionValue | null;
renderUp?: boolean;
disabled?: boolean;
displayName?: (option: OptionValue) => string;
}
const props = withDefaults(defineProps<Props>(), {
defaultValue: null,
placeholder: null,
modelValue: null,
renderUp: false,
disabled: false,
displayName: (option: OptionValue) => String(option),
});
const emit = defineEmits<{
(e: "input", value: OptionValue): void;
(e: "change", value: { option: OptionValue; index: number }): void;
(e: "update:modelValue", value: OptionValue): void;
}>();
const dropdownVisible = ref(false);
const selectedValue = ref<OptionValue | null>(props.modelValue || props.defaultValue);
const focusedOptionIndex = ref<number | null>(null);
const focusedOptionRef = ref<HTMLElement | null>(null);
const dropdown = ref<HTMLElement | null>(null);
const optionsContainer = ref<HTMLElement | null>(null);
const scrollTop = ref(0);
const isRenderingUp = ref(false);
const virtualListHeight = ref(300);
const lastFocusedElement = ref<HTMLElement | null>(null);
const positionStyle = ref<CSSProperties>({
position: "fixed",
top: "0px",
left: "0px",
width: "0px",
zIndex: 999,
});
const handleOptionRef = (el: HTMLElement | null, index: number) => {
if (focusedOptionIndex.value === index) {
focusedOptionRef.value = el;
}
};
const onFocus = async () => {
if (!props.disabled) {
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value);
lastFocusedElement.value = document.activeElement as HTMLElement;
dropdownVisible.value = true;
await updatePosition();
nextTick(() => {
dropdown.value?.focus();
});
}
};
const onBlur = (event: FocusEvent) => {
if (!isChildOfDropdown(event.relatedTarget as HTMLElement | null)) {
closeDropdown();
}
};
const isChildOfDropdown = (element: HTMLElement | null): boolean => {
let currentNode: HTMLElement | null = element;
while (currentNode) {
if (currentNode === dropdown.value || currentNode === optionsContainer.value) {
return true;
}
currentNode = currentNode.parentElement;
}
return false;
};
const totalHeight = computed(() => props.options.length * ITEM_HEIGHT);
const visibleOptions = computed(() => {
const startIndex = Math.floor(scrollTop.value / ITEM_HEIGHT) - BUFFER_ITEMS;
const visibleCount = Math.ceil(virtualListHeight.value / ITEM_HEIGHT) + 2 * BUFFER_ITEMS;
return Array.from({ length: visibleCount }, (_, i) => {
const index = startIndex + i;
if (index >= 0 && index < props.options.length) {
return {
index,
option: props.options[index],
};
}
return null;
}).filter((item): item is { index: number; option: OptionValue } => item !== null);
});
const selectedOption = computed(() => {
if (selectedValue.value !== null && selectedValue.value !== undefined) {
return props.displayName(selectedValue.value as OptionValue);
}
return props.placeholder || "Select an option";
});
const radioValue = computed<OptionValue>({
get() {
return props.modelValue ?? selectedValue.value ?? "";
},
set(newValue: OptionValue) {
emit("update:modelValue", newValue);
selectedValue.value = newValue;
},
});
const triggerClasses = computed(() => ({
"cursor-not-allowed opacity-50 grayscale": props.disabled,
"rounded-b-none": dropdownVisible.value && !isRenderingUp.value && !props.disabled,
"rounded-t-none": dropdownVisible.value && isRenderingUp.value && !props.disabled,
}));
const updatePosition = async () => {
if (!dropdown.value) return;
await nextTick();
const triggerRect = dropdown.value.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const margin = 8;
const contentHeight = props.options.length * ITEM_HEIGHT;
const preferredHeight = Math.min(contentHeight, 300);
const spaceBelow = viewportHeight - triggerRect.bottom;
const spaceAbove = triggerRect.top;
isRenderingUp.value = spaceBelow < preferredHeight && spaceAbove > spaceBelow;
virtualListHeight.value = isRenderingUp.value
? Math.min(spaceAbove - margin, preferredHeight)
: Math.min(spaceBelow - margin, preferredHeight);
positionStyle.value = {
position: "fixed",
left: `${triggerRect.left}px`,
width: `${triggerRect.width}px`,
zIndex: 999,
...(isRenderingUp.value
? { bottom: `${viewportHeight - triggerRect.top}px`, top: "auto" }
: { top: `${triggerRect.bottom}px`, bottom: "auto" }),
};
};
const openDropdown = async () => {
if (!props.disabled) {
closeAllDropdowns();
dropdownVisible.value = true;
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value);
lastFocusedElement.value = document.activeElement as HTMLElement;
await updatePosition();
requestAnimationFrame(() => {
updatePosition();
});
}
};
const toggleDropdown = () => {
if (!props.disabled) {
if (dropdownVisible.value) {
closeDropdown();
} else {
openDropdown();
}
}
};
const handleResize = () => {
if (dropdownVisible.value) {
requestAnimationFrame(() => {
updatePosition();
});
}
};
const handleScroll = (event: Event) => {
const target = event.target as HTMLElement;
scrollTop.value = target.scrollTop;
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!dropdownVisible.value) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
lastFocusedElement.value = document.activeElement as HTMLElement;
toggleDropdown();
}
} else {
handleDropdownKeyDown(event);
}
};
const handleDropdownKeyDown = (event: KeyboardEvent) => {
event.stopPropagation();
switch (event.key) {
case "ArrowDown":
event.preventDefault();
focusNextOption();
break;
case "ArrowUp":
event.preventDefault();
focusPreviousOption();
break;
case "Enter":
event.preventDefault();
if (focusedOptionIndex.value !== null) {
selectOption(props.options[focusedOptionIndex.value], focusedOptionIndex.value);
}
break;
case "Escape":
event.preventDefault();
event.stopPropagation();
closeDropdown();
break;
case "Tab":
event.preventDefault();
if (event.shiftKey) {
focusPreviousOption();
} else {
focusNextOption();
}
break;
}
};
const closeDropdown = () => {
dropdownVisible.value = false;
focusedOptionIndex.value = null;
if (lastFocusedElement.value) {
lastFocusedElement.value.focus();
lastFocusedElement.value = null;
}
};
const closeAllDropdowns = () => {
const event = new CustomEvent("close-all-dropdowns");
window.dispatchEvent(event);
};
const selectOption = (option: OptionValue, index: number) => {
radioValue.value = option;
emit("change", { option, index });
closeDropdown();
};
const focusNextOption = () => {
if (focusedOptionIndex.value === null) {
focusedOptionIndex.value = 0;
} else {
focusedOptionIndex.value = (focusedOptionIndex.value + 1) % props.options.length;
}
scrollToFocused();
nextTick(() => {
focusedOptionRef.value?.focus();
});
};
const focusPreviousOption = () => {
if (focusedOptionIndex.value === null) {
focusedOptionIndex.value = props.options.length - 1;
} else {
focusedOptionIndex.value =
(focusedOptionIndex.value - 1 + props.options.length) % props.options.length;
}
scrollToFocused();
nextTick(() => {
focusedOptionRef.value?.focus();
});
};
const scrollToFocused = () => {
if (focusedOptionIndex.value === null) return;
const optionsElement = optionsContainer.value?.querySelector(".overflow-y-auto");
if (!optionsElement) return;
const targetScrollTop = focusedOptionIndex.value * ITEM_HEIGHT;
const scrollBottom = optionsElement.clientHeight;
if (targetScrollTop < optionsElement.scrollTop) {
optionsElement.scrollTop = targetScrollTop;
} else if (targetScrollTop + ITEM_HEIGHT > optionsElement.scrollTop + scrollBottom) {
optionsElement.scrollTop = targetScrollTop - scrollBottom + ITEM_HEIGHT;
}
};
onMounted(() => {
window.addEventListener("resize", handleResize);
window.addEventListener("scroll", handleResize, true);
window.addEventListener("click", (event) => {
if (!isChildOfDropdown(event.target as HTMLElement)) {
closeDropdown();
}
});
window.addEventListener("close-all-dropdowns", closeDropdown);
});
onUnmounted(() => {
window.removeEventListener("resize", handleResize);
window.removeEventListener("scroll", handleResize, true);
window.removeEventListener("click", (event) => {
if (!isChildOfDropdown(event.target as HTMLElement)) {
closeDropdown();
}
});
window.removeEventListener("close-all-dropdowns", closeDropdown);
lastFocusedElement.value = null;
});
watch(
() => props.modelValue,
(newValue) => {
selectedValue.value = newValue;
},
);
watch(dropdownVisible, async (newValue) => {
if (newValue) {
await updatePosition();
scrollTop.value = 0;
}
});
</script>

View File

@@ -0,0 +1,433 @@
<template>
<div data-pyro-telepopover-wrapper class="relative">
<button
ref="triggerRef"
class="teleport-overflow-menu-trigger"
:aria-expanded="isOpen"
:aria-haspopup="true"
@mousedown="handleMouseDown"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@click="toggleMenu"
>
<slot></slot>
</button>
<Teleport to="#teleports">
<Transition
enter-active-class="transition duration-125 ease-out"
enter-from-class="transform scale-75 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-125 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-75 opacity-0"
>
<div
v-if="isOpen"
ref="menuRef"
data-pyro-telepopover-root
class="experimental-styles-within fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-hidden rounded-2xl border-[1px] border-solid border-button-bg bg-bg-raised p-2 shadow-lg"
:style="menuStyle"
role="menu"
tabindex="-1"
@mousedown.stop
@mouseleave="handleMouseLeave"
>
<ButtonStyled
v-for="(option, index) in filteredOptions"
:key="option.id"
type="transparent"
role="menuitem"
:color="option.color"
>
<button
v-if="typeof option.action === 'function'"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement;
}
"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@click="handleItemClick(option, index)"
@focus="selectedIndex = index"
@mouseover="handleMouseOver(index)"
>
<slot :name="option.id">{{ option.id }}</slot>
</button>
<nuxt-link
v-else-if="typeof option.action === 'string' && option.action.startsWith('/')"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement;
}
"
:to="option.action"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@click="handleItemClick(option, index)"
@focus="selectedIndex = index"
@mouseover="handleMouseOver(index)"
>
<slot :name="option.id">{{ option.id }}</slot>
</nuxt-link>
<a
v-else-if="typeof option.action === 'string' && !option.action.startsWith('http')"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement;
}
"
:href="option.action"
target="_blank"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@click="handleItemClick(option, index)"
@focus="selectedIndex = index"
@mouseover="handleMouseOver(index)"
>
<slot :name="option.id">{{ option.id }}</slot>
</a>
<span v-else>
<slot :name="option.id">{{ option.id }}</slot>
</span>
</ButtonStyled>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from "vue";
import { onClickOutside, useElementHover } from "@vueuse/core";
interface Option {
id: string;
action?: (() => void) | string;
shown?: boolean;
color?: "standard" | "brand" | "red" | "orange" | "green" | "blue" | "purple";
}
const props = withDefaults(
defineProps<{
options: Option[];
hoverable?: boolean;
}>(),
{
hoverable: false,
},
);
const emit = defineEmits<{
(e: "select", option: Option): void;
}>();
const isOpen = ref(false);
const selectedIndex = ref(-1);
const menuRef = ref<HTMLElement | null>(null);
const triggerRef = ref<HTMLElement | null>(null);
const isMouseDown = ref(false);
const typeAheadBuffer = ref("");
const typeAheadTimeout = ref<number | null>(null);
const menuItemsRef = ref<HTMLElement[]>([]);
const hoveringTrigger = useElementHover(triggerRef);
const hoveringMenu = useElementHover(menuRef);
const hovering = computed(() => hoveringTrigger.value || hoveringMenu.value);
const menuStyle = ref({
top: "0px",
left: "0px",
});
const filteredOptions = computed(() => props.options.filter((option) => option.shown !== false));
const calculateMenuPosition = () => {
if (!triggerRef.value || !menuRef.value) return { top: "0px", left: "0px" };
const triggerRect = triggerRef.value.getBoundingClientRect();
const menuRect = menuRef.value.getBoundingClientRect();
const menuWidth = menuRect.width;
const menuHeight = menuRect.height;
const margin = 8;
let top: number;
let left: number;
// okay gang lets calculate this shit
// from the top now yall
// y
if (triggerRect.bottom + menuHeight + margin <= window.innerHeight) {
top = triggerRect.bottom + margin;
} else if (triggerRect.top - menuHeight - margin >= 0) {
top = triggerRect.top - menuHeight - margin;
} else {
top = Math.max(margin, window.innerHeight - menuHeight - margin);
}
// x
if (triggerRect.left + menuWidth + margin <= window.innerWidth) {
left = triggerRect.left;
} else if (triggerRect.right - menuWidth - margin >= 0) {
left = triggerRect.right - menuWidth;
} else {
left = Math.max(margin, window.innerWidth - menuWidth - margin);
}
return {
top: `${top}px`,
left: `${left}px`,
};
};
const toggleMenu = (event: MouseEvent) => {
event.stopPropagation();
if (!props.hoverable) {
if (isOpen.value) {
closeMenu();
} else {
openMenu();
}
}
};
const openMenu = () => {
isOpen.value = true;
disableBodyScroll();
nextTick(() => {
menuStyle.value = calculateMenuPosition();
document.addEventListener("mousemove", handleMouseMove);
focusFirstMenuItem();
});
};
const closeMenu = () => {
isOpen.value = false;
selectedIndex.value = -1;
enableBodyScroll();
document.removeEventListener("mousemove", handleMouseMove);
};
const selectOption = (option: Option) => {
emit("select", option);
if (typeof option.action === "function") {
option.action();
}
closeMenu();
};
const handleMouseDown = (event: MouseEvent) => {
event.preventDefault();
isMouseDown.value = true;
};
const handleMouseEnter = () => {
if (props.hoverable) {
openMenu();
}
};
const handleMouseLeave = () => {
if (props.hoverable) {
setTimeout(() => {
if (!hovering.value) {
closeMenu();
}
}, 250);
}
};
const handleMouseMove = (event: MouseEvent) => {
if (!isOpen.value || !isMouseDown.value) return;
const menuRect = menuRef.value?.getBoundingClientRect();
if (!menuRect) return;
const menuItems = menuRef.value?.querySelectorAll('[role="menuitem"]');
if (!menuItems) return;
for (let i = 0; i < menuItems.length; i++) {
const itemRect = (menuItems[i] as HTMLElement).getBoundingClientRect();
if (
event.clientX >= itemRect.left &&
event.clientX <= itemRect.right &&
event.clientY >= itemRect.top &&
event.clientY <= itemRect.bottom
) {
selectedIndex.value = i;
break;
}
}
};
const handleItemClick = (option: Option, index: number) => {
selectedIndex.value = index;
selectOption(option);
};
const handleMouseOver = (index: number) => {
selectedIndex.value = index;
menuItemsRef.value[selectedIndex.value].focus();
};
// Scrolling is disabled for keyboard navigation
const disableBodyScroll = () => {
// Make opening not shift page when there's a vertical scrollbar
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth;
if (scrollBarWidth > 0) {
document.body.style.paddingRight = `${scrollBarWidth}px`;
} else {
document.body.style.paddingRight = "";
}
document.body.style.overflow = "hidden";
};
const enableBodyScroll = () => {
document.body.style.paddingRight = "";
document.body.style.overflow = "";
};
const focusFirstMenuItem = () => {
if (menuItemsRef.value.length > 0) {
menuItemsRef.value[0].focus();
}
};
const handleKeydown = (event: KeyboardEvent) => {
if (!isOpen.value) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openMenu();
}
return;
}
switch (event.key) {
case "ArrowDown":
event.preventDefault();
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length;
menuItemsRef.value[selectedIndex.value].focus();
break;
case "ArrowUp":
event.preventDefault();
selectedIndex.value =
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length;
menuItemsRef.value[selectedIndex.value].focus();
break;
case "Home":
event.preventDefault();
if (menuItemsRef.value.length > 0) {
selectedIndex.value = 0;
menuItemsRef.value[selectedIndex.value].focus();
}
break;
case "End":
event.preventDefault();
if (menuItemsRef.value.length > 0) {
selectedIndex.value = filteredOptions.value.length - 1;
menuItemsRef.value[selectedIndex.value].focus();
}
break;
case "Enter":
case " ":
event.preventDefault();
if (selectedIndex.value >= 0) {
selectOption(filteredOptions.value[selectedIndex.value]);
}
break;
case "Escape":
event.preventDefault();
closeMenu();
triggerRef.value?.focus();
break;
case "Tab":
event.preventDefault();
if (menuItemsRef.value.length > 0) {
if (event.shiftKey) {
selectedIndex.value =
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length;
} else {
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length;
}
menuItemsRef.value[selectedIndex.value].focus();
}
break;
default:
if (event.key.length === 1) {
typeAheadBuffer.value += event.key.toLowerCase();
const matchIndex = filteredOptions.value.findIndex((option) =>
option.id.toLowerCase().startsWith(typeAheadBuffer.value),
);
if (matchIndex !== -1) {
selectedIndex.value = matchIndex;
menuItemsRef.value[selectedIndex.value].focus();
}
if (typeAheadTimeout.value) {
clearTimeout(typeAheadTimeout.value);
}
typeAheadTimeout.value = setTimeout(() => {
typeAheadBuffer.value = "";
}, 1000) as unknown as number;
}
break;
}
};
const handleResizeOrScroll = () => {
if (isOpen.value) {
menuStyle.value = calculateMenuPosition();
}
};
const throttle = (func: (...args: any[]) => void, limit: number): ((...args: any[]) => void) => {
let inThrottle: boolean;
return function (...args: any[]) {
if (!inThrottle) {
func(...args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
};
const throttledHandleResizeOrScroll = throttle(handleResizeOrScroll, 100);
onMounted(() => {
triggerRef.value?.addEventListener("keydown", handleKeydown);
window.addEventListener("resize", throttledHandleResizeOrScroll);
window.addEventListener("scroll", throttledHandleResizeOrScroll);
});
onUnmounted(() => {
triggerRef.value?.removeEventListener("keydown", handleKeydown);
window.removeEventListener("resize", throttledHandleResizeOrScroll);
window.removeEventListener("scroll", throttledHandleResizeOrScroll);
document.removeEventListener("mousemove", handleMouseMove);
if (typeAheadTimeout.value) {
clearTimeout(typeAheadTimeout.value);
}
enableBodyScroll();
});
watch(isOpen, (newValue) => {
if (newValue) {
nextTick(() => {
menuRef.value?.addEventListener("keydown", handleKeydown);
});
} else {
menuRef.value?.removeEventListener("keydown", handleKeydown);
}
});
onClickOutside(menuRef, (event) => {
if (!triggerRef.value?.contains(event.target as Node)) {
closeMenu();
}
});
</script>

View File

@@ -0,0 +1,17 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="10" x2="14" y1="2" y2="2" />
<line x1="12" x2="15" y1="14" y2="11" />
<circle cx="12" cy="14" r="8" />
</svg>
</template>

View File

@@ -0,0 +1,18 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 12.5 8 15l2 2.5" />
<path d="m14 12.5 2 2.5-2 2.5" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z" />
</svg>
</template>

View File

@@ -0,0 +1,26 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="18" cy="18" r="3" />
<path
d="M10.3 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v3.3"
/>
<path d="m21.7 19.4-.9-.3" />
<path d="m15.2 16.9-.9-.3" />
<path d="m16.6 21.7.3-.9" />
<path d="m19.1 15.2.3-.9" />
<path d="m19.6 21.7-.4-1" />
<path d="m16.8 15.3-.4-1" />
<path d="m14.3 19.6 1-.4" />
<path d="m20.7 16.8 1-.4" />
</svg>
</template>

View File

@@ -0,0 +1,20 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21.54 15H17a2 2 0 0 0-2 2v4.54" />
<path
d="M7 3.34V5a3 3 0 0 0 3 3a2 2 0 0 1 2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2c0-1.1.9-2 2-2h3.17"
/>
<path d="M11 21.95V18a2 2 0 0 0-2-2a2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05" />
<circle cx="12" cy="12" r="10" />
</svg>
</template>

View File

@@ -0,0 +1,18 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
<circle cx="10" cy="12" r="2" />
<path d="m20 17-1.296-1.296a2.41 2.41 0 0 0-3.408 0L9 22" />
</svg>
</template>

View File

@@ -0,0 +1,172 @@
<template>
<svg
v-if="loader === 'Fabric'"
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
fill-rule="evenodd"
stroke-linecap="round"
stroke-linejoin="round"
clip-rule="evenodd"
viewBox="0 0 24 24"
>
<path fill="none" d="M0 0h24v24H0z" />
<path
fill="none"
stroke="currentColor"
stroke-width="23"
d="m820 761-85.6-87.6c-4.6-4.7-10.4-9.6-25.9 1-19.9 13.6-8.4 21.9-5.2 25.4 8.2 9 84.1 89 97.2 104 2.5 2.8-20.3-22.5-6.5-39.7 5.4-7 18-12 26-3 6.5 7.3 10.7 18-3.4 29.7-24.7 20.4-102 82.4-127 103-12.5 10.3-28.5 2.3-35.8-6-7.5-8.9-30.6-34.6-51.3-58.2-5.5-6.3-4.1-19.6 2.3-25 35-30.3 91.9-73.8 111.9-90.8"
transform="matrix(.08671 0 0 .0867 -49.8 -56)"
/>
</svg>
<svg
v-else-if="loader === 'Quilt'"
xmlns:xlink="http://www.w3.org/1999/xlink"
xml:space="preserve"
fill-rule="evenodd"
stroke-linecap="round"
stroke-linejoin="round"
stroke-miterlimit="2"
clip-rule="evenodd"
viewBox="0 0 24 24"
>
<defs>
<path
id="quilt"
fill="none"
stroke="currentColor"
stroke-width="65.6"
d="M442.5 233.9c0-6.4-5.2-11.6-11.6-11.6h-197c-6.4 0-11.6 5.2-11.6 11.6v197c0 6.4 5.2 11.6 11.6 11.6h197c6.4 0 11.6-5.2 11.6-11.7v-197Z"
></path>
</defs>
<path fill="none" d="M0 0h24v24H0z"></path>
<use
xlink:href="#quilt"
stroke-width="65.6"
transform="matrix(.03053 0 0 .03046 -3.2 -3.2)"
></use>
<use xlink:href="#quilt" stroke-width="65.6" transform="matrix(.03053 0 0 .03046 -3.2 7)"></use>
<use
xlink:href="#quilt"
stroke-width="65.6"
transform="matrix(.03053 0 0 .03046 6.9 -3.2)"
></use>
<path
fill="none"
stroke="currentColor"
stroke-width="70.4"
d="M442.5 234.8c0-7-5.6-12.5-12.5-12.5H234.7c-6.8 0-12.4 5.6-12.4 12.5V430c0 6.9 5.6 12.5 12.4 12.5H430c6.9 0 12.5-5.6 12.5-12.5V234.8Z"
transform="rotate(45 3.5 24) scale(.02843 .02835)"
></path>
</svg>
<svg
v-else-if="loader === 'Forge'"
ml:space="preserve"
fill-rule="evenodd"
stroke-linecap="round"
stroke-linejoin="round"
stroke-miterlimit="1.5"
clip-rule="evenodd"
viewBox="0 0 24 24"
>
<path fill="none" d="M0 0h24v24H0z"></path>
<path
fill="none"
stroke="currentColor"
stroke-width="2"
d="M2 7.5h8v-2h12v2s-7 3.4-7 6 3.1 3.1 3.1 3.1l.9 3.9H5l1-4.1s3.8.1 4-2.9c.2-2.7-6.5-.7-8-6Z"
></path>
</svg>
<svg
v-else-if="loader === 'NeoForge'"
enable-background="new 0 0 24 24"
version="1.1"
viewBox="0 0 24 24"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
>
<g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<path d="m12 19.2v2m0-2v2" />
<path
d="m8.4 1.3c0.5 1.5 0.7 3 0.1 4.6-0.2 0.5-0.9 1.5-1.6 1.5m8.7-6.1c-0.5 1.5-0.7 3-0.1 4.6 0.2 0.6 0.9 1.5 1.6 1.5"
/>
<path d="m3.6 15.8h-1.7m18.5 0h1.7" />
<path d="m3.2 12.1h-1.7m19.3 0h1.8" />
<path d="m8.1 12.7v1.6m7.8-1.6v1.6" />
<path d="m10.8 18h1.2m0 1.2-1.2-1.2m2.4 0h-1.2m0 1.2 1.2-1.2" />
<path
d="m4 9.7c-0.5 1.2-0.8 2.4-0.8 3.7 0 3.1 2.9 6.3 5.3 8.2 0.9 0.7 2.2 1.1 3.4 1.1m0.1-17.8c-1.1 0-2.1 0.2-3.2 0.7m11.2 4.1c0.5 1.2 0.8 2.4 0.8 3.7 0 3.1-2.9 6.3-5.3 8.2-0.9 0.7-2.2 1.1-3.4 1.1m-0.1-17.8c1.1 0 2.1 0.2 3.2 0.7"
/>
<path
d="m4 9.7c-0.2-1.8-0.3-3.7 0.5-5.5s2.2-2.6 3.9-3m11.6 8.5c0.2-1.9 0.3-3.7-0.5-5.5s-2.2-2.6-3.9-3"
/>
<path d="m12 21.2-2.4 0.4m2.4-0.4 2.4 0.4" />
</g>
</svg>
<svg
v-else-if="loader === 'Paper'"
xml:space="preserve"
fill-rule="evenodd"
stroke-linecap="round"
stroke-linejoin="round"
stroke-miterlimit="1.5"
clip-rule="evenodd"
viewBox="0 0 24 24"
>
<path fill="none" d="M0 0h24v24H0z" />
<path fill="none" stroke="currentColor" stroke-width="2" d="m12 18 6 2 3-17L2 14l6 2" />
<path stroke="currentColor" stroke-width="2" d="m9 21-1-5 4 2-3 3Z" />
<path fill="currentColor" d="m12 18-4-2 10-9-6 11Z" />
</svg>
<svg
v-else-if="loader === 'Spigot'"
viewBox="0 0 332 284"
style="
fill-rule: evenodd;
clip-rule: evenodd;
stroke-linejoin: round;
fill: none;
fill-rule: nonzero;
stroke-width: 24px;
"
stroke="currentColor"
>
<path
d="M147.5,27l27,-15l27.5,15l66.5,0l0,33.5l-73,-0.912l0,45.5l26,-0.088l0,31.5l-12.5,0l0,15.5l16,21.5l35,0l0,-21.5l35.5,0l0,21.5l24.5,0l0,55.5l-24.5,0l0,17l-35.5,0l0,-27l-35,0l-55.5,14.5l-67.5,-14.5l-15,14.5l18,12.5l-3,24.5l-41.5,1.5l-48.5,-19.5l6,-19l24.5,-4.5l16,-41l79,-36l-7,-15.5l0,-31.5l23.5,0l0,-45.5l-73.5,0l0,-32.5l67,0Z"
/>
</svg>
<svg
v-else-if="loader === 'Bukkit'"
viewBox="0 0 292 319"
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linecap: round; stroke-linejoin: round"
stroke="currentColor"
>
<g transform="matrix(1,0,0,1,0,-5)">
<path
d="M12,109.5L12,155L34.5,224L57.5,224L57.5,271L81,294L160,294L160,172L259.087,172L265,155L265,109.5M12,109.5L12,64L34.5,64L34.5,41L81,17L195.5,17L241,41L241,64L265,64L265,109.5M12,109.5L81,109.5L81,132L195.5,132L195.5,109.5L265,109.5M264.087,204L264.087,244M207.5,272L207.5,312M250,272L250,312L280,312L280,272L250,272ZM192.5,204L192.5,244L222.5,244L222.5,204L192.5,204Z"
style="fill: none; fill-rule: nonzero; stroke-width: 24px"
/>
</g>
</svg>
<svg v-else-if="loader === 'Vanilla'" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M9.504 1.132a1 1 0 01.992 0l1.75 1a1 1 0 11-.992 1.736L10 3.152l-1.254.716a1 1 0 11-.992-1.736l1.75-1zM5.618 4.504a1 1 0 01-.372 1.364L5.016 6l.23.132a1 1 0 11-.992 1.736L4 7.723V8a1 1 0 01-2 0V6a.996.996 0 01.52-.878l1.734-.99a1 1 0 011.364.372zm8.764 0a1 1 0 011.364-.372l1.733.99A1.002 1.002 0 0118 6v2a1 1 0 11-2 0v-.277l-.254.145a1 1 0 11-.992-1.736l.23-.132-.23-.132a1 1 0 01-.372-1.364zm-7 4a1 1 0 011.364-.372L10 8.848l1.254-.716a1 1 0 11.992 1.736L11 10.58V12a1 1 0 11-2 0v-1.42l-1.246-.712a1 1 0 01-.372-1.364zM3 11a1 1 0 011 1v1.42l1.246.712a1 1 0 11-.992 1.736l-1.75-1A1 1 0 012 14v-2a1 1 0 011-1zm14 0a1 1 0 011 1v2a1 1 0 01-.504.868l-1.75 1a1 1 0 11-.992-1.736L16 13.42V12a1 1 0 011-1zm-9.618 5.504a1 1 0 011.364-.372l.254.145V16a1 1 0 112 0v.277l.254-.145a1 1 0 11.992 1.736l-1.735.992a.995.995 0 01-1.022 0l-1.735-.992a1 1 0 01-.372-1.364z"
clip-rule="evenodd"
></path>
</svg>
<LoaderIcon v-else />
</template>
<script setup lang="ts">
import { LoaderIcon } from "@modrinth/assets";
defineProps<{
loader: "Fabric" | "Quilt" | "Forge" | "NeoForge" | "Paper" | "Spigot" | "Bukkit" | "Vanilla";
}>();
</script>

View File

@@ -0,0 +1,9 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0V5.36l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389A5.5 5.5 0 0 1 13.89 6.11l.311.31h-2.432a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.219Z"
clip-rule="evenodd"
/>
</svg>
</template>

View File

@@ -0,0 +1,16 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-8 text-[#FF496E]"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
/>
</svg>
</template>

View File

@@ -0,0 +1,10 @@
<template>
<svg height="32" viewBox="0 0 32 32" width="32">
<path
d="M22 5L9 28"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
</template>

View File

@@ -0,0 +1,20 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-file-text"
>
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
<path d="M10 9H8" />
<path d="M16 13H8" />
<path d="M16 17H8" />
</svg>
</template>

View File

@@ -113,7 +113,7 @@ export const getAuthUrl = (provider, redirect = "") => {
}
const fullURL = `${config.public.siteUrl}${redirect}`;
return `${config.public.apiBaseUrl}auth/init?url=${fullURL}&provider=${provider}`;
return `${config.public.apiBaseUrl}auth/init?provider=${provider}&url=${fullURL}`;
};
export const removeAuthProvider = async (provider) => {

View File

@@ -23,6 +23,8 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
showAdsWithPlus: false,
// Feature toggles
projectTypesPrimaryNav: false,
hidePlusPromoInUserMenu: false,
// advancedRendering: true,
// externalLinksNewTab: true,
// notUsingBlockers: false,

View File

@@ -0,0 +1,103 @@
import { $fetch, FetchError } from "ofetch";
interface PyroFetchOptions {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
contentType?: string;
body?: Record<string, any>;
version?: number;
override?: {
url?: string;
token?: string;
};
retry?: boolean;
}
export class PyroFetchError extends Error {
constructor(
message: string,
public statusCode?: number,
public originalError?: Error,
) {
super(message);
this.name = "PyroFetchError";
}
}
export async function usePyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promise<T> {
const config = useRuntimeConfig();
const auth = await useAuth();
const authToken = auth.value?.token;
if (!authToken) {
throw new PyroFetchError("Cannot pyrofetch without auth", 10000);
}
const { method = "GET", contentType = "application/json", body, version = 0, override } = options;
const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace(
/\/$/,
"",
);
if (!base) {
throw new PyroFetchError(
"Cannot pyrofetch without base url. Make sure to set a PYRO_BASE_URL in environment variables",
10001,
);
}
const fullUrl = override?.url
? `https://${override.url}/${path.replace(/^\//, "")}`
: `${base}/modrinth/v${version}/${path.replace(/^\//, "")}`;
type HeadersRecord = Record<string, string>;
const headers: HeadersRecord = {
Authorization: `Bearer ${override?.token ?? authToken}`,
"Access-Control-Allow-Headers": "Authorization",
"User-Agent": "Pyro/1.0 (https://pyro.host)",
Vary: "Accept, Origin",
"Content-Type": contentType,
};
if (import.meta.client && typeof window !== "undefined") {
headers.Origin = window.location.origin;
}
try {
const response = await $fetch<T>(fullUrl, {
method,
headers,
body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined,
timeout: 10000,
retry: options.retry !== false ? (method === "GET" ? 3 : 0) : 0,
});
return response;
} catch (error) {
console.error("Fetch error:", error);
if (error instanceof FetchError) {
const statusCode = error.response?.status;
const statusText = error.response?.statusText || "Unknown error";
const errorMessages: { [key: number]: string } = {
400: "Bad Request",
401: "Unauthorized",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
429: "Too Many Requests",
500: "Internal Server Error",
502: "Bad Gateway",
};
const message =
statusCode && statusCode in errorMessages
? errorMessages[statusCode]
: `HTTP Error: ${statusCode || "unknown"} ${statusText}`;
throw new PyroFetchError(`[PYROFETCH][PYRO] ${message}`, statusCode, error);
}
throw new PyroFetchError(
"[PYROFETCH][PYRO] An unexpected error occurred during the fetch operation.",
undefined,
error as Error,
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -38,7 +38,7 @@
config.public.apiBaseUrl.startsWith('https://staging-api.modrinth.com') &&
!cosmetics.hideStagingBanner
"
class="site-banner site-banner--warning"
class="site-banner site-banner--warning [&>*]:z-[6]"
>
<div class="site-banner__title">
<IssuesIcon aria-hidden="true" />
@@ -54,7 +54,7 @@
</div>
</div>
<header
class="experimental-styles-within desktop-only relative z-[5] mx-auto my-4 grid max-w-[1280px] grid-cols-[1fr_auto] items-center gap-2 px-3 sm:grid-cols-[auto_1fr_auto]"
class="experimental-styles-within desktop-only relative z-[5] mx-auto grid max-w-[1280px] grid-cols-[1fr_auto] items-center gap-2 px-3 py-4 lg:grid-cols-[auto_1fr_auto]"
>
<div>
<NuxtLink to="/" aria-label="Modrinth home page">
@@ -62,121 +62,165 @@
</NuxtLink>
</div>
<div
class="col-span-2 row-start-2 flex flex-wrap justify-center gap-1 sm:col-span-1 sm:row-start-auto"
:class="`col-span-2 row-start-2 flex flex-wrap justify-center ${flags.projectTypesPrimaryNav ? 'gap-2' : 'gap-4'} lg:col-span-1 lg:row-start-auto`"
>
<div class="hidden xl:contents">
<ButtonStyled type="transparent">
<nuxt-link to="/mods" class="temp-nav-buttons"> Mods </nuxt-link>
<template v-if="flags.projectTypesPrimaryNav">
<ButtonStyled
type="transparent"
:highlighted="route.name === 'search-mods' || route.path.startsWith('/mod/')"
:highlighted-style="
route.name === 'search-mods' ? 'main-nav-primary' : 'main-nav-secondary'
"
>
<nuxt-link to="/mods"> <BoxIcon aria-hidden="true" /> Mods </nuxt-link>
</ButtonStyled>
<ButtonStyled type="transparent">
<nuxt-link to="/resourcepacks" class="temp-nav-buttons"> Resource Packs </nuxt-link>
<ButtonStyled
type="transparent"
:highlighted="
route.name === 'search-resourcepacks' || route.path.startsWith('/resourcepack/')
"
:highlighted-style="
route.name === 'search-resourcepacks' ? 'main-nav-primary' : 'main-nav-secondary'
"
>
<nuxt-link to="/resourcepacks">
<PaintBrushIcon aria-hidden="true" /> Resource Packs
</nuxt-link>
</ButtonStyled>
<ButtonStyled type="transparent">
<nuxt-link to="/datapacks" class="temp-nav-buttons"> Data Packs </nuxt-link>
<ButtonStyled
type="transparent"
:highlighted="route.name === 'search-datapacks' || route.path.startsWith('/datapack/')"
:highlighted-style="
route.name === 'search-datapacks' ? 'main-nav-primary' : 'main-nav-secondary'
"
>
<nuxt-link to="/datapacks"> <BracesIcon aria-hidden="true" /> Data Packs </nuxt-link>
</ButtonStyled>
<ButtonStyled type="transparent">
<nuxt-link to="/modpacks" class="temp-nav-buttons"> Modpacks </nuxt-link>
<ButtonStyled
type="transparent"
:highlighted="route.name === 'search-modpacks' || route.path.startsWith('/modpack/')"
:highlighted-style="
route.name === 'search-modpacks' ? 'main-nav-primary' : 'main-nav-secondary'
"
>
<nuxt-link to="/modpacks"> <PackageOpenIcon aria-hidden="true" /> Modpacks </nuxt-link>
</ButtonStyled>
<ButtonStyled type="transparent">
<nuxt-link to="/shaders" class="temp-nav-buttons"> Shaders </nuxt-link>
<ButtonStyled
type="transparent"
:highlighted="route.name === 'search-shaders' || route.path.startsWith('/shader/')"
:highlighted-style="
route.name === 'search-shaders' ? 'main-nav-primary' : 'main-nav-secondary'
"
>
<nuxt-link to="/shaders"> <GlassesIcon aria-hidden="true" /> Shaders </nuxt-link>
</ButtonStyled>
<ButtonStyled type="transparent">
<nuxt-link to="/plugins" class="temp-nav-buttons"> Plugins </nuxt-link>
<ButtonStyled
type="transparent"
:highlighted="route.name === 'search-plugins' || route.path.startsWith('/plugin/')"
:highlighted-style="
route.name === 'search-plugins' ? 'main-nav-primary' : 'main-nav-secondary'
"
>
<nuxt-link to="/plugins"> <PlugIcon aria-hidden="true" /> Plugins </nuxt-link>
</ButtonStyled>
</div>
<div class="contents xl:hidden">
<ButtonStyled type="transparent">
<OverflowMenu
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1"
</template>
<template v-else>
<ButtonStyled
type="transparent"
:highlighted="isDiscovering || isDiscoveringSubpage"
:highlighted-style="isDiscoveringSubpage ? 'main-nav-secondary' : 'main-nav-primary'"
>
<TeleportOverflowMenu
:options="[
{
id: 'mods',
link: '/mods',
action: '/mods',
},
{
id: 'resourcepacks',
link: '/resourcepacks',
action: '/resourcepacks',
},
{
id: 'datapacks',
link: '/datapacks',
},
{
id: 'plugins',
link: '/plugins',
action: '/datapacks',
},
{
id: 'shaders',
link: '/shaders',
action: '/shaders',
},
{
id: 'modpacks',
link: '/modpacks',
action: '/modpacks',
},
{
id: 'plugins',
action: '/plugins',
},
]"
hoverable
>
<CompassIcon aria-hidden="true" /> Browse
<BoxIcon
v-if="route.name === 'search-mods' || route.path.startsWith('/mod/')"
aria-hidden="true"
/>
<PaintBrushIcon
v-else-if="
route.name === 'search-resourcepacks' || route.path.startsWith('/resourcepack/')
"
aria-hidden="true"
/>
<BracesIcon
v-else-if="route.name === 'search-datapacks' || route.path.startsWith('/datapack/')"
aria-hidden="true"
/>
<PackageOpenIcon
v-else-if="route.name === 'search-modpacks' || route.path.startsWith('/modpack/')"
aria-hidden="true"
/>
<GlassesIcon
v-else-if="route.name === 'search-shaders' || route.path.startsWith('/shader/')"
aria-hidden="true"
/>
<PlugIcon
v-else-if="route.name === 'search-plugins' || route.path.startsWith('/plugin/')"
aria-hidden="true"
/>
<CompassIcon v-else aria-hidden="true" />
<span class="hidden md:contents">Discover content</span>
<span class="contents md:hidden">Discover</span>
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
<template #mods> <BoxIcon aria-hidden="true" /> Mods </template>
<template #resourcepacks>
<PaintBrushIcon aria-hidden="true" /> Resource Packs
</template>
<template #datapacks> <BracesIcon aria-hidden="true" /> Data Packs </template>
<template #plugins> <ServerIcon aria-hidden="true" /> Plugins </template>
<template #plugins> <PlugIcon aria-hidden="true" /> Plugins </template>
<template #shaders> <GlassesIcon aria-hidden="true" /> Shaders </template>
<template #modpacks> <PackageOpenIcon aria-hidden="true" /> Modpacks </template>
</OverflowMenu>
</ButtonStyled>
</div>
<ButtonStyled type="transparent">
<OverflowMenu
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1"
:options="[
{
id: 'servers',
link: 'https://bisecthosting.com/modrinth',
shown: false,
},
{
id: 'app',
link: '/app',
},
{
id: 'plus',
color: 'purple',
link: '/plus',
shown: !auth.user || !isPermission(auth.user.badges, 1 << 0),
},
]"
>
<HamburgerIcon aria-hidden="true" /> More
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
<template #servers> <ServerIcon aria-hidden="true" /> Host a server </template>
<template #app> <DownloadIcon aria-hidden="true" /> Get Modrinth App </template>
<template #plus>
<ArrowBigUpDashIcon aria-hidden="true" /> Upgrade to Modrinth+
</template>
</OverflowMenu>
</ButtonStyled>
<div v-if="false" class="hidden lg:contents">
<ButtonStyled v-if="false" type="transparent">
<a href="https://bisecthosting.com/modrinth">
<ServerIcon aria-hidden="true" /> Host a server
</a>
</ButtonStyled>
<ButtonStyled type="transparent">
<NuxtLink to="/app"> <DownloadIcon aria-hidden="true" /> Get Modrinth App </NuxtLink>
</TeleportOverflowMenu>
</ButtonStyled>
<ButtonStyled
v-if="!auth.user || !isPermission(auth.user.badges, 1 << 0)"
type="transparent"
color="purple"
:highlighted="route.name.startsWith('servers')"
:highlighted-style="
route.name === 'servers' ? 'main-nav-primary' : 'main-nav-secondary'
"
>
<NuxtLink to="/plus">
<ArrowBigUpDashIcon aria-hidden="true" /> Upgrade to Modrinth+
</NuxtLink>
<nuxt-link to="/servers">
<ServerIcon aria-hidden="true" />
Host a server
</nuxt-link>
</ButtonStyled>
</div>
<ButtonStyled type="transparent" :highlighted="route.name === 'app'">
<nuxt-link to="/app">
<DownloadIcon aria-hidden="true" />
<span class="hidden md:contents">Get Modrinth App</span>
<span class="contents md:hidden">Modrinth App</span>
</nuxt-link>
</ButtonStyled>
</template>
</div>
<div class="flex items-center gap-2">
<ButtonStyled type="transparent">
@@ -224,7 +268,12 @@
<template #profile> <UserIcon aria-hidden="true" /> Profile </template>
<template #notifications> <BellIcon aria-hidden="true" /> Notifications </template>
<template #saved> <BookmarkIcon aria-hidden="true" /> Saved projects </template>
<template #servers> <ServerIcon aria-hidden="true" /> My servers </template>
<template #plus>
<ArrowBigUpDashIcon aria-hidden="true" /> Upgrade to Modrinth+
</template>
<template #settings> <SettingsIcon aria-hidden="true" /> Settings </template>
<template #flags> <ReportIcon aria-hidden="true" /> Feature flags </template>
<template #projects> <BoxIcon aria-hidden="true" /> Projects </template>
<template #organizations>
<OrganizationIcon aria-hidden="true" /> Organizations
@@ -302,6 +351,10 @@
<LibraryIcon class="icon" />
{{ formatMessage(commonMessages.collectionsLabel) }}
</NuxtLink>
<NuxtLink class="iconified-button" to="/servers/manage">
<ServerIcon class="icon" />
{{ formatMessage(commonMessages.serversLabel) }}
</NuxtLink>
<NuxtLink
v-if="auth.user.role === 'moderator' || auth.user.role === 'admin'"
class="iconified-button"
@@ -521,6 +574,7 @@ import {
HomeIcon,
MoonIcon,
SunIcon,
PlugIcon,
PlusIcon,
DropdownIcon,
LogOutIcon,
@@ -537,14 +591,15 @@ import {
} from "@modrinth/assets";
import { Button, ButtonStyled, OverflowMenu, Avatar } from "@modrinth/ui";
import CrossIcon from "assets/images/utils/x.svg";
import NotificationIcon from "assets/images/sidebar/notifications.svg";
import ModerationIcon from "assets/images/sidebar/admin.svg";
import ModalCreation from "~/components/ui/ModalCreation.vue";
import { getProjectTypeMessage } from "~/utils/i18n-project-type.ts";
import { commonMessages } from "~/utils/common-messages.ts";
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
import OrganizationCreateModal from "~/components/ui/OrganizationCreateModal.vue";
import CrossIcon from "assets/images/utils/x.svg";
import NotificationIcon from "assets/images/sidebar/notifications.svg";
import ModerationIcon from "assets/images/sidebar/admin.svg";
import TeleportOverflowMenu from "~/components/ui/servers/TeleportOverflowMenu.vue";
const { formatMessage } = useVIntl();
@@ -735,6 +790,7 @@ const isMobileMenuOpen = ref(false);
const isBrowseMenuOpen = ref(false);
const navRoutes = computed(() => [
{
id: "mods",
label: formatMessage(getProjectTypeMessage("mod", true)),
href: "/mods",
},
@@ -766,6 +822,12 @@ const userMenuOptions = computed(() => {
id: "profile",
link: `/user/${auth.value.user.username}`,
},
{
id: "plus",
link: "/plus",
color: "purple",
shown: !flags.value.hidePlusPromoInUserMenu && !isPermission(auth.value.user.badges, 1 << 0),
},
{
id: "notifications",
link: "/dashboard/notifications",
@@ -774,6 +836,15 @@ const userMenuOptions = computed(() => {
id: "saved",
link: "/dashboard/collections",
},
{
id: "servers",
link: "/servers/manage",
},
{
id: "flags",
link: "/flags",
shown: flags.value.developerMode,
},
{
id: "settings",
link: "/settings",
@@ -836,6 +907,10 @@ const userMenuOptions = computed(() => {
return options;
});
const isDiscovering = computed(() => route.name && route.name.startsWith("search-"));
const isDiscoveringSubpage = computed(() => route.name && route.name.startsWith("type-id"));
onMounted(() => {
if (window && import.meta.client) {
window.history.scrollRestoration = "auto";
@@ -894,19 +969,23 @@ function runAnalytics() {
const config = useRuntimeConfig();
const replacedUrl = config.public.apiBaseUrl.replace("v2/", "");
setTimeout(() => {
$fetch(`${replacedUrl}analytics/view`, {
method: "POST",
body: {
url: window.location.href,
},
headers: {
Authorization: auth.value.token,
},
})
.then(() => {})
.catch(() => {});
});
try {
setTimeout(() => {
$fetch(`${replacedUrl}analytics/view`, {
method: "POST",
body: {
url: window.location.href,
},
headers: {
Authorization: auth.value.token,
},
})
.then(() => {})
.catch(() => {});
});
} catch (e) {
console.error(`Sending analytics failed (CORS error? If so, ignore)`, e);
}
}
function toggleMobileMenu() {
isMobileMenuOpen.value = !isMobileMenuOpen.value;
@@ -1080,6 +1159,8 @@ function hideStagingBanner() {
}
.email-nag {
z-index: 6;
position: relative;
background-color: var(--color-raised-bg);
width: 100%;
display: flex;
@@ -1090,13 +1171,28 @@ function hideStagingBanner() {
}
.site-banner--warning {
background-color: var(--color-red-bg);
// On some pages, there's gradient backgrounds that seep underneath
// the banner, so we need to add a solid color underlay.
background-color: black;
border-bottom: 2px solid var(--color-red);
display: grid;
gap: 0.5rem;
grid-template: "title actions" "description actions";
padding-block: var(--gap-xl);
padding-inline: max(calc((100% - 80rem) / 2 + var(--gap-md)), var(--gap-xl));
z-index: 4;
position: relative;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--color-red-bg);
z-index: 5;
}
.site-banner__title {
grid-area: title;
@@ -1337,10 +1433,5 @@ function hideStagingBanner() {
padding-top: 0.75rem;
}
}
.temp-nav-buttons.router-link-exact-active {
color: var(--color-contrast) !important;
background-color: var(--color-brand-highlight) !important;
}
</style>
<style src="vue-multiselect/dist/vue-multiselect.css"></style>

View File

@@ -606,6 +606,7 @@
auth.user ? reportProject(project.id) : navigateTo('/auth/sign-in'),
color: 'red',
hoverOnly: true,
shown: !currentMember,
},
{ id: 'copy-id', action: () => copyId() },
]"
@@ -1383,7 +1384,7 @@ try {
},
),
useAsyncData(`project/${route.params.id}/dependencies`, () =>
useBaseFetch(`project/${route.params.id}/dependencies`),
useBaseFetch(`project/${route.params.id}/dependencies`, {}),
),
useAsyncData(`project/${route.params.id}/version?featured=true`, () =>
useBaseFetch(`project/${route.params.id}/version?featured=true`),

View File

@@ -124,8 +124,4 @@ export default defineNuxtComponent({
.markdown-disclaimer {
margin-block: 1rem;
}
.universal-card {
margin-top: 1rem;
}
</style>

View File

@@ -445,4 +445,8 @@ svg {
.small-multiselect {
max-width: 15rem;
}
.button-group {
justify-content: flex-start;
}
</style>

View File

@@ -268,4 +268,8 @@ function checkDifference(newLink, existingLink) {
height: 40px;
}
}
.button-group {
justify-content: flex-start;
}
</style>

View File

@@ -260,6 +260,10 @@ export default defineNuxtComponent({
}
}
.button-group {
justify-content: flex-start;
}
.category-list {
column-count: 4;
column-gap: var(--spacing-card-lg);

View File

@@ -158,7 +158,7 @@
Report
</nuxt-link>
</ButtonStyled>
<ButtonStyled v-else>
<ButtonStyled v-else-if="!currentMember">
<button @click="() => reportVersion(version.id)">
<ReportIcon aria-hidden="true" />
Report

View File

@@ -205,6 +205,7 @@
color: 'red',
hoverFilled: true,
action: () => reportVersion(version.id),
shown: !currentMember,
},
{ divider: true, shown: currentMember },
{

View File

@@ -35,7 +35,6 @@
accept="image/png,image/jpeg,image/gif,image/webp"
class="btn btn-transparent upload"
style="white-space: nowrap"
prompt="Upload icon"
aria-label="Upload icon"
@change="showPreviewImage"
>

View File

@@ -13,6 +13,7 @@
>
{{ projectType.display }}s <br />
</strong>
<strong class="main-header-strong">servers <br /></strong>
<strong class="main-header-strong">mods</strong>
</span>
</div>
@@ -724,6 +725,7 @@ async function updateSearchProjects() {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
@@ -1166,7 +1168,7 @@ async function updateSearchProjects() {
> span {
position: absolute;
top: 0;
animation: slide 10s infinite;
animation: slide 12s infinite;
@media (prefers-reduced-motion) {
animation-play-state: paused !important;
@@ -1175,32 +1177,36 @@ async function updateSearchProjects() {
@keyframes slide {
0%,
13% {
10% {
top: 0;
}
17%,
30% {
13%,
23% {
top: -1.2em;
}
33%,
46% {
26%,
36% {
top: -2.4em;
}
50%,
63% {
39%,
49% {
top: -3.6em;
}
66%,
79% {
52%,
62% {
top: -4.8em;
}
83%,
96% {
65%,
75% {
top: -6em;
}
78%,
88% {
top: -7.2em;
}
99.99997%,
99.99998% {
top: -7.2em;
top: -8.4em;
}
99.99999% {
top: 0;

View File

@@ -13,8 +13,38 @@
aria-label="Filters"
>
<AdPlaceholder
v-if="!auth.user || !isPermission(auth.user.badges, 1 << 0) || flags.showAdsWithPlus"
v-if="
(!auth.user || !isPermission(auth.user.badges, 1 << 0) || flags.showAdsWithPlus) &&
!server
"
/>
<section v-if="server" class="card">
<nuxt-link
:to="`/servers/manage/${server.serverId}/content`"
class="mb-2 flex items-center gap-2"
>
<Avatar :src="server.general.image" size="sm" />
<div class="flex flex-col gap-2">
<span class="font-bold">{{ server.general.name }}</span>
<span>{{ server.general.loader }} {{ server.general.mc_version }}</span>
</div>
</nuxt-link>
<Checkbox
v-if="projectType.id !== 'modpack'"
v-model="serverOverrideGameVersions"
label="Override game versions"
/>
<Checkbox
v-if="projectType.id !== 'modpack'"
v-model="serverOverrideLoaders"
label="Override loaders"
/>
<Checkbox
v-if="projectType.id !== 'modpack'"
v-model="serverHideInstalled"
label="Hide already installed"
/>
</section>
<section class="card gap-1" :class="{ 'max-lg:!hidden': !sidebarMenuOpen }">
<div class="flex items-center gap-2">
<div class="iconified-input w-full">
@@ -204,14 +234,6 @@
</button>
</div>
</div>
<pagination
v-if="false"
:page="currentPage"
:count="pageCount"
:link-function="(x) => getSearchUrl(x <= 1 ? 0 : (x - 1) * maxResults)"
class="mb-3 justify-end"
@switch-page="onSearchChangeToTop"
/>
<LogoAnimated v-if="searchLoading && !noLoad" />
<div v-else-if="results && results.hits && results.hits.length === 0" class="no-results">
<p>No results found for your query!</p>
@@ -243,10 +265,33 @@
:server-side="result.server_side"
:categories="result.display_categories"
:search="true"
:show-updated-date="sortType.name !== 'newest'"
:show-updated-date="!server && sortType.name !== 'newest'"
:show-created-date="!server"
:hide-loaders="['resourcepack', 'datapack'].includes(projectType.id)"
:color="result.color"
/>
>
<template v-if="server">
<button
v-if="
result.installed ||
server.mods.data.find((x) => x.project_id === result.project_id) ||
server.general?.project?.id === result.project_id
"
disabled
class="btn btn-outline btn-primary"
>
<CheckIcon />
Installed
</button>
<button v-else-if="result.installing" disabled class="btn btn-outline btn-primary">
Installing...
</button>
<button v-else class="btn btn-outline btn-primary" @click="serverInstall(result)">
<DownloadIcon />
Install
</button>
</template>
</ProjectCard>
</div>
</div>
<div class="pagination-after">
@@ -263,8 +308,8 @@
</template>
<script setup>
import { Multiselect } from "vue-multiselect";
import { Pagination, ScrollablePanel, Checkbox } from "@modrinth/ui";
import { BanIcon, DropdownIcon, CheckIcon, FilterXIcon } from "@modrinth/assets";
import { Pagination, ScrollablePanel, Checkbox, Avatar } from "@modrinth/ui";
import { BanIcon, DropdownIcon, CheckIcon, FilterXIcon, DownloadIcon } from "@modrinth/assets";
import ProjectCard from "~/components/ui/ProjectCard.vue";
import LogoAnimated from "~/components/brand/LogoAnimated.vue";
@@ -379,8 +424,56 @@ if (route.query.o) {
currentPage.value = Math.ceil(route.query.o / maxResults.value) + 1;
}
const server = ref();
const serverHideInstalled = ref(false);
const serverOverrideGameVersions = ref(false);
const serverOverrideLoaders = ref(false);
if (route.query.sid) {
server.value = await usePyroServer(route.query.sid, ["general", "mods"]);
}
if (route.query.shi && projectType.value.id !== "modpack") {
serverHideInstalled.value = route.query.shi === "true";
}
if (route.query.sogv && projectType.value.id !== "modpack") {
serverOverrideGameVersions.value = route.query.sogv === "true";
}
if (route.query.sol && projectType.value.id !== "modpack") {
serverOverrideLoaders.value = route.query.sol === "true";
}
async function serverInstall(project) {
project.installing = true;
try {
const versions = await useBaseFetch(`project/${project.project_id}/version`, {}, false, true);
const version =
versions.find(
(x) =>
x.game_versions.includes(server.value.general.mc_version) &&
x.loaders.includes(server.value.general.loader.toLowerCase()),
) ?? versions[0];
if (projectType.value.id === "modpack") {
await server.value.general?.reinstall(route.query.sid, false, project.project_id, version.id);
project.installed = true;
navigateTo(`/servers/manage/${route.query.sid}/options/loader`);
} else if (projectType.value.id === "mod") {
await server.value.mods.install(version.project_id, version.id);
await server.value.refresh(["mods"]);
project.installed = true;
}
} catch (e) {
console.error(e);
}
project.installing = false;
}
projectType.value = tags.value.projectTypes.find(
(x) => x.id === route.path.substring(1, route.path.length - 1),
(x) => x.id === route.path.replaceAll(/^\/|s\/?$/g, ""), // Removes prefix `/` and suffixes `s` and `s/`
);
const noLoad = ref(false);
@@ -392,7 +485,6 @@ const {
() => {
const config = useRuntimeConfig();
const base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl;
const params = [`limit=${maxResults.value}`, `index=${sortType.value.name}`];
if (query.value.length > 0) {
@@ -416,8 +508,20 @@ const {
formattedFacets.push([facet.replace(":", "!=")]);
}
if (server.value && serverHideInstalled.value) {
const installedMods = server.value.mods.data
.filter((x) => x.project_id)
.map((x) => x.project_id);
installedMods.map((x) => [`project_id != ${x}`]).forEach((x) => formattedFacets.push(x));
}
// loaders specifier
if (orFacets.value.length > 0) {
if (server.value && !(serverOverrideLoaders.value || projectType.value.id === "modpack")) {
formattedFacets.push([
`categories:${encodeURIComponent(server.value.general.loader.toLowerCase())}`,
]);
} else if (orFacets.value.length > 0) {
formattedFacets.push(orFacets.value);
} else if (projectType.value.id === "plugin") {
formattedFacets.push(
@@ -435,7 +539,12 @@ const {
);
}
if (selectedVersions.value.length > 0) {
if (
server.value &&
!(serverOverrideGameVersions.value || projectType.value.id === "modpack")
) {
formattedFacets.push([`versions:${encodeURIComponent(server.value.general.mc_version)}`]);
} else if (selectedVersions.value.length > 0) {
const versionFacets = [];
for (const facet of selectedVersions.value) {
versionFacets.push("versions:" + facet);
@@ -574,6 +683,22 @@ function getSearchUrl(offset, useObj) {
queryItems.push(`m=${encodeURIComponent(maxResults.value)}`);
obj.m = maxResults.value;
}
if (server.value) {
queryItems.push(`sid=${encodeURIComponent(server.value.serverId)}`);
obj.sid = server.value.serverId;
}
if (serverHideInstalled.value) {
queryItems.push("shi=true");
obj.shi = true;
}
if (serverOverrideGameVersions.value) {
queryItems.push("sogv=true");
obj.sogv = true;
}
if (serverOverrideLoaders.value) {
queryItems.push("sol=true");
obj.sol = true;
}
let url = `${route.path}`;
@@ -648,7 +773,11 @@ const queryFilter = ref("");
const filters = computed(() => {
const filters = {};
if (projectType.value.id !== "resourcepack" && projectType.value.id !== "datapack") {
if (
projectType.value.id !== "resourcepack" &&
projectType.value.id !== "datapack" &&
(!server.value || serverOverrideLoaders.value || projectType.value.id === "modpack")
) {
const loaders = tags.value.loaders
.filter((x) => {
if (projectType.value.id === "mod" && !showAllLoaders.value) {
@@ -701,9 +830,11 @@ const filters = computed(() => {
}
}
filters.gameVersion = tags.value.gameVersions
.filter((x) => (showSnapshots.value ? true : x.version_type === "release"))
.map((x) => ({ name: x.version, type: "gameVersion" }));
if (!server.value || serverOverrideGameVersions.value || projectType.value.id === "modpack") {
filters.gameVersion = tags.value.gameVersions
.filter((x) => (showSnapshots.value ? true : x.version_type === "release"))
.map((x) => ({ name: x.version, type: "gameVersion" }));
}
if (!["resourcepack", "plugin", "shader", "datapack"].includes(projectType.value.id)) {
filters.environment = [

View File

@@ -0,0 +1,11 @@
<template>
<div class="flex h-full w-full flex-col">
<div
class="flex items-center justify-between gap-2 border-0 border-b border-solid border-bg-raised p-3"
>
<h2 class="m-0 text-2xl font-bold text-contrast">Admin</h2>
</div>
</div>
</template>
<script setup lang="ts"></script>

Some files were not shown because too many files have changed in this diff Show More