From 881d3a4f29f4eb092c05ca2dd4ee782a803784fc Mon Sep 17 00:00:00 2001 From: nkanaev Date: Tue, 30 Jun 2026 21:35:15 +0100 Subject: [PATCH] frontend: extract vue components --- src/frontend/js/app.ts | 227 ++------------------ src/frontend/js/components/drag.ts | 24 +++ src/frontend/js/components/dropdown.ts | 52 +++++ src/frontend/js/components/icon.ts | 9 + src/frontend/js/components/modal.ts | 36 ++++ src/frontend/js/components/relative-time.ts | 22 ++ src/frontend/js/directives/focus.ts | 5 + src/frontend/js/directives/scroll.ts | 9 + src/frontend/js/templates/index.html | 26 +-- src/frontend/js/utils.ts | 49 +++++ 10 files changed, 238 insertions(+), 221 deletions(-) create mode 100644 src/frontend/js/components/drag.ts create mode 100644 src/frontend/js/components/dropdown.ts create mode 100644 src/frontend/js/components/icon.ts create mode 100644 src/frontend/js/components/modal.ts create mode 100644 src/frontend/js/components/relative-time.ts create mode 100644 src/frontend/js/directives/focus.ts create mode 100644 src/frontend/js/directives/scroll.ts create mode 100644 src/frontend/js/utils.ts diff --git a/src/frontend/js/app.ts b/src/frontend/js/app.ts index dbc77a4..f154d61 100644 --- a/src/frontend/js/app.ts +++ b/src/frontend/js/app.ts @@ -4,222 +4,33 @@ import api from './api' import template from './templates/index.html' with {type: 'text'} import icons from './icons' import { setupKeybindings } from './key' +import { scrollto, debounce, dateRepr } from './utils' +import drag from './components/drag' +import dropdown from './components/dropdown' +import modal from './components/modal' +import relativeTime from './components/relative-time' +import icon from './components/icon' +import scrollDir from './directives/scroll' +import focusDir from './directives/focus' var app = window.app var vm var TITLE = document.title -function scrollto(target, scroll) { - var padding = 10 - var targetRect = target.getBoundingClientRect() - var scrollRect = scroll.getBoundingClientRect() - - // target - var relativeOffset = targetRect.y - scrollRect.y - var absoluteOffset = relativeOffset + scroll.scrollTop - - if (padding <= relativeOffset && relativeOffset + targetRect.height <= scrollRect.height - padding) return - - var newPos = scroll.scrollTop - if (relativeOffset < padding) { - newPos = absoluteOffset - padding - } else { - newPos = absoluteOffset - scrollRect.height + targetRect.height + padding - } - scroll.scrollTop = Math.round(newPos) -} - -var debounce = function(callback, wait) { - var timeout - return function() { - var ctx = this, args = arguments - clearTimeout(timeout) - timeout = setTimeout(function() { - callback.apply(ctx, args) - }, wait) - } -} - -Vue.directive('scroll', { - inserted: function(el, binding) { - el.addEventListener('scroll', debounce(function(event) { - binding.value(event, el) - }, 200)) - }, -}) - -Vue.directive('focus', { - inserted: function(el) { - el.focus() - } -}) - -Vue.component('drag', { - props: ['width'], - template: '
', - mounted: function() { - var self = this - var startX = undefined - var initW = undefined - var onMouseMove = function(e) { - var offset = e.clientX - startX - var newWidth = initW + offset - self.$emit('resize', newWidth) - } - var onMouseUp = function(e) { - document.removeEventListener('mousemove', onMouseMove) - document.removeEventListener('mouseup', onMouseUp) - } - this.$el.addEventListener('mousedown', function(e) { - startX = e.clientX - initW = self.width - document.addEventListener('mousemove', onMouseMove) - document.addEventListener('mouseup', onMouseUp) - }) - }, -}) - -Vue.component('dropdown', { - props: ['toggle-class', 'drop', 'title'], - data: function() { - return {open: false} - }, - template: ` - - `, - computed: { - btnToggleClass: function() { - var c = this.$props.toggleClass || '' - c += ' dropdown-toggle dropdown-toggle-no-caret' - c += this.open ? ' show' : '' - return c.trim() - } - }, - methods: { - toggle: function(e) { - this.open ? this.hide() : this.show() - }, - show: function(e) { - this.open = true - this.$refs.menu.style.top = this.$refs.btn.offsetHeight + 'px' - var drop = this.$props.drop - - if (drop === 'right') { - this.$refs.menu.style.left = 'auto' - this.$refs.menu.style.right = '0' - } else if (drop === 'center') { - this.$nextTick(function() { - var btnWidth = this.$refs.btn.getBoundingClientRect().width - var menuWidth = this.$refs.menu.getBoundingClientRect().width - this.$refs.menu.style.left = '-' + ((menuWidth - btnWidth) / 2) + 'px' - }.bind(this)) - } - - document.addEventListener('click', this.clickHandler) - }, - hide: function() { - this.open = false - document.removeEventListener('click', this.clickHandler) - }, - clickHandler: function(e) { - var dropdown = e.target.closest('.dropdown') - if (dropdown == null || dropdown != this.$el) return this.hide() - if (e.target.closest('.dropdown-item') != null) return this.hide() - } - }, -}) - -Vue.component('modal', { - props: ['open'], - template: ` - - `, - data: function() { - return {opening: false} - }, - watch: { - 'open': function(newVal) { - if (newVal) { - this.opening = true - document.addEventListener('click', this.handleClick) - } else { - document.removeEventListener('click', this.handleClick) - } - }, - }, - methods: { - handleClick: function(e) { - if (this.opening) { - this.opening = false - return - } - if (e.target.closest('.modal-content') == null) this.$emit('hide') - }, - }, -}) - -function dateRepr(d) { - var sec = (new Date().getTime() - d.getTime()) / 1000 - var neg = sec < 0 - var out = '' - - sec = Math.abs(sec) - if (sec < 2700) // less than 45 minutes - out = Math.round(sec / 60) + 'm' - else if (sec < 86400) // less than 24 hours - out = Math.round(sec / 3600) + 'h' - else if (sec < 604800) // less than a week - out = Math.round(sec / 86400) + 'd' - else - out = d.toLocaleDateString(undefined, {year: "numeric", month: "long", day: "numeric"}) - - if (neg) return '-' + out - return out -} - -Vue.component('relative-time', { - props: ['val'], - data: function() { - var d = new Date(this.val) - return { - 'date': d, - 'formatted': dateRepr(d), - 'interval': null, - } - }, - template: '', - mounted: function() { - this.interval = setInterval(function() { - this.formatted = dateRepr(this.date) - }.bind(this), 600000) // every 10 minutes - }, - destroyed: function() { - clearInterval(this.interval) - }, -}) - -Vue.component('v-icon', { - props: ['name'], - template: '', - computed: { - content: function () { return icons[this.name] || '' } - } -}) - export default { template: template, + components: { + 'v-drag': drag, + 'v-dropdown': dropdown, + 'v-modal': modal, + 'v-relative-time': relativeTime, + 'v-icon': icon, + }, + directives: { + scroll: scrollDir, + focus: focusDir, + }, created: function() { vm = this this.refreshStats() diff --git a/src/frontend/js/components/drag.ts b/src/frontend/js/components/drag.ts new file mode 100644 index 0000000..2da6095 --- /dev/null +++ b/src/frontend/js/components/drag.ts @@ -0,0 +1,24 @@ +export default { + props: ['width'], + template: '
', + mounted: function() { + var self = this + var startX = undefined + var initW = undefined + var onMouseMove = function(e) { + var offset = e.clientX - startX + var newWidth = initW + offset + self.$emit('resize', newWidth) + } + var onMouseUp = function(e) { + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + } + this.$el.addEventListener('mousedown', function(e) { + startX = e.clientX + initW = self.width + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + }) + }, +} diff --git a/src/frontend/js/components/dropdown.ts b/src/frontend/js/components/dropdown.ts new file mode 100644 index 0000000..c20a5e3 --- /dev/null +++ b/src/frontend/js/components/dropdown.ts @@ -0,0 +1,52 @@ +export default { + props: ['toggle-class', 'drop', 'title'], + data: function() { + return {open: false} + }, + template: ` + + `, + computed: { + btnToggleClass: function() { + var c = this.$props.toggleClass || '' + c += ' dropdown-toggle dropdown-toggle-no-caret' + c += this.open ? ' show' : '' + return c.trim() + } + }, + methods: { + toggle: function(e) { + this.open ? this.hide() : this.show() + }, + show: function(e) { + this.open = true + this.$refs.menu.style.top = this.$refs.btn.offsetHeight + 'px' + var drop = this.$props.drop + + if (drop === 'right') { + this.$refs.menu.style.left = 'auto' + this.$refs.menu.style.right = '0' + } else if (drop === 'center') { + this.$nextTick(function() { + var btnWidth = this.$refs.btn.getBoundingClientRect().width + var menuWidth = this.$refs.menu.getBoundingClientRect().width + this.$refs.menu.style.left = '-' + ((menuWidth - btnWidth) / 2) + 'px' + }.bind(this)) + } + + document.addEventListener('click', this.clickHandler) + }, + hide: function() { + this.open = false + document.removeEventListener('click', this.clickHandler) + }, + clickHandler: function(e) { + var dropdown = e.target.closest('.dropdown') + if (dropdown == null || dropdown != this.$el) return this.hide() + if (e.target.closest('.dropdown-item') != null) return this.hide() + } + }, +} diff --git a/src/frontend/js/components/icon.ts b/src/frontend/js/components/icon.ts new file mode 100644 index 0000000..3dd86c3 --- /dev/null +++ b/src/frontend/js/components/icon.ts @@ -0,0 +1,9 @@ +import icons from '../icons' + +export default { + props: ['name'], + template: '', + computed: { + content: function () { return icons[this.name] || '' } + } +} diff --git a/src/frontend/js/components/modal.ts b/src/frontend/js/components/modal.ts new file mode 100644 index 0000000..e5ec6d1 --- /dev/null +++ b/src/frontend/js/components/modal.ts @@ -0,0 +1,36 @@ +export default { + props: ['open'], + template: ` + + `, + data: function() { + return {opening: false} + }, + watch: { + 'open': function(newVal) { + if (newVal) { + this.opening = true + document.addEventListener('click', this.handleClick) + } else { + document.removeEventListener('click', this.handleClick) + } + }, + }, + methods: { + handleClick: function(e) { + if (this.opening) { + this.opening = false + return + } + if (e.target.closest('.modal-content') == null) this.$emit('hide') + }, + }, +} diff --git a/src/frontend/js/components/relative-time.ts b/src/frontend/js/components/relative-time.ts new file mode 100644 index 0000000..176e1ec --- /dev/null +++ b/src/frontend/js/components/relative-time.ts @@ -0,0 +1,22 @@ +import { dateRepr } from '../utils' + +export default { + props: ['val'], + data: function() { + var d = new Date(this.val) + return { + 'date': d, + 'formatted': dateRepr(d), + 'interval': null, + } + }, + template: '', + mounted: function() { + this.interval = setInterval(function() { + this.formatted = dateRepr(this.date) + }.bind(this), 600000) // every 10 minutes + }, + destroyed: function() { + clearInterval(this.interval) + }, +} diff --git a/src/frontend/js/directives/focus.ts b/src/frontend/js/directives/focus.ts new file mode 100644 index 0000000..2208314 --- /dev/null +++ b/src/frontend/js/directives/focus.ts @@ -0,0 +1,5 @@ +export default { + inserted: function(el) { + el.focus() + } +} diff --git a/src/frontend/js/directives/scroll.ts b/src/frontend/js/directives/scroll.ts new file mode 100644 index 0000000..963fe66 --- /dev/null +++ b/src/frontend/js/directives/scroll.ts @@ -0,0 +1,9 @@ +import { debounce } from '../utils' + +export default { + inserted: function(el, binding) { + el.addEventListener('scroll', debounce(function(event) { + binding.value(event, el) + }, 200)) + }, +} diff --git a/src/frontend/js/templates/index.html b/src/frontend/js/templates/index.html index a70b358..e63d2c5 100644 --- a/src/frontend/js/templates/index.html +++ b/src/frontend/js/templates/index.html @@ -1,7 +1,7 @@
- +
@@ -27,7 +27,7 @@
- + @@ -122,7 +122,7 @@ {{ $t('log_out') }} - +
- +
- {{ $t('delete') }} - - + {{ $t('delete') }} - +
{{ item.title || $t('untitled') }}
@@ -305,7 +305,7 @@ - + @@ -318,7 +318,7 @@
- +
- + @@ -425,5 +425,5 @@ - + diff --git a/src/frontend/js/utils.ts b/src/frontend/js/utils.ts new file mode 100644 index 0000000..835ccf9 --- /dev/null +++ b/src/frontend/js/utils.ts @@ -0,0 +1,49 @@ +export function scrollto(target, scroll) { + var padding = 10 + var targetRect = target.getBoundingClientRect() + var scrollRect = scroll.getBoundingClientRect() + + // target + var relativeOffset = targetRect.y - scrollRect.y + var absoluteOffset = relativeOffset + scroll.scrollTop + + if (padding <= relativeOffset && relativeOffset + targetRect.height <= scrollRect.height - padding) return + + var newPos = scroll.scrollTop + if (relativeOffset < padding) { + newPos = absoluteOffset - padding + } else { + newPos = absoluteOffset - scrollRect.height + targetRect.height + padding + } + scroll.scrollTop = Math.round(newPos) +} + +export function debounce(callback, wait) { + var timeout + return function() { + var ctx = this, args = arguments + clearTimeout(timeout) + timeout = setTimeout(function() { + callback.apply(ctx, args) + }, wait) + } +} + +export function dateRepr(d) { + var sec = (new Date().getTime() - d.getTime()) / 1000 + var neg = sec < 0 + var out = '' + + sec = Math.abs(sec) + if (sec < 2700) // less than 45 minutes + out = Math.round(sec / 60) + 'm' + else if (sec < 86400) // less than 24 hours + out = Math.round(sec / 3600) + 'h' + else if (sec < 604800) // less than a week + out = Math.round(sec / 86400) + 'd' + else + out = d.toLocaleDateString(undefined, {year: "numeric", month: "long", day: "numeric"}) + + if (neg) return '-' + out + return out +}