Lock file maintenance #28

Merged
redbinder0526 merged 2 commits from renovate/lock-file-maintenance into master 2026-07-29 15:54:05 +02:00
12 changed files with 1037 additions and 33 deletions
Showing only changes of commit 15f4a7c4e1 - Show all commits

Merge branch 'master' into renovate/lock-file-maintenance
All checks were successful
ci / ci (22, debian-trixie) (push) Successful in 29s

redbinder0526 2026-07-29 15:53:54 +02:00

4
.gitmodules vendored Normal file
View file

@ -0,0 +1,4 @@
[submodule "public/policy"]
path = public/policy
url = https://git.spbr.net/spacebar/policy.git
branch = main

View file

@ -12,14 +12,14 @@ useHead({
})
const title = 'Spacebar Network'
const description = 'Spacebar Network(SPBR) is a extension of Splatfestival Network(SPFN), which aims to be a Nintendo Network recreation primarily with the intention of hosting Splatfests with Splatfestival. (Spacebar is seperate from this purpose, however we are still partnered with Splatfestival.)'
const description = 'Spacebar Network, shortened as "Spacebar", "SPBR", or as our former name "Splatfestival Network", is a Nintendo Network continuation with the goal of providing the best experience possible. Our servers are designed to be: (A) Fast, (B) Stable, (C) Secure, AND MOST IMPORTANTLY:, (D) With functionality as close to Nintendo Network as possible.'
useSeoMeta({
title,
description,
ogTitle: title,
ogDescription: description,
twitterCard: 'summary_large_image'
ogImage: 'https://git.spbr.net/spacebar/website/raw/branch/master/public/spbr-coloured.png'
})
</script>

View file

@ -4,17 +4,17 @@
@theme static {
--font-sans: 'Public Sans', sans-serif;
--color-green-50: #EFFDF5;
--color-green-100: #D9FBE8;
--color-green-200: #B3F5D1;
--color-green-300: #75EDAE;
--color-green-400: #00DC82;
--color-green-500: #00C16A;
--color-green-600: #00A155;
--color-green-700: #007F45;
--color-green-800: #016538;
--color-green-900: #0A5331;
--color-green-950: #052E16;
--color-green-50: #eff6ff;
--color-green-100: #dbeafe;
--color-green-200: #bfdbfe;
--color-green-300: #93c5fd;
--color-green-400: #60a5fa;
--color-green-500: #3b82f6;
--color-green-600: #2563eb;
--color-green-700: #1d4ed8;
--color-green-800: #1e40af;
--color-green-900: #1e3a8a;
--color-green-950: #172554;
}
body {

View file

@ -13,8 +13,8 @@
<nav class="links">
<a href="#">{{ t.nav?.supportedGames }}</a>
<a href="#">{{ t.nav?.errorCodes }}</a>
<a href="#">{{ t.nav?.discordServer }}</a>
<a href="/docs/policy/general">{{ t.nav?.errorCodes }}</a>
<a href="https://discord.gg/grMSxZf">{{ t.nav?.discordServer }}</a>
<a href="/docs/connect">{{ t.nav?.installSpbr }}</a>
</nav>

View file

@ -1,12 +0,0 @@
<template>
<div class="layout-wrapper">
<StatusHeader />
<main>
<slot />
</main>
</div>
</template>
<script setup>
import StatusHeader from '~/components/StatusHeader.vue'
</script>

View file

@ -1,7 +1,7 @@
{
"nav": {
"supportedGames": "Supported games",
"errorCodes": "Error Codes",
"errorCodes": "View Policies",
"discordServer": "Discord Server",
"installSpbr": "Install SPBR",
"searchPlaceholder": "Search"
@ -9,7 +9,7 @@
"footer": {
"siteMaintained": "Site maintained by Spacebar Network",
"developedBy": "Developed by our contributors",
"description": "Spacebar Network (SPBR) is an extension of Splatfestival Network (SPFN), which aims to be a Nintendo Network recreation primarily for hosting Splatfests. Spacebar is separate from this purpose, though we remain partnered with Splatfestival.",
"description": "Spacebar Network, shortened as \"Spacebar\", \"SPBR\", or as our former name \"Splatfestival Network\", is a Nintendo Network continuation with the goal of providing the best experience possible. Our servers are designed to be: (A) Fast, (B) Stable, (C) Secure, AND MOST IMPORTANTLY:, (D) With functionality as close to Nintendo Network as possible.",
"copyright": "Spacebar Network. All rights reserved."
},
"home": {
@ -68,5 +68,32 @@
"aromaTitle": "Aroma",
"aromaDesc": "Hacked"
}
},
"policies": {
"sidebarHeader": "Spacebar Network Policies",
"sidebarSubheader": "Game Rules",
"loading": "Loading...",
"errorText": "Error",
"lastUpdated": "Last updated: "
},
"guides": {
"header": "Guides",
"errorCodesRedir": "Search Error Codes",
"loading": "Loading...",
"lastUpdated": "Last updated: ",
"docs": {
"sssl": {
"name": "SSSL GUIDE",
"version": "1.0.1",
"date": "2026-06-26",
"md": "> [!NOTE]\n> SSSL does not support the full feature set SPFN has to offer, features such as the Miiverse applet and in game posting are not supported through SSSL and can only be used through the Aroma method. We currently don't have Miiverse fully functional on our servers, but once it is available, users connected through SSSL will only be able to view in game posts, not create them.\n\n### Requirements\n- Wii U\n- An internet connection\n\nIf you have a modded console, it is recommended that you use the Aroma method instead, but if you wish to continue with SSSL anyways, ensure you either disable Pretendo's Inkay if present or make a duplicate environment without the Pretendo Inkay plugin and module. Guides to making a duplicate environment can be found in Step 1 of the Aroma guide. Note that when switching between servers, you will have to disable or reenable SSSL by following this guide again.\n\n### Step 1: Console Configuration\n\nSSSL uses the DNS settings of the console to redirect you to our servers. In order to connect, you must configure your active WiFi connection to use this DNS server.\n\nBoot up your Wii U and log in. Open the `System Settings` app and navigate to `Internet`. In here, select `Connect to the Internet`, press `X` to open your Connection List and select your current internet connection.\n\nSelect `Change Settings`, then navigate to `DNS`. Change it to `Do not auto-obtain` and input the following values:\n\n- Primary DNS: 45.85.147.85\n- Secondary DNS: 1.1.1.1\n\nThe Wii U may automatically format the values you inputted so that, for example, the primary DNS may look like `045.085.147.085`. This is normal and will still work correctly.\n\nThen press confirm and press `B` to save the internet settings. The console will prompt you to perform a connection test, select `Perform Test`. The test should pass. Once this is done, exit the System Settings app by repeatedly pressing `B` to return through the menus. You should now be connected to SPFN.\n\n### Step 2: Creating an Account\n\nSPFN's servers are separate from both Nintendo and Pretendo, so you will need to create a new account to access our services. Once you have exited System Settings, tap on your Mii in the top right corner to open User Settings, then select `Switch User`. Press `+` on your gamepad to create a new account. Follow the process of creating the account. Select no when prompted if you have an NNID. Select a Mii for your account. When asked to create an account, press Link.\n\nBefore continuing, confirm that the agreement references Splatfestival Network. If it does not, you may have configured SSSL incorrectly. Return to step 1 and ensure it is correctly configured. If this doesn't resolve it, contact a SPFN developer through the assistance channel of the [Discord server](https://discord.gg/grMSxZf).\n\nIf it does reference Splatfestival Network, read through the agreement and press `Agree`, then enter your account information.\n\n> [!IMPORTANT]\n> DO NOT REUSE PASSWORDS ACROSS MULTIPLE ACCOUNTS INSIDE OR OUTSIDE OF OUR NETWORK\n\nOnce you have created your account, you will be sent a verification code to your registered email address. It may show up in your spam folder, so look around for it before resending a new one. To enter it, log into your account on your Wii U and it will prompt you for the code. If you are already logged in, press on your Mii in the top left of your gamepad and select Switch User. Then, select your SPFN account to log back in. The prompt should appear.\n\nOpen Splatoon and check that you get the news.\n\nYou are now ready to play on the Splatfestival Network! [Join the Discord](https://discord.gg/grMSxZf) for information about upcoming or active Splatfests and other updates."
},
"aroma": {
"name": "AROMA GUIDE",
"version": "2.0.1",
"date": "2026-06-26",
"md": "### Requirements\n- Modded Wii U with Aroma\n- SD Card (Preferably 32GB)\n- Computer or other device to access SD Card\n- An internet connection\n\nIn order to follow this guide, you must have a modded Wii U console with the latest version of Aroma. If your console isn't modded, [follow this guide](https://wiiu.hacks.guide/). If your console is modded, check for any Aroma updates with the Aroma Updater and install it if any are found. If you are unable to mod your Wii U, follow the SSSL guide instead.\n\nThis guide will create a duplicate Aroma environment which you can use to connect to our servers. Once set up, you will be able to switch between Pretendo and SPFN by switching which environment you launch into when starting your Wii U.\n\nEnsure that you don't have a default user set before setting up SPFN as it could cause you to log in with the wrong account. To disable it, turn on your Wii U and when it logs in, press your Mii in the top left and scroll down to the option labelled \"Default User\" and disable it.\n\nIf you are currently using Pretendo's DNS servers to connect to Pretendo, disable the DNS server before continuing.\n\n### Step 1: SD Card Preparation\n\nPlug your SD card into your computer and navigate to `SD:/wiiu/environments`\n\nMake a copy of your `aroma` environment folder and rename it to `SPFN` - this will be the environment you use for connecting to our servers.\n\nFor users who previously used Pretendo's Inkay plugin, delete the files `plugins/Inkay-pretendo.wps` and `modules/Inkay-pretendo.wms` from the SPFN environment.\n\n> [!WARNING]\n> **Using both Pretendo and Splatfestival Network plugins on the same environment will cause issues! Please do not do this.**\n\nDownload the plugins and modules found on the [Downloads page](/downloads/) - make sure you enable file name extensions in your file browser and take note of the extension.\n\n- `Inkay-spfn.wps` goes into the `plugins` folder\n- `Inkay-spfn.wms` goes into the `modules` folder\n\nWith these files installed, you can eject your SD card and put it into your Wii U.\n\n### Step 2: Setup on Console\n\nPower on the console and hold `X` on the gamepad to open the Environment Loader.\n\nNavigate using the D-Pad to `SPFN` and press `A` to select. Your console will now load into the environment and connect to our servers.\n\nIn the top left of your screen, you should see a message saying \"Using SPFN\".\n\n> [!WARNING]\n> If you see \"Using Pretendo Network\", DO NOT PROGRESS. Turn off the console and delete the Pretendo plugins as mentioned in step 1.\n\n> [!NOTE]\n> If you do not see the \"Using SPFN\" message, you likely didn't install the plugin correctly. Check the locations of the `Inkay-spfn.wps` and `Inkay-spfn.wms` files.\n\nSPFN's servers are separate from both Nintendo and Pretendo, so you will need to create a new account to access our services. Now that your console is connected to our servers, you should be brought to the accounts page. Press `+` on your gamepad to create a new account. Follow the process of creating the account. Select no when prompted if you have an NNID. Select a Mii for your account. When asked to create an account, press Link.\n\nBefore continuing, confirm that the agreement references Splatfestival Network. If it does not, your plugins aren't set up correctly. Return to step 1 and ensure they are correctly placed.\n\nIf it does reference Splatfestival Network, read through the agreement and press `Agree`, then enter your account information.\n\n> [!IMPORTANT]\n> DO NOT REUSE PASSWORDS ACROSS MULTIPLE ACCOUNTS INSIDE OR OUTSIDE OF OUR NETWORK\n\nOnce you have created your account, you will be sent a verification code to your registered email address. It may show up in your spam folder, so look around for it before resending a new one. To enter it, log into your account on your Wii U and it will prompt you for the code. If you are already logged in, press on your Mii in the top left of your gamepad and select Switch User. Then, select your SPFN account to log back in. The prompt should appear.\n\nOpen Splatoon and check that you get the news.\n\nYou are now ready to play on the Splatfestival Network! [Join the Discord](https://discord.gg/grMSxZf) for information about upcoming or active Splatfests and other updates."
}
}
}
}

View file

@ -0,0 +1,424 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
const { t } = useLocale()
const parsedHtml = ref('')
let markedParser: ((text: string) => string | Promise<string>) | null = null
type GuideId = 'sssl' | 'aroma'
const selectedCategory = ref<GuideId>('sssl')
const isPageLoading = ref(true)
const guideRules = [
{ id: 'sssl', name: 'SSSL Guide', route: '/docs/connect/wiiu/sssl' },
{ id: 'aroma', name: 'Aroma Guide', route: '/docs/connect/wiiu/aroma' }
] as const satisfies ReadonlyArray<{ id: GuideId, name: string, route: string }>
interface PolicyItem {
name?: string
version?: string
date?: string
md?: string
}
const guideDocs = computed<Partial<Record<GuideId, PolicyItem>>>(() => {
return (t.value.guides?.docs as Partial<Record<GuideId, PolicyItem>> | undefined) ?? {}
})
const activeDoc = computed<PolicyItem>(() => {
return guideDocs.value[selectedCategory.value] || guideDocs.value.sssl || {}
})
onMounted(async () => {
try {
const { marked } = await import('marked')
const { default: markedAlert } = await import('marked-alert')
marked.use(markedAlert())
markedParser = marked.parse
const pathSegments = window.location.pathname.split('/')
const currentId = pathSegments[pathSegments.length - 1]
if (currentId === 'sssl' || currentId === 'aroma') {
selectedCategory.value = currentId
}
await renderMarkdown()
} catch (err) {
console.error(err)
} finally {
isPageLoading.value = false
}
})
const formatDate = (isoString?: string) => {
if (!isoString) return ''
return new Date(isoString).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
const renderMarkdown = async () => {
if (!markedParser || !activeDoc.value.md) return
parsedHtml.value = await markedParser(activeDoc.value.md)
}
const handleCategorySelect = (id: GuideId) => {
selectedCategory.value = id
window.history.pushState({}, '', `/docs/connect/wiiu/${id}`)
}
watch(selectedCategory, () => {
renderMarkdown()
})
watch(() => activeDoc.value.md, () => {
renderMarkdown()
})
</script>
<template>
<div class="settings-page-wrapper">
<div
v-if="isPageLoading"
class="loader-container full-page-loader"
>
<div class="spinner" />
<p>{{ t.guides?.loading }}</p>
</div>
<div
v-else
class="settings-container"
>
<aside class="settings-sidebar">
<div class="sidebar-header archivo-black">
{{ t.guides?.header }}
</div>
<nav class="sidebar-menu">
<button
v-for="guide in guideRules"
:key="guide.id"
class="nav-item"
:class="{ 'active-nav': selectedCategory === guide.id }"
@click="handleCategorySelect(guide.id)"
>
<span>{{ guide.name }}</span>
</button>
<a
href="/docs/error-codes/"
target="_blank"
rel="noopener noreferrer"
class="nav-item external-link"
>
<span>{{ t.guides?.errorCodesRedir }}</span>
<span class="chevron"></span>
</a>
</nav>
</aside>
<main class="settings-main-content">
<section class="settings-panel">
<div class="panel-header archivo-black split-header">
<div class="header-left">
<span>{{ activeDoc.name }}</span>
</div>
<div class="header-right">
<span
v-if="activeDoc.date"
class="last-updated-text"
>
{{ t.guides?.lastUpdated }}{{ formatDate(activeDoc.date) }}
</span>
<span
v-if="activeDoc.version"
class="version-tag"
>
v{{ activeDoc.version }}
</span>
</div>
</div>
<div class="panel-body">
<div
class="markdown-body"
v-html="parsedHtml"
/>
</div>
</section>
</main>
</div>
</div>
</template>
<style scoped>
@import url('https://fonts.googleapis.com/css2?family=Archivo+Black&display=swap');
.archivo-black {
font-family: "Archivo Black", sans-serif;
font-weight: 400;
}
.text-wiiu-blue {
color: #00aeef;
}
.settings-page-wrapper {
min-height: 100vh;
width: 100vw;
background-color: #1d1e1f;
color: #c9d1d9;
padding: 2rem 1rem;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
.settings-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: 1280px;
margin: 0 auto;
width: 100%;
}
.settings-sidebar {
background-color: #1a1a1f;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
overflow: hidden;
height: max-content;
}
.sidebar-header {
padding: 0.85rem 1rem;
font-size: 1.1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background-color: rgba(0, 0, 0, 0.15);
}
.sidebar-menu {
display: flex;
flex-direction: column;
}
.nav-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: transparent;
border: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
color: #8b949e;
text-align: left;
cursor: pointer;
font-size: 0.95rem;
transition: all 0.2s;
width: 100%;
text-decoration: none;
box-sizing: border-box;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.03);
color: #ffffff;
}
.active-nav {
background: rgba(0, 174, 239, 0.1) !important;
border-left: 3px solid #00aeef;
color: #00aeef;
font-weight: 600;
padding-left: calc(1rem - 3px);
}
.chevron {
font-size: 1.25rem;
line-height: 1;
opacity: 0.7;
}
.settings-main-content {
flex: 1;
}
.settings-panel {
background-color: #1a1a1f;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
overflow: hidden;
}
.split-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.header-right {
display: flex;
align-items: center;
gap: 0.85rem;
}
.last-updated-text {
font-size: 0.8rem;
color: #8b949e;
font-family: system-ui, sans-serif;
font-weight: normal;
text-transform: none;
}
.version-tag {
font-size: 0.85rem;
background-color: rgba(0, 174, 239, 0.15);
color: #00aeef;
padding: 0.2rem 0.6rem;
border-radius: 4px;
border: 1px solid rgba(0, 174, 239, 0.3);
font-family: monospace;
}
.panel-header {
padding: 0.85rem 1.25rem;
font-size: 1.1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background-color: rgba(0, 0, 0, 0.15);
}
.panel-body {
padding: 1.5rem;
}
.markdown-body {
line-height: 1.6;
font-size: 1rem;
color: #c9d1d9;
}
.markdown-body :deep(h1),
.markdown-body :deep(h2),
.markdown-body :deep(h3) {
color: #ffffff;
margin-top: 1.5rem;
margin-bottom: 1rem;
font-weight: 600;
}
.markdown-body :deep(p) {
margin-bottom: 1rem;
}
.markdown-body :deep(a) {
color: #00aeef;
text-decoration: underline;
}
.markdown-body :deep(ul) {
list-style-type: disc;
padding-left: 1.5rem;
margin-bottom: 1rem;
}
.markdown-body :deep(ol) {
list-style-type: decimal;
padding-left: 1.5rem;
margin-bottom: 1rem;
}
.markdown-body :deep(li) {
margin-bottom: 0.35rem;
}
.markdown-body :deep(.markdown-alert) {
padding: 0.75rem 1rem 0.75rem 1.25rem;
margin-bottom: 1.5rem;
border-left: 4px solid #30363d;
border-radius: 0 6px 6px 0;
background-color: rgba(255, 255, 255, 0.02);
}
.markdown-body :deep(.markdown-alert-title) {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
margin-bottom: 0.5rem;
font-size: 0.95rem;
text-transform: capitalize;
}
.markdown-body :deep(.markdown-alert-note) {
border-left-color: #00aeef;
background-color: rgba(0, 174, 239, 0.06);
}
.markdown-body :deep(.markdown-alert-note .markdown-alert-title) {
color: #ffffff !important;
}
.markdown-body :deep(.markdown-alert-warning) {
border-left-color: #f0883e;
background-color: rgba(240, 136, 62, 0.06);
}
.markdown-body :deep(.markdown-alert-warning .markdown-alert-title) {
color: #f0883e;
}
.loader-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 3rem 0;
color: #8b949e;
}
.full-page-loader {
min-height: 50vh;
justify-content: center;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
border-top-color: #00aeef;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (min-width: 768px) {
.settings-container {
flex-direction: row;
align-items: flex-start;
}
.settings-sidebar {
width: 280px;
flex-shrink: 0;
}
}
</style>

View file

@ -0,0 +1,493 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
const { t } = useLocale()
const parsedHtml = ref('')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let markedParser: any = null
const baseRules = ref<Array<{ id: string, name: string }>>([])
const gameRules = ref<Array<{ id: string, name: string, version: string }>>([])
const selectedCategory = ref<string>('general')
const isPageLoading = ref(true)
const isDocLoading = ref(false)
interface PolicyMetadata {
name: string
iso8601: string
version: string
icons?: {
icon?: string
}
}
const activeMetadata = ref<PolicyMetadata | null>(null)
onMounted(async () => {
try {
const { marked } = await import('marked')
const { default: markedAlert } = await import('marked-alert')
marked.use(markedAlert())
markedParser = marked
const data = await $fetch<{
baseRules: Array<{ id: string, name: string }>
games: Array<{ id: string, name: string, version: string }>
}>('/api/policies')
baseRules.value = data.baseRules
gameRules.value = data.games
const pathSegments = window.location.pathname.split('/')
const currentId = pathSegments[pathSegments.length - 1]
const allValidIds = [...data.baseRules, ...data.games].map(item => item.id)
if (currentId && allValidIds.includes(currentId)) {
selectedCategory.value = currentId
}
await loadPolicyData(selectedCategory.value)
} catch (err) {
console.error(err)
} finally {
isPageLoading.value = false
}
})
const formatDate = (isoString?: string) => {
if (!isoString) return ''
return new Date(isoString).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
const getAssetUrl = (fileName: string | undefined, itemId: string) => {
if (!fileName) return ''
return `/policy/game-rules/${itemId}/${fileName}`
}
const loadPolicyData = async (id: string) => {
if (!markedParser) return
isDocLoading.value = true
activeMetadata.value = null
parsedHtml.value = ''
try {
let markdownPath = ''
let metadataPath = ''
if (id === 'general') markdownPath = '/policy/GENERAL-NETWORK-RULES.md'
else if (id === 'conduct') markdownPath = '/policy/CODE_OF_CONDUCT.md'
else if (id === 'privacy') markdownPath = '/policy/PRIVACY-POLICY.md'
else if (id === 'service') markdownPath = '/policy/serviceagreement.txt'
else {
markdownPath = `/policy/game-rules/${id}/rule.md`
metadataPath = `/policy/game-rules/${id}/metadata.json`
}
const mdResponse = await fetch(markdownPath)
if (mdResponse.ok) {
const text = await mdResponse.text()
parsedHtml.value = await markedParser.parse(text)
} else {
parsedHtml.value = `<p class="error-text">${t.value.policies?.errorText || 'Error'}</p>`
}
if (metadataPath) {
const metaResponse = await fetch(metadataPath)
if (metaResponse.ok) {
activeMetadata.value = await metaResponse.json()
}
}
} catch (err) {
console.error(err)
parsedHtml.value = `<p class="error-text">${t.value.policies?.errorText || 'Error'}</p>`
} finally {
isDocLoading.value = false
}
}
const handleCategorySelect = (id: string) => {
selectedCategory.value = id
window.history.pushState({}, '', `/docs/policy/${id}`)
}
watch(selectedCategory, (newId) => {
loadPolicyData(newId)
})
</script>
<template>
<div class="settings-page-wrapper">
<div
v-if="isPageLoading"
class="loader-container full-page-loader"
>
<div class="spinner" />
<p>{{ t.policies?.loading }}</p>
</div>
<div
v-else
class="settings-container"
>
<aside class="settings-sidebar">
<div class="sidebar-header archivo-black">
{{ t.policies?.sidebarHeader }}
</div>
<nav class="sidebar-menu">
<button
v-for="rule in baseRules"
:key="rule.id"
class="nav-item"
:class="{ 'active-nav': selectedCategory === rule.id }"
@click="handleCategorySelect(rule.id)"
>
<span>{{ rule.name }}</span>
</button>
<template v-if="gameRules.length > 0">
<div class="sidebar-subheader archivo-black">
{{ t.policies?.sidebarSubheader }}
</div>
<button
v-for="game in gameRules"
:key="game.id"
class="nav-item"
:class="{ 'active-nav': selectedCategory === game.id }"
@click="handleCategorySelect(game.id)"
>
<span>{{ game.name }}</span>
<span class="chevron"></span>
</button>
</template>
</nav>
</aside>
<main class="settings-main-content">
<section class="settings-panel">
<div class="panel-header archivo-black split-header">
<div class="header-left">
<img
v-if="activeMetadata?.icons?.icon"
:src="getAssetUrl(activeMetadata.icons.icon, selectedCategory)"
class="item-icon"
alt="Icon"
>
<span>{{ activeMetadata?.name || selectedCategory.replace('-', ' ').toUpperCase() }}</span>
</div>
<div class="header-right">
<span
v-if="activeMetadata?.iso8601"
class="last-updated-text"
>
{{ t.policies?.lastUpdated }}{{ formatDate(activeMetadata.iso8601) }}
</span>
<span
v-if="activeMetadata?.version"
class="version-tag"
>
v{{ activeMetadata.version }}
</span>
</div>
</div>
<div class="panel-body">
<div
v-show="isDocLoading"
class="loader-container"
>
<div class="spinner" />
<p>{{ t.policies?.loading }}</p>
</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div
v-show="!isDocLoading"
class="markdown-body"
v-html="parsedHtml"
/>
</div>
</section>
</main>
</div>
</div>
</template>
<style scoped>
@import url('https://fonts.googleapis.com/css2?family=Archivo+Black&display=swap');
.archivo-black {
font-family: "Archivo Black", sans-serif;
font-weight: 400;
}
.text-wiiu-blue {
color: #00aeef;
}
.settings-page-wrapper {
min-height: 100vh;
width: 100vw;
background-color: #1d1e1f;
color: #c9d1d9;
padding: 2rem 1rem;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
.settings-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: 1280px;
margin: 0 auto;
width: 100%;
}
.settings-sidebar {
background-color: #1a1a1f;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
overflow: hidden;
height: max-content;
}
.sidebar-header {
padding: 0.85rem 1rem;
font-size: 1.1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background-color: rgba(0, 0, 0, 0.15);
}
.sidebar-subheader {
padding: 0.85rem 1rem 0.35rem 1rem;
font-size: 0.8rem;
text-transform: uppercase;
color: #64748b;
letter-spacing: 0.05em;
background-color: rgba(0, 0, 0, 0.05);
}
.sidebar-menu {
display: flex;
flex-direction: column;
}
.nav-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: transparent;
border: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
color: #8b949e;
text-align: left;
cursor: pointer;
font-size: 0.95rem;
transition: all 0.2s;
width: 100%;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.03);
color: #ffffff;
}
.active-nav {
background: rgba(0, 174, 239, 0.1) !important;
border-left: 3px solid #00aeef;
color: #00aeef;
font-weight: 600;
padding-left: calc(1rem - 3px);
}
.chevron {
font-size: 1.25rem;
line-height: 1;
opacity: 0.7;
}
.settings-main-content {
flex: 1;
}
.settings-panel {
background-color: #1a1a1f;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
overflow: hidden;
}
.split-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.header-right {
display: flex;
align-items: center;
gap: 0.85rem;
}
.last-updated-text {
font-size: 0.8rem;
color: #8b949e;
font-family: system-ui, sans-serif;
font-weight: normal;
text-transform: none;
}
.item-icon {
width: 32px;
height: 32px;
object-fit: cover;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.15);
}
.version-tag {
font-size: 0.85rem;
background-color: rgba(0, 174, 239, 0.15);
color: #00aeef;
padding: 0.2rem 0.6rem;
border-radius: 4px;
border: 1px solid rgba(0, 174, 239, 0.3);
font-family: monospace;
}
.panel-header {
padding: 0.85rem 1.25rem;
font-size: 1.1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background-color: rgba(0, 0, 0, 0.15);
}
.panel-body {
padding: 1.5rem;
}
.markdown-body {
line-height: 1.6;
font-size: 1rem;
color: #c9d1d9;
}
.markdown-body :deep(h1),
.markdown-body :deep(h2),
.markdown-body :deep(h3) {
color: #ffffff;
margin-top: 1.5rem;
margin-bottom: 1rem;
font-weight: 600;
}
.markdown-body :deep(p) {
margin-bottom: 1rem;
}
.markdown-body :deep(a) {
color: #00aeef;
text-decoration: underline;
}
.error-text {
color: #f85149;
}
.markdown-body :deep(.markdown-alert) {
padding: 0.75rem 1rem 0.75rem 1.25rem;
margin-bottom: 1.5rem;
border-left: 4px solid #30363d;
border-radius: 0 6px 6px 0;
background-color: rgba(255, 255, 255, 0.02);
}
.markdown-body :deep(.markdown-alert-title) {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
margin-bottom: 0.5rem;
font-size: 0.95rem;
text-transform: capitalize;
}
.markdown-body :deep(.markdown-alert-note) {
border-left-color: #00aeef;
background-color: rgba(0, 174, 239, 0.06);
}
.markdown-body :deep(.markdown-alert-note .markdown-alert-title) {
color: #ffffff !important;
}
.markdown-body :deep(.markdown-alert-note .markdown-alert-title svg) {
fill: #ffffff !important;
stroke: #ffffff !important;
color: #ffffff !important;
}
.markdown-body :deep(.markdown-alert-warning) {
border-left-color: #f0883e;
background-color: rgba(240, 136, 62, 0.06);
}
.markdown-body :deep(.markdown-alert-warning .markdown-alert-title) {
color: #f0883e;
}
.loader-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 3rem 0;
color: #8b949e;
}
.full-page-loader {
min-height: 50vh;
justify-content: center;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
border-top-color: #00aeef;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (min-width: 768px) {
.settings-container {
flex-direction: row;
align-items: flex-start;
}
.settings-sidebar {
width: 280px;
flex-shrink: 0;
}
}
</style>

View file

@ -10,8 +10,10 @@
"@nuxt/ui": "^4.10.0",
"@nuxt/vite-builder": "^4.5.0",
"@primer/octicons": "^19.29.2",
"@types/node": "^26.1.1",
"@unhead/ssr": "^3.2.1",
"@unhead/vue": "^3.2.1",
"marked-alert": "^2.1.2",
"nuxt": "^4.5.0",
"tailwindcss": "^4.3.3",
},
@ -616,6 +618,8 @@
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
@ -1318,6 +1322,8 @@
"marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
"marked-alert": ["marked-alert@2.1.2", "", { "peerDependencies": { "marked": ">=7.0.0" } }, "sha512-EFNRZ08d8L/iEIPLTlQMDjvwIsj03gxWCczYTht6DCiHJIZhMk4NK5gtPY9UqAYb09eV5VGT+jD4lp396E0I+w=="],
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
@ -1770,6 +1776,8 @@
"undici": ["undici@8.8.0", "", {}, "sha512-ubshXMXwF3MQIMF1y/WxZdNBnjEKeSg2wF5mcGUtU55YTw34tnVVpKRlLf7ruDXZ5344KokPVX4RBx1wJm64Bw=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
"unhead": ["unhead@3.2.1", "", { "dependencies": { "hookable": "^6.1.1", "unplugin": "^3.3.0" }, "peerDependencies": { "vite": ">=6.4.2" }, "optionalPeers": ["vite"] }, "sha512-Z7VNRf0QJlRZmwA1p5gsnmKYkjnbvV/CXdJAL+VMDmMF+RS2P4FqLouEhBA6WOuKPe3ZZ8EZgX2zQm7ZkAGDPg=="],

View file

@ -20,11 +20,13 @@
"@iconify-json/lucide": "^1.2.118",
"@iconify-json/simple-icons": "^1.2.91",
"@nuxt/ui": "^4.10.0",
"@nuxt/vite-builder": "^4.5.0",
"@nuxt/vite-builder": "^4.5.1",
"@primer/octicons": "^19.29.2",
"@unhead/ssr": "^3.2.1",
"@unhead/vue": "^3.2.1",
"nuxt": "^4.5.0",
"@types/node": "^26.1.1",
"@unhead/ssr": "^3.2.3",
"@unhead/vue": "^3.2.3",
"marked-alert": "^2.1.2",
"nuxt": "^4.5.1",
"tailwindcss": "^4.3.3"
},
"devDependencies": {

1
public/policy Submodule

@ -0,0 +1 @@
Subproject commit 4a501cf3f23a0b57bc370cc124c1c55e80bba5f7

57
server/api/policies.ts Normal file
View file

@ -0,0 +1,57 @@
import fs from 'node:fs'
import path from 'node:path'
export default defineEventHandler(async () => {
const gameRulesDir = path.resolve(process.cwd(), 'public/policy/game-rules')
const baseRules = [
{ id: 'general', name: 'General Rules' },
{ id: 'conduct', name: 'Code of Conduct' },
{ id: 'privacy', name: 'Privacy Policy' },
{ id: 'service', name: 'Network Service Agreement' }
]
const games: Array<{ id: string, name: string, version: string }> = []
try {
if (fs.existsSync(gameRulesDir)) {
const folders = await fs.promises.readdir(gameRulesDir)
for (const folder of folders) {
const folderPath = path.join(gameRulesDir, folder)
if (!folderPath.startsWith(gameRulesDir)) {
continue
}
const stat = await fs.promises.stat(folderPath)
if (stat.isDirectory()) {
const metadataPath = path.join(folderPath, 'metadata.json')
if (fs.existsSync(metadataPath)) {
try {
const metaContent = await fs.promises.readFile(metadataPath, 'utf-8')
const parsedMeta = JSON.parse(metaContent)
games.push({
id: folder,
name: parsedMeta.name || folder,
version: parsedMeta.version || '1.0.0'
})
} catch (jsonError) {
console.error(`Malformed metadata.json in ${folder}:`, jsonError)
}
}
}
}
}
} catch (error) {
console.error('Error scanning public policy tree:', error)
}
return {
baseRules,
games
}
})