Skip to content
Merged
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
165 changes: 165 additions & 0 deletions src/components/TemplateSection.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<section class="template-section">
<h3 class="template-section__heading">
{{ t('richdocuments', 'Create new') }}
</h3>

<ul class="template-section__list">
<!-- Blank file card -->
<li class="template-section__item">
<button class="template-card" @click="$emit('select', creator, null)">
<span class="template-card__preview template-card__preview--blank"
:style="previewStyle">
<!-- eslint-disable-next-line vue/no-v-html -->
<span class="template-card__icon" v-html="creator.iconSvgInline" />
</span>
<span class="template-card__name">{{ t('richdocuments', 'Blank') }}</span>
</button>
</li>

<!-- Template cards -->
<li v-for="template in creator.templates"
:key="template.fileid"
class="template-section__item">
<button class="template-card" @click="$emit('select', creator, template)">
<span class="template-card__preview" :style="previewStyle">
<img v-if="template.hasPreview"
:src="template.previewUrl"
:alt="nameWithoutExt(template.basename)"
loading="lazy"
class="template-card__image">
<span v-else class="template-card__icon" v-html="creator.iconSvgInline" />

Check warning on line 35 in src/components/TemplateSection.vue

View workflow job for this annotation

GitHub Actions / NPM lint

'v-html' directive can lead to XSS attack
</span>
<span class="template-card__name">{{ nameWithoutExt(template.basename) }}</span>
</button>
</li>
</ul>
</section>
</template>

<script>
export default {
name: 'TemplateSection',

props: {
creator: {
type: Object,
required: true,
},
},

emits: ['select'],

computed: {
previewStyle() {
if (!this.creator.ratio) {
return {}
}
// ratio is width/height; convert to padding-bottom trick
return { paddingBottom: `${(1 / this.creator.ratio) * 100}%` }
},
},

methods: {
nameWithoutExt(basename) {
const dot = basename.lastIndexOf('.')
return dot > 0 ? basename.slice(0, dot) : basename
},
},
}
</script>

<style scoped>
.template-section {
padding: calc(var(--default-grid-baseline) * 4);
}

.template-section__heading {
margin: 0 0 calc(var(--default-grid-baseline) * 2);
font-size: var(--default-font-size);
font-weight: 600;
}

.template-section__list {
display: flex;
gap: calc(var(--default-grid-baseline) * 3);
overflow-x: auto;
padding-bottom: calc(var(--default-grid-baseline) * 2);
list-style: none;
margin: 0;
padding-left: 0;
}

.template-section__item {
flex: 0 0 auto;
}

.template-card {
display: flex;
flex-direction: column;
align-items: center;
width: 160px;
padding: 0;
border: none;
background: none;
cursor: pointer;
border-radius: var(--border-radius-large);

&:hover .template-card__preview,
&:focus-visible .template-card__preview {
border-color: var(--color-primary-element);
}
}

.template-card__preview {
position: relative;
width: 100%;
overflow: hidden;
border: 2px solid var(--color-border);
border-radius: var(--border-radius-large);
background-color: var(--color-main-background);
box-sizing: border-box;
}

.template-card__preview--blank {
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
}

.template-card__image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}

.template-card__icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;

:deep(svg) {
width: 100%;
height: 100%;
}
}

.template-card__name {
margin-top: calc(var(--default-grid-baseline) * 1);
font-size: var(--font-size-small, 12px);
text-align: center;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
119 changes: 110 additions & 9 deletions src/views/OfficeOverview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,14 @@
<template #list>
<NcAppNavigationItem v-for="creator in creators"
:key="creator.app + '-' + creator.extension"
:name="creator.label"
:name="categoryName(creator)"
:active="activeCreator === creator"
@click="setCreator(creator)" />
@click="setCreator(creator)">
<template #icon>
<!-- eslint-disable-next-line vue/no-v-html -->
<span class="office-overview__nav-icon" v-html="creator.iconSvgInline" />
</template>
</NcAppNavigationItem>
</template>
</NcAppNavigation>

Expand All @@ -28,10 +33,14 @@
<template v-else>
<div class="office-overview__search">
<NcTextField v-model="searchQuery"
:label="t('richdocuments', 'Search {category}', { category: activeCreator.label })"
:label="t('richdocuments', 'Search {category}', { category: categoryName(activeCreator) })"
type="search" />
</div>

<TemplateSection v-if="!searchQuery && activeCreator"
:creator="activeCreator"
@select="onTemplateSelect" />

<NcEmptyContent v-if="error"
:name="error" />

Expand Down Expand Up @@ -66,6 +75,27 @@
</FileCard>
</div>
</template>

<!-- Create from template dialog -->
<NcDialog v-if="showCreateDialog"
:name="pendingCreator ? pendingCreator.label : ''"
:open="showCreateDialog"
close-on-click-outside
@update:open="showCreateDialog = false">
<template #actions>
<NcButton :disabled="creating || !newFileName.trim()" variant="primary" @click="doCreateFromTemplate">
{{ t('richdocuments', 'Create') }}
</NcButton>
</template>
<form class="office-overview__create-form" @submit.prevent="doCreateFromTemplate">
<NcTextField ref="createInput"
v-model="newFileName"
:label="t('richdocuments', 'Filename')"
:error="!!createError"
:helper-text="createError"
:disabled="creating" />
</form>
</NcDialog>
</template>
</NcAppContent>
</NcContent>
Expand All @@ -75,26 +105,30 @@
import { sortNodes } from '@nextcloud/files'
import { loadState } from '@nextcloud/initial-state'
import { generateUrl } from '@nextcloud/router'
import { NcAppContent, NcAppNavigation, NcAppNavigationItem, NcContent, NcDateTime, NcEmptyContent, NcLoadingIcon, NcTextField } from '@nextcloud/vue'
import { NcAppContent, NcAppNavigation, NcAppNavigationItem, NcButton, NcContent, NcDateTime, NcDialog, NcEmptyContent, NcLoadingIcon, NcTextField } from '@nextcloud/vue'
import FileDocumentOutline from 'vue-material-design-icons/FileDocumentOutline.vue'
import FileCard from '../components/FileCard.vue'
import { getAllOfficeFiles, filterByMimes } from '../services/officeFiles.js'
import { getTemplates } from '../services/templates.js'
import TemplateSection from '../components/TemplateSection.vue'
import { getAllOfficeFiles, filterByMimes, invalidateOfficeFilesCache } from '../services/officeFiles.js'
import { getTemplates, createFromTemplate } from '../services/templates.js'

export default {
name: 'OfficeOverview',

components: {
FileCard,
FileDocumentOutline,
NcAppContent,
NcAppNavigation,
NcAppNavigationItem,
NcButton,
NcContent,
NcDateTime,
NcDialog,
NcEmptyContent,
NcLoadingIcon,
NcTextField,
FileDocumentOutline,
TemplateSection,
},

data() {
Expand All @@ -106,6 +140,12 @@ export default {
error: null,
previewEnabled: loadState('richdocuments', 'previewEnabled', false),
searchQuery: '',
showCreateDialog: false,
newFileName: '',
pendingCreator: null,
pendingTemplate: null,
creating: false,
createError: '',
}
},

Expand Down Expand Up @@ -137,6 +177,12 @@ export default {
},

methods: {
categoryName(creator) {
const base = creator.label.replace(/^new\s+/i, '').trim()
const capitalized = base.charAt(0).toUpperCase() + base.slice(1)
return capitalized.endsWith('s') ? capitalized : capitalized + 's'
},

setCreator(creator) {
this.activeCreator = creator
},
Expand All @@ -155,13 +201,53 @@ export default {
}
},

async fetchAll() {
onTemplateSelect(creator, template) {
this.pendingCreator = creator
this.pendingTemplate = template
this.newFileName = creator.label.replace(/^New\s+/i, '') + creator.extension
this.createError = ''
this.showCreateDialog = true
this.$nextTick(() => {
const input = this.$refs.createInput?.$el?.querySelector('input')
if (input) {
input.focus()
input.setSelectionRange(0, this.newFileName.length - creator.extension.length)
}
})
},

async doCreateFromTemplate() {
if (!this.newFileName.trim() || this.creating) {
return
}
this.creating = true
this.createError = ''
try {
const filePath = '/' + this.newFileName.trim()
const templatePath = this.pendingTemplate?.filename ?? ''
const templateType = this.pendingTemplate ? 'user' : 'user_system'
await createFromTemplate(filePath, templatePath, templateType)
this.showCreateDialog = false
const previousCreator = this.activeCreator
invalidateOfficeFilesCache()
await this.fetchAll(previousCreator)
} catch (e) {
this.createError = t('richdocuments', 'A file with that name already exists')
} finally {
this.creating = false
}
},

async fetchAll(restoreCreator = null) {
this.loading = true
this.error = null

try {
this.creators = await getTemplates()
this.activeCreator = this.creators[0] ?? null
const match = restoreCreator
? this.creators.find(c => c.app === restoreCreator.app && c.extension === restoreCreator.extension)
: null
this.activeCreator = match ?? this.creators[0] ?? null

if (this.creators.length > 0) {
const allMimes = this.creators.flatMap(c => c.mimetypes)
Expand Down Expand Up @@ -211,4 +297,19 @@ export default {
max-width: 400px;
margin: 0 auto;
}

.office-overview__create-form {
min-height: calc(2 * var(--default-clickable-area));
}

.office-overview__nav-icon {
display: flex;
width: 20px;
height: 20px;

:deep(svg) {
width: 100%;
height: 100%;
}
}
</style>
Loading