Compare commits
95 Commits
cache-alia
...
app-users-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61c2ce2107 | ||
|
|
d37c634216 | ||
|
|
25016053ca | ||
|
|
f9c0c1bc53 | ||
|
|
73e54a5fbb | ||
|
|
6f902e2107 | ||
|
|
ecb1379585 | ||
|
|
068711e7a9 | ||
|
|
f695fe0ee7 | ||
|
|
6cdc07406d | ||
|
|
daf6999111 | ||
|
|
42731521f1 | ||
|
|
182119aedf | ||
|
|
59e18b3104 | ||
|
|
5c1f198397 | ||
|
|
3cd6718384 | ||
|
|
1903980b71 | ||
|
|
84a28e045b | ||
|
|
d0aef27f7b | ||
|
|
d6a74b0cfe | ||
|
|
0c43eb0d22 | ||
|
|
f8494030aa | ||
|
|
817151e47c | ||
|
|
d5dfb609cf | ||
|
|
09aae0edc9 | ||
|
|
6aa6db4e8c | ||
|
|
76be502e16 | ||
|
|
04659a8198 | ||
|
|
6c16688ca9 | ||
|
|
f2ec89e62b | ||
|
|
edd09b0b16 | ||
|
|
59edc8d618 | ||
|
|
56520572b2 | ||
|
|
487bdd1e48 | ||
|
|
8ad5e011ca | ||
|
|
6f43fc272b | ||
|
|
e008b657a5 | ||
|
|
365367dd16 | ||
|
|
36367e475e | ||
|
|
13f2961e43 | ||
|
|
69b70d70a8 | ||
|
|
d0d0dcf09f | ||
|
|
41b9729b9b | ||
|
|
a2009cae39 | ||
|
|
fab086b3e1 | ||
|
|
f379126242 | ||
|
|
8e0d9f2da6 | ||
|
|
e931b5c8ef | ||
|
|
84617d0c49 | ||
|
|
0908cf4e94 | ||
|
|
916f27c5ab | ||
|
|
9b442d04d9 | ||
|
|
9024b2eec5 | ||
|
|
4624a29332 | ||
|
|
3d2cef40d5 | ||
|
|
e8f8be1940 | ||
|
|
49faba6ad2 | ||
|
|
b9d90aa635 | ||
|
|
5bcf65dd67 | ||
|
|
742d2ed9c3 | ||
|
|
86128f953a | ||
|
|
b8e5a6944e | ||
|
|
4508fad588 | ||
|
|
fd2f500038 | ||
|
|
a20374d6e3 | ||
|
|
ffc69dbaba | ||
|
|
6b98655069 | ||
|
|
b5a9a93323 | ||
|
|
5fbf5b22c0 | ||
|
|
99cd96faa8 | ||
|
|
c4b60f1720 | ||
|
|
a19bf3dc0e | ||
|
|
77021d2af8 | ||
|
|
16893ec0e3 | ||
|
|
d49cc87b8c | ||
|
|
c998d2566e | ||
|
|
84a9438a70 | ||
|
|
09ae3515f7 | ||
|
|
b665c17be8 | ||
|
|
eccd852426 | ||
|
|
827e3ec0a0 | ||
|
|
801c03981a | ||
|
|
31a001bbc1 | ||
|
|
621ed5fb02 | ||
|
|
887e437d35 | ||
|
|
1ea196051f | ||
|
|
366f528853 | ||
|
|
cd2fcc06fe | ||
|
|
a731f4758e | ||
|
|
0e0fce0e66 | ||
|
|
ea2f97ae23 | ||
|
|
2a0722d0d0 | ||
|
|
adf213f32a | ||
|
|
54f408dc6c | ||
|
|
c82c4ddc5b |
2
.github/ISSUE_TEMPLATE/1-app-bug.yml
vendored
@@ -16,7 +16,7 @@ body:
|
||||
id: version
|
||||
attributes:
|
||||
label: What version of the Modrinth App are you using?
|
||||
description: Find this in ⚙️ Settings (bottom right) -> About -> App version.
|
||||
description: Find this in ⚙️ Settings (bottom right) -> After Modrinth App (bottom left)
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
BIN
.github/assets/api_cover.png
vendored
Normal file
|
After Width: | Height: | Size: 22 KiB |
2
.github/workflows/daedalus-docker.yml
vendored
@@ -7,11 +7,13 @@ on:
|
||||
paths:
|
||||
- .github/workflows/daedalus-docker.yml
|
||||
- 'apps/daedalus_client/**'
|
||||
- 'packages/daedalus/**'
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
paths:
|
||||
- .github/workflows/daedalus-docker.yml
|
||||
- 'apps/daedalus_client/**'
|
||||
- 'packages/daedalus/**'
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
|
||||
1022
Cargo.lock
generated
@@ -2,6 +2,7 @@
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
UserIcon,
|
||||
ArrowBigUpDashIcon,
|
||||
CompassIcon,
|
||||
DownloadIcon,
|
||||
@@ -233,6 +234,9 @@ async function fetchCredentials() {
|
||||
credentials.value = creds
|
||||
}
|
||||
|
||||
const profileMenu = ref()
|
||||
const isProfileMenuOpen = computed(() => profileMenu.value?.isOpen)
|
||||
|
||||
async function signIn() {
|
||||
await login().catch(handleError)
|
||||
await fetchCredentials()
|
||||
@@ -410,26 +414,34 @@ function handleAuxClick(e) {
|
||||
<NavButton v-tooltip.right="'Settings'" :to="() => $refs.settingsModal.show()">
|
||||
<SettingsIcon />
|
||||
</NavButton>
|
||||
<ButtonStyled v-if="credentials" type="transparent" circular>
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'sign-out',
|
||||
action: () => logOut(),
|
||||
color: 'danger',
|
||||
},
|
||||
]"
|
||||
direction="left"
|
||||
>
|
||||
<Avatar
|
||||
:src="credentials.user.avatar_url"
|
||||
:alt="credentials.user.username"
|
||||
size="32px"
|
||||
circle
|
||||
/>
|
||||
<template #sign-out> <LogOutIcon /> Sign out </template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
<OverflowMenu
|
||||
v-if="credentials"
|
||||
ref="profileMenu"
|
||||
placement="right-end"
|
||||
class="w-12 h-12 border-none cursor-pointer rounded-full flex items-center justify-center text-2xl transition-all button-animation"
|
||||
:class="isProfileMenuOpen ? 'bg-button-bg' : 'bg-transparent hover:bg-button-bg'"
|
||||
:options="[
|
||||
{
|
||||
id: 'profile',
|
||||
action: () => router.push(`/user/${credentials.user.id}`),
|
||||
},
|
||||
{
|
||||
id: 'sign-out',
|
||||
action: () => logOut(),
|
||||
color: 'danger',
|
||||
},
|
||||
]"
|
||||
direction="left"
|
||||
>
|
||||
<Avatar
|
||||
:src="credentials.user.avatar_url"
|
||||
:alt="credentials.user.username"
|
||||
size="32px"
|
||||
circle
|
||||
/>
|
||||
<template #profile> <UserIcon /> Profile </template>
|
||||
<template #sign-out> <LogOutIcon /> Sign out </template>
|
||||
</OverflowMenu>
|
||||
<NavButton v-else v-tooltip.right="'Sign in'" :to="() => signIn()">
|
||||
<LogInIcon />
|
||||
<template #label>Sign in</template>
|
||||
@@ -473,7 +485,7 @@ function handleAuxClick(e) {
|
||||
<RunningAppBar />
|
||||
</Suspense>
|
||||
</div>
|
||||
<section v-if="!nativeDecorations" class="window-controls">
|
||||
<section v-if="!nativeDecorations" class="window-controls" data-tauri-drag-region-exclude>
|
||||
<Button class="titlebar-button" icon-only @click="() => getCurrentWindow().minimize()">
|
||||
<MinimizeIcon />
|
||||
</Button>
|
||||
@@ -521,6 +533,16 @@ function handleAuxClick(e) {
|
||||
width: 'calc(100% - var(--right-bar-width))',
|
||||
}"
|
||||
></div>
|
||||
<div
|
||||
v-if="criticalErrorMessage"
|
||||
class="m-6 mb-0 flex flex-col border-red bg-bg-red rounded-2xl border-2 border-solid p-4 gap-1 font-semibold text-contrast"
|
||||
>
|
||||
<h1 class="m-0 text-lg font-extrabold">{{ criticalErrorMessage.header }}</h1>
|
||||
<div
|
||||
class="markdown-body text-primary"
|
||||
v-html="renderString(criticalErrorMessage.body ?? '')"
|
||||
></div>
|
||||
</div>
|
||||
<RouterView v-slot="{ Component }">
|
||||
<template v-if="Component">
|
||||
<Suspense @pending="loading.startLoading()" @resolve="loading.stopLoading()">
|
||||
@@ -592,12 +614,6 @@ function handleAuxClick(e) {
|
||||
<PromotionWrapper />
|
||||
</template>
|
||||
</div>
|
||||
<div class="view">
|
||||
<div v-if="criticalErrorMessage" class="critical-error-banner" data-tauri-drag-region>
|
||||
<h1>{{ criticalErrorMessage.header }}</h1>
|
||||
<div class="markdown-body" v-html="renderString(criticalErrorMessage.body ?? '')"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<URLConfirmModal ref="urlModal" />
|
||||
<Notifications ref="notificationsWrapper" sidebar />
|
||||
@@ -694,12 +710,23 @@ function handleAuxClick(e) {
|
||||
|
||||
.app-grid-navbar {
|
||||
grid-area: nav;
|
||||
|
||||
// Fixes SVG scaling issues
|
||||
filter: brightness(1.00001);
|
||||
}
|
||||
|
||||
.app-grid-statusbar {
|
||||
grid-area: status;
|
||||
}
|
||||
|
||||
[data-tauri-drag-region] {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
[data-tauri-drag-region-exclude] {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.app-contents {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
@@ -769,6 +796,7 @@ function handleAuxClick(e) {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.app-contents::before {
|
||||
|
||||
@@ -181,24 +181,26 @@ const maxInstancesPerRow = ref(1)
|
||||
const maxProjectsPerRow = ref(1)
|
||||
|
||||
const calculateCardsPerRow = () => {
|
||||
// Calculate how many cards fit in one row
|
||||
const containerWidth = rows.value[0].clientWidth
|
||||
// Convert container width from pixels to rem
|
||||
const containerWidthInRem =
|
||||
containerWidth / parseFloat(getComputedStyle(document.documentElement).fontSize)
|
||||
if (rows.value && rows.value[0]) {
|
||||
// Calculate how many cards fit in one row
|
||||
const containerWidth = rows.value[0].clientWidth
|
||||
// Convert container width from pixels to rem
|
||||
const containerWidthInRem =
|
||||
containerWidth / parseFloat(getComputedStyle(document.documentElement).fontSize)
|
||||
|
||||
maxInstancesPerCompactRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
|
||||
maxInstancesPerRow.value = Math.floor((containerWidthInRem + 0.75) / 20.75)
|
||||
maxProjectsPerRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
|
||||
maxInstancesPerCompactRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
|
||||
maxInstancesPerRow.value = Math.floor((containerWidthInRem + 0.75) / 20.75)
|
||||
maxProjectsPerRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
|
||||
|
||||
if (maxInstancesPerRow.value < 5) {
|
||||
maxInstancesPerRow.value *= 2
|
||||
}
|
||||
if (maxInstancesPerCompactRow.value < 5) {
|
||||
maxInstancesPerCompactRow.value *= 2
|
||||
}
|
||||
if (maxProjectsPerRow.value < 3) {
|
||||
maxProjectsPerRow.value *= 2
|
||||
if (maxInstancesPerRow.value < 5) {
|
||||
maxInstancesPerRow.value *= 2
|
||||
}
|
||||
if (maxInstancesPerCompactRow.value < 5) {
|
||||
maxInstancesPerCompactRow.value *= 2
|
||||
}
|
||||
if (maxProjectsPerRow.value < 3) {
|
||||
maxProjectsPerRow.value *= 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,13 +209,17 @@ const resizeObserver = ref(null)
|
||||
onMounted(() => {
|
||||
calculateCardsPerRow()
|
||||
resizeObserver.value = new ResizeObserver(calculateCardsPerRow)
|
||||
resizeObserver.value.observe(rowContainer.value)
|
||||
if (rowContainer.value) {
|
||||
resizeObserver.value.observe(rowContainer.value)
|
||||
}
|
||||
window.addEventListener('resize', calculateCardsPerRow)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', calculateCardsPerRow)
|
||||
resizeObserver.value.unobserve(rowContainer.value)
|
||||
if (rowContainer.value) {
|
||||
resizeObserver.value.unobserve(rowContainer.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ const exportPack = async () => {
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showingFiles" class="table-content">
|
||||
<div v-for="[path, children] of folders" :key="path.name" class="table-row">
|
||||
<div v-for="[path, children] in folders" :key="path.name" class="table-row">
|
||||
<div class="table-cell file-entry">
|
||||
<div class="file-primary">
|
||||
<Checkbox
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
DownloadIcon,
|
||||
GameIcon,
|
||||
PlayIcon,
|
||||
SpinnerIcon,
|
||||
StopCircleIcon,
|
||||
GameIcon,
|
||||
TimerIcon,
|
||||
StopCircleIcon,
|
||||
PlayIcon,
|
||||
DownloadIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Avatar, ButtonStyled } from '@modrinth/ui'
|
||||
import { ButtonStyled, Avatar, SmartClickable } from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { finish_install, kill, run } from '@/helpers/profile'
|
||||
import { get_by_profile_path } from '@/helpers/process'
|
||||
@@ -134,22 +134,26 @@ onUnmounted(() => unlisten())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="compact">
|
||||
<div
|
||||
class="card-shadow grid grid-cols-[auto_1fr_auto] bg-bg-raised rounded-xl p-3 pl-4 gap-2 cursor-pointer hover:brightness-90 transition-all"
|
||||
@click="seeInstance"
|
||||
@mouseenter="checkProcess"
|
||||
>
|
||||
<SmartClickable class="card-shadow bg-bg-raised rounded-xl" @mouseenter="checkProcess">
|
||||
<template #clickable>
|
||||
<router-link
|
||||
class="no-click-animation"
|
||||
:to="`/instance/${encodeURIComponent(instance.path)}/`"
|
||||
/>
|
||||
</template>
|
||||
<div v-if="compact" class="grid grid-cols-[auto_1fr_auto] p-3 pl-4 gap-2">
|
||||
<Avatar
|
||||
size="48px"
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
|
||||
:tint-by="instance.path"
|
||||
alt="Mod card"
|
||||
/>
|
||||
<div class="h-full flex items-center font-bold text-contrast leading-normal">
|
||||
<div
|
||||
class="h-full flex items-center font-bold text-contrast leading-normal smart-clickable:underline-on-hover"
|
||||
>
|
||||
<span class="line-clamp-2">{{ instance.name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center smart-clickable:allow-pointer-events">
|
||||
<ButtonStyled v-if="playing" color="red" circular @mousehover="checkProcess">
|
||||
<button v-tooltip="'Stop'" @click="(e) => stop(e, 'InstanceCard')">
|
||||
<StopCircleIcon />
|
||||
@@ -176,13 +180,7 @@ onUnmounted(() => unlisten())
|
||||
<span class="text-sm"> Played {{ dayjs(instance.last_played).fromNow() }} </span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else>
|
||||
<div
|
||||
class="button-base bg-bg-raised p-4 rounded-xl flex gap-3 group"
|
||||
@click="seeInstance"
|
||||
@mouseenter="checkProcess"
|
||||
>
|
||||
<div v-else class="p-4 rounded-xl flex gap-3 group" @mouseenter="checkProcess">
|
||||
<div class="relative flex items-center justify-center">
|
||||
<Avatar
|
||||
size="48px"
|
||||
@@ -231,7 +229,9 @@ onUnmounted(() => unlisten())
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="m-0 text-md font-bold text-contrast leading-tight line-clamp-1">
|
||||
<p
|
||||
class="m-0 text-md font-bold text-contrast leading-tight line-clamp-1 smart-clickable:underline-on-hover"
|
||||
>
|
||||
{{ instance.name }}
|
||||
</p>
|
||||
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold mt-auto">
|
||||
@@ -242,5 +242,5 @@ onUnmounted(() => unlisten())
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SmartClickable>
|
||||
</template>
|
||||
|
||||
@@ -3,23 +3,18 @@ import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { formatCategory } from '@modrinth/utils'
|
||||
import { GameIcon, LeftArrowIcon } from '@modrinth/assets'
|
||||
import { Avatar, ButtonStyled } from '@modrinth/ui'
|
||||
|
||||
type Instance = {
|
||||
game_version: string
|
||||
loader: string
|
||||
path: string
|
||||
install_stage: string
|
||||
icon_path?: string
|
||||
name: string
|
||||
}
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
defineProps<{
|
||||
instance: Instance
|
||||
instance?: GameInstance
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-between items-center border-0 border-b border-solid border-divider pb-4">
|
||||
<div
|
||||
v-if="instance"
|
||||
class="flex justify-between items-center border-0 border-b border-solid border-divider pb-4"
|
||||
>
|
||||
<router-link
|
||||
:to="`/instance/${encodeURIComponent(instance.path)}`"
|
||||
tabindex="-1"
|
||||
@@ -49,5 +44,3 @@ defineProps<{
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
<script setup>
|
||||
import { Avatar, TagItem } from '@modrinth/ui'
|
||||
import { Avatar, SmartClickable, TagItem } from '@modrinth/ui'
|
||||
import { DownloadIcon, HeartIcon, TagIcon } from '@modrinth/assets'
|
||||
import { formatNumber, formatCategory } from '@modrinth/utils'
|
||||
import { computed } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
@@ -40,29 +37,15 @@ const toColor = computed(() => {
|
||||
const r = (color >>> 16) & 0xff
|
||||
return 'rgba(' + [r, g, b, 1].join(',') + ')'
|
||||
})
|
||||
|
||||
const toTransparent = computed(() => {
|
||||
let color = props.project.color
|
||||
|
||||
color >>>= 0
|
||||
const b = color & 0xff
|
||||
const g = (color >>> 8) & 0xff
|
||||
const r = (color >>> 16) & 0xff
|
||||
return (
|
||||
'linear-gradient(rgba(' +
|
||||
[r, g, b, 0.03].join(',') +
|
||||
'), 65%, rgba(' +
|
||||
[r, g, b, 0.3].join(',') +
|
||||
'))'
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
<SmartClickable
|
||||
class="card-shadow bg-bg-raised rounded-xl overflow-clip cursor-pointer hover:brightness-90 transition-all"
|
||||
@click="router.push(`/project/${project.slug}`)"
|
||||
>
|
||||
<template #clickable>
|
||||
<router-link class="no-click-animation" :to="`/project/${project.slug}`" />
|
||||
</template>
|
||||
<div
|
||||
class="w-full aspect-[2/1] bg-cover bg-center bg-no-repeat"
|
||||
:style="{
|
||||
@@ -73,21 +56,13 @@ const toTransparent = computed(() => {
|
||||
'https://launcher-files.modrinth.com/assets/maze-bg.png'
|
||||
})`,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="badges-wrapper"
|
||||
:class="{
|
||||
'no-image': !project.featured_gallery && !project.gallery[0],
|
||||
}"
|
||||
:style="{
|
||||
background: !project.featured_gallery && !project.gallery[0] ? toTransparent : null,
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
></div>
|
||||
<div class="flex flex-col justify-center gap-2 px-4 py-3">
|
||||
<div class="flex gap-2 items-center">
|
||||
<Avatar size="48px" :src="project.icon_url" />
|
||||
<div class="h-full flex items-center font-bold text-contrast leading-normal">
|
||||
<div
|
||||
class="h-full flex items-center font-bold text-contrast leading-normal smart-clickable:underline-on-hover"
|
||||
>
|
||||
<span class="line-clamp-2">{{ project.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,7 +90,7 @@ const toTransparent = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SmartClickable>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
|
||||
194
apps/app-frontend/src/components/ui/ProjectCardActions.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
VersionIcon,
|
||||
ImageIcon,
|
||||
BookmarkIcon,
|
||||
DownloadIcon,
|
||||
HeartIcon,
|
||||
MoreVerticalIcon,
|
||||
ExternalIcon,
|
||||
LinkIcon,
|
||||
ReportIcon,
|
||||
SpinnerIcon,
|
||||
CheckIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { ButtonStyled, commonMessages, OverflowMenu } from '@modrinth/ui'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { computed, ref, type Ref } from 'vue'
|
||||
import { install as installVersion } from '@/store/install'
|
||||
import { useVIntl, defineMessages } from '@vintl/vintl'
|
||||
import { isSearchResult, type Project, type SearchResult } from '@modrinth/utils'
|
||||
import type { InstanceContentMap } from '@/composables/instance-context.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = defineProps<{
|
||||
project: Project | SearchResult
|
||||
instance?: GameInstance
|
||||
instanceContent?: InstanceContentMap
|
||||
}>()
|
||||
|
||||
const installing = ref(false)
|
||||
|
||||
const installed: Ref<boolean> = ref(false)
|
||||
|
||||
function checkInstallStatus() {
|
||||
if (props.instanceContent) {
|
||||
installed.value = Object.values(props.instanceContent).some(
|
||||
(content) => content.metadata?.project_id === projectId.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function install(toInstance: boolean) {
|
||||
if (toInstance) {
|
||||
installing.value = true
|
||||
}
|
||||
await installVersion(
|
||||
projectId.value,
|
||||
null,
|
||||
props.instance && toInstance ? props.instance.path : null,
|
||||
'SearchCard',
|
||||
() => {
|
||||
if (toInstance) {
|
||||
installing.value = false
|
||||
installed.value = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const modpack = computed(() => props.project.project_type === 'modpack')
|
||||
|
||||
const projectWebUrl = computed(
|
||||
() => `https://modrinth.com/${props.project.project_type}/${props.project.slug}`,
|
||||
)
|
||||
|
||||
const tooltip = defineMessages({
|
||||
installing: {
|
||||
id: 'project.card.actions.installing.tooltip',
|
||||
defaultMessage: 'This project is being installed',
|
||||
},
|
||||
installed: {
|
||||
id: 'project.card.actions.installed.tooltip',
|
||||
defaultMessage: 'This project is already installed',
|
||||
},
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
viewVersions: {
|
||||
id: 'project.card.actions.view-versions',
|
||||
defaultMessage: 'View versions',
|
||||
},
|
||||
viewGallery: {
|
||||
id: 'project.card.actions.view-gallery',
|
||||
defaultMessage: 'View gallery',
|
||||
},
|
||||
})
|
||||
|
||||
const projectId = computed(() =>
|
||||
isSearchResult(props.project) ? props.project.project_id : props.project.id,
|
||||
)
|
||||
|
||||
const copyText = (text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
checkInstallStatus()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="
|
||||
installing
|
||||
? formatMessage(tooltip.installing)
|
||||
: installed
|
||||
? formatMessage(tooltip.installed)
|
||||
: null
|
||||
"
|
||||
:disabled="installing || installed"
|
||||
@click="() => install(true)"
|
||||
>
|
||||
<SpinnerIcon v-if="installing" />
|
||||
<CheckIcon v-else-if="installed" />
|
||||
<DownloadIcon v-else />
|
||||
{{
|
||||
formatMessage(
|
||||
installing
|
||||
? commonMessages.installingButton
|
||||
: installed
|
||||
? commonMessages.installedButton
|
||||
: commonMessages.installButton,
|
||||
)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<!-- TODO: Add in later -->
|
||||
<ButtonStyled v-if="false" circular>
|
||||
<button v-tooltip="'Follow'">
|
||||
<HeartIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="false" circular>
|
||||
<button v-tooltip="'Save'">
|
||||
<BookmarkIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'install-elsewhere',
|
||||
color: 'primary',
|
||||
action: () => install(false),
|
||||
shown: !!instance && !modpack,
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
shown: !!instance && !modpack,
|
||||
},
|
||||
{
|
||||
id: 'versions',
|
||||
link: `/project/${projectId}/versions`,
|
||||
},
|
||||
{
|
||||
id: 'gallery',
|
||||
link: `/project/${projectId}/gallery`,
|
||||
shown: (project.gallery?.length ?? 0) > 0,
|
||||
},
|
||||
{
|
||||
id: 'open-link',
|
||||
link: projectWebUrl,
|
||||
},
|
||||
{
|
||||
id: 'copy-link',
|
||||
action: () => copyText(projectWebUrl),
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
},
|
||||
{
|
||||
id: 'report',
|
||||
color: 'red',
|
||||
hoverFilled: true,
|
||||
action: () => {},
|
||||
},
|
||||
]"
|
||||
>
|
||||
<MoreVerticalIcon />
|
||||
<template #install-elsewhere>
|
||||
<DownloadIcon /> {{ formatMessage(commonMessages.installToButton) }}
|
||||
</template>
|
||||
<template #versions> <VersionIcon /> {{ formatMessage(messages.viewVersions) }} </template>
|
||||
<template #gallery> <ImageIcon /> {{ formatMessage(messages.viewGallery) }} </template>
|
||||
<template #open-link>
|
||||
<ExternalIcon /> {{ formatMessage(commonMessages.openInBrowserButton) }}
|
||||
</template>
|
||||
<template #copy-link>
|
||||
<LinkIcon /> {{ formatMessage(commonMessages.copyLinkButton) }}
|
||||
</template>
|
||||
<template #report> <ReportIcon /> {{ formatMessage(commonMessages.reportButton) }} </template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
@@ -1,157 +1,72 @@
|
||||
<template>
|
||||
<div
|
||||
class="card-shadow p-4 bg-bg-raised rounded-xl flex gap-3 group cursor-pointer hover:brightness-90 transition-all"
|
||||
@click="
|
||||
() => {
|
||||
emit('open')
|
||||
$router.push({
|
||||
path: `/project/${project.project_id ?? project.id}`,
|
||||
<NewProjectCard
|
||||
:project="project"
|
||||
:link="
|
||||
asLink(
|
||||
{
|
||||
path: `/project/${projectId}`,
|
||||
query: { i: props.instance ? props.instance.path : undefined },
|
||||
})
|
||||
}
|
||||
},
|
||||
() => emit('open'),
|
||||
)
|
||||
"
|
||||
:experimental-colors="themeStore.featureFlags.project_card_background"
|
||||
:creator-link="
|
||||
creator
|
||||
? asLink(
|
||||
{
|
||||
path: `/user/${creator}`,
|
||||
query: { i: props.instance ? props.instance.path : undefined },
|
||||
},
|
||||
() => emit('open'),
|
||||
)
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<div class="icon w-[96px] h-[96px] relative">
|
||||
<Avatar :src="project.icon_url" size="96px" class="search-icon origin-top transition-all" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 overflow-hidden">
|
||||
<div class="gap-2 overflow-hidden no-wrap text-ellipsis">
|
||||
<span class="text-lg font-extrabold text-contrast m-0 leading-none">
|
||||
{{ project.title }}
|
||||
</span>
|
||||
<span v-if="project.author" class="text-secondary"> by {{ project.author }}</span>
|
||||
</div>
|
||||
<div class="m-0 line-clamp-2">
|
||||
{{ project.description }}
|
||||
</div>
|
||||
<div v-if="categories.length > 0" class="mt-auto flex items-center gap-1 no-wrap">
|
||||
<TagsIcon class="h-4 w-4 shrink-0" />
|
||||
<div
|
||||
v-if="project.project_type === 'mod' || project.project_type === 'modpack'"
|
||||
class="text-sm font-semibold text-secondary flex gap-1 px-[0.375rem] py-0.5 bg-button-bg rounded-full"
|
||||
<template #actions>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:disabled="installed || installing"
|
||||
class="shrink-0 no-wrap"
|
||||
@click.stop="install()"
|
||||
>
|
||||
<template v-if="project.client_side === 'optional' && project.server_side === 'optional'">
|
||||
Client or server
|
||||
<template v-if="!installed">
|
||||
<DownloadIcon />
|
||||
</template>
|
||||
<template
|
||||
v-else-if="
|
||||
(project.client_side === 'optional' || project.client_side === 'required') &&
|
||||
(project.server_side === 'optional' || project.server_side === 'unsupported')
|
||||
"
|
||||
>
|
||||
Client
|
||||
</template>
|
||||
<template
|
||||
v-else-if="
|
||||
(project.server_side === 'optional' || project.server_side === 'required') &&
|
||||
(project.client_side === 'optional' || project.client_side === 'unsupported')
|
||||
"
|
||||
>
|
||||
Server
|
||||
</template>
|
||||
<template
|
||||
v-else-if="
|
||||
project.client_side === 'unsupported' && project.server_side === 'unsupported'
|
||||
"
|
||||
>
|
||||
Unsupported
|
||||
</template>
|
||||
<template
|
||||
v-else-if="project.client_side === 'required' && project.server_side === 'required'"
|
||||
>
|
||||
Client and server
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-for="tag in categories"
|
||||
:key="tag"
|
||||
class="text-sm font-semibold text-secondary flex gap-1 px-[0.375rem] py-0.5 bg-button-bg rounded-full"
|
||||
>
|
||||
{{ formatCategory(tag.name) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 items-end shrink-0 ml-auto">
|
||||
<div class="flex items-center gap-2">
|
||||
<DownloadIcon class="shrink-0" />
|
||||
<span>
|
||||
{{ formatNumber(project.downloads) }}
|
||||
<span class="text-secondary">downloads</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<HeartIcon class="shrink-0" />
|
||||
<span>
|
||||
{{ formatNumber(project.follows ?? project.followers) }}
|
||||
<span class="text-secondary">followers</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-auto relative">
|
||||
<div class="absolute bottom-0 right-0 w-fit">
|
||||
<ButtonStyled color="brand" type="outlined">
|
||||
<button
|
||||
:disabled="installed || installing"
|
||||
class="shrink-0 no-wrap"
|
||||
@click.stop="install()"
|
||||
>
|
||||
<template v-if="!installed">
|
||||
<DownloadIcon v-if="modpack || instance" />
|
||||
<PlusIcon v-else />
|
||||
</template>
|
||||
<CheckIcon v-else />
|
||||
{{
|
||||
installing
|
||||
? 'Installing'
|
||||
: installed
|
||||
? 'Installed'
|
||||
: modpack || instance
|
||||
? 'Install'
|
||||
: 'Add to an instance'
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CheckIcon v-else />
|
||||
{{ installing ? 'Installing' : installed ? 'Installed' : 'Install' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</NewProjectCard>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { TagsIcon, DownloadIcon, HeartIcon, PlusIcon, CheckIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, Avatar } from '@modrinth/ui'
|
||||
import { formatNumber, formatCategory } from '@modrinth/utils'
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, CheckIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, NewProjectCard, asLink } from '@modrinth/ui'
|
||||
import type { Project, SearchResult } from '@modrinth/utils'
|
||||
import { isSearchResult } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { ref, computed } from 'vue'
|
||||
import { install as installVersion } from '@/store/install.js'
|
||||
import { useTheming } from '@/store/state.js'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
const props = defineProps({
|
||||
backgroundImage: {
|
||||
type: String,
|
||||
default: null,
|
||||
const themeStore = useTheming()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
project: Project | SearchResult
|
||||
instance?: GameInstance
|
||||
installed?: boolean
|
||||
}>(),
|
||||
{
|
||||
instance: undefined,
|
||||
installed: false,
|
||||
},
|
||||
project: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
categories: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
instance: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
featured: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
installed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const emit = defineEmits(['open', 'install'])
|
||||
|
||||
@@ -160,16 +75,19 @@ const installing = ref(false)
|
||||
async function install() {
|
||||
installing.value = true
|
||||
await installVersion(
|
||||
props.project.project_id ?? props.project.id,
|
||||
projectId.value,
|
||||
null,
|
||||
props.instance ? props.instance.path : null,
|
||||
'SearchCard',
|
||||
() => {
|
||||
installing.value = false
|
||||
emit('install', props.project.project_id ?? props.project.id)
|
||||
emit('install', projectId.value)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const modpack = computed(() => props.project.project_type === 'modpack')
|
||||
const projectId = computed(() =>
|
||||
isSearchResult(props.project) ? props.project.project_id : props.project.id,
|
||||
)
|
||||
const creator = computed(() => (isSearchResult(props.project) ? props.project.author : undefined))
|
||||
</script>
|
||||
|
||||
@@ -116,10 +116,6 @@ function devModeCount() {
|
||||
themeStore.devMode = !themeStore.devMode
|
||||
settings.value.developer_mode = !!themeStore.devMode
|
||||
devModeCounter.value = 0
|
||||
|
||||
if (!themeStore.devMode && tabs[modal.value.selectedTab].developerOnly) {
|
||||
modal.value.setTab(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { Toggle } from '@modrinth/ui'
|
||||
import { ButtonStyled, Toggle } from '@modrinth/ui'
|
||||
import { useTheming } from '@/store/state'
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, type Ref, watch } from 'vue'
|
||||
import { get, set } from '@/helpers/settings'
|
||||
import { DEFAULT_FEATURE_FLAGS } from '@/store/theme'
|
||||
|
||||
type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
const settings = ref(await get())
|
||||
const options = ref(['project_background', 'page_path'])
|
||||
const options: Ref<FeatureFlag[]> = ref(Object.keys(DEFAULT_FEATURE_FLAGS) as FeatureFlag[])
|
||||
|
||||
function getStoreValue(key: string) {
|
||||
function getStoreValue(key: FeatureFlag) {
|
||||
return themeStore.featureFlags[key] ?? false
|
||||
}
|
||||
|
||||
function setStoreValue(key: string, value: boolean) {
|
||||
function setStoreValue(key: FeatureFlag, value: boolean) {
|
||||
themeStore.featureFlags[key] = value
|
||||
settings.value.feature_flags[key] = value
|
||||
}
|
||||
@@ -27,17 +30,39 @@ watch(
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<div v-for="option in options" :key="option" class="mt-4 flex items-center justify-between">
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Feature flags</h2>
|
||||
<p class="mt-1 mb-0 leading-tight text-secondary">
|
||||
These are developer tools that are not intended to be used by end users except for debugging
|
||||
purposes.
|
||||
</p>
|
||||
<p class="my-3 font-bold">Do not report bugs or issues if you have any feature flags enabled.</p>
|
||||
<div
|
||||
v-for="option in options"
|
||||
:key="option"
|
||||
class="mt-2 px-4 py-3 flex items-center justify-between bg-bg rounded-2xl"
|
||||
>
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast capitalize">
|
||||
{{ option }}
|
||||
<h2 class="m-0 text-base font-bold text-primary capitalize">
|
||||
{{ option.replace(new RegExp('_', 'g'), ' ') }}
|
||||
</h2>
|
||||
<p class="m-0 text-sm text-secondary">Default: {{ DEFAULT_FEATURE_FLAGS[option] }}</p>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
id="advanced-rendering"
|
||||
:model-value="getStoreValue(option)"
|
||||
@update:model-value="() => setStoreValue(option, !themeStore.featureFlags[option])"
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
class="text-sm"
|
||||
:disabled="getStoreValue(option) === DEFAULT_FEATURE_FLAGS[option]"
|
||||
@click="() => setStoreValue(option, !themeStore.featureFlags[option])"
|
||||
>
|
||||
Reset to default
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<Toggle
|
||||
id="advanced-rendering"
|
||||
:model-value="getStoreValue(option)"
|
||||
@update:model-value="() => setStoreValue(option, !themeStore.featureFlags[option])"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
41
apps/app-frontend/src/composables/instance-context.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ref, computed, type Ref, watch } from 'vue'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile'
|
||||
import type { GameInstance, InstanceContent } from '@/helpers/types'
|
||||
|
||||
export type InstanceContentMap = Record<string, InstanceContent>
|
||||
|
||||
export async function useInstanceContext() {
|
||||
const route = useRoute()
|
||||
|
||||
const instance: Ref<GameInstance | undefined> = ref()
|
||||
const instanceContent: Ref<InstanceContentMap | undefined> = ref()
|
||||
|
||||
await loadInstance()
|
||||
|
||||
watch(route, () => {
|
||||
loadInstance()
|
||||
})
|
||||
|
||||
async function loadInstance() {
|
||||
;[instance.value, instanceContent.value] = await Promise.all([
|
||||
route.query.i ? getInstance(route.query.i).catch(handleError) : Promise.resolve(),
|
||||
route.query.i ? getInstanceProjects(route.query.i).catch(handleError) : Promise.resolve(),
|
||||
])
|
||||
}
|
||||
|
||||
const instanceQueryAppendage = computed(() => {
|
||||
if (instance.value) {
|
||||
return `?i=${instance.value.path}`
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
instance,
|
||||
instanceContent,
|
||||
instanceQueryAppendage,
|
||||
}
|
||||
}
|
||||
12
apps/app-frontend/src/helpers/types.d.ts
vendored
@@ -32,6 +32,18 @@ type GameInstance = {
|
||||
hooks: Hooks
|
||||
}
|
||||
|
||||
type InstanceContent = {
|
||||
hash: string
|
||||
file_name: string
|
||||
size: number
|
||||
metadata?: {
|
||||
project_id: ModrinthId
|
||||
version_id: ModrinthId
|
||||
}
|
||||
update_version_id: string
|
||||
project_type: 'mod' | 'resourcepack' | 'datapack' | 'shaderpack'
|
||||
}
|
||||
|
||||
type InstallStage =
|
||||
| 'installed'
|
||||
| 'minecraft_installing'
|
||||
|
||||
@@ -308,6 +308,18 @@
|
||||
"instance.settings.title": {
|
||||
"message": "Settings"
|
||||
},
|
||||
"project.card.actions.installed.tooltip": {
|
||||
"message": "This project is already installed"
|
||||
},
|
||||
"project.card.actions.installing.tooltip": {
|
||||
"message": "This project is being installed"
|
||||
},
|
||||
"project.card.actions.view-gallery": {
|
||||
"message": "View gallery"
|
||||
},
|
||||
"project.card.actions.view-versions": {
|
||||
"message": "View versions"
|
||||
},
|
||||
"search.filter.locked.instance": {
|
||||
"message": "Provided by the instance"
|
||||
},
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { SearchIcon, XIcon, ClipboardCopyIcon, GlobeIcon, ExternalIcon } from '@modrinth/assets'
|
||||
import type { Category, GameVersion, Platform, ProjectType, SortType, Tags } from '@modrinth/ui'
|
||||
import type {
|
||||
CategoryTag,
|
||||
GameVersionTag,
|
||||
PlatformTag,
|
||||
ProjectType,
|
||||
SortType,
|
||||
Tags,
|
||||
} from '@modrinth/ui'
|
||||
import {
|
||||
SearchFilterControl,
|
||||
SearchSidebarFilter,
|
||||
@@ -19,14 +26,14 @@ import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import SearchCard from '@/components/ui/SearchCard.vue'
|
||||
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile.js'
|
||||
import { get_search_results } from '@/helpers/cache.js'
|
||||
import NavTabs from '@/components/ui/NavTabs.vue'
|
||||
import type Instance from '@/components/ui/Instance.vue'
|
||||
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { useInstanceContext } from '@/composables/instance-context.ts'
|
||||
import type { SearchResult } from '@modrinth/utils'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -38,62 +45,45 @@ const projectTypes = computed(() => {
|
||||
})
|
||||
|
||||
const [categories, loaders, availableGameVersions] = await Promise.all([
|
||||
get_categories().catch(handleError).then(ref),
|
||||
get_loaders().catch(handleError).then(ref),
|
||||
get_game_versions().catch(handleError).then(ref),
|
||||
get_categories()
|
||||
.catch(handleError)
|
||||
.then((x: CategoryTag[]) => ref(x)),
|
||||
get_loaders()
|
||||
.catch(handleError)
|
||||
.then((x: PlatformTag[]) => ref(x)),
|
||||
get_game_versions()
|
||||
.catch(handleError)
|
||||
.then((x: GameVersionTag[]) => ref(x)),
|
||||
])
|
||||
|
||||
const tags: Ref<Tags> = computed(() => ({
|
||||
gameVersions: availableGameVersions.value as GameVersion[],
|
||||
loaders: loaders.value as Platform[],
|
||||
categories: categories.value as Category[],
|
||||
gameVersions: availableGameVersions.value as GameVersionTag[],
|
||||
loaders: loaders.value as PlatformTag[],
|
||||
categories: categories.value as CategoryTag[],
|
||||
}))
|
||||
|
||||
type Instance = {
|
||||
game_version: string
|
||||
loader: string
|
||||
path: string
|
||||
install_stage: string
|
||||
icon_path?: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type InstanceProject = {
|
||||
metadata: {
|
||||
project_id: string
|
||||
}
|
||||
}
|
||||
|
||||
const instance: Ref<Instance | null> = ref(null)
|
||||
const instanceProjects: Ref<InstanceProject[] | null> = ref(null)
|
||||
const instanceHideInstalled = ref(false)
|
||||
const newlyInstalled = ref([])
|
||||
const newlyInstalled: Ref<string[]> = ref([])
|
||||
|
||||
const { instance, instanceContent } = await useInstanceContext()
|
||||
|
||||
const PERSISTENT_QUERY_PARAMS = ['i', 'ai']
|
||||
|
||||
await updateInstanceContext()
|
||||
await checkHideInstalledQuery()
|
||||
|
||||
watch(route, () => {
|
||||
updateInstanceContext()
|
||||
watch(instance, () => {
|
||||
checkHideInstalledQuery()
|
||||
})
|
||||
|
||||
async function updateInstanceContext() {
|
||||
if (route.query.i) {
|
||||
;[instance.value, instanceProjects.value] = await Promise.all([
|
||||
getInstance(route.query.i).catch(handleError),
|
||||
getInstanceProjects(route.query.i).catch(handleError),
|
||||
])
|
||||
newlyInstalled.value = []
|
||||
}
|
||||
|
||||
async function checkHideInstalledQuery() {
|
||||
if (route.query.ai && !(projectTypes.value.length === 1 && projectTypes.value[0] === 'modpack')) {
|
||||
instanceHideInstalled.value = route.query.ai === 'true'
|
||||
}
|
||||
|
||||
if (instance.value && instance.value.path !== route.query.i && route.path.startsWith('/browse')) {
|
||||
instance.value = null
|
||||
instanceHideInstalled.value = false
|
||||
}
|
||||
// if (instance.value && instance.value.path !== route.query.i && route.path.startsWith('/browse')) {
|
||||
// instance.value = undefined
|
||||
// instanceHideInstalled.value = false
|
||||
// }
|
||||
}
|
||||
|
||||
const instanceFilters = computed(() => {
|
||||
@@ -119,10 +109,10 @@ const instanceFilters = computed(() => {
|
||||
})
|
||||
}
|
||||
|
||||
if (instanceHideInstalled.value && instanceProjects.value) {
|
||||
const installedMods = Object.values(instanceProjects.value)
|
||||
if (instanceHideInstalled.value && instanceContent.value) {
|
||||
const installedMods: string[] = Object.values(instanceContent.value)
|
||||
.filter((x) => x.metadata)
|
||||
.map((x) => x.metadata.project_id)
|
||||
.map((x) => x.metadata!.project_id)
|
||||
|
||||
installedMods.push(...newlyInstalled.value)
|
||||
|
||||
@@ -173,23 +163,27 @@ breadcrumbs.setContext({ name: 'Discover content', link: route.path, query: rout
|
||||
|
||||
const loading = ref(true)
|
||||
|
||||
const projectType = ref(route.params.projectType)
|
||||
const projectType: Ref<ProjectType | undefined> = ref(
|
||||
typeof route.params.projectType === 'string'
|
||||
? (route.params.projectType as ProjectType)
|
||||
: undefined,
|
||||
)
|
||||
|
||||
watch(projectType, () => {
|
||||
loading.value = true
|
||||
})
|
||||
|
||||
type SearchResult = {
|
||||
project_id: string
|
||||
type ExtendedSearchResult = SearchResult & {
|
||||
installed?: boolean
|
||||
}
|
||||
|
||||
type SearchResults = {
|
||||
total_hits: number
|
||||
limit: number
|
||||
hits: SearchResult[]
|
||||
hits: ExtendedSearchResult[]
|
||||
}
|
||||
|
||||
const results: Ref<SearchResults | null> = shallowRef(null)
|
||||
const results: Ref<SearchResults | undefined> = shallowRef()
|
||||
const pageCount = computed(() =>
|
||||
results.value ? Math.ceil(results.value.total_hits / results.value.limit) : 1,
|
||||
)
|
||||
@@ -200,7 +194,7 @@ watch(requestParams, () => {
|
||||
})
|
||||
|
||||
async function refreshSearch() {
|
||||
let rawResults = await get_search_results(requestParams.value)
|
||||
let rawResults = (await get_search_results(requestParams.value)) as { result: SearchResults }
|
||||
if (!rawResults) {
|
||||
rawResults = {
|
||||
result: {
|
||||
@@ -211,13 +205,15 @@ async function refreshSearch() {
|
||||
}
|
||||
}
|
||||
if (instance.value) {
|
||||
for (const val of rawResults.result.hits) {
|
||||
val.installed =
|
||||
newlyInstalled.value.includes(val.project_id) ||
|
||||
Object.values(instanceProjects.value).some(
|
||||
(x) => x.metadata && x.metadata.project_id === val.project_id,
|
||||
)
|
||||
}
|
||||
rawResults.result.hits.map((x) => ({
|
||||
...x,
|
||||
installed:
|
||||
newlyInstalled.value.includes(x.project_id) ||
|
||||
(instanceContent.value &&
|
||||
Object.values(instanceContent.value).some(
|
||||
(content) => content.metadata && content.metadata.project_id === x.project_id,
|
||||
)),
|
||||
}))
|
||||
}
|
||||
results.value = rawResults.result
|
||||
|
||||
@@ -271,9 +267,9 @@ watch(
|
||||
() => route.params.projectType,
|
||||
async (newType) => {
|
||||
// Check if the newType is not the same as the current value
|
||||
if (!newType || newType === projectType.value) return
|
||||
if (!newType || newType === projectType.value || typeof newType !== 'string') return
|
||||
|
||||
projectType.value = newType
|
||||
projectType.value = newType as ProjectType
|
||||
|
||||
currentSortType.value = { display: 'Relevance', name: 'relevance' }
|
||||
query.value = ''
|
||||
@@ -287,7 +283,7 @@ const selectableProjectTypes = computed(() => {
|
||||
|
||||
if (instance.value) {
|
||||
if (
|
||||
availableGameVersions.value.findIndex((x) => x.version === instance.value.game_version) <=
|
||||
availableGameVersions.value.findIndex((x) => x.version === instance.value?.game_version) <=
|
||||
availableGameVersions.value.findIndex((x) => x.version === '1.13')
|
||||
) {
|
||||
dataPacks = true
|
||||
@@ -353,9 +349,10 @@ const messages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
const options = ref(null)
|
||||
const handleRightClick = (event, result) => {
|
||||
options.value.showMenu(event, result, [
|
||||
const options: Ref<InstanceType<typeof ContextMenu> | null> = ref(null)
|
||||
|
||||
const handleRightClick = (event: MouseEvent, result: ExtendedSearchResult) => {
|
||||
options.value?.showMenu(event, result, [
|
||||
{
|
||||
name: 'open_link',
|
||||
},
|
||||
@@ -364,7 +361,7 @@ const handleRightClick = (event, result) => {
|
||||
},
|
||||
])
|
||||
}
|
||||
const handleOptionsClick = (args) => {
|
||||
const handleOptionsClick = (args: { item: ExtendedSearchResult; option: string }) => {
|
||||
switch (args.option) {
|
||||
case 'open_link':
|
||||
openUrl(`https://modrinth.com/${args.item.project_type}/${args.item.slug}`)
|
||||
@@ -477,33 +474,26 @@ await refreshSearch()
|
||||
<section v-if="loading" class="offline">
|
||||
<LoadingIndicator />
|
||||
</section>
|
||||
<section v-else-if="offline && results.total_hits === 0" class="offline">
|
||||
<section v-else-if="offline && (!results || results.total_hits === 0)" class="offline">
|
||||
You are currently offline. Connect to the internet to browse Modrinth!
|
||||
</section>
|
||||
<section v-else class="project-list display-mode--list instance-results" role="list">
|
||||
<section
|
||||
v-else-if="results"
|
||||
class="project-list display-mode--list instance-results"
|
||||
role="list"
|
||||
>
|
||||
<SearchCard
|
||||
v-for="result in results.hits"
|
||||
:key="result?.project_id"
|
||||
:project="result"
|
||||
:instance="instance"
|
||||
:categories="[
|
||||
...categories.filter(
|
||||
(cat) =>
|
||||
result?.display_categories.includes(cat.name) && cat.project_type === projectType,
|
||||
),
|
||||
...loaders.filter(
|
||||
(loader) =>
|
||||
result?.display_categories.includes(loader.name) &&
|
||||
loader.supported_project_types?.includes(projectType),
|
||||
),
|
||||
]"
|
||||
:installed="result.installed || newlyInstalled.includes(result.project_id)"
|
||||
@install="
|
||||
(id) => {
|
||||
newlyInstalled.push(id)
|
||||
}
|
||||
"
|
||||
@contextmenu.prevent.stop="(event) => handleRightClick(event, result)"
|
||||
@contextmenu.prevent.stop="(event: MouseEvent) => handleRightClick(event, result)"
|
||||
/>
|
||||
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
|
||||
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>
|
||||
|
||||
136
apps/app-frontend/src/pages/collection/Index.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<Teleport to="#sidebar-teleport-target">
|
||||
<CollectionSidebarDescription
|
||||
v-if="collection"
|
||||
:collection="collection"
|
||||
class="project-sidebar-section"
|
||||
/>
|
||||
<CollectionSidebarCurator
|
||||
v-if="curator"
|
||||
:user="curator"
|
||||
:link="`/user/${curator.id}`"
|
||||
class="project-sidebar-section"
|
||||
/>
|
||||
<CollectionSidebarDetails
|
||||
v-if="collection"
|
||||
:collection="collection"
|
||||
class="project-sidebar-section"
|
||||
/>
|
||||
</Teleport>
|
||||
<div v-if="collection" class="p-6 flex flex-col gap-4">
|
||||
<InstanceIndicator :instance="instance" />
|
||||
<CollectionHeader :collection="collection">
|
||||
<template #actions>
|
||||
<ButtonStyled v-if="themeStore.devMode" circular type="transparent" size="large">
|
||||
<OverflowMenu
|
||||
:options="[{ id: 'copy-id', action: () => copyId(), shown: themeStore.devMode }]"
|
||||
aria-label="More options"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #copy-id>
|
||||
<ClipboardCopyIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.copyIdButton) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</CollectionHeader>
|
||||
<div v-if="projects">
|
||||
<ProjectsList
|
||||
:projects="projects"
|
||||
:project-link="(project) => `/project/${project.id}${instanceQueryAppendage}`"
|
||||
:experimental-colors="themeStore.featureFlags.project_card_background"
|
||||
>
|
||||
<template #project-actions="{ project }">
|
||||
<ProjectCardActions
|
||||
:instance="instance"
|
||||
:instance-content="instanceContent"
|
||||
:project="project"
|
||||
/>
|
||||
</template>
|
||||
</ProjectsList>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ref, type Ref, watch } from 'vue'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import {
|
||||
ProjectsList,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
OverflowMenu,
|
||||
CollectionHeader,
|
||||
CollectionSidebarCurator,
|
||||
CollectionSidebarDescription,
|
||||
CollectionSidebarDetails,
|
||||
} from '@modrinth/ui'
|
||||
import { ClipboardCopyIcon, MoreVerticalIcon } from '@modrinth/assets'
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
import { useFetch } from '@/helpers/fetch'
|
||||
import type { User, Project, Collection } from '@modrinth/utils'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
import { useTheming } from '@/store/theme'
|
||||
import { useInstanceContext } from '@/composables/instance-context'
|
||||
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
|
||||
import ProjectCardActions from '@/components/ui/ProjectCardActions.vue'
|
||||
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const route = useRoute()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const collection: Ref<Collection | null> = ref(null)
|
||||
const curator: Ref<User | null> = ref(null)
|
||||
const projects: Ref<Project[]> = ref([])
|
||||
|
||||
async function fetchCollection() {
|
||||
collection.value = await useFetch(
|
||||
`https://api.modrinth.com/v3/collection/${route.params.id}`,
|
||||
).catch(handleError)
|
||||
|
||||
if (!collection.value) {
|
||||
return
|
||||
}
|
||||
|
||||
;[projects.value, curator.value] = await Promise.all([
|
||||
useFetch(
|
||||
`https://api.modrinth.com/v2/projects?ids=${encodeURIComponent(JSON.stringify(collection.value.projects))}`,
|
||||
),
|
||||
useFetch(`https://api.modrinth.com/v2/user/${collection.value.user}`).catch(handleError),
|
||||
])
|
||||
|
||||
breadcrumbs.setContext({ name: 'Collection', link: `/collection/${collection.value.name}` })
|
||||
breadcrumbs.setName('Collection', collection.value.name)
|
||||
}
|
||||
|
||||
await fetchCollection()
|
||||
|
||||
const { instance, instanceContent, instanceQueryAppendage } = await useInstanceContext()
|
||||
|
||||
watch(
|
||||
() => route.params.id,
|
||||
async () => {
|
||||
if (route.params.id && route.path.startsWith('/collection')) {
|
||||
await fetchCollection()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
async function copyId() {
|
||||
if (collection.value) {
|
||||
await navigator.clipboard.writeText(String(collection.value.id))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.project-sidebar-section {
|
||||
@apply p-4 flex flex-col gap-2 border-0 border-[--brand-gradient-border] border-solid;
|
||||
}
|
||||
.project-sidebar-section:not(:last-child) {
|
||||
@apply border-b-[1px];
|
||||
}
|
||||
</style>
|
||||
3
apps/app-frontend/src/pages/collection/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import Index from './Index.vue'
|
||||
|
||||
export { Index }
|
||||
@@ -17,11 +17,11 @@
|
||||
<div
|
||||
class="flex items-center gap-2 font-semibold transform capitalize border-0 border-solid border-divider pr-4 md:border-r"
|
||||
>
|
||||
<GameIcon class="h-6 w-6 text-secondary" />
|
||||
<GameIcon class="h-5 w-5 text-secondary" />
|
||||
{{ instance.loader }} {{ instance.game_version }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 font-semibold">
|
||||
<TimerIcon class="h-6 w-6 text-secondary" />
|
||||
<TimerIcon class="h-5 w-5 text-secondary" />
|
||||
<template v-if="timePlayed > 0">
|
||||
{{ timePlayedHumanized }}
|
||||
</template>
|
||||
|
||||
@@ -66,10 +66,13 @@
|
||||
|
||||
if (x.author) {
|
||||
item.creator = {
|
||||
name: x.author,
|
||||
type: 'user',
|
||||
id: x.author,
|
||||
link: 'https://modrinth.com/user/' + x.author,
|
||||
name: x.author.name,
|
||||
type: x.author.type,
|
||||
id: x.author.slug,
|
||||
link: {
|
||||
path: `/${x.author.type}/${x.author.slug}`,
|
||||
query: { i: props.instance.path },
|
||||
},
|
||||
linkProps: { target: '_blank' },
|
||||
}
|
||||
}
|
||||
@@ -329,6 +332,28 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
type ProjectListEntryAuthor = {
|
||||
name: string
|
||||
slug: string
|
||||
type: 'user' | 'organization'
|
||||
}
|
||||
|
||||
type ProjectListEntry = {
|
||||
path: string
|
||||
name: string
|
||||
slug?: string
|
||||
author: ProjectListEntryAuthor | null
|
||||
version: string | null
|
||||
file_name: string
|
||||
icon: string | null
|
||||
disabled: boolean
|
||||
updateVersion?: string
|
||||
outdated: boolean
|
||||
updated: dayjs.Dayjs
|
||||
project_type: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
const isPackLocked = computed(() => {
|
||||
return props.instance.linked_data && props.instance.linked_data.locked
|
||||
})
|
||||
@@ -338,7 +363,7 @@ const canUpdatePack = computed(() => {
|
||||
})
|
||||
const exportModal = ref(null)
|
||||
|
||||
const projects = ref([])
|
||||
const projects = ref<ProjectListEntry[]>([])
|
||||
const selectedFiles = ref([])
|
||||
const selectedProjects = computed(() =>
|
||||
projects.value.filter((x) => selectedFiles.value.includes(x.file_name)),
|
||||
@@ -347,7 +372,7 @@ const selectedProjects = computed(() =>
|
||||
const selectionMap = ref(new Map())
|
||||
|
||||
const initProjects = async (cacheBehaviour?) => {
|
||||
const newProjects = []
|
||||
const newProjects: ProjectListEntry[] = []
|
||||
|
||||
const profileProjects = await get_projects(props.instance.path, cacheBehaviour)
|
||||
const fetchProjects = []
|
||||
@@ -384,21 +409,29 @@ const initProjects = async (cacheBehaviour?) => {
|
||||
|
||||
const team = modrinthTeams.find((x) => x[0].team_id === project.team)
|
||||
|
||||
let owner
|
||||
|
||||
let author: ProjectListEntryAuthor | null
|
||||
if (org) {
|
||||
owner = org.name
|
||||
author = {
|
||||
name: org.name,
|
||||
slug: org.slug,
|
||||
type: 'organization',
|
||||
}
|
||||
} else if (team) {
|
||||
owner = team.find((x) => x.is_owner).user.username
|
||||
const teamMember = team.find((x) => x.is_owner)
|
||||
author = {
|
||||
name: teamMember.user.username,
|
||||
slug: teamMember.user.username,
|
||||
type: 'user',
|
||||
}
|
||||
} else {
|
||||
owner = null
|
||||
author = null
|
||||
}
|
||||
|
||||
newProjects.push({
|
||||
path,
|
||||
name: project.title,
|
||||
slug: project.slug,
|
||||
author: owner,
|
||||
author,
|
||||
version: version.version_number,
|
||||
file_name: file.file_name,
|
||||
icon: project.icon_url,
|
||||
@@ -417,7 +450,7 @@ const initProjects = async (cacheBehaviour?) => {
|
||||
newProjects.push({
|
||||
path,
|
||||
name: file.file_name.replace('.disabled', ''),
|
||||
author: '',
|
||||
author: null,
|
||||
version: null,
|
||||
file_name: file.file_name,
|
||||
icon: null,
|
||||
|
||||
@@ -10,7 +10,7 @@ defineProps({
|
||||
</script>
|
||||
<template>
|
||||
<GridDisplay
|
||||
v-if="instances.length > 0"
|
||||
v-if="instances && instances.length > 0"
|
||||
label="Instances"
|
||||
:instances="instances.filter((i) => !i.linked_data)"
|
||||
/>
|
||||
|
||||
@@ -10,7 +10,7 @@ defineProps({
|
||||
</script>
|
||||
<template>
|
||||
<GridDisplay
|
||||
v-if="instances.length > 0"
|
||||
v-if="instances && instances.length > 0"
|
||||
label="Instances"
|
||||
:instances="instances.filter((i) => i.linked_data)"
|
||||
/>
|
||||
|
||||
@@ -9,5 +9,5 @@ defineProps({
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<GridDisplay v-if="instances.length > 0" label="Instances" :instances="instances" />
|
||||
<GridDisplay v-if="instances && instances.length > 0" label="Instances" :instances="instances" />
|
||||
</template>
|
||||
|
||||
176
apps/app-frontend/src/pages/organization/Index.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<Teleport to="#sidebar-teleport-target">
|
||||
<OrganizationSidebarMembers
|
||||
v-if="organization"
|
||||
:members="organization.members"
|
||||
:user-link="(user) => `/user/${user.id}`"
|
||||
class="project-sidebar-section"
|
||||
/>
|
||||
</Teleport>
|
||||
<div v-if="organization" class="flex flex-col gap-4 p-6">
|
||||
<InstanceIndicator :instance="instance" />
|
||||
<OrganizationHeader
|
||||
:organization="organization"
|
||||
:download-count="sumDownloads"
|
||||
:project-count="projects.length"
|
||||
>
|
||||
<template #actions>
|
||||
<ButtonStyled v-if="themeStore.devMode" circular type="transparent" size="large">
|
||||
<OverflowMenu
|
||||
:options="[{ id: 'copy-id', action: () => copyId(), shown: themeStore.devMode }]"
|
||||
aria-label="More options"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #copy-id>
|
||||
<ClipboardCopyIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.copyIdButton) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</OrganizationHeader>
|
||||
<div v-if="projects">
|
||||
<ProjectsList
|
||||
:projects="projects"
|
||||
:project-link="(project) => `/project/${project.id}${instanceQueryAppendage}`"
|
||||
:experimental-colors="themeStore.featureFlags.project_card_background"
|
||||
>
|
||||
<template #project-actions="{ project }">
|
||||
<ProjectCardActions
|
||||
:project="project"
|
||||
:instance="instance"
|
||||
:instance-content="instanceContent"
|
||||
/>
|
||||
</template>
|
||||
</ProjectsList>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ref, type Ref, watch, computed } from 'vue'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import {
|
||||
ProjectsList,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
OverflowMenu,
|
||||
OrganizationHeader,
|
||||
OrganizationSidebarMembers,
|
||||
} from '@modrinth/ui'
|
||||
import { ClipboardCopyIcon, MoreVerticalIcon } from '@modrinth/assets'
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
import { useFetch } from '@/helpers/fetch'
|
||||
import type { Project, Organization, ProjectV3, Environment } from '@modrinth/utils'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
import { useTheming } from '@/store/theme'
|
||||
import { useInstanceContext } from '@/composables/instance-context.ts'
|
||||
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
|
||||
import ProjectCardActions from '@/components/ui/ProjectCardActions.vue'
|
||||
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const route = useRoute()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const organization: Ref<Organization | null> = ref(null)
|
||||
const projects: Ref<Project[]> = ref([])
|
||||
|
||||
const { instance, instanceContent, instanceQueryAppendage } = await useInstanceContext()
|
||||
|
||||
async function fetchOrganization() {
|
||||
organization.value = await useFetch(
|
||||
`https://api.modrinth.com/v3/organization/${route.params.id}`,
|
||||
).catch(handleError)
|
||||
projects.value = (
|
||||
await useFetch(`https://api.modrinth.com/v3/organization/${route.params.id}/projects`).catch(
|
||||
handleError,
|
||||
)
|
||||
).map((projectV3: ProjectV3) => {
|
||||
let type = projectV3.project_types[0]
|
||||
|
||||
if (type === 'plugin' || type === 'datapack') {
|
||||
type = 'mod'
|
||||
}
|
||||
|
||||
let clientSide: Environment = 'unknown'
|
||||
let serverSide: Environment = 'unknown'
|
||||
|
||||
const singleplayer = projectV3.singleplayer && projectV3.singleplayer[0]
|
||||
const clientAndServer = projectV3.client_and_server && projectV3.client_and_server[0]
|
||||
const clientOnly = projectV3.client_only && projectV3.client_only[0]
|
||||
const serverOnly = projectV3.server_only && projectV3.server_only[0]
|
||||
|
||||
// quick and dirty hack to show envs as legacy
|
||||
if (singleplayer && clientAndServer && !clientOnly && !serverOnly) {
|
||||
clientSide = 'required'
|
||||
serverSide = 'required'
|
||||
} else if (singleplayer && clientAndServer && clientOnly && !serverOnly) {
|
||||
clientSide = 'required'
|
||||
serverSide = 'unsupported'
|
||||
} else if (singleplayer && clientAndServer && !clientOnly && serverOnly) {
|
||||
clientSide = 'unsupported'
|
||||
serverSide = 'required'
|
||||
} else if (singleplayer && clientAndServer && clientOnly && serverOnly) {
|
||||
clientSide = 'optional'
|
||||
serverSide = 'optional'
|
||||
}
|
||||
|
||||
const projectV2: Project = {
|
||||
...projectV3,
|
||||
title: projectV3.name,
|
||||
description: projectV3.summary,
|
||||
body: projectV3.description,
|
||||
project_type: type,
|
||||
team: projectV3.team_id,
|
||||
donation_urls: [],
|
||||
client_side: clientSide,
|
||||
server_side: serverSide,
|
||||
}
|
||||
return projectV2
|
||||
})
|
||||
|
||||
if (!organization.value) {
|
||||
return
|
||||
}
|
||||
|
||||
breadcrumbs.setName('Organization', organization.value.name)
|
||||
}
|
||||
|
||||
await fetchOrganization()
|
||||
|
||||
watch(
|
||||
() => route.params.id,
|
||||
async () => {
|
||||
if (route.params.id && route.path.startsWith('/organization')) {
|
||||
await fetchOrganization()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
async function copyId() {
|
||||
if (organization.value) {
|
||||
await navigator.clipboard.writeText(String(organization.value.id))
|
||||
}
|
||||
}
|
||||
|
||||
const sumDownloads = computed(() => {
|
||||
let sum = 0
|
||||
|
||||
for (const project of projects.value) {
|
||||
sum += project.downloads
|
||||
}
|
||||
|
||||
return sum
|
||||
})
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.project-sidebar-section {
|
||||
@apply p-4 flex flex-col gap-2 border-0 border-[--brand-gradient-border] border-solid;
|
||||
}
|
||||
.project-sidebar-section:not(:last-child) {
|
||||
@apply border-b-[1px];
|
||||
}
|
||||
</style>
|
||||
3
apps/app-frontend/src/pages/organization/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import Index from './Index.vue'
|
||||
|
||||
export { Index }
|
||||
@@ -31,10 +31,10 @@
|
||||
: 'https://cdn.modrinth.com/placeholder-banner.svg'
|
||||
"
|
||||
:alt="expandedGalleryItem.title ? expandedGalleryItem.title : 'gallery-image'"
|
||||
@click.stop=""
|
||||
@click.stop="() => {}"
|
||||
/>
|
||||
|
||||
<div class="floating" @click.stop="">
|
||||
<div class="floating" @click.stop="() => {}">
|
||||
<div class="text">
|
||||
<h2 v-if="expandedGalleryItem.title">
|
||||
{{ expandedGalleryItem.title }}
|
||||
@@ -99,7 +99,7 @@ import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -8,11 +8,10 @@
|
||||
/>
|
||||
<ProjectSidebarLinks link-target="_blank" :project="data" class="project-sidebar-section" />
|
||||
<ProjectSidebarCreators
|
||||
:organization="null"
|
||||
:organization="organization"
|
||||
:members="members"
|
||||
:org-link="(slug) => `https://modrinth.com/organization/${slug}`"
|
||||
:user-link="(username) => `https://modrinth.com/user/${username}`"
|
||||
link-target="_blank"
|
||||
:org-link="(org) => `/organization/${org.id}${instance ? '?i=' + instance.path : ''}`"
|
||||
:user-link="(user) => `/user/${user.id}${instance ? '?i=' + instance.path : ''}`"
|
||||
class="project-sidebar-section"
|
||||
/>
|
||||
<ProjectSidebarDetails
|
||||
@@ -23,7 +22,7 @@
|
||||
/>
|
||||
</Teleport>
|
||||
<div class="flex flex-col gap-4 p-6">
|
||||
<InstanceIndicator v-if="instance" :instance="instance" />
|
||||
<InstanceIndicator :instance="instance" />
|
||||
<template v-if="data">
|
||||
<Teleport
|
||||
v-if="themeStore.featureFlags.project_background"
|
||||
@@ -92,6 +91,11 @@
|
||||
label: 'Description',
|
||||
href: `/project/${$route.params.id}`,
|
||||
},
|
||||
{
|
||||
label: 'Gallery',
|
||||
href: `/project/${$route.params.id}/gallery`,
|
||||
shown: data.gallery.length > 0,
|
||||
},
|
||||
{
|
||||
label: 'Versions',
|
||||
href: {
|
||||
@@ -100,11 +104,6 @@
|
||||
},
|
||||
subpages: ['version'],
|
||||
},
|
||||
{
|
||||
label: 'Gallery',
|
||||
href: `/project/${$route.params.id}/gallery`,
|
||||
shown: data.gallery.length > 0,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<RouterView
|
||||
@@ -166,6 +165,7 @@ import NavTabs from '@/components/ui/NavTabs.vue'
|
||||
import { useTheming } from '@/store/state.js'
|
||||
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { useFetch } from '@/helpers/fetch.js'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
@@ -177,6 +177,7 @@ const installing = ref(false)
|
||||
const data = shallowRef(null)
|
||||
const versions = shallowRef([])
|
||||
const members = shallowRef([])
|
||||
const organization = shallowRef(null)
|
||||
const categories = shallowRef([])
|
||||
const instance = ref(null)
|
||||
const instanceProjects = ref(null)
|
||||
@@ -202,6 +203,12 @@ async function fetchProjectData() {
|
||||
route.query.i ? getInstanceProjects(route.query.i).catch(handleError) : Promise.resolve(),
|
||||
])
|
||||
|
||||
if (project.organization) {
|
||||
organization.value = await useFetch(
|
||||
`https://api.modrinth.com/v3/organization/${project.organization}`,
|
||||
).catch(handleError)
|
||||
}
|
||||
|
||||
versions.value = versions.value.sort((a, b) => dayjs(b.date_published) - dayjs(a.date_published))
|
||||
|
||||
if (instanceProjects.value) {
|
||||
@@ -213,6 +220,7 @@ async function fetchProjectData() {
|
||||
installedVersion.value = installedFile.metadata.version_id
|
||||
}
|
||||
}
|
||||
|
||||
breadcrumbs.setName('Project', data.value.title)
|
||||
}
|
||||
|
||||
@@ -437,6 +445,10 @@ const handleOptionsClick = (args) => {
|
||||
}
|
||||
|
||||
.project-sidebar-section {
|
||||
@apply p-4 flex flex-col gap-2 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid;
|
||||
@apply p-4 flex flex-col gap-2 border-0 border-[--brand-gradient-border] border-solid;
|
||||
}
|
||||
|
||||
.project-sidebar-section:not(:last-child) {
|
||||
@apply border-b-[1px];
|
||||
}
|
||||
</style>
|
||||
|
||||
156
apps/app-frontend/src/pages/user/Index.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<Teleport to="#sidebar-teleport-target">
|
||||
<UserSidebarOrganizations
|
||||
:organizations="organizations"
|
||||
:link="(org: Organization) => `/organization/${org.id}${instanceQueryAppendage}`"
|
||||
class="project-sidebar-section"
|
||||
/>
|
||||
<UserSidebarBadges
|
||||
v-if="user"
|
||||
:user="user"
|
||||
:download-count="sumDownloads"
|
||||
class="project-sidebar-section"
|
||||
/>
|
||||
<UserSidebarCollections
|
||||
:collections="collections"
|
||||
:link="(collection: Collection) => `/collection/${collection.id}${instanceQueryAppendage}`"
|
||||
class="project-sidebar-section"
|
||||
/>
|
||||
</Teleport>
|
||||
<div v-if="user" class="p-6 flex flex-col gap-4">
|
||||
<InstanceIndicator :instance="instance" />
|
||||
<UserHeader :user="user" :project-count="projects.length" :download-count="sumDownloads">
|
||||
<template #actions>
|
||||
<ButtonStyled circular type="transparent" size="large">
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'report',
|
||||
link: `https://modrinth.com/report?item=user&itemID=${user.id}`,
|
||||
color: 'red',
|
||||
},
|
||||
{ id: 'copy-id', action: () => copyId(), shown: themeStore.devMode },
|
||||
]"
|
||||
aria-label="More options"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #report>
|
||||
<ReportIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.reportButton) }}
|
||||
</template>
|
||||
<template #copy-id>
|
||||
<ClipboardCopyIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.copyIdButton) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</UserHeader>
|
||||
<div v-if="projects">
|
||||
<ProjectsList
|
||||
:projects="projects"
|
||||
:project-link="(project) => `/project/${project.id}${instanceQueryAppendage}`"
|
||||
:experimental-colors="themeStore.featureFlags.project_card_background"
|
||||
>
|
||||
<template #project-actions="{ project }">
|
||||
<ProjectCardActions
|
||||
:instance="instance"
|
||||
:instance-content="instanceContent"
|
||||
:project="project"
|
||||
/>
|
||||
</template>
|
||||
</ProjectsList>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ref, type Ref, watch, computed } from 'vue'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import {
|
||||
ProjectsList,
|
||||
UserSidebarOrganizations,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
OverflowMenu,
|
||||
UserHeader,
|
||||
UserSidebarBadges,
|
||||
UserSidebarCollections,
|
||||
} from '@modrinth/ui'
|
||||
import { ReportIcon, ClipboardCopyIcon, MoreVerticalIcon } from '@modrinth/assets'
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
import { useFetch } from '@/helpers/fetch'
|
||||
import type { User, Project, Organization, Collection } from '@modrinth/utils'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
import { useTheming } from '@/store/theme'
|
||||
import { useInstanceContext } from '@/composables/instance-context'
|
||||
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
|
||||
import ProjectCardActions from '@/components/ui/ProjectCardActions.vue'
|
||||
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const route = useRoute()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const user: Ref<User | null> = ref(null)
|
||||
const projects: Ref<Project[]> = ref([])
|
||||
const organizations: Ref<Organization[]> = ref([])
|
||||
const collections: Ref<Collection[]> = ref([])
|
||||
|
||||
async function fetchUser() {
|
||||
;[user.value, projects.value, organizations.value, collections.value] = await Promise.all([
|
||||
useFetch(`https://api.modrinth.com/v2/user/${route.params.id}`).catch(handleError),
|
||||
useFetch(`https://api.modrinth.com/v2/user/${route.params.id}/projects`).catch(handleError),
|
||||
useFetch(`https://api.modrinth.com/v3/user/${route.params.id}/organizations`).catch(
|
||||
handleError,
|
||||
),
|
||||
useFetch(`https://api.modrinth.com/v3/user/${route.params.id}/collections`).catch(handleError),
|
||||
])
|
||||
|
||||
if (!user.value) {
|
||||
return
|
||||
}
|
||||
|
||||
breadcrumbs.setContext({ name: 'User', link: `/user/${user.value.username}` })
|
||||
breadcrumbs.setName('User', user.value.username)
|
||||
}
|
||||
|
||||
await fetchUser()
|
||||
|
||||
const { instance, instanceContent, instanceQueryAppendage } = await useInstanceContext()
|
||||
|
||||
watch(
|
||||
() => route.params.id,
|
||||
async () => {
|
||||
if (route.params.id && route.path.startsWith('/user')) {
|
||||
await fetchUser()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
async function copyId() {
|
||||
if (user.value) {
|
||||
await navigator.clipboard.writeText(String(user.value.id))
|
||||
}
|
||||
}
|
||||
|
||||
const sumDownloads = computed(() => {
|
||||
let sum = 0
|
||||
|
||||
for (const project of projects.value) {
|
||||
sum += project.downloads
|
||||
}
|
||||
|
||||
return sum
|
||||
})
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.project-sidebar-section {
|
||||
@apply p-4 flex flex-col gap-2 border-0 border-[--brand-gradient-border] border-solid;
|
||||
}
|
||||
.project-sidebar-section:not(:last-child) {
|
||||
@apply border-b-[1px];
|
||||
}
|
||||
</style>
|
||||
3
apps/app-frontend/src/pages/user/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import Index from './Index.vue'
|
||||
|
||||
export { Index }
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import * as Pages from '@/pages'
|
||||
import * as Project from '@/pages/project'
|
||||
import * as User from '@/pages/user'
|
||||
import * as Organization from '@/pages/organization'
|
||||
import * as Collection from '@/pages/collection'
|
||||
import * as Instance from '@/pages/instance'
|
||||
import * as Library from '@/pages/library'
|
||||
|
||||
@@ -100,6 +103,36 @@ export default new createRouter({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/user/:id',
|
||||
name: 'User',
|
||||
component: User.Index,
|
||||
props: true,
|
||||
meta: {
|
||||
useContext: true,
|
||||
breadcrumb: [{ name: '?User', link: '/user/{id}' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/organization/:id',
|
||||
name: 'Organization',
|
||||
component: Organization.Index,
|
||||
props: true,
|
||||
meta: {
|
||||
useContext: true,
|
||||
breadcrumb: [{ name: '?Organization', link: '/organization/{id}' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/collection/:id',
|
||||
name: 'Collection',
|
||||
component: Collection.Index,
|
||||
props: true,
|
||||
meta: {
|
||||
useContext: true,
|
||||
breadcrumb: [{ name: '?Collection', link: '/collection/{id}' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/instance/:id',
|
||||
name: 'Instance',
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const DEFAULT_FEATURE_FLAGS = {
|
||||
project_background: false,
|
||||
page_path: false,
|
||||
project_card_background: false,
|
||||
}
|
||||
|
||||
export const useTheming = defineStore('themeStore', {
|
||||
state: () => ({
|
||||
themeOptions: ['dark', 'light', 'oled', 'system'],
|
||||
@@ -8,7 +14,7 @@ export const useTheming = defineStore('themeStore', {
|
||||
toggleSidebar: false,
|
||||
|
||||
devMode: false,
|
||||
featureFlags: {},
|
||||
featureFlags: DEFAULT_FEATURE_FLAGS,
|
||||
}),
|
||||
actions: {
|
||||
setThemeState(newTheme) {
|
||||
|
||||
@@ -7,18 +7,7 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
theseus = { path = "../../packages/app-lib", features = ["cli"] }
|
||||
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
thiserror = "1.0"
|
||||
url = "2.2"
|
||||
webbrowser = "0.8.13"
|
||||
dunce = "1.0.3"
|
||||
|
||||
futures = "0.3"
|
||||
uuid = { version = "1.1", features = ["serde", "v4"] }
|
||||
|
||||
tracing = "0.1.37"
|
||||
tracing-subscriber = "0.3.18"
|
||||
tracing-error = "0.2.0"
|
||||
|
||||
@@ -28,12 +28,9 @@ tauri-plugin-single-instance = { version = "2.2.0" }
|
||||
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
thiserror = "1.0"
|
||||
futures = "0.3"
|
||||
daedalus = { path = "../../packages/daedalus" }
|
||||
chrono = "0.4.26"
|
||||
|
||||
dirs = "5.0.1"
|
||||
|
||||
url = "2.2"
|
||||
uuid = { version = "1.1", features = ["serde", "v4"] }
|
||||
os_info = "3.7.0"
|
||||
@@ -41,9 +38,6 @@ os_info = "3.7.0"
|
||||
tracing = "0.1.37"
|
||||
tracing-error = "0.2.0"
|
||||
|
||||
lazy_static = "1"
|
||||
once_cell = "1"
|
||||
|
||||
dashmap = "6.0.1"
|
||||
paste = "1.0.15"
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"fileAssociations": [
|
||||
{
|
||||
"ext": ["mrpack"],
|
||||
"mimeType": "application/zip+mrpack"
|
||||
"mimeType": "application/x-modrinth-modpack+zip"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -22,7 +22,6 @@ reqwest = { version = "0.12.5", default-features = false, features = [
|
||||
"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 = { version = "0.33.0", default-features = false, features = [
|
||||
@@ -39,4 +38,3 @@ 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"] }
|
||||
|
||||
@@ -598,7 +598,7 @@ async fn fetch(
|
||||
))
|
||||
})?;
|
||||
|
||||
let file_name = value.split('/').last()
|
||||
let file_name = value.split('/').next_back()
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InvalidInput(format!(
|
||||
"Unable reading filename for data key {key} at path {value}",
|
||||
|
||||
@@ -44,6 +44,10 @@ export default defineConfig({
|
||||
label: 'Contributing to Modrinth',
|
||||
autogenerate: { directory: 'contributing' },
|
||||
},
|
||||
{
|
||||
label: 'Guides',
|
||||
autogenerate: { directory: 'guide' },
|
||||
},
|
||||
// Add the generated sidebar group to the sidebar.
|
||||
...openAPISidebarGroups,
|
||||
],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: '3.0.0'
|
||||
|
||||
info:
|
||||
version: v2.7.0/15cf3fc
|
||||
version: v2.7.0/366f528
|
||||
title: Labrinth
|
||||
termsOfService: https://modrinth.com/legal/terms
|
||||
contact:
|
||||
@@ -51,35 +51,7 @@ info:
|
||||
Please note that certain scopes and requests cannot be completed with a personal access token or using OAuth.
|
||||
For example, deleting a user account can only be done through Modrinth's frontend.
|
||||
|
||||
### OAuth2
|
||||
Applications interacting with an authenticated API should create an OAuth2 application.
|
||||
You can do this in [the developer settings](https://modrinth.com/settings/applications).
|
||||
|
||||
Make sure to save your application secret, as you will not be able to access it after you leave the page.
|
||||
|
||||
Once you have created a client, use the following URL to have a user authorize your client:
|
||||
```
|
||||
https://modrinth.com/auth/authorize?client_id=<CLIENT_ID>&redirect_uri=<CALLBACK_URL>&scope=<SCOPE_ONE>+<SCOPE_TWO>+<SCOPE_THREE>
|
||||
```
|
||||
> You can get a list of all scope names [here](https://github.com/modrinth/code/tree/main/apps/labrinth/src/models/v3/pats.rs).
|
||||
|
||||
Then, send a `POST` request to the following URL to get the token:
|
||||
|
||||
```
|
||||
https://api.modrinth.com/_internal/oauth/token
|
||||
```
|
||||
|
||||
> Note that you will need to provide your application's secret under the Authorization header.
|
||||
|
||||
In the body of your request, make sure to include the following:
|
||||
- `code`: The code generated when authorizing your client
|
||||
- `client_id`: Your client ID (found in developer settings)
|
||||
- `redirect_uri`: A valid redirect URI provided in your application's settings
|
||||
- `grant_type`: This will need to be `authorization_code`.
|
||||
|
||||
If your token request fails for any reason, you will need to get another code from the authorization process.
|
||||
|
||||
This route will be changed in the future to move the `_internal` part to `v3`.
|
||||
A detailed guide on OAuth has been published in [Modrinth's technical documentation](https://docs.modrinth.com/guide/oauth).
|
||||
|
||||
### Personal access tokens
|
||||
Personal access tokens (PATs) can be generated in from [the user settings](https://modrinth.com/settings/account).
|
||||
@@ -1823,7 +1795,7 @@ components:
|
||||
description: Number of projects on Modrinth
|
||||
versions:
|
||||
type: integer
|
||||
description: Number of projects on Modrinth
|
||||
description: Number of versions on Modrinth
|
||||
files:
|
||||
type: integer
|
||||
description: Number of version files on Modrinth
|
||||
@@ -3018,6 +2990,24 @@ paths:
|
||||
$ref: '#/components/schemas/InvalidInputError'
|
||||
'404':
|
||||
description: The requested item(s) were not found or no authorization to access the requested item(s)
|
||||
delete:
|
||||
summary: Remove user's avatar
|
||||
operationId: deleteUserIcon
|
||||
tags:
|
||||
- users
|
||||
security:
|
||||
- TokenAuth: ['USER_WRITE']
|
||||
responses:
|
||||
'204':
|
||||
description: Expected response to a valid request
|
||||
'400':
|
||||
description: Request was invalid, see given error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/InvalidInputError'
|
||||
'404':
|
||||
description: The requested item(s) were not found or no authorization to access the requested item(s)
|
||||
/user/{id|username}/projects:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/UserIdentifier'
|
||||
|
||||
95
apps/docs/src/content/docs/guide/oauth.md
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
title: The hitchhiker's guide to OAuth
|
||||
description: Guide for using Modrinth OAuth to interact with the API on users' behalf.
|
||||
---
|
||||
|
||||
Modrinth allows developers to create applications which, once authorized by a Modrinth user, let the developer interact with the API on their behalf. The flow used to get an API token is based on the OAuth 2 protocol. It is recommended that most people use an existing OAuth library to handle the authentication. If you want to implement it from scratch, you will need to look into [RFC 6749]. If the only user of the application is yourself, a personal access token (PAT) may be a better fit.
|
||||
|
||||
If you're familiar with OAuth 2, these are the URLs you will need:
|
||||
|
||||
| Name | URL |
|
||||
|--------------------|--------------------------------------------------|
|
||||
| Authorization page | `https://modrinth.com/auth/authorize` |
|
||||
| Token exchange | `https://api.modrinth.com/_internal/oauth/token` |
|
||||
|
||||
The flow will generally look like this:
|
||||
|
||||
1. User is redirected to Modrinth to authorize your application
|
||||
2. User is redirected back to your site after authorizing, with an authorization code
|
||||
3. Your backend exchanges this code for an access token
|
||||
|
||||
## Register your application
|
||||
|
||||
To start off, you need to [register an application] in Modrinth's systems. The settings chosen here can always be changed later. You need to select what permissions you need, called scopes. For security reasons you will want to select only the scopes you need. See the [principle of least privilege].
|
||||
|
||||
In addition to name and scopes, you will also need to add one or more redirect URIs. These are the URIs that the user can be redirected to after they authorize your application.
|
||||
|
||||
After you've registered your application, it is important that you take note of the client secret somewhere safe. If the client secret is to ever leak, it is important that you regenerate it to ensure the security of your authorized users. If your client secret or access tokens are found exposed in the wild, your application may be disabled without prior notice.
|
||||
|
||||
## Getting authorization
|
||||
|
||||
Once the user is ready to authorize your application, you need to construct a URL to redirect them to. The authorization URL for Modrinth is `https://api.modrinth.com/_internal/oauth/token`. Supply the following query parameters:
|
||||
|
||||
| Query parameter | Description |
|
||||
|-----------------|-------------------------------------------------------------------------------------------|
|
||||
| `response_type` | In Modrinth this always needs to be `code`, since only code grants are supported |
|
||||
| `client_id` | The application identifier found in the settings |
|
||||
| `scope` | The permissions you need access to |
|
||||
| `state` | A mechanism to prevent certain attacks. Explained further below. Recommended but optional |
|
||||
| `redirect_uri` | The URI the user is redirect to after finishing authorization |
|
||||
|
||||
You might have noticed the `state` parameter. [CSRF] (Cross-site request forgery), and [clickjacking] are security vulnerabilities that you're recommended to protect against. In OAuth2 this is usually done with the `state` parameter. When the user initiates a request to start authorization, you include a `state` which is unique to this request. This can, for example, be saved in localStorge or a cookie. When the redirect URI is called, you verify that the `state` parameter is the same. Using `state` is optional, but recommended.
|
||||
|
||||
The scope identifiers are currently best found in the backend source code located at [`apps/labrinth/src/models/v3/pats.rs`]. The scope parameter is an array of scope identifiers, seperated by a plus sign (`+`).
|
||||
|
||||
The redirect URI is the endpoint on your server that will receive the code which can eventually be used to act on the user's behalf. For security reasons the redirect URI used has to be allowlisted in your application settings. The redirect will contain the following query parameters:
|
||||
|
||||
| Query parameter | Description |
|
||||
|-----------------|----------------------------------------------------|
|
||||
| `code` | The code that can be exchanged for an access token |
|
||||
| `client_id` | Your client id |
|
||||
| `redirect_uri` | The redirect URI which was used |
|
||||
| `grant_type` | Always `authorization_code` in Modrinth |
|
||||
|
||||
## Exchanging tokens
|
||||
|
||||
If you've followed the previous section on getting authorization, you should now have an authorization code. Before you can access the API, you need to exchange this code for an access token. This is done by sending a POST request to the exchange token endpoint, `https://api.modrinth.com/_internal/oauth/token`. This request has to be of type urlencoded form. Make sure the `Content-Type` header is set to `application/x-www-form-urlencoded`. To authenticate this request you need to place your client secret in the `Authorization` header.
|
||||
|
||||
In the body use these fields:
|
||||
|
||||
| Field | Description |
|
||||
|----------------|--------------------------------------------------------------|
|
||||
| `code` | The authorization code |
|
||||
| `client_id` | Your client id, the same as in the authorization request |
|
||||
| `redirect_uri` | The redirect URI which was redirected to after authorization |
|
||||
| `grant_type` | Always `authorization_code` in Modrinth |
|
||||
|
||||
If the request succeeds, you should receive a JSON payload with these fields:
|
||||
|
||||
| Field | Description |
|
||||
|----------------|------------------------------------------------------|
|
||||
| `access_token` | The access token you can use to access the API |
|
||||
| `token_type` | Currently only `Bearer` |
|
||||
| `expires_in` | The amount of seconds until the access token expires |
|
||||
|
||||
To use this access token, you attach it to API requests in the `Authorization` header. To get basic information about the authorizer, you can use the [`/user` endpoint], which automatically gets the user from the header.
|
||||
|
||||
If you have any questions, you're welcome to ask in #api-development in the [Discord guild], or create a ticket on the [support portal].
|
||||
|
||||
[RFC 6749]: https://datatracker.ietf.org/doc/html/rfc6749
|
||||
|
||||
[register an application]: https://modrinth.com/settings/applications
|
||||
|
||||
[principle of least privilege]: https://en.wikipedia.org/wiki/Principle_of_least_privilege
|
||||
|
||||
[`apps/labrinth/src/models/v3/pats.rs`]: https://github.com/modrinth/code/blob/main/apps/labrinth/src/models/v3/pats.rs
|
||||
|
||||
[CSRF]: https://en.wikipedia.org/wiki/Cross-site_request_forgery
|
||||
|
||||
[Clickjacking]: https://en.wikipedia.org/wiki/Clickjacking
|
||||
|
||||
[`/user` endpoint]: https://docs.modrinth.com/api/operations/getuserfromauth/
|
||||
|
||||
[Discord guild]: https://discord.modrinth.com
|
||||
|
||||
[support portal]: https://support.modrinth.com/en/
|
||||
@@ -1,2 +1,3 @@
|
||||
BASE_URL=https://api.modrinth.com/v2/
|
||||
BROWSER_BASE_URL=https://api.modrinth.com/v2/
|
||||
PYRO_BASE_URL=https://archon.modrinth.com/
|
||||
|
||||
@@ -126,6 +126,7 @@ export default defineNuxtConfig({
|
||||
homePageSearch?: any[];
|
||||
homePageNotifs?: any[];
|
||||
products?: any[];
|
||||
errors?: number[];
|
||||
} = {};
|
||||
|
||||
try {
|
||||
@@ -157,6 +158,14 @@ export default defineNuxtConfig({
|
||||
},
|
||||
};
|
||||
|
||||
const caughtErrorCodes = new Set<number>();
|
||||
|
||||
function handleFetchError(err: any, defaultValue: any) {
|
||||
console.error("Error generating state: ", err);
|
||||
caughtErrorCodes.add(err.status);
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const [
|
||||
categories,
|
||||
loaders,
|
||||
@@ -168,15 +177,25 @@ export default defineNuxtConfig({
|
||||
homePageNotifs,
|
||||
products,
|
||||
] = await Promise.all([
|
||||
$fetch(`${API_URL}tag/category`, headers),
|
||||
$fetch(`${API_URL}tag/loader`, headers),
|
||||
$fetch(`${API_URL}tag/game_version`, headers),
|
||||
$fetch(`${API_URL}tag/donation_platform`, headers),
|
||||
$fetch(`${API_URL}tag/report_type`, headers),
|
||||
$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),
|
||||
$fetch(`${API_URL.replace("/v2/", "/_internal/")}billing/products`, headers),
|
||||
$fetch(`${API_URL}tag/category`, headers).catch((err) => handleFetchError(err, [])),
|
||||
$fetch(`${API_URL}tag/loader`, headers).catch((err) => handleFetchError(err, [])),
|
||||
$fetch(`${API_URL}tag/game_version`, headers).catch((err) => handleFetchError(err, [])),
|
||||
$fetch(`${API_URL}tag/donation_platform`, headers).catch((err) =>
|
||||
handleFetchError(err, []),
|
||||
),
|
||||
$fetch(`${API_URL}tag/report_type`, headers).catch((err) => handleFetchError(err, [])),
|
||||
$fetch(`${API_URL}projects_random?count=60`, headers).catch((err) =>
|
||||
handleFetchError(err, []),
|
||||
),
|
||||
$fetch(`${API_URL}search?limit=3&query=leave&index=relevance`, headers).catch((err) =>
|
||||
handleFetchError(err, {}),
|
||||
),
|
||||
$fetch(`${API_URL}search?limit=3&query=&index=updated`, headers).catch((err) =>
|
||||
handleFetchError(err, {}),
|
||||
),
|
||||
$fetch(`${API_URL.replace("/v2/", "/_internal/")}billing/products`, headers).catch((err) =>
|
||||
handleFetchError(err, []),
|
||||
),
|
||||
]);
|
||||
|
||||
state.categories = categories;
|
||||
@@ -188,6 +207,7 @@ export default defineNuxtConfig({
|
||||
state.homePageSearch = homePageSearch;
|
||||
state.homePageNotifs = homePageNotifs;
|
||||
state.products = products;
|
||||
state.errors = [...caughtErrorCodes];
|
||||
|
||||
await fs.writeFile("./src/generated/state.json", JSON.stringify(state));
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"postinstall": "nuxi prepare",
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"fix": "eslint . --fix && prettier --write .",
|
||||
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/layouts,src/middleware,src/modules,src/pages,src/plugins,src/utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace"
|
||||
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/layouts,src/middleware,src/modules,src/pages,src/plugins,src/utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formatjs/cli": "^6.2.12",
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<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="21" y1="6" x2="3" y2="6"></line>
|
||||
<line x1="15" y1="12" x2="3" y2="12"></line>
|
||||
<line x1="17" y1="18" x2="3" y2="18"></line>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 328 B |
@@ -1,6 +0,0 @@
|
||||
<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">
|
||||
<rect x="2" y="4" width="20" height="5" rx="2"></rect>
|
||||
<path d="M4 9v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9"></path>
|
||||
<path d="M10 13h4"></path>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 335 B |
@@ -1,6 +0,0 @@
|
||||
<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="M12 6v12"></path>
|
||||
<path d="M17.196 9 6.804 15"></path>
|
||||
<path d="m6.804 9 10.392 6"></path>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 295 B |
@@ -1,7 +0,0 @@
|
||||
<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="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
|
||||
<path d="M2 8c0-2.2.7-4.3 2-6"></path>
|
||||
<path d="M22 8a10 10 0 0 0-2-6"></path>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 383 B |
@@ -1,5 +0,0 @@
|
||||
<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="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 300 B |
@@ -1,6 +0,0 @@
|
||||
<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 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
|
||||
<polyline points="3.29 7 12 12 20.71 7"></polyline>
|
||||
<line x1="12" y1="22" x2="12" y2="12"></line>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 433 B |
@@ -1 +0,0 @@
|
||||
<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-calendar-clock"><path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h5"/><path d="M17.5 17.5 16 16.25V14"/><path d="M22 16a6 6 0 1 1-12 0 6 6 0 0 1 12 0Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 436 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M8 7V3M16 7V3M7 11H17M5 21H19C20.1046 21 21 20.1046 21 19V7C21 5.89543 20.1046 5 19 5H5C3.89543 5 3 5.89543 3 7V19C3 20.1046 3.89543 21 5 21Z"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 349 B |
@@ -1,7 +0,0 @@
|
||||
<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="M3 3v18h18"></path>
|
||||
<path d="M18 17V9"></path>
|
||||
<path d="M13 17V5"></path>
|
||||
<path d="M8 17v-3"></path>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 307 B |
@@ -1,4 +0,0 @@
|
||||
<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="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"></path>
|
||||
<path d="m9 12 2 2 4-4"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 315 B |
@@ -1,4 +0,0 @@
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 197 B |
@@ -1,4 +0,0 @@
|
||||
<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">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 238 B |
@@ -1 +0,0 @@
|
||||
<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"><polyline points="9 18 15 12 9 6"></polyline></svg>
|
||||
|
Before Width: | Height: | Size: 233 B |
@@ -1,5 +0,0 @@
|
||||
<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="12" cy="12" r="10"></circle>
|
||||
<line x1="15" y1="9" x2="9" y2="15"></line>
|
||||
<line x1="9" y1="9" x2="15" y2="15"></line>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 323 B |
@@ -1,4 +0,0 @@
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 291 B |
@@ -1 +0,0 @@
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" /></svg>
|
||||
|
Before Width: | Height: | Size: 299 B |
@@ -1,7 +0,0 @@
|
||||
<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="8" cy="8" r="6"></circle>
|
||||
<path d="M18.09 10.37A6 6 0 1 1 10.34 18"></path>
|
||||
<path d="M7 6h1v4"></path>
|
||||
<path d="m16.71 13.88.7.71-2.82 2.82"></path>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 358 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" style="enable-background:new 0 0 24 24" xml:space="preserve"><path d="M9 5v4m0 0H5m4 0L4 4m11 1v4m0 0h4m-4 0 5-5M9 19v-4m0 0H5m4 0-5 5m11-5 5 5m-5-5v4m0-4h4" style="fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round"/></svg>
|
||||
|
Before Width: | Height: | Size: 322 B |
@@ -1,5 +0,0 @@
|
||||
<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="12" cy="12" r="10"></circle>
|
||||
<path d="M15 9.354a4 4 0 1 0 0 5.292"></path>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 280 B |
@@ -1,5 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 306 B |
@@ -1,7 +0,0 @@
|
||||
<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">
|
||||
<rect x="3" y="3" width="7" height="9"></rect>
|
||||
<rect x="14" y="3" width="7" height="5"></rect>
|
||||
<rect x="14" y="12" width="7" height="9"></rect>
|
||||
<rect x="3" y="16" width="7" height="5"></rect>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 389 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 244 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 9L12 16L5 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 219 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M11 5H6C4.89543 5 4 5.89543 4 7V18C4 19.1046 4.89543 20 6 20H17C18.1046 20 19 19.1046 19 18V13M17.5858 3.58579C18.3668 2.80474 19.6332 2.80474 20.4142 3.58579C21.1953 4.36683 21.1953 5.63316 20.4142 6.41421L11.8284 15H9L9 12.1716L17.5858 3.58579Z"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 454 B |
@@ -1 +0,0 @@
|
||||
<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="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
|
Before Width: | Height: | Size: 274 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 271 B |
@@ -1,6 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||
<polyline points="15 3 21 3 21 9"></polyline>
|
||||
<line x1="10" y1="14" x2="21" y2="3"></line>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 348 B |
@@ -1 +0,0 @@
|
||||
<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="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
||||
|
Before Width: | Height: | Size: 417 B |
@@ -1 +0,0 @@
|
||||
<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="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
|
Before Width: | Height: | Size: 275 B |
@@ -1,8 +0,0 @@
|
||||
<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="feather feather-file-text">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||
<polyline points="14 2 14 8 20 8"></polyline>
|
||||
<line x1="16" y1="13" x2="8" y2="13"></line>
|
||||
<line x1="16" y1="17" x2="8" y2="17"></line>
|
||||
<polyline points="10 9 9 9 8 9"></polyline>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 486 B |
@@ -1 +0,0 @@
|
||||
<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="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>
|
||||
|
Before Width: | Height: | Size: 320 B |
@@ -1 +0,0 @@
|
||||
<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="feather feather-filter"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg>
|
||||
|
Before Width: | Height: | Size: 290 B |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="2" viewBox="0 0 14 2">
|
||||
<path d="M18,12H6" transform="translate(-5 -11)" fill="none" stroke="currentColor" stroke-linecap="round"
|
||||
stroke-linejoin="round" stroke-width="2" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 242 B |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" version="1.1" viewBox="0 0 24 24" xml:space="preserve">
|
||||
<path fill="currentColor" class="st0"
|
||||
d="m12 1c-6.3 0-11.3 5-11.3 11.3 0 5 3.2 9.2 7.7 10.7 0.6 0.1 0.8-0.2 0.8-0.5v-1.9c-3.2 0.6-3.8-1.6-3.8-1.6-0.5-1.3-1.3-1.7-1.3-1.7-1-0.7 0.1-0.7 0.1-0.7 1.1 0.1 1.7 1.2 1.7 1.2 1 1.7 2.7 1.2 3.3 0.9 0.1-0.7 0.4-1.2 0.7-1.5-2.5-0.2-5.1-1.2-5.1-5.5 0-1.2 0.4-2.2 1.2-3-0.1-0.3-0.5-1.4 0.1-3 0 0 1-0.3 3.1 1.2 0.9-0.3 1.8-0.5 2.8-0.5s1.9 0.1 2.8 0.4c2.2-1.5 3.1-1.2 3.1-1.2 0.6 1.6 0.2 2.7 0.1 3 0.7 0.8 1.2 1.8 1.2 3 0 4.4-2.6 5.3-5.2 5.6 0.4 0.3 0.8 1 0.8 2.1v3.1c0 0.3 0.2 0.7 0.8 0.5 4.5-1.5 7.7-5.7 7.7-10.7 0-6.2-5-11.2-11.3-11.2z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 717 B |
@@ -1,6 +0,0 @@
|
||||
<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="12" cy="12" r="10"></circle>
|
||||
<line x1="2" y1="12" x2="22" y2="12"></line>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 390 B |
@@ -1,7 +0,0 @@
|
||||
<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">
|
||||
<rect x="3" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="14" width="7" height="7"></rect>
|
||||
<rect x="3" y="14" width="7" height="7"></rect>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 389 B |
@@ -1,6 +0,0 @@
|
||||
<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="feather feather-menu">
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 352 B |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="9" x2="20" y2="9"></line><line x1="4" y1="15" x2="20" y2="15"></line><line x1="10" y1="3" x2="8" y2="21"></line><line x1="16" y1="3" x2="14" y2="21"></line></svg>
|
||||
|
Before Width: | Height: | Size: 303 B |
@@ -1,7 +0,0 @@
|
||||
<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="M20.42 4.58a5.4 5.4 0 0 0-7.65 0l-.77.78-.77-.78a5.4 5.4 0 0 0-7.65 0C1.46 6.7 1.33 10.28 4 13l8 8 8-8c2.67-2.72 2.54-6.3.42-8.42z"></path>
|
||||
<path d="M12 5.36 8.87 8.5a2.13 2.13 0 0 0 0 3h0a2.13 2.13 0 0 0 3 0l2.26-2.21a3 3 0 0 1 4.22 0l2.4 2.4"></path>
|
||||
<path d="m18 15-2-2"></path>
|
||||
<path d="m15 18-2-2"></path>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 517 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 305 B |
@@ -1,6 +0,0 @@
|
||||
<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="M3 3v5h5"></path>
|
||||
<path d="M3.05 13A9 9 0 1 0 6 5.3L3 8"></path>
|
||||
<path d="M12 7v5l4 2"></path>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 299 B |
@@ -1,6 +0,0 @@
|
||||
<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">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<circle cx="9" cy="9" r="2"></circle>
|
||||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"></path>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 356 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
|
||||
|
Before Width: | Height: | Size: 296 B |
@@ -1 +0,0 @@
|
||||
<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.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
|
||||
|
Before Width: | Height: | Size: 386 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/></svg>
|
||||
|
Before Width: | Height: | Size: 261 B |
@@ -1 +0,0 @@
|
||||
<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-languages"><path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/></svg>
|
||||
|
Before Width: | Height: | Size: 349 B |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14.414" height="12.162" viewBox="0 0 14.414 12.162">
|
||||
<path d="M7.667,14.333,3,9.667m0,0L7.667,5M3,9.667H15" transform="translate(-1.586 -3.586)" fill="none"
|
||||
stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 303 B |
@@ -1,6 +0,0 @@
|
||||
<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="9" y1="18" x2="15" y2="18"></line>
|
||||
<line x1="10" y1="22" x2="14" y2="22"></line>
|
||||
<path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"></path>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 419 B |
@@ -1,5 +0,0 @@
|
||||
<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 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 350 B |
@@ -1,8 +0,0 @@
|
||||
<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="M16 12H3"></path>
|
||||
<path d="M16 6H3"></path>
|
||||
<path d="M10 18H3"></path>
|
||||
<path d="M21 6v10a2 2 0 0 1-2 2h-4"></path>
|
||||
<path d="m16 16-2 2 2 2"></path>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 356 B |
@@ -1,9 +0,0 @@
|
||||
<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">
|
||||
<rect x="3" y="14" width="7" height="7"></rect>
|
||||
<rect x="3" y="3" width="7" height="7"></rect>
|
||||
<line x1="14" y1="4" x2="21" y2="4"></line>
|
||||
<line x1="14" y1="9" x2="21" y2="9"></line>
|
||||
<line x1="14" y1="15" x2="21" y2="15"></line>
|
||||
<line x1="14" y1="20" x2="21" y2="20"></line>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 476 B |
@@ -1 +0,0 @@
|
||||
<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="feather feather-lock"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
|
||||
|
Before Width: | Height: | Size: 321 B |
@@ -1,6 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||
<polyline points="16 17 21 12 16 7"></polyline>
|
||||
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 333 B |
@@ -1,4 +0,0 @@
|
||||
<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 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 271 B |
@@ -1,5 +0,0 @@
|
||||
<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="m12 8-9.04 9.06a2.82 2.82 0 1 0 3.98 3.98L16 12"></path>
|
||||
<circle cx="17" cy="7" r="5"></circle>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 298 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="512" height="514" viewBox="0 0 512 514" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M503.16 323.56C514.55 281.47 515.32 235.91 503.2 190.76C466.57 54.2299 326.04 -26.8001 189.33 9.77991C83.8101 38.0199 11.3899 128.07 0.689941 230.47H43.99C54.29 147.33 113.74 74.7298 199.75 51.7098C306.05 23.2598 415.13 80.6699 453.17 181.38L411.03 192.65C391.64 145.8 352.57 111.45 306.3 96.8198L298.56 140.66C335.09 154.13 364.72 184.5 375.56 224.91C391.36 283.8 361.94 344.14 308.56 369.17L320.09 412.16C390.25 383.21 432.4 310.3 422.43 235.14L464.41 223.91C468.91 252.62 467.35 281.16 460.55 308.07L503.16 323.56Z" fill="currentColor"/>
|
||||
<path d="M321.99 504.22C185.27 540.8 44.7501 459.77 8.11011 323.24C3.84011 307.31 1.17 291.33 0 275.46H43.27C44.36 287.37 46.4699 299.35 49.6799 311.29C53.0399 323.8 57.45 335.75 62.79 347.07L101.38 323.92C98.1299 316.42 95.39 308.6 93.21 300.47C69.17 210.87 122.41 118.77 212.13 94.7601C229.13 90.2101 246.23 88.4401 262.93 89.1501L255.19 133C244.73 133.05 234.11 134.42 223.53 137.25C157.31 154.98 118.01 222.95 135.75 289.09C136.85 293.16 138.13 297.13 139.59 300.99L188.94 271.38L174.07 231.95L220.67 184.08L279.57 171.39L296.62 192.38L269.47 219.88L245.79 227.33L228.87 244.72L237.16 267.79C237.16 267.79 253.95 285.63 253.98 285.64L277.7 279.33L294.58 260.79L331.44 249.12L342.42 273.82L304.39 320.45L240.66 340.63L212.08 308.81L162.26 338.7C187.8 367.78 226.2 383.93 266.01 380.56L277.54 423.55C218.13 431.41 160.1 406.82 124.05 361.64L85.6399 384.68C136.25 451.17 223.84 484.11 309.61 461.16C371.35 444.64 419.4 402.56 445.42 349.38L488.06 364.88C457.17 431.16 398.22 483.82 321.99 504.22Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |