diff --git a/components/servers/ServerAccess.vue b/components/servers/ServerAccess.vue index e88d09a67..496ecc318 100644 --- a/components/servers/ServerAccess.vue +++ b/components/servers/ServerAccess.vue @@ -3,16 +3,9 @@ import { computed, onBeforeUnmount, ref, watch } from "vue"; import { useI18n } from "vue-i18n"; import { useQuery } from "@vue/apollo-composable"; import gql from "graphql-tag"; -import { - CalendarDays, - Globe, - Lock, - ShieldCheck, - TriangleAlert, - X, -} from "lucide-vue-next"; -import { Badge } from "~/components/ui/badge"; +import { X } from "lucide-vue-next"; import { Button } from "~/components/ui/button"; +import { Switch } from "~/components/ui/switch"; import { Select, SelectContent, @@ -20,16 +13,13 @@ import { SelectTrigger, SelectValue, } from "~/components/ui/select"; -import AnimatedFilters from "~/components/common/AnimatedFilters.vue"; -import SettingsSaveBar from "~/components/settings/SettingsSaveBar.vue"; +import SettingsSection from "~/components/settings/SettingsSection.vue"; import PlayerSearch from "~/components/PlayerSearch.vue"; import PlayerDisplay from "~/components/PlayerDisplay.vue"; import EventSearch from "~/components/events/EventSearch.vue"; -import { toast } from "@/components/ui/toast"; import { eventPhase, formatEventDate, - phaseBadgeVariant, phaseLabelKey, } from "~/utilities/eventDisplay"; @@ -57,7 +47,6 @@ type Draft = { const props = defineProps<{ serverId: string }>(); const { t } = useI18n(); -const nuxtApp = useNuxtApp(); // Moderators and up always get in, so only the roles below them are useful // as a floor. @@ -65,8 +54,8 @@ const MIN_ROLES = ["user", "verified_user", "streamer"]; const NO_ROLE = "none"; const PLUGIN_STALE_MS = 3 * 60 * 1000; -// Raw documents: the access tables and setServerAccess are newer than -// ~/generated/zeus until codegen runs against a migrated database. +// Raw documents: the access tables are newer than ~/generated/zeus until +// codegen runs against a migrated database. const QUERY = gql` query ServerAccess($serverId: uuid!) { servers_by_pk(id: $serverId) { @@ -95,41 +84,28 @@ const QUERY = gql` } `; -const SAVE = gql` - mutation SetServerAccess( - $serverId: uuid! - $restricted: Boolean! - $minRole: String - $steamIds: [String!]! - $eventIds: [uuid!]! - ) { - setServerAccess( - server_id: $serverId - restricted: $restricted - min_role: $minRole - steam_ids: $steamIds - event_ids: $eventIds - ) { - success - } - } -`; - const { result, refetch } = useQuery( QUERY, () => ({ serverId: props.serverId }), { fetchPolicy: "cache-and-network" }, ); -const draft = ref({ - restricted: false, - minRole: NO_ROLE, - players: [], - events: [], -}); -const saved = ref(snapshot(draft.value)); -const revision = ref(0); -const submitting = ref(false); +function emptyDraft(): Draft { + return { restricted: false, minRole: NO_ROLE, players: [], events: [] }; +} + +// The entries themselves are never edited, only added or dropped, so copying +// the lists is a full copy; structuredClone refuses Vue's reactive proxies. +function copy(value: Draft): Draft { + return { + ...value, + players: [...value.players], + events: [...value.events], + }; +} + +const draft = ref(emptyDraft()); +const savedDraft = ref(emptyDraft()); const now = ref(Date.now()); const clock = setInterval(() => { @@ -140,6 +116,52 @@ onBeforeUnmount(() => clearInterval(clock)); const server = computed(() => (result.value as any)?.servers_by_pk); +const steamIdsOf = (value: Draft) => + value.players.map((player) => player.steam_id); +const eventIdsOf = (value: Draft) => value.events.map((event) => event.id); + +function countMissing(from: Array, other: Array): number { + return from.filter((id) => !other.includes(id)).length; +} + +const changes = computed>(() => { + const before = savedDraft.value; + const after = draft.value; + const list: Array = []; + const key = "pages.dedicated_servers.detail.settings.changes"; + + if (after.restricted !== before.restricted) { + list.push(t(after.restricted ? `${key}.restricted` : `${key}.opened`)); + } + + if (after.minRole !== before.minRole) { + list.push(t(`${key}.min_role`)); + } + + const playersAdded = countMissing(steamIdsOf(after), steamIdsOf(before)); + const playersRemoved = countMissing(steamIdsOf(before), steamIdsOf(after)); + const eventsAdded = countMissing(eventIdsOf(after), eventIdsOf(before)); + const eventsRemoved = countMissing(eventIdsOf(before), eventIdsOf(after)); + + if (playersAdded) { + list.push(t(`${key}.players_added`, { count: playersAdded })); + } + + if (playersRemoved) { + list.push(t(`${key}.players_removed`, { count: playersRemoved })); + } + + if (eventsAdded) { + list.push(t(`${key}.events_added`, { count: eventsAdded })); + } + + if (eventsRemoved) { + list.push(t(`${key}.events_removed`, { count: eventsRemoved })); + } + + return list.map((text) => ({ text, restart: false })); +}); + function fromServer(): Draft { return { restricted: server.value?.access_restricted ?? false, @@ -153,111 +175,76 @@ function fromServer(): Draft { }; } -function snapshot(value: Draft): string { - return JSON.stringify({ - restricted: value.restricted, - minRole: value.minRole, - players: value.players.map((player) => player.steam_id).sort(), - events: value.events.map((event) => event.id).sort(), - }); -} - -const dirty = computed(() => snapshot(draft.value) !== saved.value); - function reset() { - draft.value = fromServer(); - saved.value = snapshot(draft.value); + savedDraft.value = fromServer(); + draft.value = copy(savedDraft.value); } watch( result, () => { - if (!dirty.value) { + if (!changes.value.length) { reset(); } }, { immediate: true }, ); -function touch() { - revision.value++; -} - -const modeModel = computed({ - get: () => (draft.value.restricted ? "restricted" : "open"), - set: (value: string) => { - draft.value.restricted = value === "restricted"; - touch(); - }, -}); - -const modeOptions = computed(() => [ - { - key: "open", - label: t("pages.dedicated_servers.detail.access.open"), - icon: Globe, - }, - { - key: "restricted", - label: t("pages.dedicated_servers.detail.access.restricted"), - icon: Lock, - }, -]); - const minRoleModel = computed({ get: () => draft.value.minRole, set: (value: string) => { - draft.value.minRole = value; - touch(); + draft.value = { ...draft.value, minRole: value }; }, }); -const pickedSteamIds = computed(() => - draft.value.players.map((player) => player.steam_id), -); - const ineligiblePlayers = computed(() => Object.fromEntries( - pickedSteamIds.value.map((steamId) => [ + steamIdsOf(draft.value).map((steamId) => [ steamId, t("pages.dedicated_servers.detail.access.already_added"), ]), ), ); +function setRestricted(restricted: boolean) { + draft.value = { ...draft.value, restricted }; +} + function addPlayer(player: AccessPlayer) { - if (pickedSteamIds.value.includes(String(player.steam_id))) { + const steamId = String(player.steam_id); + + if (steamIdsOf(draft.value).includes(steamId)) { return; } - draft.value.players = [ - ...draft.value.players, - { ...player, steam_id: String(player.steam_id) }, - ]; - touch(); + draft.value = { + ...draft.value, + players: [...draft.value.players, { ...player, steam_id: steamId }], + }; } function removePlayer(steamId: string) { - draft.value.players = draft.value.players.filter( - (player) => player.steam_id !== steamId, - ); - touch(); + draft.value = { + ...draft.value, + players: draft.value.players.filter( + (player) => player.steam_id !== steamId, + ), + }; } function addEvent(event: AccessEvent) { - if (draft.value.events.some((existing) => existing.id === event.id)) { + if (eventIdsOf(draft.value).includes(event.id)) { return; } - draft.value.events = [...draft.value.events, event]; - touch(); + draft.value = { ...draft.value, events: [...draft.value.events, event] }; } function removeEvent(eventId: string) { - draft.value.events = draft.value.events.filter( - (event) => event.id !== eventId, - ); - touch(); + draft.value = { + ...draft.value, + events: draft.value.events.filter((event) => event.id !== eventId), + }; } function eventWindow(event: AccessEvent): string { @@ -279,251 +266,184 @@ const pluginMissing = computed(() => { return !seenAt || now.value - new Date(seenAt).getTime() >= PLUGIN_STALE_MS; }); -async function save() { - if (submitting.value) { - return; - } - - submitting.value = true; - const startedAt = revision.value; - const saving = snapshot(draft.value); - - try { - await nuxtApp.$apollo.defaultClient.mutate({ - mutation: SAVE, - variables: { - serverId: props.serverId, - restricted: draft.value.restricted, - minRole: - draft.value.minRole === NO_ROLE ? null : draft.value.minRole, - steamIds: pickedSteamIds.value, - eventIds: draft.value.events.map((event) => event.id), - }, - }); - - toast({ title: t("pages.dedicated_servers.detail.access.saved") }); - - saved.value = saving; - - // Edits made while saving stay pending. - if (revision.value === startedAt) { - await refetch(); - } - } finally { - submitting.value = false; - } +function payload() { + return { + restricted: draft.value.restricted, + min_role: draft.value.minRole === NO_ROLE ? null : draft.value.minRole, + steam_ids: steamIdsOf(draft.value), + event_ids: eventIdsOf(draft.value), + }; } -const cardClasses = - "relative isolate overflow-hidden rounded-lg border border-border [background:linear-gradient(180deg,hsl(var(--card)/0.2)_0%,hsl(var(--card)/0.04)_100%)]"; +async function saved() { + savedDraft.value = copy(draft.value); -const microLabelClasses = - "font-mono text-[0.6rem] font-bold uppercase tracking-[0.18em] text-muted-foreground"; + await refetch(); +} -const chipClasses = - "inline-flex items-center gap-1.5 rounded border px-1.5 py-0.5 font-mono text-[0.58rem] font-bold uppercase leading-none tracking-[0.12em]"; +defineExpose({ changes, payload, reset, saved }); diff --git a/components/servers/ServerMapRotation.vue b/components/servers/ServerMapRotation.vue index e7661b18b..32189328d 100644 --- a/components/servers/ServerMapRotation.vue +++ b/components/servers/ServerMapRotation.vue @@ -6,17 +6,14 @@ import gql from "graphql-tag"; import { ChevronDown, ChevronUp, - Download, GripVertical, - ListOrdered, Map as MapIcon, - Plus, - Shuffle, - TriangleAlert, X, } from "lucide-vue-next"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { Switch } from "~/components/ui/switch"; import { Select, SelectContent, @@ -26,8 +23,7 @@ import { SelectTrigger, SelectValue, } from "~/components/ui/select"; -import AnimatedFilters from "~/components/common/AnimatedFilters.vue"; -import SettingsSaveBar from "~/components/settings/SettingsSaveBar.vue"; +import SettingsSection from "~/components/settings/SettingsSection.vue"; import { toast } from "@/components/ui/toast"; type RotationMap = { @@ -38,7 +34,7 @@ type RotationMap = { workshop_map_id: string | null; }; -const props = defineProps<{ serverId: string; enabled?: boolean }>(); +const props = defineProps<{ serverId: string }>(); const { t } = useI18n(); const nuxtApp = useNuxtApp(); @@ -93,22 +89,6 @@ const QUERY = gql` } `; -const SAVE = gql` - mutation SetServerMapRotation( - $serverId: uuid! - $mapIds: [uuid!]! - $shuffle: Boolean! - ) { - setServerMapRotation( - server_id: $serverId - map_ids: $mapIds - shuffle: $shuffle - ) { - success - } - } -`; - const IMPORT = gql` mutation ImportWorkshopCollection($collection: String!) { importWorkshopCollection(collection: $collection) { @@ -132,9 +112,10 @@ const { result, refetch } = useQuery( const entries = ref>([]); const shuffle = ref(true); -const dirty = ref(false); -const revision = ref(0); -const submitting = ref(false); +const baseline = ref<{ ids: Array; shuffle: boolean }>({ + ids: [], + shuffle: true, +}); const importing = ref(false); const selectedMapId = ref(); const collection = ref(""); @@ -143,6 +124,46 @@ const brokenPosters = ref(new Set()); const mapKey = (map: RotationMap) => map.workshop_map_id || map.name; const mapLabel = (map: RotationMap) => map.label || map.name; +const changes = computed>(() => { + const before = baseline.value.ids; + const after = entries.value.map((map) => map.id); + const added = after.filter((id) => !before.includes(id)).length; + const removed = before.filter((id) => !after.includes(id)).length; + const list: Array = []; + + if (added) { + list.push( + t("pages.dedicated_servers.detail.settings.changes.maps_added", { + count: added, + }), + ); + } + + if (removed) { + list.push( + t("pages.dedicated_servers.detail.settings.changes.maps_removed", { + count: removed, + }), + ); + } + + if (!added && !removed && after.join() !== before.join()) { + list.push(t("pages.dedicated_servers.detail.settings.changes.map_order")); + } + + if (shuffle.value !== baseline.value.shuffle) { + list.push( + t( + shuffle.value + ? "pages.dedicated_servers.detail.settings.changes.shuffle_on" + : "pages.dedicated_servers.detail.settings.changes.shuffle_off", + ), + ); + } + + return list.map((text) => ({ text, restart: true })); +}); + function reset() { const server = (result.value as any)?.servers_by_pk; @@ -150,13 +171,16 @@ function reset() { (row: { map: RotationMap }) => row.map, ); shuffle.value = server?.map_rotation_shuffle ?? true; - dirty.value = false; + baseline.value = { + ids: entries.value.map((map) => map.id), + shuffle: shuffle.value, + }; } watch( result, () => { - if (!dirty.value) { + if (!changes.value.length) { reset(); } }, @@ -212,43 +236,12 @@ const warnWorkshopOrder = computed( entries.value.some((map) => map.workshop_map_id), ); -const orderModel = computed({ - get: () => (shuffle.value ? "shuffle" : "ordered"), - set: (value: string) => { - shuffle.value = value === "shuffle"; - touch(); - }, -}); - -const orderOptions = computed(() => [ - { - key: "shuffle", - label: t("pages.dedicated_servers.detail.map_rotation.shuffle"), - icon: Shuffle, - title: t("pages.dedicated_servers.detail.map_rotation.shuffle"), - desc: t("pages.dedicated_servers.detail.map_rotation.shuffle_hint"), - }, - { - key: "ordered", - label: t("pages.dedicated_servers.detail.map_rotation.in_order"), - icon: ListOrdered, - title: t("pages.dedicated_servers.detail.map_rotation.in_order"), - desc: t("pages.dedicated_servers.detail.map_rotation.in_order_hint"), - }, -]); - -function touch() { - dirty.value = true; - revision.value++; -} - function add(map: RotationMap) { if (entries.value.some((entry) => mapKey(entry) === mapKey(map))) { return; } - entries.value.push(map); - touch(); + entries.value = [...entries.value, map]; } function addSelected() { @@ -268,14 +261,14 @@ function moveTo(from: number, to: number) { return; } - const [map] = entries.value.splice(from, 1); - entries.value.splice(to, 0, map); - touch(); + const next = [...entries.value]; + const [map] = next.splice(from, 1); + next.splice(to, 0, map); + entries.value = next; } function remove(index: number) { - entries.value.splice(index, 1); - touch(); + entries.value = entries.value.filter((_, position) => position !== index); } const dragIndex = ref(null); @@ -365,289 +358,179 @@ async function importCollection() { await refetch(); } -async function save() { - if (submitting.value) { - return; - } - - submitting.value = true; - const startedAt = revision.value; - - try { - await nuxtApp.$apollo.defaultClient.mutate({ - mutation: SAVE, - variables: { - serverId: props.serverId, - mapIds: entries.value.map((map) => map.id), - shuffle: shuffle.value, - }, - }); - - toast({ - title: t( - props.enabled === false - ? "pages.dedicated_servers.detail.map_rotation.saved_disabled" - : "pages.dedicated_servers.detail.map_rotation.saved", - ), - }); - - // Changes made while the server was restarting stay pending. - if (revision.value === startedAt) { - dirty.value = false; - await refetch(); - } - } finally { - submitting.value = false; - } +function payload() { + return { + map_ids: entries.value.map((map) => map.id), + shuffle: shuffle.value, + }; } -const cardClasses = - "relative isolate overflow-hidden rounded-lg border border-border [background:linear-gradient(180deg,hsl(var(--card)/0.2)_0%,hsl(var(--card)/0.04)_100%)]"; +async function saved() { + baseline.value = { + ids: entries.value.map((map) => map.id), + shuffle: shuffle.value, + }; -const microLabelClasses = - "font-mono text-[0.6rem] font-bold uppercase tracking-[0.18em] text-muted-foreground"; + await refetch(); +} -const chipClasses = - "inline-flex items-center rounded border px-1.5 py-0.5 font-mono text-[0.58rem] font-bold uppercase leading-none tracking-[0.12em]"; +defineExpose({ changes, payload, reset, saved });