mirror of
https://github.com/modrinth/code.git
synced 2026-09-03 05:25:58 +00:00
fix: ux changes for sync settings/overrides
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
UPDATE sync_feature_settings
|
||||
SET globally_enabled = 0, new_instance_default = 1
|
||||
WHERE feature IN (
|
||||
'command_history',
|
||||
'multiplayer_servers',
|
||||
'creative_hotbars'
|
||||
);
|
||||
|
||||
UPDATE instance_sync_preferences
|
||||
SET enabled = 1
|
||||
WHERE feature IN (
|
||||
'command_history',
|
||||
'multiplayer_servers',
|
||||
'creative_hotbars'
|
||||
);
|
||||
@@ -67,9 +67,14 @@ pub async fn list_screenshots(
|
||||
|
||||
pub async fn list_synced_screenshots() -> crate::Result<Vec<InstanceScreenshot>>
|
||||
{
|
||||
if !super::super::synced_options::get_global_options()
|
||||
.await?
|
||||
.screenshots
|
||||
{
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let state = State::get().await?;
|
||||
let sources =
|
||||
instance_rows::list_synced_screenshot_sources(&state.pool).await?;
|
||||
let sources = instance_rows::list_screenshot_sources(&state.pool).await?;
|
||||
list_source_screenshot_sets(&state, sources).await
|
||||
}
|
||||
|
||||
|
||||
@@ -288,47 +288,28 @@ async fn version_capability(
|
||||
pub async fn set_global_option(
|
||||
option: SyncedOption,
|
||||
enabled: bool,
|
||||
base_instance_id: Option<&str>,
|
||||
) -> crate::Result<GlobalSyncedOptions> {
|
||||
let state = State::get().await?;
|
||||
let _guard = state.lock_synced_options().await;
|
||||
let reset_participation =
|
||||
enabled && !canonical_exists(option, &state).await?;
|
||||
|
||||
let option_name = option.as_str();
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO sync_feature_settings
|
||||
(feature, globally_enabled, new_instance_default)
|
||||
VALUES (?, ?, 1)
|
||||
ON CONFLICT(feature) DO UPDATE SET
|
||||
globally_enabled = excluded.globally_enabled
|
||||
",
|
||||
option_name,
|
||||
enabled,
|
||||
)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
if enabled && option != SyncedOption::Screenshots {
|
||||
let base_instance_id = base_instance_id.ok_or_else(|| {
|
||||
ErrorKind::InputError(
|
||||
"Choose an instance to use as the sync source.".to_string(),
|
||||
)
|
||||
})?;
|
||||
return enable_global_option_from_base(
|
||||
option,
|
||||
base_instance_id,
|
||||
&state,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
set_global_option_enabled(option, enabled, &state).await?;
|
||||
|
||||
let instances = crate::state::list_instances(&state.pool).await?;
|
||||
if reset_participation {
|
||||
for metadata in instances {
|
||||
if instance_option_enabled(&metadata, option) {
|
||||
instance_rows::set_instance_sync_preference(
|
||||
&metadata.instance.id,
|
||||
option,
|
||||
false,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
if !sync_files_are_protected(&metadata)
|
||||
&& !instance_is_running(&metadata, &state).await?
|
||||
{
|
||||
detach_option(&metadata, option, &state).await?;
|
||||
}
|
||||
}
|
||||
return get_global_options_with_state(&state).await;
|
||||
}
|
||||
for metadata in instances {
|
||||
if sync_files_are_protected(&metadata)
|
||||
|| instance_is_running(&metadata, &state).await?
|
||||
@@ -353,6 +334,112 @@ pub async fn set_global_option(
|
||||
get_global_options_with_state(&state).await
|
||||
}
|
||||
|
||||
async fn set_global_option_enabled(
|
||||
option: SyncedOption,
|
||||
enabled: bool,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
let option_name = option.as_str();
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO sync_feature_settings
|
||||
(feature, globally_enabled, new_instance_default)
|
||||
VALUES (?, ?, 1)
|
||||
ON CONFLICT(feature) DO UPDATE SET
|
||||
globally_enabled = excluded.globally_enabled
|
||||
",
|
||||
option_name,
|
||||
enabled,
|
||||
)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn enable_global_option_from_base(
|
||||
option: SyncedOption,
|
||||
base_instance_id: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<GlobalSyncedOptions> {
|
||||
let source = crate::state::get_instance(base_instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ErrorKind::InputError("Unknown sync source instance.".to_string())
|
||||
})?;
|
||||
if sync_files_are_protected(&source)
|
||||
|| instance_is_running(&source, state).await?
|
||||
{
|
||||
return Err(ErrorKind::InputError(
|
||||
"Close the source instance before using it for syncing."
|
||||
.to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
match capability_status(&source, option, true, state).await {
|
||||
CapabilityStatus::Supported => {}
|
||||
CapabilityStatus::Unsupported(reason)
|
||||
| CapabilityStatus::Indeterminate(reason) => {
|
||||
return Err(ErrorKind::InputError(reason).into());
|
||||
}
|
||||
}
|
||||
|
||||
let instances = crate::state::list_instances(&state.pool).await?;
|
||||
for metadata in &instances {
|
||||
if instance_option_enabled(metadata, option)
|
||||
&& (sync_files_are_protected(metadata)
|
||||
|| instance_is_running(metadata, state).await?)
|
||||
{
|
||||
return Err(ErrorKind::InputError(
|
||||
"Close all instances using this synced setting before choosing a new sync source."
|
||||
.to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
for metadata in &instances {
|
||||
if instance_option_enabled(metadata, option) {
|
||||
detach_option(metadata, option, state).await?;
|
||||
}
|
||||
}
|
||||
if !instance_option_enabled(&source, option) {
|
||||
detach_option(&source, option, state).await?;
|
||||
}
|
||||
|
||||
seed_from_instance(&source, option, state).await?;
|
||||
instance_rows::set_instance_sync_preference(
|
||||
base_instance_id,
|
||||
option,
|
||||
true,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
set_global_option_enabled(option, true, state).await?;
|
||||
|
||||
for metadata in crate::state::list_instances(&state.pool).await? {
|
||||
if sync_files_are_protected(&metadata)
|
||||
|| instance_is_running(&metadata, state).await?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !instance_option_enabled(&metadata, option) {
|
||||
detach_option(&metadata, option, state).await?;
|
||||
continue;
|
||||
}
|
||||
match capability_status(&metadata, option, true, state).await {
|
||||
CapabilityStatus::Supported => {
|
||||
ensure_option(&metadata, option, state).await?
|
||||
}
|
||||
CapabilityStatus::Unsupported(_) => {
|
||||
detach_option(&metadata, option, state).await?
|
||||
}
|
||||
CapabilityStatus::Indeterminate(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
get_global_options_with_state(state).await
|
||||
}
|
||||
|
||||
pub async fn set_instance_option(
|
||||
instance_id: &str,
|
||||
option: SyncedOption,
|
||||
|
||||
@@ -172,7 +172,48 @@ pub(in crate::api::instance) async fn detach_servers(
|
||||
) -> crate::Result<()> {
|
||||
let generated = generated_path(state, &metadata.instance.id);
|
||||
let local = instance_dir(metadata, state).join(SERVERS_FILE);
|
||||
detach_link(&generated, &local).await
|
||||
let Some(current_checkpoint) = checkpoint(
|
||||
&metadata.instance.id,
|
||||
SyncedOption::MultiplayerServers,
|
||||
"default",
|
||||
state,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return detach_link(&generated, &local).await;
|
||||
};
|
||||
let linked_to_generated = tokio::fs::symlink_metadata(&local)
|
||||
.await
|
||||
.is_ok_and(|metadata| metadata.file_type().is_symlink())
|
||||
&& tokio::fs::read_link(&local)
|
||||
.await
|
||||
.is_ok_and(|target| target == generated);
|
||||
let matches_checkpoint = local.exists()
|
||||
&& sha1_file(&local).await? == current_checkpoint.expected_sha1;
|
||||
if current_checkpoint.status != CheckpointStatus::Ready
|
||||
|| (!linked_to_generated && !matches_checkpoint)
|
||||
{
|
||||
return detach_link(&generated, &local).await;
|
||||
}
|
||||
|
||||
let current = read_servers(&local).await?;
|
||||
let projections =
|
||||
load_projection_entries(&metadata.instance.id, state).await?;
|
||||
let projection_matches = match_projection_entries(¤t, &projections);
|
||||
let instance_servers = current
|
||||
.into_iter()
|
||||
.zip(projection_matches)
|
||||
.filter_map(|(server, projection)| {
|
||||
projection
|
||||
.is_none_or(|projection| {
|
||||
projection.owner == ProjectionOwner::Instance
|
||||
})
|
||||
.then_some(server)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
detach_link(&generated, &local).await?;
|
||||
write_servers(&local, &instance_servers).await
|
||||
}
|
||||
|
||||
pub(in crate::api::instance) async fn reconcile_servers(
|
||||
|
||||
@@ -494,32 +494,6 @@ pub(crate) async fn get_instance_screenshot_source(
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_synced_screenshot_sources(
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Vec<InstanceScreenshotSource>> {
|
||||
let sources = sqlx::query_as!(
|
||||
InstanceScreenshotSource,
|
||||
"
|
||||
SELECT instances.id, instances.name, instances.path
|
||||
FROM instances
|
||||
INNER JOIN instance_sync_preferences preferences
|
||||
ON preferences.instance_id = instances.id
|
||||
WHERE preferences.feature = 'screenshots'
|
||||
AND preferences.enabled = 1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_feature_settings
|
||||
WHERE feature = 'screenshots' AND globally_enabled = 1
|
||||
)
|
||||
ORDER BY instances.name, instances.id
|
||||
",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(sources)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_screenshot_sources(
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Vec<InstanceScreenshotSource>> {
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
<template>
|
||||
<div class="flex flex-row items-center w-full">
|
||||
<div class="w-full relative">
|
||||
<div class="absolute top-0 h-1/2 w-full">
|
||||
<div
|
||||
class="relative inline-block align-middle w-[calc(100%-0.75rem)] h-3 left-[calc(0.75rem/2)]"
|
||||
>
|
||||
<div
|
||||
v-for="snapPoint in snapPoints"
|
||||
:key="snapPoint"
|
||||
class="absolute inline-block w-1 h-full rounded-sm -translate-x-1/2"
|
||||
:class="{
|
||||
'opacity-0': disabled,
|
||||
}"
|
||||
:style="{
|
||||
left: ((snapPoint - min) / (max - min)) * 100 + '%',
|
||||
backgroundColor:
|
||||
snapPoint <= currentValue ? 'var(--color-brand)' : 'var(--color-base)',
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex w-full items-center gap-4">
|
||||
<span class="shrink-0 whitespace-nowrap py-2 text-sm leading-5 text-secondary">
|
||||
{{ min }}
|
||||
</span>
|
||||
|
||||
<div class="relative h-10 min-w-0 flex-1" :class="disabled ? 'opacity-50' : ''">
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-surface-5"
|
||||
>
|
||||
<div class="h-full rounded-full bg-brand" :style="{ width: `${currentPercentage}%` }" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="visibleSnapPoints.length"
|
||||
class="pointer-events-none absolute inset-x-0 top-1/2 h-6 -translate-y-1/2"
|
||||
>
|
||||
<span
|
||||
v-for="snapPoint in visibleSnapPoints"
|
||||
:key="snapPoint"
|
||||
class="absolute top-0 h-6 w-1 -translate-x-1/2 rounded-full"
|
||||
:class="snapPoint <= currentValue ? 'bg-brand' : 'bg-surface-5'"
|
||||
:style="{ left: `${getPercentage(snapPoint)}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="input"
|
||||
v-model="currentValue"
|
||||
@@ -27,27 +31,24 @@
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
class="slider relative rounded-sm h-1 w-full p-0 min-h-0 shadow-none outline-none align-middle appearance-none"
|
||||
:class="{
|
||||
'opacity-50 cursor-not-allowed': disabled,
|
||||
}"
|
||||
class="slider absolute top-0 h-10 min-h-0 appearance-none border-0 bg-transparent p-0 shadow-none outline-none"
|
||||
:class="disabled ? 'cursor-not-allowed' : 'cursor-pointer'"
|
||||
:disabled="disabled"
|
||||
:style="{
|
||||
'--current-value': currentValue,
|
||||
'--min-value': min,
|
||||
'--max-value': max,
|
||||
}"
|
||||
@input="onInputWithSnap(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<div class="flex flex-row justify-between text-xs m-0">
|
||||
<span> {{ min }} {{ unit }} </span>
|
||||
<span> {{ max }} {{ unit }} </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="shrink-0 whitespace-nowrap py-2 text-sm leading-5 text-secondary">
|
||||
{{ formatValue(max) }}
|
||||
</span>
|
||||
|
||||
<Input
|
||||
:model-value="String(currentValue)"
|
||||
type="number"
|
||||
class="w-24 ml-3"
|
||||
size="medium"
|
||||
wrapper-class="slider-value shrink-0"
|
||||
input-class="!font-semibold"
|
||||
:style="{ width: valueInputWidth }"
|
||||
:disabled="disabled"
|
||||
:min="min"
|
||||
:max="max"
|
||||
@@ -58,7 +59,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import Input from './inputs/Input.vue'
|
||||
|
||||
@@ -88,104 +89,133 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
unit: '',
|
||||
})
|
||||
|
||||
const currentValue = ref(Math.max(props.min, props.modelValue))
|
||||
const currentValue = ref(clampValue(props.modelValue))
|
||||
const currentPercentage = computed(() => getPercentage(currentValue.value))
|
||||
const valueInputWidth = computed(
|
||||
() => `calc(${Math.max(String(currentValue.value).length, 1)}ch + 2.125rem)`,
|
||||
)
|
||||
const visibleSnapPoints = computed(() =>
|
||||
props.snapPoints.filter((snapPoint) => snapPoint >= props.min && snapPoint <= props.max),
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
currentValue.value = Math.max(props.min, newValue ?? props.min)
|
||||
currentValue.value = clampValue(newValue ?? props.min)
|
||||
},
|
||||
)
|
||||
|
||||
const inputValueValid = (inputValue: number) => {
|
||||
let newValue = inputValue || props.min
|
||||
function clampValue(value: number) {
|
||||
return Math.max(props.min, Math.min(value, props.max))
|
||||
}
|
||||
|
||||
if (props.forceStep) {
|
||||
function getPercentage(value: number) {
|
||||
const range = props.max - props.min
|
||||
if (range <= 0) return 0
|
||||
|
||||
return Math.max(0, Math.min(((value - props.min) / range) * 100, 100))
|
||||
}
|
||||
|
||||
function formatValue(value: number) {
|
||||
return props.unit ? `${value} ${props.unit}` : String(value)
|
||||
}
|
||||
|
||||
function inputValueValid(inputValue: number) {
|
||||
if (Number.isNaN(inputValue)) return
|
||||
|
||||
let newValue = inputValue
|
||||
if (props.forceStep && props.step > 0) {
|
||||
newValue -= newValue % props.step
|
||||
}
|
||||
newValue = Math.max(props.min, Math.min(newValue, props.max))
|
||||
|
||||
currentValue.value = newValue
|
||||
currentValue.value = clampValue(newValue)
|
||||
emit('update:modelValue', currentValue.value)
|
||||
}
|
||||
|
||||
const onInputWithSnap = (value: string) => {
|
||||
let parsedValue = parseInt(value)
|
||||
function onInputWithSnap(value: string) {
|
||||
let parsedValue = Number.parseFloat(value)
|
||||
|
||||
for (const snapPoint of props.snapPoints) {
|
||||
const distance = Math.abs(snapPoint - parsedValue)
|
||||
|
||||
if (distance < props.snapRange) {
|
||||
parsedValue = snapPoint
|
||||
}
|
||||
if (distance < props.snapRange) parsedValue = snapPoint
|
||||
}
|
||||
|
||||
inputValueValid(parsedValue)
|
||||
}
|
||||
|
||||
const onInput = (value: string) => {
|
||||
inputValueValid(parseInt(value))
|
||||
function onInput(value: string) {
|
||||
inputValueValid(Number.parseFloat(value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--color-brand) 0%,
|
||||
var(--color-brand)
|
||||
calc(
|
||||
(var(--current-value) - var(--min-value)) / (var(--max-value) - var(--min-value)) * 100%
|
||||
),
|
||||
var(--color-base)
|
||||
calc(
|
||||
(var(--current-value) - var(--min-value)) / (var(--max-value) - var(--min-value)) * 100%
|
||||
),
|
||||
var(--color-base) 100%
|
||||
)
|
||||
100% 100% no-repeat;
|
||||
left: -0.625rem;
|
||||
width: calc(100% + 1.25rem);
|
||||
|
||||
&::-webkit-slider-runnable-track {
|
||||
height: 0.25rem;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-moz-range-track,
|
||||
&::-moz-range-progress {
|
||||
height: 0.25rem;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
background: var(--color-brand);
|
||||
border-radius: 50%;
|
||||
transition:
|
||||
width 0.2s,
|
||||
height 0.2s;
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-top: -0.5rem;
|
||||
border: 0;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-text-default);
|
||||
box-shadow:
|
||||
0 0 0 2px var(--surface-3),
|
||||
0 0 0 4px var(--color-brand);
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
border: none;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
background: var(--color-brand);
|
||||
border-radius: 50%;
|
||||
transition:
|
||||
width 0.2s,
|
||||
height 0.2s;
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 0;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-text-default);
|
||||
box-shadow:
|
||||
0 0 0 2px var(--surface-3),
|
||||
0 0 0 4px var(--color-brand);
|
||||
}
|
||||
|
||||
&:hover:not(:disabled)::-webkit-slider-thumb,
|
||||
&:hover:not(:disabled)::-moz-range-thumb {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
&:focus-visible::-webkit-slider-thumb {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--surface-3),
|
||||
0 0 0 4px var(--color-brand),
|
||||
0 0 0 8px var(--color-brand-highlight);
|
||||
}
|
||||
|
||||
&:focus-visible::-moz-range-thumb {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--surface-3),
|
||||
0 0 0 4px var(--color-brand),
|
||||
0 0 0 8px var(--color-brand-highlight);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.slider-value :deep(input[type='number']) {
|
||||
-moz-appearance: textfield;
|
||||
|
||||
&::-webkit-inner-spin-button,
|
||||
&::-webkit-outer-spin-button {
|
||||
margin: 0;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user