Lay groundwork for public profiles, clean up user loading
Some checks failed
Build / build (push) Has been cancelled

This commit is contained in:
Trenton Zimmer 2025-07-06 12:47:48 -04:00
parent ffe3d7133c
commit 4632b9cd19
12 changed files with 398 additions and 128 deletions

View File

@ -1,4 +1,4 @@
VITE_APP_VERSION="3.0.16"
VITE_APP_VERSION="3.0.17"
VITE_API_URL="http://10.5.7.5:8000/"
VITE_API_KEY="your_api_key_should_be_here"
VITE_ASSET_PATH="/assets"

View File

@ -1,4 +1,4 @@
VITE_APP_VERSION="3.0.16"
VITE_APP_VERSION="3.0.17"
VITE_API_URL="https://restfulsleep.phaseii.network"
VITE_API_KEY="your_api_key_should_be_here"
VITE_ASSET_PATH="https://cdn.phaseii.network/file/PhaseII/web-assets"

View File

@ -15,5 +15,6 @@
"3.0.13": ["- (Feature) Add new game event settings to beatmaniaIIDX EPOLIS."],
"3.0.14": ["- (Minor) Fix footer buttons on tables, add generic export table button for every table."],
"3.0.15": ["- (Minor) Add beta support for Pinky Crush, clean up gameDB a little.", "- (Optimization) Convert dashboard to a more modular backend.", "- (Bugfix) Fix warnings on auth pages."],
"3.0.16": ["- (Major) Add multiple new admin features for network management.", "- (Optimization) Optimize backend for arcade operations.", "- (Bugfix) Fix possible backend issues with better type enforcing.", "- (Bugfix) Fix event data and game data for IIDX Pinky Crush.", "- (Minor) Add assets for Nostalgia."]
"3.0.16": ["- (Major) Add multiple new admin features for network management.", "- (Optimization) Optimize backend for arcade operations.", "- (Bugfix) Fix possible backend issues with better type enforcing.", "- (Bugfix) Fix event data and game data for IIDX Pinky Crush.", "- (Minor) Add assets for Nostalgia."],
"3.0.17": ["- (Major) Add initial public profile support.", "- (Minor) Lay groundwork for public profile page", "- (Optimization) Clean up admin pages", "- (Bugfix) Clean up random 500 errors."]
}

View File

@ -1,11 +1,11 @@
<script setup>
import { ref } from "vue";
import { useMainStore } from "@/stores/main";
import { RoleConstants } from "@/constants/discordRoles";
import {
mdiSecurity,
mdiTestTube,
mdiAccountStar,
mdiCodeBraces,
mdiAccountOff,
mdiFlowerPoppy,
mdiSharkFinOutline,
@ -15,15 +15,20 @@ import {
mdiHeartMultipleOutline,
mdiCheckDecagramOutline,
mdiLinkBoxVariantOutline,
// mdiAccountCheck,
mdiAccountCheck,
} from "@mdi/js";
import BaseLevel from "@/components/BaseLevel.vue";
import UserAvatarCurrentUser from "@/components/UserAvatarCurrentUser.vue";
import UserAvatar from "@/components/UserAvatar.vue";
import CardBox from "@/components/CardBox.vue";
import PillTag from "@/components/PillTag.vue";
const ASSET_PATH = import.meta.env.VITE_ASSET_PATH;
defineProps({
const props = defineProps({
overrideProfile: {
type: Object,
required: false,
default: null,
},
useSmall: {
type: Boolean,
required: false,
@ -38,14 +43,46 @@ defineProps({
import { GetRandomMessage } from "@/constants";
const mainStore = useMainStore();
const cardData = ref({
userId: null,
userName: "",
userAvatar: "",
userAdmin: false,
userPublic: false,
discordRoles: null,
userCustomize: null,
});
if (props.overrideProfile) {
const overrideProfile = props.overrideProfile;
cardData.value = {
userId: overrideProfile.id,
userName: overrideProfile.name,
userAvatar: overrideProfile.avatar,
userAdmin: overrideProfile.admin,
userPublic: overrideProfile.public,
discordRoles: overrideProfile.discordRoles,
userCustomize: overrideProfile.customize,
};
} else {
const mainStore = useMainStore();
cardData.value = {
userId: mainStore.userId,
userName: mainStore.userName,
userAvatar: mainStore.userAvatar,
userAdmin: mainStore.userAdmin,
userPublic: mainStore.userPublic,
discordRoles: mainStore.discordRoles,
userCustomize: mainStore.userCustomize,
};
}
const greeting = GetRandomMessage();
function getCardStyle() {
return `
background-image: url('${ASSET_PATH}/card/${
mainStore.userCustomize?.card ?? "time"
cardData.value.userCustomize?.card ?? "time"
}.webp');
background-size: cover;
background-repeat: no-repeat;
@ -59,23 +96,26 @@ function getCardStyle() {
type="justify-around lg:justify-center md:space-x-4 lg:space-x-0"
class="bg-white dark:bg-slate-900/90 rounded-2xl p-3"
>
<UserAvatarCurrentUser
<UserAvatar
class="w-28 md:w-30 lg:w-[128px] lg:mx-12 lg:m-2"
:username="cardData.userName"
:avatar="cardData.userAvatar"
:border="cardData.userCustomize?.border ?? null"
/>
<div class="space-y-3 text-center md:text-left lg:mx-12">
<div class="space-y-2 md:space-y-0">
<h1
v-if="!useSmall && !mainStore.userCustomize.disableGreeting"
v-if="!useSmall && !cardData.userCustomize?.disableGreeting"
class="text-2xl md:text-xl lg:text-2xl"
>
{{ greeting.header[0] }}<b>{{ mainStore.userName }} </b
{{ greeting.header[0] }}<b>{{ cardData.userName }} </b
>{{ greeting.header[1] }}
</h1>
<h1
v-if="useSmall || mainStore.userCustomize.disableGreeting"
v-if="useSmall || cardData.userCustomize?.disableGreeting"
class="text-3xl md:text-4xl"
>
<b>{{ mainStore.userName }}</b>
<b>{{ cardData.userName }}</b>
</h1>
</div>
<div
@ -83,90 +123,91 @@ function getCardStyle() {
class="flex flex-wrap gap-2 md:place-content-start place-content-center px-5 sm:px-0 py-2 sm:py-0 md:max-w-[400px]"
>
<PillTag
v-if="mainStore.userAdmin"
v-if="cardData.userAdmin"
label="System Admin"
color="danger"
:icon="mdiSecurity"
small
/>
<PillTag
v-if="mainStore.userId < 300"
v-if="cardData.userId < 300"
label="Veteran"
color="success"
:icon="mdiAccountStar"
small
/>
<PillTag
v-if="mainStore.userData.dev"
label="Active Dev"
color="info"
:icon="mdiCodeBraces"
small
/>
<PillTag
v-if="!cardData.userPublic"
label="Private Profile"
color="info"
:icon="mdiAccountOff"
small
/>
<PillTag
v-if="mainStore.discordRoles?.includes(RoleConstants.PLAYER)"
v-if="cardData.userPublic"
label="Public Profile"
color="success"
:icon="mdiAccountCheck"
small
/>
<PillTag
v-if="cardData.discordRoles?.includes(RoleConstants.PLAYER)"
label="Verified"
color="success"
:icon="mdiCheckDecagramOutline"
small
/>
<PillTag
v-if="mainStore.discordRoles?.includes(RoleConstants.JACKASS)"
v-if="cardData.discordRoles?.includes(RoleConstants.JACKASS)"
label="Jackass"
color="slight_danger"
:icon="mdiHeartMultipleOutline"
small
/>
<PillTag
v-if="mainStore.discordRoles?.includes(RoleConstants.DEVELOPER)"
v-if="cardData.discordRoles?.includes(RoleConstants.DEVELOPER)"
label="Developer"
color="success"
:icon="mdiAccountTie"
small
/>
<PillTag
v-if="mainStore.discordRoles?.includes(RoleConstants.MODERATOR)"
v-if="cardData.discordRoles?.includes(RoleConstants.MODERATOR)"
label="Moderator"
color="slight_danger"
:icon="mdiAccountTieHat"
small
/>
<PillTag
v-if="mainStore.discordRoles?.includes(RoleConstants.BETA_TESTER)"
v-if="cardData.discordRoles?.includes(RoleConstants.BETA_TESTER)"
label="Beta Tester"
color="warning"
:icon="mdiTestTube"
small
/>
<PillTag
v-if="mainStore.discordRoles?.includes(RoleConstants.DONOR)"
v-if="cardData.discordRoles?.includes(RoleConstants.DONOR)"
label="Donor"
color="gold"
:icon="mdiHandCoinOutline"
small
/>
<PillTag
v-if="mainStore.discordRoles?.includes(RoleConstants.BLAHAJ)"
v-if="cardData.discordRoles?.includes(RoleConstants.BLAHAJ)"
label="Blåhaj"
color="info"
:icon="mdiSharkFinOutline"
small
/>
<PillTag
v-if="mainStore.discordRoles?.includes(RoleConstants.RHYTHM_RIOT)"
v-if="cardData.discordRoles?.includes(RoleConstants.RHYTHM_RIOT)"
label="Rhythm Riot"
color="sakura"
:icon="mdiFlowerPoppy"
small
/>
<PillTag
v-if="mainStore.userCustomize.shrimpLinks"
v-if="cardData.userCustomize?.shrimpLinks"
label="Shrimp Links"
color="sakura"
:icon="mdiLinkBoxVariantOutline"
@ -180,7 +221,7 @@ function getCardStyle() {
</div>
</div>
<div
v-if="!useSmall && !mainStore.userCustomize.disableGreeting"
v-if="!useSmall && !cardData.userCustomize.disableGreeting"
class="text-center md:text-right"
>
<p class="text-xl md:text-lg lg:text-2lg">{{ greeting.comment }}</p>

View File

@ -158,10 +158,6 @@ const menuAside = computed(() => {
label: "Arcades",
to: "/admin/arcades",
},
{
label: "Cards",
to: "/admin/cards",
},
{
label: "Users",
to: "/admin/users",

View File

@ -116,7 +116,7 @@ const routes = [
},
{
meta: {
title: "View Profile",
title: "View User",
},
path: "/profiles/:id",
name: "profile_viewer",
@ -173,14 +173,6 @@ const routes = [
name: "admin_arcades",
component: () => import("@/views/Admin/ArcadesView.vue"),
},
{
meta: {
title: "Cards",
},
path: "/admin/cards",
name: "admin_cards",
component: () => import("@/views/Admin/CardsView.vue"),
},
{
meta: {
title: "Users",

View File

@ -1,5 +1,33 @@
import { useMainStore } from "@/stores/main";
export async function APIGetUser(userId) {
const mainStore = useMainStore();
try {
const user = await mainStore.callApi(`/user?userId=${userId}`);
const userData = user.data;
const data = {
id: userData.id,
name: userData.name,
email: userData.email,
avatar: userData.avatar,
admin: userData.admin,
data: userData.data,
discordRoles: userData.discordRoles,
cardStyle: "time",
profiles: userData.profiles,
arcades: userData.arcades,
customize: userData.data?.customize,
userScoreStats: userData.scoreStats,
public: userData.public,
};
return data;
} catch (error) {
console.log("Error loading user:", error);
throw error;
}
}
export async function APIEmailAuth(email) {
const mainStore = useMainStore();

View File

@ -21,6 +21,7 @@ export const useMainStore = defineStore("main", {
profiles: {},
userCustomize: {},
userScoreStats: {},
userPublic: false,
/* Field focus with ctrl+k (to register only once) */
isFieldFocusRegistered: false,
@ -59,6 +60,9 @@ export const useMainStore = defineStore("main", {
if (payload.admin) {
this.userAdmin = payload.admin;
}
if (payload.public) {
this.userPublic = payload.public;
}
if (payload.data) {
this.userData = payload.data;
}
@ -258,8 +262,8 @@ export const useMainStore = defineStore("main", {
if (validSession && validSession.activeSession && !this.userLoaded) {
const userId = validSession.userId;
if (userId) {
const response = await this.callApi(`/user?userId=${userId}`);
var user = response.user;
const response = await this.callApi(`/user`);
var user = response.data;
this.setUser({
id: userId,
name: user.name,
@ -268,11 +272,11 @@ export const useMainStore = defineStore("main", {
admin: user.admin,
data: user.data,
discordRoles: user.discordRoles,
cardStyle: "time",
profiles: user.profiles,
arcades: user.arcades,
customize: user.data?.customize,
userScoreStats: user.scoreStats,
public: user.public,
});
this.userLoaded = true;
return true;

View File

@ -1,30 +0,0 @@
<script setup>
import { mdiFlagCheckered, mdiSecurity } from "@mdi/js";
import SectionMain from "@/components/SectionMain.vue";
import CardBox from "@/components/CardBox.vue";
import CardBoxWidget from "@/components/CardBoxWidget.vue";
import LayoutAuthenticated from "@/layouts/LayoutAuthenticated.vue";
import SectionTitleLine from "@/components/SectionTitleLine.vue";
</script>
<template>
<LayoutAuthenticated>
<SectionMain>
<SectionTitleLine
:icon="mdiSecurity"
title="Network Administration"
color="text-red-600"
main
/>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 mb-6">
<CardBoxWidget :number="1" label="User Account(s)" />
<CardBoxWidget :number="2" label="Registered Arcades" />
<CardBoxWidget :number="3" label="Published Scores" />
</div>
<SectionTitleLine :icon="mdiFlagCheckered" title="Recent Errors" />
<CardBox> </CardBox>
</SectionMain>
</LayoutAuthenticated>
</template>

View File

@ -78,7 +78,7 @@ async function loadData() {
const openUser = (item) => {
const userId = item.id;
$router.push(`/user/${userId}`);
$router.push(`/profiles/${userId}`);
};
const filterForm = reactive({
@ -173,7 +173,7 @@ function filterUsers() {
<GeneralTable
:headers="headers"
:items="userData"
@row-clicked="openUser(user)"
@row-clicked="openUser"
/>
</div>
</div>

View File

@ -9,6 +9,7 @@ import CardBox from "@/components/CardBox.vue";
import BaseDivider from "@/components/BaseDivider.vue";
import FormField from "@/components/FormField.vue";
import FormControl from "@/components/FormControl.vue";
import FormCheckRadio from "@/components/FormCheckRadio.vue";
import BaseButton from "@/components/BaseButton.vue";
import BaseIcon from "@/components/BaseIcon.vue";
import UserCard from "@/components/UserCard.vue";
@ -23,12 +24,14 @@ const currentProfile = reactive({
username: JSON.parse(JSON.stringify(mainStore.userName)),
email: JSON.parse(JSON.stringify(mainStore.userEmail)),
pin: null,
public: mainStore.userPublic ?? false,
});
const profileForm = reactive({
username: JSON.parse(JSON.stringify(mainStore.userName)),
email: JSON.parse(JSON.stringify(mainStore.userEmail)),
pin: null,
public: mainStore.userPublic ?? false,
});
const passwordForm = reactive({
@ -56,6 +59,14 @@ watch(
}
);
watch(
() => mainStore.userPublic,
(newValue) => {
profileForm.public = newValue;
currentProfile.public = newValue;
}
);
async function submitProfile() {
profileLoading.value = true;
const response = await mainStore.putUser(profileForm);
@ -139,6 +150,19 @@ function userChanged(oldProfile, newProfile) {
/>
</FormField>
<FormField
label="Public Profile"
help="Show my profile publicly. If disabled, only game profiles and scores will be visible."
>
<FormCheckRadio
v-model="profileForm.public"
name="public"
:model-value="profileForm.public"
:input-value="profileForm.public"
type="switch"
/>
</FormField>
<template #footer>
<BaseButton
v-if="userChanged(currentProfile, profileForm)"

View File

@ -1,70 +1,284 @@
<script setup>
import { computed, ref, onMounted } from "vue";
import { useRoute } from "vue-router";
import {
mdiAccount,
mdiAccountPlusOutline,
mdiAccountMinusOutline,
mdiGamepad,
mdiChartTimelineVariant,
mdiCounter,
mdiGamepadOutline,
mdiFire,
mdiTrendingUp,
} from "@mdi/js";
import SectionMain from "@/components/SectionMain.vue";
import CardBox from "@/components/CardBox.vue";
import CardBoxWidget from "@/components/CardBoxWidget.vue";
import CardBoxGameStat from "@/components/CardBoxGameStat.vue";
import UserCard from "@/components/UserCard.vue";
import LayoutAuthenticated from "@/layouts/LayoutAuthenticated.vue";
import SectionTitleLine from "@/components/SectionTitleLine.vue";
import BaseButton from "@/components/BaseButton.vue";
import { GameConstants } from "@/constants";
import LineChart from "@/components/Charts/LineChart.vue";
import { getGameInfo } from "@/constants";
import { APIGetUser } from "@/stores/api/account";
const $route = useRoute();
const reqUserId = $route.params.id;
const userProfile = ref({});
async function loadUser() {
try {
userProfile.value = {};
var data = await APIGetUser(reqUserId);
if (!data.name) {
data.name = "Unclaimed Account";
}
userProfile.value = data;
} catch (error) {
console.error("Failed to fetch user profile data:", error);
}
}
onMounted(async () => {
loadUser();
console.log(userProfile.value);
});
const userProfiles = computed(() => userProfile.value.userProfiles);
const userScoreStats = computed(() => userProfile.value.userScoreStats);
const cumulativePlays = computed(() => {
return userProfiles.value?.reduce(
(total, user) => total + user.data.total_plays,
0
);
});
const uniqueProfiles = computed(() => {
return userProfiles.value?.length;
});
const longestStreak = computed(() => {
const groupByDay = (timestamps) => {
const dayMap = {};
timestamps.forEach((timestamp) => {
const day = new Date(timestamp * 1000).toISOString().split("T")[0];
dayMap[day] = (dayMap[day] || 0) + 1;
});
return dayMap;
};
var maxStreak = 0;
userProfiles.value?.forEach((user) => {
const arcadeHistory = user.data?.arcade_history
? user.data?.arcade_history
: {};
const allTimestamps = [];
Object.values(arcadeHistory).forEach((machines) => {
Object.values(machines).forEach((timestamps) => {
allTimestamps.push(...timestamps);
});
});
const playsByDay = groupByDay(allTimestamps);
const longestStreakForUser = Math.max(...Object.values(playsByDay));
maxStreak = Math.max(maxStreak, longestStreakForUser);
});
return maxStreak;
});
function filterUserProfiles(userProfiles) {
if (!userProfiles) {
return;
}
var filteredProfiles = [];
for (const profile of userProfiles) {
const game = getGameInfo(profile.game);
if (game && !game.skip) {
filteredProfiles.push(profile);
}
}
filteredProfiles.sort(function (x, y) {
return y.data.last_play_timestamp - x.data.last_play_timestamp;
});
return filteredProfiles;
}
const today = new Date().toISOString().split("T")[0];
const totalAttempts = computed(() => {
return userScoreStats.value?.attempts?.length || 0;
});
const totalRecords = computed(() => {
return userScoreStats.value?.records?.length || 0;
});
const todayAttempts = computed(() => {
return (
userScoreStats.value?.attempts?.filter((a) => {
const attemptDate = new Date(a.timestamp * 1000)
.toISOString()
.split("T")[0];
return attemptDate === today;
}).length || 0
);
});
const todayRecords = computed(() => {
return (
userScoreStats.value?.records?.filter((r) => {
const recordDate = new Date(r.timestamp * 1000)
.toISOString()
.split("T")[0];
return recordDate === today;
}).length || 0
);
});
const todayPlays = computed(() => {
const todayStr = new Date().toISOString().split("T")[0];
let total = 0;
userProfiles.value?.forEach((profile) => {
const arcadeHistory = profile.data?.arcade_history || {};
Object.values(arcadeHistory).forEach((machines) => {
Object.values(machines).forEach((timestamps) => {
timestamps.forEach((ts) => {
const dateStr = new Date(ts * 1000).toISOString().split("T")[0];
if (dateStr === todayStr) {
total++;
}
});
});
});
});
return total;
});
const cardBoxes = ref([
{
label: "Cumulative Plays",
icon: mdiCounter,
iconColor: "text-emerald-600",
suffix: "play",
number: cumulativePlays,
},
{
label: "Games Played",
icon: mdiGamepadOutline,
iconColor: "text-sky-300",
suffix: "game",
number: uniqueProfiles,
},
{
label: "Plays Today",
icon: mdiCounter,
iconColor: "text-sky-300",
suffix: "play",
number: todayPlays,
},
{
label: "Longest Play Streak",
icon: mdiFire,
iconColor: "text-red-500",
suffix: "play",
number: longestStreak,
},
{
label: "Total Records",
icon: mdiGamepadOutline,
iconColor: "text-sky-300",
suffix: "record",
number: totalRecords,
},
{
label: "Total Attempts",
icon: mdiGamepadOutline,
iconColor: "text-sky-300",
suffix: "attempt",
number: totalAttempts,
},
{
label: "Records Today",
icon: mdiGamepadOutline,
iconColor: "text-sky-300",
suffix: "record",
number: todayRecords,
},
{
label: "Attempts Today",
icon: mdiGamepadOutline,
iconColor: "text-sky-300",
suffix: "attempt",
number: todayAttempts,
},
]);
</script>
<template>
<LayoutAuthenticated>
<SectionMain>
<SectionTitleLine :icon="mdiAccount" title="Trmazi's Profile" main />
<UserCard class="mb-6" use-small />
<template v-if="userProfile != null">
<SectionTitleLine
:icon="mdiAccount"
:title="`${userProfile.name}'s Profile`"
main
/>
<UserCard class="mb-6" :override-profile="userProfile" use-small />
<CardBox is-form class="mb-6">
<div class="flex gap-4">
<BaseButton
color="success"
label="Add Friend"
:icon="mdiAccountPlusOutline"
/>
<BaseButton
color="danger"
label="Remove Friend"
:icon="mdiAccountMinusOutline"
<SectionTitleLine
:icon="mdiChartTimelineVariant"
title="Quick Stats"
main
/>
<div
class="grid grid-cols-2 sm:grid-cols-3 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 3xl:grid-cols-5 mb-6"
>
<template v-for="box of cardBoxes" :key="box.label">
<CardBoxWidget
v-if="box.number"
:icon="box.icon"
:number="box.number"
:label="box.label"
:suffix="`${box.suffix}${box.number == 1 ? '' : 's'}`"
:icon-color="box.iconColor"
/>
</template>
</div>
<SectionTitleLine :icon="mdiGamepad" title="Showcase" main />
<div
class="grid grid-flow-row auto-rows-auto grid-cols-2 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3 3xl:grid-cols-4 4xl:grid-cols-6 gap-5 mb-5"
>
<CardBoxGameStat
v-for="profile of filterUserProfiles(userProfiles)"
:key="profile.game"
:game="profile.game"
:value="profile.data.total_plays"
profile-name=""
type="plays"
/>
</div>
</CardBox>
<SectionTitleLine :icon="mdiGamepad" title="Game Stats" main />
<div
class="grid grid-flow-row auto-rows-auto grid-cols-1 md:grid-cols-2 gap-5 mb-5"
>
<CardBoxGameStat
:game="GameConstants.DDR"
value="#10 out of 132"
profile-name="DJ. TRMAZI"
type="ranking"
/>
<CardBoxGameStat
:game="GameConstants.POPN_MUSIC"
:value="300"
profile-name="TRMAZI"
type="plays"
/>
<CardBoxGameStat
:game="GameConstants.JUBEAT"
:value="392"
profile-name="TRMAZI"
type="scores"
/>
<CardBoxGameStat
:game="GameConstants.SDVX"
value="#15 out of 200"
profile-name="TRMAZI"
type="ranking"
/>
</div>
<SectionTitleLine :icon="mdiTrendingUp" title="Play Trends" main />
<CardBox class="mb-6">
<div v-if="userProfiles">
<LineChart
:data="generateChartData(userProfiles, userScoreStats)"
class="h-96"
/>
</div>
</CardBox>
</template>
</SectionMain>
</LayoutAuthenticated>
</template>