Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,623 changes: 127 additions & 1,496 deletions src/App.vue

Large diffs are not rendered by default.

150 changes: 150 additions & 0 deletions src/components/AppInfoPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<script setup lang="ts">
import type { AppOption, AppVersion, VersionRangeInfo } from '../types'

const props = defineProps<{
selectedApp: string
selectedAppOption: AppOption | null
installedVersion: string
selectedVersion: string
selectedVersionRange: VersionRangeInfo | null
versionRangeText: (summary: VersionRangeInfo | null) => string
versions: AppVersion[]
filteredVersions: AppVersion[]
visibleVersions: AppVersion[]
versionFilter: string
changeActionLabel: string
availableSource: string
hasCheckedVersions: boolean
errorMessage: string
isCheckingVersions: boolean
isInstallingVersion: boolean
}>()

const emit = defineEmits<{
'update:versionFilter': [value: string]
'clear-selected-app': []
'select-version': [version: string]
'deselect-version': []
'perform-install': []
}>()
</script>

<template>
<div :class="[$style.infoPanel, { [$style.infoPanelOpen]: props.selectedApp || props.installedVersion || props.versions.length > 0 || props.availableSource || props.errorMessage || props.hasCheckedVersions }]">
<div v-if="props.selectedApp || props.installedVersion" :class="$style.installed">
<div v-if="props.selectedApp" :class="$style.selectedApp">
<span :class="$style.installedLabel">Selected app</span>
<span :class="$style.installedValue">{{ props.selectedAppOption?.label || props.selectedApp }}</span>
<span v-if="props.selectedAppOption?.label && props.selectedAppOption.id !== props.selectedAppOption.label" :class="$style.installedSubvalue">{{ props.selectedApp }}</span>
<button
type="button"
:class="$style.changeAppButton"
:disabled="props.isCheckingVersions || props.isInstallingVersion"
@click="emit('clear-selected-app')"
>
Choose another app
</button>
</div>
<div v-if="props.installedVersion" :class="$style.installedCurrent">
<span :class="$style.installedLabel">Current installed</span>
<span :class="$style.installedValue">{{ props.installedVersion }}</span>
</div>
<div v-if="props.selectedVersion" :class="$style.selectedVersion">
<span :class="$style.installedLabel">Selected version</span>
<span :class="$style.versionTransition">
<span :class="$style.versionChip">{{ props.installedVersion || '—' }}</span>
<span :class="$style.versionArrow">→</span>
<span :class="$style.versionChip">{{ props.selectedVersion }}</span>
</span>
</div>
<p v-if="props.selectedVersionRange" :class="$style.versionSummary">
{{ props.versionRangeText(props.selectedVersionRange) }}
</p>
<p v-if="props.selectedVersionRange?.direction === 'degrade'" :class="$style.versionDegradeSummary">
Downgrade path detected.
</p>
</div>

<div v-if="props.versions.length > 0" :class="$style.versionListContainer">
<input
v-if="!props.selectedVersion"
:value="props.versionFilter"
type="text"
placeholder="Filter versions"
:class="$style.versionFilterInput"
:disabled="props.isInstallingVersion"
@input="emit('update:versionFilter', ($event.target as HTMLInputElement).value)"
/>
<div :class="$style.versionListWrapper">
<transition-group
name="versionFade"
tag="ul"
:class="$style.versionList"
>
<li v-for="version in props.visibleVersions" :key="version.version" :class="$style.versionItem">
<div :class="$style.versionItemMain">
<span>{{ version.version }}</span>
<button
v-if="props.selectedVersion !== version.version"
type="button"
:class="$style.versionSelectButton"
:disabled="props.isInstallingVersion"
@click="emit('select-version', version.version)"
>
Select
</button>
<span v-else :class="$style.selectedVersionFlag">
Selected
</span>
</div>
<div
v-if="props.selectedVersion === version.version && props.selectedVersion !== ''"
:class="$style.versionActionGroup"
>
<p
v-if="props.changeActionLabel === 'Degrade'"
:class="$style.versionDegradeWarning"
>
Warning! Downgrading can result in breaking the database if earlier updates or migrations added database columns. Only do this when u can fix the database or are sure no migrations have been executed since the version u downgrade to!
</p>
<div :class="$style.versionItemActions">
<button
v-if="props.changeActionLabel"
type="button"
:class="[$style.versionActionButton, props.changeActionLabel === 'Update' ? $style.versionActionUpdateButton : (props.changeActionLabel === 'Degrade' ? $style.versionActionDegradeButton : '')]"
:aria-busy="props.isInstallingVersion"
:disabled="props.isInstallingVersion"
@click="emit('perform-install')"
>
<span v-if="props.isInstallingVersion" :class="$style.spinner" aria-hidden="true" />
{{ props.isInstallingVersion ? 'Installing…' : props.changeActionLabel }}
</button>
<button
type="button"
:class="$style.versionDeselectButton"
:disabled="props.isInstallingVersion"
@click="emit('deselect-version')"
>
Pick other
</button>
</div>
</div>
</li>
</transition-group>
<p v-if="props.filteredVersions.length === 0" :class="$style.noFilterResult">
No versions match your filter.
</p>
</div>
</div>

<p v-if="props.availableSource" :class="$style.note">
Versions source: {{ props.availableSource }}
</p>
<p v-else-if="props.hasCheckedVersions" :class="$style.note">
No versions available for this app.
</p>
<p v-if="props.errorMessage" :class="$style.error">{{ props.errorMessage }}</p>
</div>
</template>

<style module src="../styles/AppInfoPanel.module.css"></style>
117 changes: 117 additions & 0 deletions src/components/AppPickerPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<script setup lang="ts">
import type { AppOption } from '../types'

const props = defineProps<{
apps: AppOption[]
appFilter: string
showFilters: boolean
coreAppsVisibility: 'show' | 'hide'
selectedApp: string
hasSplitLayout: boolean
hasSidebarSelect: boolean
isLoading: boolean
isCheckingVersions: boolean
isInstallingVersion: boolean
sidebarLabel: string
}>()

const emit = defineEmits<{
'update:appFilter': [value: string]
'update:showFilters': [value: boolean]
'update:coreAppsVisibility': [value: 'show' | 'hide']
'pick-app': [appId: string]
}>()

const appCardDescription = (app: AppOption): string => app.summary || app.description || 'No description available.'

const appCardFallback = (app: AppOption): string => {
const source = (app.label || app.id).trim()
return source === '' ? '?' : source.charAt(0).toUpperCase()
}
</script>

<template>
<div :class="$style.selectSection">
<label :class="$style.label" for="app-filter">Pick an installed App</label>
<div :class="$style.filterToolbar">
<button
type="button"
:class="$style.filterToggleButton"
@click="emit('update:showFilters', !props.showFilters)"
>
{{ props.showFilters ? 'Hide filters' : 'Show filters' }}
</button>
</div>
<div v-if="props.showFilters" :class="$style.filterPanel">
<label :class="$style.filterField">
<span :class="$style.filterFieldLabel">Core apps</span>
<select
:value="props.coreAppsVisibility"
:class="$style.filterSelect"
@change="emit('update:coreAppsVisibility', ($event.target as HTMLSelectElement).value as 'show' | 'hide')"
>
<option value="show">Show core apps</option>
<option value="hide">Hide core apps</option>
</select>
</label>
</div>
<input
id="app-filter"
:value="props.appFilter"
type="text"
placeholder="Search apps"
:class="$style.appFilterInput"
:disabled="!props.hasSidebarSelect || props.isLoading || props.apps.length === 0 || props.isCheckingVersions || props.isInstallingVersion"
:aria-label="props.sidebarLabel"
@input="emit('update:appFilter', ($event.target as HTMLInputElement).value)"
/>
<div
v-if="!props.selectedApp"
:class="[$style.appCardList, { [$style.appCardListSplit]: props.hasSplitLayout }]"
>
<article
v-for="app in props.apps"
:key="app.id"
:class="[$style.appCard, { [$style.appCardSelected]: props.selectedApp === app.id, [$style.appCardCore]: app.isCore }]"
>
<div :class="$style.appCardBody">
<div :class="$style.appCardHeader">
<div :class="$style.appCardTitleBlock">
<div :class="$style.appCardTitleRow">
<p :class="$style.appCardTitle">{{ app.label }}</p>
<span v-if="app.isCore" :class="$style.appCardCoreFlag">CORE</span>
</div>
<p :class="$style.appCardMeta">{{ app.id }}</p>
</div>
<div :class="$style.appCardMedia">
<img
v-if="app.preview"
:src="app.preview"
:alt="`${app.label} icon`"
:class="$style.appCardIcon"
/>
<div v-else :class="$style.appCardFallbackIcon" aria-hidden="true">
{{ appCardFallback(app) }}
</div>
</div>
</div>
<p :class="$style.appCardDescription">{{ appCardDescription(app) }}</p>
</div>
<button
v-if="!app.isCore"
type="button"
:class="$style.appCardButton"
:disabled="props.isCheckingVersions || props.isInstallingVersion"
@click="emit('pick-app', app.id)"
>
{{ props.selectedApp === app.id && props.isCheckingVersions ? 'Loading…' : 'Choose app' }}
</button>
</article>
</div>
<p v-if="!props.selectedApp && props.apps.length === 0" :class="$style.noFilterResult">
No apps match your filter.
</p>
</div>
</template>

<style module src="../styles/AppPickerPanel.module.css"></style>
45 changes: 45 additions & 0 deletions src/components/AppSettingsPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<script setup lang="ts">
const props = defineProps<{
updateChannel: string
safeModeEnabled: boolean
debugModeEnabled: boolean
disabled: boolean
}>()

const emit = defineEmits<{
'update:safeModeEnabled': [value: boolean]
'update:debugModeEnabled': [value: boolean]
}>()
</script>

<template>
<div :class="$style.settingsPanel">
<p v-if="props.updateChannel" :class="$style.updateChannel">
Update channel: <strong>{{ props.updateChannel }}</strong>
</p>
<div :class="$style.settingsToggles">
<label :class="$style.safeMode">
<input
type="checkbox"
:checked="props.safeModeEnabled"
:class="$style.safeModeCheckbox"
:disabled="props.disabled"
@change="emit('update:safeModeEnabled', ($event.target as HTMLInputElement).checked)"
/>
<span>Safe mode (block downgrades and respects update channel)</span>
</label>
<label :class="$style.safeMode">
<input
type="checkbox"
:checked="props.debugModeEnabled"
:class="$style.safeModeCheckbox"
:disabled="props.disabled"
@change="emit('update:debugModeEnabled', ($event.target as HTMLInputElement).checked)"
/>
<span>Enable install dry-run (show debug output)</span>
</label>
</div>
</div>
</template>

<style module src="../styles/AppSettingsPanel.module.css"></style>
44 changes: 44 additions & 0 deletions src/components/DowngradeConfirmDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<script setup lang="ts">
import NcDialog from '@nextcloud/vue/components/NcDialog'
import type { VersionRangeInfo } from '../types'

defineProps<{
open: boolean
buttons: Array<Record<string, unknown>>
appId: string
fromVersion: string
toVersion: string
range: VersionRangeInfo | null
versionRangeText: (summary: VersionRangeInfo | null) => string
}>()

const emit = defineEmits<{
'update:open': [value: boolean]
}>()
</script>

<template>
<NcDialog
:open="open"
name="Confirm downgrade"
:buttons="buttons"
@update:open="emit('update:open', $event)"
>
<p :class="$style.downgradeConfirmText">
<strong>{{ appId }}</strong>
</p>
<p :class="$style.versionTransitionRow">
<span :class="$style.versionChip">{{ fromVersion || '—' }}</span>
<span :class="$style.versionArrow">→</span>
<span :class="$style.versionChip">{{ toVersion }}</span>
</p>
<p v-if="range" :class="$style.versionRangeSummary">
<strong>Downgrade info:</strong> {{ versionRangeText(range) }}
</p>
<p :class="$style.versionItemDegradeMessage">
Downgrading can break database schema assumptions if migrations were already applied in newer versions. Continue only if you are sure no incompatible schema changes are involved.
</p>
</NcDialog>
</template>

<style module src="../styles/DowngradeConfirmDialog.module.css"></style>
Loading
Loading