diff --git a/l10n/de.js b/l10n/de.js index a5bb8c4..ad95187 100644 --- a/l10n/de.js +++ b/l10n/de.js @@ -695,6 +695,12 @@ OC.L10N.register( "{actor} cancelled the appointment" : "{actor} hat diesen Termin abgesagt", "{actor} reactivated the appointment" : "{actor} hat diesen Termin reaktiviert", "{actor} performed {verb}" : "{actor} hat {verb} durchgeführt", - "Guests" : "Gäste" + "Guests" : "Gäste", + "Heading 1" : "Überschrift 1", + "Heading 2" : "Überschrift 2", + "Heading 3" : "Überschrift 3", + "Show details" : "Details anzeigen", + "Some users may appear in the section \"Others\" in the response summary because they are not configured for tracking." : "Einige Personen erscheinen möglicherweise im Abschnitt \"Andere\" in der Antwortübersicht, weil sie nicht für die Erfassung konfiguriert sind.", + "year" : "Jahr" }, "nplurals=2; plural=(n != 1);"); diff --git a/l10n/de.json b/l10n/de.json index 3d834c4..a49f136 100644 --- a/l10n/de.json +++ b/l10n/de.json @@ -693,6 +693,12 @@ "{actor} cancelled the appointment" : "{actor} hat diesen Termin abgesagt", "{actor} reactivated the appointment" : "{actor} hat diesen Termin reaktiviert", "{actor} performed {verb}" : "{actor} hat {verb} durchgeführt", - "Guests" : "Gäste" + "Guests" : "Gäste", + "Heading 1" : "Überschrift 1", + "Heading 2" : "Überschrift 2", + "Heading 3" : "Überschrift 3", + "Show details" : "Details anzeigen", + "Some users may appear in the section \"Others\" in the response summary because they are not configured for tracking." : "Einige Personen erscheinen möglicherweise im Abschnitt \"Andere\" in der Antwortübersicht, weil sie nicht für die Erfassung konfiguriert sind.", + "year" : "Jahr" },"pluralForm" :"nplurals=2; plural=(n != 1);" } \ No newline at end of file diff --git a/l10n/de_DE.js b/l10n/de_DE.js index 0b5de8e..4d4dd0f 100644 --- a/l10n/de_DE.js +++ b/l10n/de_DE.js @@ -696,6 +696,11 @@ OC.L10N.register( "{actor} cancelled the appointment" : "{actor} hat diesen Termin abgesagt", "{actor} reactivated the appointment" : "{actor} hat diesen Termin reaktiviert", "{actor} performed {verb}" : "{actor} hat {verb} durchgeführt", - "Guests" : "Gäste" + "Guests" : "Gäste", + "Heading 1" : "Überschrift 1", + "Heading 2" : "Überschrift 2", + "Heading 3" : "Überschrift 3", + "Show details" : "Details anzeigen", + "Some users may appear in the section \"Others\" in the response summary because they are not configured for tracking." : "Einige Personen erscheinen möglicherweise im Abschnitt \"Andere\" in der Antwortübersicht, weil sie nicht für die Erfassung konfiguriert sind." }, "nplurals=2; plural=(n != 1);"); diff --git a/l10n/de_DE.json b/l10n/de_DE.json index 4ca2f8a..01f7212 100644 --- a/l10n/de_DE.json +++ b/l10n/de_DE.json @@ -694,6 +694,11 @@ "{actor} cancelled the appointment" : "{actor} hat diesen Termin abgesagt", "{actor} reactivated the appointment" : "{actor} hat diesen Termin reaktiviert", "{actor} performed {verb}" : "{actor} hat {verb} durchgeführt", - "Guests" : "Gäste" + "Guests" : "Gäste", + "Heading 1" : "Überschrift 1", + "Heading 2" : "Überschrift 2", + "Heading 3" : "Überschrift 3", + "Show details" : "Details anzeigen", + "Some users may appear in the section \"Others\" in the response summary because they are not configured for tracking." : "Einige Personen erscheinen möglicherweise im Abschnitt \"Andere\" in der Antwortübersicht, weil sie nicht für die Erfassung konfiguriert sind." },"pluralForm" :"nplurals=2; plural=(n != 1);" } \ No newline at end of file diff --git a/src/App.vue b/src/App.vue index 43767f8..9ffde1a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -230,6 +230,7 @@ @navigateToUpcoming="setView('current')" @navigateToUnanswered="setView('unanswered')" @showAuditLog="openAuditLog" + @openDetail="navigateToAppointment" @clearSearch="searchQuery = ''" /> diff --git a/src/components/appointment/AppointmentCard.vue b/src/components/appointment/AppointmentCard.vue index b943eea..81e3d6c 100644 --- a/src/components/appointment/AppointmentCard.vue +++ b/src/components/appointment/AppointmentCard.vue @@ -2,62 +2,49 @@
- - + + + {{ subtitleText }} + + + +
@@ -213,6 +200,18 @@
+ +
['detail', 'list'].includes(value), + }, }) const emit = defineEmits([ @@ -555,6 +562,7 @@ const emit = defineEmits([ 'updateComment', 'closedToggled', 'showAuditLog', + 'openDetail', ]) const { capabilities } = usePermissions() @@ -609,6 +617,16 @@ const renderedDescription = computed(() => { return sanitizeHtml(html) }) +const isListVariant = computed(() => props.variant === 'list') + +const dateRangeText = computed(() => formatDateRange(props.appointment.startDatetime, props.appointment.endDatetime)) + +const titleText = computed(() => (props.displayOrder === 'date_first' ? dateRangeText.value : props.appointment.name)) + +const subtitleText = computed(() => (props.displayOrder === 'date_first' ? props.appointment.name : dateRangeText.value)) + +const detailUrl = computed(() => appointmentDetailUrl(props.appointment.id)) + const calendarLink = computed(() => { if ( !props.appointment.calendarUri @@ -655,9 +673,7 @@ watch( ) function copyShareLink() { - const appointmentUrl - = window.location.origin - + generateUrl(`/apps/attendance/appointment/${props.appointment.id}`) + const appointmentUrl = window.location.origin + detailUrl.value return copyToClipboard(appointmentUrl, { successMessage: t('attendance', 'Link copied to clipboard'), }) @@ -905,6 +921,30 @@ function handleCommentInputEvent() { color: var(--color-main-text); } + .title-link { + display: inline-flex; + align-items: center; + color: inherit; + text-decoration: none; + + &:hover, + &:focus-visible { + text-decoration: underline; + + .title-chevron { + transform: translateX(2px); + } + } + } + + .title-chevron { + display: inline-flex; + align-items: center; + margin-left: 2px; + color: var(--color-text-maxcontrast); + transition: transform 0.15s ease; + } + .appointment-date-subtitle { display: block; font-size: 15px; @@ -935,6 +975,10 @@ function handleCommentInputEvent() { } } +.details-link-row { + margin-bottom: 15px; +} + .appointment-description { color: var(--color-text-lighter); margin-bottom: 15px; @@ -1107,8 +1151,8 @@ function handleCommentInputEvent() { } } -.appointment-description + .response-section, -.attachment-chips + .response-section { +// Any content block between the header and the response section gets a divider +:not(.appointment-header) + .response-section { border-top: 1px solid var(--color-border); padding-top: 15px; } diff --git a/src/components/checkin/CheckinAppointmentInfo.vue b/src/components/checkin/CheckinAppointmentInfo.vue index 557e63a..a7c3c4f 100644 --- a/src/components/checkin/CheckinAppointmentInfo.vue +++ b/src/components/checkin/CheckinAppointmentInfo.vue @@ -12,17 +12,13 @@ {{ formatDateRange(appointment.startDatetime, appointment.endDatetime) }}

- -
diff --git a/src/components/common/MarkdownEditor.vue b/src/components/common/MarkdownEditor.vue index d5a4508..a2896d7 100644 --- a/src/components/common/MarkdownEditor.vue +++ b/src/components/common/MarkdownEditor.vue @@ -64,16 +64,24 @@ onMounted(() => { editorContainer.value.addEventListener('focusin', onFocusIn) editorContainer.value.addEventListener('focusout', onFocusOut) - // Icon paths from vue-material-design-icons (same as Nextcloud uses) - const iconPaths = { - bold: 'M13.5,15.5H10V12.5H13.5A1.5,1.5 0 0,1 15,14A1.5,1.5 0 0,1 13.5,15.5M10,6.5H13A1.5,1.5 0 0,1 14.5,8A1.5,1.5 0 0,1 13,9.5H10M15.6,10.79C16.57,10.11 17.25,9 17.25,8C17.25,5.74 15.5,4 13.25,4H7V18H14.04C16.14,18 17.75,16.3 17.75,14.21C17.75,12.69 16.89,11.39 15.6,10.79Z', - italic: 'M10,4V7H12.21L8.79,15H6V18H14V15H11.79L15.21,7H18V4H10Z', - strikethrough: 'M3,14H21V12H3M5,4V7H10V10H14V7H19V4M10,19H14V16H10V19Z', - bulletList: 'M7,5H21V7H7V5M7,13V11H21V13H7M4,4.5A1.5,1.5 0 0,1 5.5,6A1.5,1.5 0 0,1 4,7.5A1.5,1.5 0 0,1 2.5,6A1.5,1.5 0 0,1 4,4.5M4,10.5A1.5,1.5 0 0,1 5.5,12A1.5,1.5 0 0,1 4,13.5A1.5,1.5 0 0,1 2.5,12A1.5,1.5 0 0,1 4,10.5M7,19V17H21V19H7M4,16.5A1.5,1.5 0 0,1 5.5,18A1.5,1.5 0 0,1 4,19.5A1.5,1.5 0 0,1 2.5,18A1.5,1.5 0 0,1 4,16.5Z', - numberedList: 'M7,13V11H21V13H7M7,19V17H21V19H7M7,7V5H21V7H7M3,8V5H2V4H4V8H3M2,17V16H5V20H2V19H4V18.5H3V17.5H4V17H2M4.25,10A0.75,0.75 0 0,1 5,10.75C5,10.95 4.92,11.14 4.79,11.27L3.12,13H5V14H2V13.08L4,11H2V10H4.25Z', - link: 'M3.9,12C3.9,10.29 5.29,8.9 7,8.9H11V7H7A5,5 0 0,0 2,12A5,5 0 0,0 7,17H11V15.1H7C5.29,15.1 3.9,13.71 3.9,12M8,13H16V11H8V13M17,7H13V8.9H17C18.71,8.9 20.1,10.29 20.1,12C20.1,13.71 18.71,15.1 17,15.1H13V17H17A5,5 0 0,0 22,12A5,5 0 0,0 17,7Z', - quote: 'M14,17H17L19,13V7H13V13H16M6,17H9L11,13V7H5V13H8L6,17Z', - } + // One descriptor per button: the EasyMDE toolbar config and the SVG icon + // injection below are both derived from this list. '|' is a separator. + // Icon paths from vue-material-design-icons (same as Nextcloud uses). + const toolbarButtons = [ + { name: 'bold', action: EasyMDE.toggleBold, title: t('attendance', 'Bold'), iconPath: 'M13.5,15.5H10V12.5H13.5A1.5,1.5 0 0,1 15,14A1.5,1.5 0 0,1 13.5,15.5M10,6.5H13A1.5,1.5 0 0,1 14.5,8A1.5,1.5 0 0,1 13,9.5H10M15.6,10.79C16.57,10.11 17.25,9 17.25,8C17.25,5.74 15.5,4 13.25,4H7V18H14.04C16.14,18 17.75,16.3 17.75,14.21C17.75,12.69 16.89,11.39 15.6,10.79Z' }, + { name: 'italic', action: EasyMDE.toggleItalic, title: t('attendance', 'Italic'), iconPath: 'M10,4V7H12.21L8.79,15H6V18H14V15H11.79L15.21,7H18V4H10Z' }, + { name: 'strikethrough', action: EasyMDE.toggleStrikethrough, title: t('attendance', 'Strikethrough'), iconPath: 'M3,14H21V12H3M5,4V7H10V10H14V7H19V4M10,19H14V16H10V19Z' }, + '|', + { name: 'heading-1', action: EasyMDE.toggleHeading1, title: t('attendance', 'Heading 1'), iconPath: 'M3,4H5V10H9V4H11V18H9V12H5V18H3V4M14,18V16H16V6.31L13.5,7.75V5.44L16,4H18V16H20V18H14Z' }, + { name: 'heading-2', action: EasyMDE.toggleHeading2, title: t('attendance', 'Heading 2'), iconPath: 'M3,4H5V10H9V4H11V18H9V12H5V18H3V4M21,18H15A2,2 0 0,1 13,16C13,15.47 13.2,15 13.54,14.64L18.41,9.41C18.78,9.05 19,8.55 19,8A2,2 0 0,0 17,6A2,2 0 0,0 15,8H13A4,4 0 0,1 17,4A4,4 0 0,1 21,8C21,9.1 20.55,10.1 19.83,10.83L15,16H21V18Z' }, + { name: 'heading-3', action: EasyMDE.toggleHeading3, title: t('attendance', 'Heading 3'), iconPath: 'M3,4H5V10H9V4H11V18H9V12H5V18H3V4M15,4H19A2,2 0 0,1 21,6V16A2,2 0 0,1 19,18H15A2,2 0 0,1 13,16V15H15V16H19V12H15V10H19V6H15V7H13V6A2,2 0 0,1 15,4Z' }, + '|', + { name: 'unordered-list', action: EasyMDE.toggleUnorderedList, title: t('attendance', 'Bullet list'), iconPath: 'M7,5H21V7H7V5M7,13V11H21V13H7M4,4.5A1.5,1.5 0 0,1 5.5,6A1.5,1.5 0 0,1 4,7.5A1.5,1.5 0 0,1 2.5,6A1.5,1.5 0 0,1 4,4.5M4,10.5A1.5,1.5 0 0,1 5.5,12A1.5,1.5 0 0,1 4,13.5A1.5,1.5 0 0,1 2.5,12A1.5,1.5 0 0,1 4,10.5M7,19V17H21V19H7M4,16.5A1.5,1.5 0 0,1 5.5,18A1.5,1.5 0 0,1 4,19.5A1.5,1.5 0 0,1 2.5,18A1.5,1.5 0 0,1 4,16.5Z' }, + { name: 'ordered-list', action: EasyMDE.toggleOrderedList, title: t('attendance', 'Numbered list'), iconPath: 'M7,13V11H21V13H7M7,19V17H21V19H7M7,7V5H21V7H7M3,8V5H2V4H4V8H3M2,17V16H5V20H2V19H4V18.5H3V17.5H4V17H2M4.25,10A0.75,0.75 0 0,1 5,10.75C5,10.95 4.92,11.14 4.79,11.27L3.12,13H5V14H2V13.08L4,11H2V10H4.25Z' }, + { name: 'quote', action: EasyMDE.toggleBlockquote, title: t('attendance', 'Quote'), iconPath: 'M14,17H17L19,13V7H13V13H16M6,17H9L11,13V7H5V13H8L6,17Z' }, + '|', + { name: 'link', action: EasyMDE.drawLink, title: t('attendance', 'Insert link'), iconPath: 'M3.9,12C3.9,10.29 5.29,8.9 7,8.9H11V7H7A5,5 0 0,0 2,12A5,5 0 0,0 7,17H11V15.1H7C5.29,15.1 3.9,13.71 3.9,12M8,13H16V11H8V13M17,7H13V8.9H17C18.71,8.9 20.1,10.29 20.1,12C20.1,13.71 18.71,15.1 17,15.1H13V17H17A5,5 0 0,0 22,12A5,5 0 0,0 17,7Z' }, + ] editor = new EasyMDE({ element: textarea, @@ -83,35 +91,20 @@ onMounted(() => { spellChecker: false, status: false, minHeight: props.minHeight, - toolbar: [ - { name: 'bold', action: EasyMDE.toggleBold, className: 'mde-btn-bold', title: t('attendance', 'Bold') }, - { name: 'italic', action: EasyMDE.toggleItalic, className: 'mde-btn-italic', title: t('attendance', 'Italic') }, - { name: 'strikethrough', action: EasyMDE.toggleStrikethrough, className: 'mde-btn-strikethrough', title: t('attendance', 'Strikethrough') }, - '|', - { name: 'unordered-list', action: EasyMDE.toggleUnorderedList, className: 'mde-btn-bullet', title: t('attendance', 'Bullet list') }, - { name: 'ordered-list', action: EasyMDE.toggleOrderedList, className: 'mde-btn-numbered', title: t('attendance', 'Numbered list') }, - { name: 'quote', action: EasyMDE.toggleBlockquote, className: 'mde-btn-quote', title: t('attendance', 'Quote') }, - '|', - { name: 'link', action: EasyMDE.drawLink, className: 'mde-btn-link', title: t('attendance', 'Insert link') }, - ], + toolbar: toolbarButtons.map((button) => (button === '|' + ? button + : { name: button.name, action: button.action, className: `mde-btn-${button.name}`, title: button.title })), shortcuts: { toggleSideBySide: null, toggleFullScreen: null }, }) // Inject SVG icons into toolbar buttons after editor is created - const injectIcon = (className, path) => { - const btn = editorContainer.value.querySelector(`.${className}`) + toolbarButtons.forEach((button) => { + if (button === '|') return + const btn = editorContainer.value.querySelector(`.mde-btn-${button.name}`) if (btn) { - btn.innerHTML = `` + btn.innerHTML = `` } - } - - injectIcon('mde-btn-bold', iconPaths.bold) - injectIcon('mde-btn-italic', iconPaths.italic) - injectIcon('mde-btn-strikethrough', iconPaths.strikethrough) - injectIcon('mde-btn-bullet', iconPaths.bulletList) - injectIcon('mde-btn-numbered', iconPaths.numberedList) - injectIcon('mde-btn-quote', iconPaths.quote) - injectIcon('mde-btn-link', iconPaths.link) + }) // Listen for changes editor.codemirror.on('change', () => { diff --git a/src/utils/appointment.js b/src/utils/appointment.js index 9f543a8..4730420 100644 --- a/src/utils/appointment.js +++ b/src/utils/appointment.js @@ -1,6 +1,17 @@ import { translate as t } from '@nextcloud/l10n' +import { generateUrl } from '@nextcloud/router' import { formatDateTime } from './datetime.js' +/** + * Relative URL of an appointment's detail page. + * + * @param {number} appointmentId The appointment id. + * @return {string} Relative URL, e.g. "/apps/attendance/appointment/42". + */ +export function appointmentDetailUrl(appointmentId) { + return generateUrl('/apps/attendance/appointment/{id}', { id: appointmentId }) +} + /** * Human label for a closed inquiry. Distinguishes auto-close-by-deadline * from a manual close so the message matches across the appointment card diff --git a/src/views/AllAppointments.vue b/src/views/AllAppointments.vue index 0b97c0b..677276f 100644 --- a/src/views/AllAppointments.vue +++ b/src/views/AllAppointments.vue @@ -113,6 +113,8 @@ :canSeeComments="permissions.canSeeComments" :canSeeAuditLog="canSeeAuditLog" :displayOrder="config.displayOrder" + variant="list" + @openDetail="(id) => emit('openDetail', id)" @startCheckin="startCheckin" @edit="editAppointment" @copy="copyAppointment" @@ -191,6 +193,7 @@ const emit = defineEmits([ 'appointmentDeleted', 'clearSearch', 'showAuditLog', + 'openDetail', ]) const activeSearch = computed(() => props.searchQuery.trim())