Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
405829dc
Unverified
Commit
405829dc
authored
Mar 03, 2026
by
Wesley Liddick
Committed by
GitHub
Mar 03, 2026
Browse files
Merge pull request #727 from touwaeriol/pr/custom-menu-pages
feat: custom menu pages with iframe embedding and CSP injection
parents
7abec188
451a8511
Changes
25
Hide whitespace changes
Inline
Side-by-side
frontend/src/utils/embedded-url.ts
0 → 100644
View file @
405829dc
/**
* Shared URL builder for iframe-embedded pages.
* Used by PurchaseSubscriptionView and CustomPageView to build consistent URLs
* with user_id, token, theme, ui_mode, src_host, and src parameters.
*/
const
EMBEDDED_USER_ID_QUERY_KEY
=
'
user_id
'
const
EMBEDDED_AUTH_TOKEN_QUERY_KEY
=
'
token
'
const
EMBEDDED_THEME_QUERY_KEY
=
'
theme
'
const
EMBEDDED_UI_MODE_QUERY_KEY
=
'
ui_mode
'
const
EMBEDDED_UI_MODE_VALUE
=
'
embedded
'
const
EMBEDDED_SRC_HOST_QUERY_KEY
=
'
src_host
'
const
EMBEDDED_SRC_QUERY_KEY
=
'
src_url
'
export
function
buildEmbeddedUrl
(
baseUrl
:
string
,
userId
?:
number
,
authToken
?:
string
|
null
,
theme
:
'
light
'
|
'
dark
'
=
'
light
'
,
):
string
{
if
(
!
baseUrl
)
return
baseUrl
try
{
const
url
=
new
URL
(
baseUrl
)
if
(
userId
)
{
url
.
searchParams
.
set
(
EMBEDDED_USER_ID_QUERY_KEY
,
String
(
userId
))
}
if
(
authToken
)
{
url
.
searchParams
.
set
(
EMBEDDED_AUTH_TOKEN_QUERY_KEY
,
authToken
)
}
url
.
searchParams
.
set
(
EMBEDDED_THEME_QUERY_KEY
,
theme
)
url
.
searchParams
.
set
(
EMBEDDED_UI_MODE_QUERY_KEY
,
EMBEDDED_UI_MODE_VALUE
)
// Source tracking: let the embedded page know where it's being loaded from
if
(
typeof
window
!==
'
undefined
'
)
{
url
.
searchParams
.
set
(
EMBEDDED_SRC_HOST_QUERY_KEY
,
window
.
location
.
origin
)
url
.
searchParams
.
set
(
EMBEDDED_SRC_QUERY_KEY
,
window
.
location
.
href
)
}
return
url
.
toString
()
}
catch
{
return
baseUrl
}
}
export
function
detectTheme
():
'
light
'
|
'
dark
'
{
if
(
typeof
document
===
'
undefined
'
)
return
'
light
'
return
document
.
documentElement
.
classList
.
contains
(
'
dark
'
)
?
'
dark
'
:
'
light
'
}
frontend/src/utils/sanitize.ts
0 → 100644
View file @
405829dc
import
DOMPurify
from
'
dompurify
'
export
function
sanitizeSvg
(
svg
:
string
):
string
{
if
(
!
svg
)
return
''
return
DOMPurify
.
sanitize
(
svg
,
{
USE_PROFILES
:
{
svg
:
true
,
svgFilters
:
true
}
})
}
frontend/src/views/admin/SettingsView.vue
View file @
405829dc
...
@@ -832,64 +832,14 @@
...
@@ -832,64 +832,14 @@
<label
class=
"mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
<label
class=
"mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t('admin.settings.site.siteLogo') }}
{{ t('admin.settings.site.siteLogo') }}
</label>
</label>
<div
class=
"flex items-start gap-6"
>
<ImageUpload
<!-- Logo Preview -->
v-model=
"form.site_logo"
<div
class=
"flex-shrink-0"
>
mode=
"image"
<div
:upload-label=
"t('admin.settings.site.uploadImage')"
class=
"flex h-20 w-20 items-center justify-center overflow-hidden rounded-xl border-2 border-dashed border-gray-300 bg-gray-50 dark:border-dark-600 dark:bg-dark-800"
:remove-label=
"t('admin.settings.site.remove')"
:class=
"{ 'border-solid': form.site_logo }"
:hint=
"t('admin.settings.site.logoHint')"
>
:max-size=
"300 * 1024"
<img
/>
v-if=
"form.site_logo"
:src=
"form.site_logo"
alt=
"Site Logo"
class=
"h-full w-full object-contain"
/>
<svg
v-else
class=
"h-8 w-8 text-gray-400 dark:text-dark-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"1.5"
d=
"M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
</div>
<!-- Upload Controls -->
<div
class=
"flex-1 space-y-3"
>
<div
class=
"flex items-center gap-3"
>
<label
class=
"btn btn-secondary btn-sm cursor-pointer"
>
<input
type=
"file"
accept=
"image/*"
class=
"hidden"
@
change=
"handleLogoUpload"
/>
<Icon
name=
"upload"
size=
"sm"
class=
"mr-1.5"
:stroke-width=
"2"
/>
{{ t('admin.settings.site.uploadImage') }}
</label>
<button
v-if=
"form.site_logo"
type=
"button"
@
click=
"form.site_logo = ''"
class=
"btn btn-secondary btn-sm text-red-600 hover:text-red-700 dark:text-red-400"
>
<Icon
name=
"trash"
size=
"sm"
class=
"mr-1.5"
:stroke-width=
"2"
/>
{{ t('admin.settings.site.remove') }}
</button>
</div>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ t('admin.settings.site.logoHint') }}
</p>
<p
v-if=
"logoError"
class=
"text-xs text-red-500"
>
{{ logoError }}
</p>
</div>
</div>
</div>
</div>
<!-- Home Content -->
<!-- Home Content -->
...
@@ -1160,6 +1110,127 @@
...
@@ -1160,6 +1110,127 @@
</div>
</div>
</div>
</div>
<!-- Custom Menu Items -->
<div
class=
"card"
>
<div
class=
"border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h2
class=
"text-lg font-semibold text-gray-900 dark:text-white"
>
{{ t('admin.settings.customMenu.title') }}
</h2>
<p
class=
"mt-1 text-sm text-gray-500 dark:text-gray-400"
>
{{ t('admin.settings.customMenu.description') }}
</p>
</div>
<div
class=
"space-y-4 p-6"
>
<!-- Existing menu items -->
<div
v-for=
"(item, index) in form.custom_menu_items"
:key=
"item.id || index"
class=
"rounded-lg border border-gray-200 p-4 dark:border-dark-600"
>
<div
class=
"mb-3 flex items-center justify-between"
>
<span
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t('admin.settings.customMenu.itemLabel', { n: index + 1 }) }}
</span>
<div
class=
"flex items-center gap-2"
>
<!-- Move up -->
<button
v-if=
"index > 0"
type=
"button"
class=
"rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dark-700"
:title=
"t('admin.settings.customMenu.moveUp')"
@
click=
"moveMenuItem(index, -1)"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M5 15l7-7 7 7"
/></svg>
</button>
<!-- Move down -->
<button
v-if=
"index < form.custom_menu_items.length - 1"
type=
"button"
class=
"rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dark-700"
:title=
"t('admin.settings.customMenu.moveDown')"
@
click=
"moveMenuItem(index, 1)"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M19 9l-7 7-7-7"
/></svg>
</button>
<!-- Delete -->
<button
type=
"button"
class=
"rounded p-1 text-red-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
:title=
"t('admin.settings.customMenu.remove')"
@
click=
"removeMenuItem(index)"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/></svg>
</button>
</div>
</div>
<div
class=
"grid grid-cols-1 gap-3 sm:grid-cols-2"
>
<!-- Label -->
<div>
<label
class=
"mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{ t('admin.settings.customMenu.name') }}
</label>
<input
v-model=
"item.label"
type=
"text"
class=
"input text-sm"
:placeholder=
"t('admin.settings.customMenu.namePlaceholder')"
/>
</div>
<!-- Visibility -->
<div>
<label
class=
"mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{ t('admin.settings.customMenu.visibility') }}
</label>
<select
v-model=
"item.visibility"
class=
"input text-sm"
>
<option
value=
"user"
>
{{ t('admin.settings.customMenu.visibilityUser') }}
</option>
<option
value=
"admin"
>
{{ t('admin.settings.customMenu.visibilityAdmin') }}
</option>
</select>
</div>
<!-- URL (full width) -->
<div
class=
"sm:col-span-2"
>
<label
class=
"mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{ t('admin.settings.customMenu.url') }}
</label>
<input
v-model=
"item.url"
type=
"url"
class=
"input font-mono text-sm"
:placeholder=
"t('admin.settings.customMenu.urlPlaceholder')"
/>
</div>
<!-- SVG Icon (full width) -->
<div
class=
"sm:col-span-2"
>
<label
class=
"mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
{{ t('admin.settings.customMenu.iconSvg') }}
</label>
<ImageUpload
:model-value=
"item.icon_svg"
mode=
"svg"
size=
"sm"
:upload-label=
"t('admin.settings.customMenu.uploadSvg')"
:remove-label=
"t('admin.settings.customMenu.removeSvg')"
@
update:model-value=
"(v: string) => item.icon_svg = v"
/>
</div>
</div>
</div>
<!-- Add button -->
<button
type=
"button"
class=
"flex w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-gray-300 py-3 text-sm text-gray-500 transition-colors hover:border-primary-400 hover:text-primary-600 dark:border-dark-600 dark:text-gray-400 dark:hover:border-primary-500 dark:hover:text-primary-400"
@
click=
"addMenuItem"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
><path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 4v16m8-8H4"
/></svg>
{{ t('admin.settings.customMenu.add') }}
</button>
</div>
</div>
<!-- Send Test Email - Only show when email verification is enabled -->
<!-- Send Test Email - Only show when email verification is enabled -->
<div
v-if=
"form.email_verify_enabled"
class=
"card"
>
<div
v-if=
"form.email_verify_enabled"
class=
"card"
>
<div
class=
"border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<div
class=
"border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
...
@@ -1261,6 +1332,7 @@ import Select from '@/components/common/Select.vue'
...
@@ -1261,6 +1332,7 @@ import Select from '@/components/common/Select.vue'
import
GroupBadge
from
'
@/components/common/GroupBadge.vue
'
import
GroupBadge
from
'
@/components/common/GroupBadge.vue
'
import
GroupOptionItem
from
'
@/components/common/GroupOptionItem.vue
'
import
GroupOptionItem
from
'
@/components/common/GroupOptionItem.vue
'
import
Toggle
from
'
@/components/common/Toggle.vue
'
import
Toggle
from
'
@/components/common/Toggle.vue
'
import
ImageUpload
from
'
@/components/common/ImageUpload.vue
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
{
useAppStore
}
from
'
@/stores
'
import
{
useAppStore
}
from
'
@/stores
'
...
@@ -1273,7 +1345,6 @@ const saving = ref(false)
...
@@ -1273,7 +1345,6 @@ const saving = ref(false)
const
testingSmtp
=
ref
(
false
)
const
testingSmtp
=
ref
(
false
)
const
sendingTestEmail
=
ref
(
false
)
const
sendingTestEmail
=
ref
(
false
)
const
testEmailAddress
=
ref
(
''
)
const
testEmailAddress
=
ref
(
''
)
const
logoError
=
ref
(
''
)
// Admin API Key 状态
// Admin API Key 状态
const
adminApiKeyLoading
=
ref
(
true
)
const
adminApiKeyLoading
=
ref
(
true
)
...
@@ -1332,6 +1403,7 @@ const form = reactive<SettingsForm>({
...
@@ -1332,6 +1403,7 @@ const form = reactive<SettingsForm>({
purchase_subscription_enabled
:
false
,
purchase_subscription_enabled
:
false
,
purchase_subscription_url
:
''
,
purchase_subscription_url
:
''
,
sora_client_enabled
:
false
,
sora_client_enabled
:
false
,
custom_menu_items
:
[]
as
Array
<
{
id
:
string
;
label
:
string
;
icon_svg
:
string
;
url
:
string
;
visibility
:
'
user
'
|
'
admin
'
;
sort_order
:
number
}
>
,
smtp_host
:
''
,
smtp_host
:
''
,
smtp_port
:
587
,
smtp_port
:
587
,
smtp_username
:
''
,
smtp_username
:
''
,
...
@@ -1396,42 +1468,37 @@ async function setAndCopyLinuxdoRedirectUrl() {
...
@@ -1396,42 +1468,37 @@ async function setAndCopyLinuxdoRedirectUrl() {
await
copyToClipboard
(
url
,
t
(
'
admin.settings.linuxdo.redirectUrlSetAndCopied
'
))
await
copyToClipboard
(
url
,
t
(
'
admin.settings.linuxdo.redirectUrlSetAndCopied
'
))
}
}
function
handleLogoUpload
(
event
:
Event
)
{
// Custom menu item management
const
input
=
event
.
target
as
HTMLInputElement
function
addMenuItem
()
{
const
file
=
input
.
files
?.[
0
]
form
.
custom_menu_items
.
push
({
logoError
.
value
=
''
id
:
''
,
label
:
''
,
if
(
!
file
)
return
icon_svg
:
''
,
url
:
''
,
// Check file size (300KB = 307200 bytes)
visibility
:
'
user
'
,
const
maxSize
=
300
*
1024
sort_order
:
form
.
custom_menu_items
.
length
,
if
(
file
.
size
>
maxSize
)
{
})
logoError
.
value
=
t
(
'
admin.settings.site.logoSizeError
'
,
{
}
size
:
(
file
.
size
/
1024
).
toFixed
(
1
)
})
input
.
value
=
''
return
}
// Check file type
if
(
!
file
.
type
.
startsWith
(
'
image/
'
))
{
logoError
.
value
=
t
(
'
admin.settings.site.logoTypeError
'
)
input
.
value
=
''
return
}
// Convert to base64
function
removeMenuItem
(
index
:
number
)
{
const
reader
=
new
FileReader
()
form
.
custom_menu_items
.
splice
(
index
,
1
)
reader
.
onload
=
(
e
)
=>
{
// Re-index sort_order
form
.
site_logo
=
e
.
target
?.
result
as
string
form
.
custom_menu_items
.
forEach
((
item
,
i
)
=>
{
}
item
.
sort_order
=
i
reader
.
onerror
=
()
=>
{
})
logoError
.
value
=
t
(
'
admin.settings.site.logoReadError
'
)
}
}
reader
.
readAsDataURL
(
file
)
// Reset input
function
moveMenuItem
(
index
:
number
,
direction
:
-
1
|
1
)
{
input
.
value
=
''
const
targetIndex
=
index
+
direction
if
(
targetIndex
<
0
||
targetIndex
>=
form
.
custom_menu_items
.
length
)
return
const
items
=
form
.
custom_menu_items
const
temp
=
items
[
index
]
items
[
index
]
=
items
[
targetIndex
]
items
[
targetIndex
]
=
temp
// Re-index sort_order
items
.
forEach
((
item
,
i
)
=>
{
item
.
sort_order
=
i
})
}
}
async
function
loadSettings
()
{
async
function
loadSettings
()
{
...
@@ -1534,6 +1601,7 @@ async function saveSettings() {
...
@@ -1534,6 +1601,7 @@ async function saveSettings() {
purchase_subscription_enabled
:
form
.
purchase_subscription_enabled
,
purchase_subscription_enabled
:
form
.
purchase_subscription_enabled
,
purchase_subscription_url
:
form
.
purchase_subscription_url
,
purchase_subscription_url
:
form
.
purchase_subscription_url
,
sora_client_enabled
:
form
.
sora_client_enabled
,
sora_client_enabled
:
form
.
sora_client_enabled
,
custom_menu_items
:
form
.
custom_menu_items
,
smtp_host
:
form
.
smtp_host
,
smtp_host
:
form
.
smtp_host
,
smtp_port
:
form
.
smtp_port
,
smtp_port
:
form
.
smtp_port
,
smtp_username
:
form
.
smtp_username
,
smtp_username
:
form
.
smtp_username
,
...
...
frontend/src/views/user/CustomPageView.vue
0 → 100644
View file @
405829dc
<
template
>
<AppLayout>
<div
class=
"custom-page-layout"
>
<div
class=
"card flex-1 min-h-0 overflow-hidden"
>
<div
v-if=
"loading"
class=
"flex h-full items-center justify-center py-12"
>
<div
class=
"h-8 w-8 animate-spin rounded-full border-2 border-primary-500 border-t-transparent"
></div>
</div>
<div
v-else-if=
"!menuItem"
class=
"flex h-full items-center justify-center p-10 text-center"
>
<div
class=
"max-w-md"
>
<div
class=
"mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700"
>
<Icon
name=
"link"
size=
"lg"
class=
"text-gray-400"
/>
</div>
<h3
class=
"text-lg font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
customPage.notFoundTitle
'
)
}}
</h3>
<p
class=
"mt-2 text-sm text-gray-500 dark:text-dark-400"
>
{{
t
(
'
customPage.notFoundDesc
'
)
}}
</p>
</div>
</div>
<div
v-else-if=
"!isValidUrl"
class=
"flex h-full items-center justify-center p-10 text-center"
>
<div
class=
"max-w-md"
>
<div
class=
"mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700"
>
<Icon
name=
"link"
size=
"lg"
class=
"text-gray-400"
/>
</div>
<h3
class=
"text-lg font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
customPage.notConfiguredTitle
'
)
}}
</h3>
<p
class=
"mt-2 text-sm text-gray-500 dark:text-dark-400"
>
{{
t
(
'
customPage.notConfiguredDesc
'
)
}}
</p>
</div>
</div>
<div
v-else
class=
"custom-embed-shell"
>
<a
:href=
"embeddedUrl"
target=
"_blank"
rel=
"noopener noreferrer"
class=
"btn btn-secondary btn-sm custom-open-fab"
>
<Icon
name=
"externalLink"
size=
"sm"
class=
"mr-1.5"
:stroke-width=
"2"
/>
{{
t
(
'
customPage.openInNewTab
'
)
}}
</a>
<iframe
:src=
"embeddedUrl"
class=
"custom-embed-frame"
allowfullscreen
></iframe>
</div>
</div>
</div>
</AppLayout>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
,
onMounted
,
onUnmounted
,
ref
}
from
'
vue
'
import
{
useRoute
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
buildEmbeddedUrl
,
detectTheme
}
from
'
@/utils/embedded-url
'
const
{
t
}
=
useI18n
()
const
route
=
useRoute
()
const
appStore
=
useAppStore
()
const
authStore
=
useAuthStore
()
const
loading
=
ref
(
false
)
const
pageTheme
=
ref
<
'
light
'
|
'
dark
'
>
(
'
light
'
)
let
themeObserver
:
MutationObserver
|
null
=
null
const
menuItemId
=
computed
(()
=>
route
.
params
.
id
as
string
)
const
menuItem
=
computed
(()
=>
{
const
items
=
appStore
.
cachedPublicSettings
?.
custom_menu_items
??
[]
const
found
=
items
.
find
((
item
)
=>
item
.
id
===
menuItemId
.
value
)
??
null
if
(
found
&&
found
.
visibility
===
'
admin
'
&&
!
authStore
.
isAdmin
)
{
return
null
}
return
found
})
const
embeddedUrl
=
computed
(()
=>
{
if
(
!
menuItem
.
value
)
return
''
return
buildEmbeddedUrl
(
menuItem
.
value
.
url
,
authStore
.
user
?.
id
,
authStore
.
token
,
pageTheme
.
value
,
)
})
const
isValidUrl
=
computed
(()
=>
{
const
url
=
embeddedUrl
.
value
return
url
.
startsWith
(
'
http://
'
)
||
url
.
startsWith
(
'
https://
'
)
})
onMounted
(
async
()
=>
{
pageTheme
.
value
=
detectTheme
()
if
(
typeof
document
!==
'
undefined
'
)
{
themeObserver
=
new
MutationObserver
(()
=>
{
pageTheme
.
value
=
detectTheme
()
})
themeObserver
.
observe
(
document
.
documentElement
,
{
attributes
:
true
,
attributeFilter
:
[
'
class
'
],
})
}
if
(
appStore
.
publicSettingsLoaded
)
return
loading
.
value
=
true
try
{
await
appStore
.
fetchPublicSettings
()
}
finally
{
loading
.
value
=
false
}
})
onUnmounted
(()
=>
{
if
(
themeObserver
)
{
themeObserver
.
disconnect
()
themeObserver
=
null
}
})
</
script
>
<
style
scoped
>
.custom-page-layout
{
@apply
flex
flex-col;
height
:
calc
(
100vh
-
64px
-
4rem
);
}
.custom-embed-shell
{
@apply
relative;
@apply
h-full
w-full
overflow-hidden
rounded-2xl;
@apply
bg-gradient-to-b
from-gray-50
to-white
dark
:
from-dark-900
dark
:
to-dark-950
;
@apply
p-0;
}
.custom-open-fab
{
@apply
absolute
right-3
top-3
z-10;
@apply
shadow-sm
backdrop-blur
supports-[backdrop-filter]:bg-white/80;
}
.custom-embed-frame
{
display
:
block
;
margin
:
0
;
width
:
100%
;
height
:
100%
;
border
:
0
;
border-radius
:
0
;
box-shadow
:
none
;
background
:
transparent
;
}
</
style
>
frontend/src/views/user/PurchaseSubscriptionView.vue
View file @
405829dc
...
@@ -74,17 +74,12 @@ import { useAppStore } from '@/stores'
...
@@ -74,17 +74,12 @@ import { useAppStore } from '@/stores'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
buildEmbeddedUrl
,
detectTheme
}
from
'
@/utils/embedded-url
'
const
{
t
}
=
useI18n
()
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
appStore
=
useAppStore
()
const
authStore
=
useAuthStore
()
const
authStore
=
useAuthStore
()
const
PURCHASE_USER_ID_QUERY_KEY
=
'
user_id
'
const
PURCHASE_AUTH_TOKEN_QUERY_KEY
=
'
token
'
const
PURCHASE_THEME_QUERY_KEY
=
'
theme
'
const
PURCHASE_UI_MODE_QUERY_KEY
=
'
ui_mode
'
const
PURCHASE_UI_MODE_EMBEDDED
=
'
embedded
'
const
loading
=
ref
(
false
)
const
loading
=
ref
(
false
)
const
purchaseTheme
=
ref
<
'
light
'
|
'
dark
'
>
(
'
light
'
)
const
purchaseTheme
=
ref
<
'
light
'
|
'
dark
'
>
(
'
light
'
)
let
themeObserver
:
MutationObserver
|
null
=
null
let
themeObserver
:
MutationObserver
|
null
=
null
...
@@ -93,37 +88,9 @@ const purchaseEnabled = computed(() => {
...
@@ -93,37 +88,9 @@ const purchaseEnabled = computed(() => {
return
appStore
.
cachedPublicSettings
?.
purchase_subscription_enabled
??
false
return
appStore
.
cachedPublicSettings
?.
purchase_subscription_enabled
??
false
})
})
function
detectTheme
():
'
light
'
|
'
dark
'
{
if
(
typeof
document
===
'
undefined
'
)
return
'
light
'
return
document
.
documentElement
.
classList
.
contains
(
'
dark
'
)
?
'
dark
'
:
'
light
'
}
function
buildPurchaseUrl
(
baseUrl
:
string
,
userId
?:
number
,
authToken
?:
string
|
null
,
theme
:
'
light
'
|
'
dark
'
=
'
light
'
,
):
string
{
if
(
!
baseUrl
)
return
baseUrl
try
{
const
url
=
new
URL
(
baseUrl
)
if
(
userId
)
{
url
.
searchParams
.
set
(
PURCHASE_USER_ID_QUERY_KEY
,
String
(
userId
))
}
if
(
authToken
)
{
url
.
searchParams
.
set
(
PURCHASE_AUTH_TOKEN_QUERY_KEY
,
authToken
)
}
url
.
searchParams
.
set
(
PURCHASE_THEME_QUERY_KEY
,
theme
)
url
.
searchParams
.
set
(
PURCHASE_UI_MODE_QUERY_KEY
,
PURCHASE_UI_MODE_EMBEDDED
)
return
url
.
toString
()
}
catch
{
return
baseUrl
}
}
const
purchaseUrl
=
computed
(()
=>
{
const
purchaseUrl
=
computed
(()
=>
{
const
baseUrl
=
(
appStore
.
cachedPublicSettings
?.
purchase_subscription_url
||
''
).
trim
()
const
baseUrl
=
(
appStore
.
cachedPublicSettings
?.
purchase_subscription_url
||
''
).
trim
()
return
build
Purchase
Url
(
baseUrl
,
authStore
.
user
?.
id
,
authStore
.
token
,
purchaseTheme
.
value
)
return
build
Embedded
Url
(
baseUrl
,
authStore
.
user
?.
id
,
authStore
.
token
,
purchaseTheme
.
value
)
})
})
const
isValidUrl
=
computed
(()
=>
{
const
isValidUrl
=
computed
(()
=>
{
...
...
Prev
1
2
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment