Page MenuHomePhorge

No OneTemporary

Size
24 KB
Referenced Files
None
Subscribers
None
diff --git a/src/components/media_modal/media_modal.js b/src/components/media_modal/media_modal.js
index 684fbe450a..b4c0cfb4b0 100644
--- a/src/components/media_modal/media_modal.js
+++ b/src/components/media_modal/media_modal.js
@@ -1,181 +1,159 @@
import StillImage from '../still-image/still-image.vue'
import VideoAttachment from '../video_attachment/video_attachment.vue'
import Modal from '../modal/modal.vue'
import PinchZoom from '../pinch_zoom/pinch_zoom.vue'
-import fileTypeService from '../../services/file_type/file_type.service.js'
+import SwipeClick from '../swipe_click/swipe_click.vue'
import GestureService from '../../services/gesture_service/gesture_service'
import Flash from 'src/components/flash/flash.vue'
import Vuex from 'vuex'
+import fileTypeService from '../../services/file_type/file_type.service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faChevronLeft,
faChevronRight,
faCircleNotch
} from '@fortawesome/free-solid-svg-icons'
library.add(
faChevronLeft,
faChevronRight,
faCircleNotch
)
const onlyXAxis = ([x, y]) => [x, 0]
const SCALING_RESET_MIN = 1.1
const SCALING_ENABLE_MOVE_THRESHOLD = 1
const MediaModal = {
components: {
StillImage,
VideoAttachment,
PinchZoom,
+ SwipeClick,
Modal,
Flash
},
data () {
return {
loading: false,
- pinchZoomOptions: {}
+ swipeDirection: GestureService.DIRECTION_LEFT,
+ swipeThreshold: 50
}
},
computed: {
showing () {
return this.$store.state.mediaViewer.activated
},
media () {
return this.$store.state.mediaViewer.media
},
description () {
return this.currentMedia.description
},
currentIndex () {
return this.$store.state.mediaViewer.currentIndex
},
currentMedia () {
return this.media[this.currentIndex]
},
canNavigate () {
return this.media.length > 1
},
type () {
return this.currentMedia ? this.getType(this.currentMedia) : null
},
scaling () {
return this.$store.state.mediaViewer.swipeScaler.scaling
},
offsets () {
return this.$store.state.mediaViewer.swipeScaler.offsets
},
transform () {
return `translate(${this.offsets[0]}px, ${this.offsets[1]}px) scale(${this.scaling}, ${this.scaling})`
}
},
created () {
- this.mediaGesture = new GestureService.SwipeAndScaleGesture({
- direction: GestureService.DIRECTION_LEFT,
- callbackPositive: this.goNext,
- callbackNegative: this.goPrev,
- swipePreviewCallback: this.handleSwipePreview,
- swipeEndCallback: this.handleSwipeEnd,
- pinchPreviewCallback: this.handlePinchPreview,
- pinchEndCallback: this.handlePinchEnd,
- threshold: 50
- })
+ // this.mediaGesture = new GestureService.SwipeAndScaleGesture({
+ // callbackPositive: this.goNext,
+ // callbackNegative: this.goPrev,
+ // swipePreviewCallback: this.handleSwipePreview,
+ // swipeEndCallback: this.handleSwipeEnd,
+ // pinchPreviewCallback: this.handlePinchPreview,
+ // pinchEndCallback: this.handlePinchEnd
+ // })
},
methods: {
getType (media) {
return fileTypeService.fileType(media.mimetype)
},
- mediaTouchStart (e) {
- this.mediaGesture.start(e)
- },
- mediaTouchMove (e) {
- this.mediaGesture.move(e)
- },
- mediaTouchEnd (e) {
- this.mediaGesture.end(e)
- },
hide () {
this.$store.dispatch('closeMediaViewer')
},
goPrev () {
if (this.canNavigate) {
const prevIndex = this.currentIndex === 0 ? this.media.length - 1 : (this.currentIndex - 1)
const newMedia = this.media[prevIndex]
if (this.getType(newMedia) === 'image') {
this.loading = true
}
this.$store.dispatch('setCurrentMedia', newMedia)
+ this.$refs.pinchZoom.setTransform({ scale: 1, x: 0, y: 0 })
}
},
goNext () {
if (this.canNavigate) {
const nextIndex = this.currentIndex === this.media.length - 1 ? 0 : (this.currentIndex + 1)
const newMedia = this.media[nextIndex]
if (this.getType(newMedia) === 'image') {
this.loading = true
}
this.$store.dispatch('setCurrentMedia', newMedia)
+ this.$refs.pinchZoom.setTransform({ scale: 1, x: 0, y: 0 })
}
},
onImageLoaded () {
this.loading = false
},
handleSwipePreview (offsets) {
- this.$store.dispatch('swipeScaler/apply', {
- offsets: this.scaling > SCALING_ENABLE_MOVE_THRESHOLD ? offsets : onlyXAxis(offsets)
- })
+ this.$refs.pinchZoom.setTransform({ scale: 1, x: offsets[0], y: 0 })
},
handleSwipeEnd (sign) {
- if (this.scaling > SCALING_ENABLE_MOVE_THRESHOLD) {
- this.$store.dispatch('swipeScaler/finish')
- return
- }
+ console.log('handleSwipeEnd:', sign)
if (sign === 0) {
- this.$store.dispatch('swipeScaler/reset')
+ this.$refs.pinchZoom.setTransform({ scale: 1, x: 0, y: 0 })
} else if (sign > 0) {
this.goNext()
} else {
this.goPrev()
}
},
- handlePinchPreview (offsets, scaling) {
- console.log('handle pinch preview:', offsets, scaling)
- this.$store.dispatch('swipeScaler/apply', { offsets, scaling })
- },
- handlePinchEnd () {
- if (this.scaling > SCALING_RESET_MIN) {
- this.$store.dispatch('swipeScaler/finish')
- } else {
- this.$store.dispatch('swipeScaler/reset')
- }
- },
handleKeyupEvent (e) {
if (this.showing && e.keyCode === 27) { // escape
this.hide()
}
},
handleKeydownEvent (e) {
if (!this.showing) {
return
}
if (e.keyCode === 39) { // arrow right
this.goNext()
} else if (e.keyCode === 37) { // arrow left
this.goPrev()
}
}
},
mounted () {
window.addEventListener('popstate', this.hide)
document.addEventListener('keyup', this.handleKeyupEvent)
document.addEventListener('keydown', this.handleKeydownEvent)
},
destroyed () {
window.removeEventListener('popstate', this.hide)
document.removeEventListener('keyup', this.handleKeyupEvent)
document.removeEventListener('keydown', this.handleKeydownEvent)
}
}
export default MediaModal
diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue
index a3fad4c5a4..e385024ec8 100644
--- a/src/components/media_modal/media_modal.vue
+++ b/src/components/media_modal/media_modal.vue
@@ -1,252 +1,259 @@
<template>
<Modal
v-if="showing"
class="media-modal-view"
@backdropClicked="hide"
>
- <div class="modal-image-container">
+ <SwipeClick
+ class="modal-image-container"
+ :direction="swipeDirection"
+ :threshold="swipeThreshold"
+ @preview-requested="handleSwipePreview"
+ @swipe-finished="handleSwipeEnd"
+ @swipeless-clicked="hide"
+ >
<PinchZoom
- options="pinchZoomOptions"
+ ref="pinchZoom"
class="modal-image-container-inner"
selector=".modal-image"
allow-pan-min-scale="1"
min-scale="1"
reset-to-min-scale-limit="1.2"
reach-min-scale-strategy="reset"
stop-propagate-handled="stop-propgate-handled"
:inner-class="'modal-image-container-inner'"
>
<img
v-if="type === 'image'"
:class="{ loading }"
class="modal-image"
:src="currentMedia.url"
:alt="currentMedia.description"
:title="currentMedia.description"
@load="onImageLoaded"
>
</PinchZoom>
- </div>
+ </SwipeClick>
<VideoAttachment
v-if="type === 'video'"
class="modal-image"
:attachment="currentMedia"
:controls="true"
/>
<audio
v-if="type === 'audio'"
class="modal-image"
:src="currentMedia.url"
:alt="currentMedia.description"
:title="currentMedia.description"
controls
/>
<Flash
v-if="type === 'flash'"
class="modal-image"
:src="currentMedia.url"
:alt="currentMedia.description"
:title="currentMedia.description"
/>
<button
v-if="canNavigate"
:title="$t('media_modal.previous')"
class="modal-view-button-arrow modal-view-button-arrow--prev"
@click.stop.prevent="goPrev"
>
<FAIcon
class="arrow-icon"
icon="chevron-left"
/>
</button>
<button
v-if="canNavigate"
:title="$t('media_modal.next')"
class="modal-view-button-arrow modal-view-button-arrow--next"
@click.stop.prevent="goNext"
>
<FAIcon
class="arrow-icon"
icon="chevron-right"
/>
</button>
<span
v-if="description"
class="description"
>
{{ description }}
</span>
<span
class="counter"
>
{{ $tc('media_modal.counter', currentIndex + 1, { current: currentIndex + 1, total: media.length }) }}
</span>
<span
v-if="loading"
class="loading-spinner"
>
<FAIcon
spin
icon="circle-notch"
size="5x"
/>
</span>
</Modal>
</template>
<script src="./media_modal.js"></script>
<style lang="scss">
.modal-view.media-modal-view {
z-index: 1001;
flex-direction: column;
.modal-view-button-arrow {
opacity: 0.75;
&:focus,
&:hover {
outline: none;
box-shadow: none;
}
&:hover {
opacity: 1;
}
}
overflow: hidden;
}
.media-modal-view {
@keyframes media-fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal-image-container {
display: flex;
overflow: hidden;
align-items: center;
flex-direction: column;
max-width: 90%;
max-height: 95%;
flex-grow: 1;
&-inner {
width: 100%;
height: 100%;
flex-grow: 1;
display: flex;
flex-direction: column;
align-items: center;
}
}
.modal-image {
max-width: 100%;
max-height: 100%;
min-width: 0;
min-height: 0;
box-shadow: 0px 5px 15px 0 rgba(0, 0, 0, 0.5);
image-orientation: from-image; // NOTE: only FF supports this
animation: 0.1s cubic-bezier(0.7, 0, 1, 0.6) media-fadein;
}
//.modal-image {
// height: 90vh;
// width: 100%;
//}
.description,
.counter {
/* Hardcoded since background is also hardcoded */
color: white;
margin-top: 1em;
text-shadow: 0 0 10px black, 0 0 10px black;
padding: 0.2em 2em;
}
.description {
flex: 0 0 auto;
overflow-y: auto;
min-height: 1em;
max-width: 500px;
max-height: 9.5em;
word-break: break-all;
}
.modal-image {
max-width: 90%;
max-height: 90%;
box-shadow: 0px 5px 15px 0 rgba(0, 0, 0, 0.5);
image-orientation: from-image; // NOTE: only FF supports this
animation: 0.1s cubic-bezier(0.7, 0, 1, 0.6) media-fadein;
&.loading {
opacity: 0.5;
}
}
.loading-spinner {
width: 100%;
height: 100%;
position: absolute;
pointer-events: none;
display: flex;
justify-content: center;
align-items: center;
svg {
color: white;
}
}
.modal-view-button-arrow {
position: absolute;
display: block;
top: 50%;
margin-top: -50px;
width: 70px;
height: 100px;
border: 0;
padding: 0;
opacity: 0;
box-shadow: none;
background: none;
appearance: none;
overflow: visible;
cursor: pointer;
transition: opacity 333ms cubic-bezier(.4,0,.22,1);
.arrow-icon {
position: absolute;
top: 35px;
height: 30px;
width: 32px;
font-size: 14px;
line-height: 30px;
color: #FFF;
text-align: center;
background-color: rgba(0,0,0,.3);
}
&--prev {
left: 0;
.arrow-icon {
left: 6px;
}
}
&--next {
right: 0;
.arrow-icon {
right: 6px;
}
}
}
}
</style>
diff --git a/src/components/pinch_zoom/pinch_zoom.js b/src/components/pinch_zoom/pinch_zoom.js
index 75bb608276..36bebbce5b 100644
--- a/src/components/pinch_zoom/pinch_zoom.js
+++ b/src/components/pinch_zoom/pinch_zoom.js
@@ -1,6 +1,11 @@
import PinchZoom from '@kazvmoe-infra/pinch-zoom-element'
export default {
props: {
+ },
+ methods: {
+ setTransform ({ scale, x, y }) {
+ this.$el.setTransform({ scale, x, y })
+ }
}
}
diff --git a/src/modules/media_viewer.js b/src/modules/media_viewer.js
index ddcccb7948..0299b04ee1 100644
--- a/src/modules/media_viewer.js
+++ b/src/modules/media_viewer.js
@@ -1,94 +1,40 @@
import fileTypeService from '../services/file_type/file_type.service.js'
const supportedTypes = new Set(['image', 'video', 'audio', 'flash'])
const mediaViewer = {
state: {
media: [],
currentIndex: 0,
activated: false
},
mutations: {
setMedia (state, media) {
state.media = media
},
setCurrentMedia (state, index) {
state.activated = true
state.currentIndex = index
},
close (state) {
state.activated = false
}
},
actions: {
setMedia ({ commit, dispatch }, attachments) {
const media = attachments.filter(attachment => {
const type = fileTypeService.fileType(attachment.mimetype)
return supportedTypes.has(type)
})
commit('setMedia', media)
- dispatch('swipeScaler/reset')
},
- setCurrentMedia ({ commit, state, dispatch }, current) {
+ setCurrentMedia ({ commit, state }, current) {
const index = state.media.indexOf(current)
commit('setCurrentMedia', index || 0)
- dispatch('swipeScaler/reset')
},
closeMediaViewer ({ commit, dispatch }) {
commit('close')
- dispatch('swipeScaler/reset')
- }
- },
- modules: {
- swipeScaler: {
- namespaced: true,
-
- state: {
- origOffsets: [0, 0],
- offsets: [0, 0],
- origScaling: 1,
- scaling: 1
- },
-
- mutations: {
- reset (state) {
- state.origOffsets = [0, 0]
- state.offsets = [0, 0]
- state.origScaling = 1
- state.scaling = 1
- },
- applyOffsets (state, { offsets }) {
- state.offsets = state.origOffsets.map((k, n) => k + offsets[n])
- },
- applyScaling (state, { scaling }) {
- state.scaling = state.origScaling * scaling
- },
- finish (state) {
- state.origOffsets = [...state.offsets]
- state.origScaling = state.scaling
- },
- revert (state) {
- state.offsets = [...state.origOffsets]
- state.scaling = state.origScaling
- }
- },
-
- actions: {
- reset ({ commit }) {
- commit('reset')
- },
- apply ({ commit }, { offsets, scaling = 1 }) {
- commit('applyOffsets', { offsets })
- commit('applyScaling', { scaling })
- },
- finish ({ commit }) {
- commit('finish')
- },
- revert ({ commit }) {
- commit('revert')
- }
- }
}
}
}
export default mediaViewer
diff --git a/src/services/gesture_service/gesture_service.js b/src/services/gesture_service/gesture_service.js
index 82337bc6b0..f10dec3a79 100644
--- a/src/services/gesture_service/gesture_service.js
+++ b/src/services/gesture_service/gesture_service.js
@@ -1,216 +1,206 @@
const DIRECTION_LEFT = [-1, 0]
const DIRECTION_RIGHT = [1, 0]
const DIRECTION_UP = [0, -1]
const DIRECTION_DOWN = [0, 1]
-const DISTANCE_MIN = 1
+// const DISTANCE_MIN = 1
-const isSwipeEvent = e => (e.touches.length === 1)
-const isSwipeEventEnd = e => (e.changedTouches.length === 1)
+// const isSwipeEvent = e => (e.touches.length === 1)
+// const isSwipeEventEnd = e => (e.changedTouches.length === 1)
-const isScaleEvent = e => (e.targetTouches.length === 2)
-const isScaleEventEnd = e => (e.targetTouches.length === 1)
+// const isScaleEvent = e => (e.targetTouches.length === 2)
+// const isScaleEventEnd = e => (e.targetTouches.length === 1)
const deltaCoord = (oldCoord, newCoord) => [newCoord[0] - oldCoord[0], newCoord[1] - oldCoord[1]]
-const vectorMinus = (a, b) => a.map((k, n) => k - b[n])
-const vectorAdd = (a, b) => a.map((k, n) => k + b[n])
+// const vectorMinus = (a, b) => a.map((k, n) => k - b[n])
+// const vectorAdd = (a, b) => a.map((k, n) => k + b[n])
-const avgCoord = (coords) => [...coords].reduce(vectorAdd, [0, 0]).map(d => d / coords.length)
+// const avgCoord = (coords) => [...coords].reduce(vectorAdd, [0, 0]).map(d => d / coords.length)
const touchCoord = touch => [touch.screenX, touch.screenY]
const touchEventCoord = e => touchCoord(e.touches[0])
+const pointerEventCoord = e => [e.clientX, e.clientY]
+
const vectorLength = v => Math.sqrt(v[0] * v[0] + v[1] * v[1])
const perpendicular = v => [v[1], -v[0]]
const dotProduct = (v1, v2) => v1[0] * v2[0] + v1[1] * v2[1]
// const numProduct = (num, v) => v.map(k => num * k)
const project = (v1, v2) => {
const scalar = (dotProduct(v1, v2) / dotProduct(v2, v2))
return [scalar * v2[0], scalar * v2[1]]
}
// direction: either use the constants above or an arbitrary 2d vector.
// threshold: how many Px to move from touch origin before checking if the
// callback should be called.
// divergentTolerance: a scalar for much of divergent direction we tolerate when
// above threshold. for example, with 1.0 we only call the callback if
// divergent component of delta is < 1.0 * direction component of delta.
const swipeGesture = (direction, onSwipe, threshold = 30, perpendicularTolerance = 1.0) => {
return {
direction,
onSwipe,
threshold,
perpendicularTolerance,
_startPos: [0, 0],
_swiping: false
}
}
const beginSwipe = (event, gesture) => {
gesture._startPos = touchEventCoord(event)
gesture._swiping = true
}
const updateSwipe = (event, gesture) => {
if (!gesture._swiping) return
// movement too small
const delta = deltaCoord(gesture._startPos, touchEventCoord(event))
if (vectorLength(delta) < gesture.threshold) return
// movement is opposite from direction
if (dotProduct(delta, gesture.direction) < 0) return
// movement perpendicular to direction is too much
const towardsDir = project(delta, gesture.direction)
const perpendicularDir = perpendicular(gesture.direction)
const towardsPerpendicular = project(delta, perpendicularDir)
if (
vectorLength(towardsDir) * gesture.perpendicularTolerance <
vectorLength(towardsPerpendicular)
) return
gesture.onSwipe()
gesture._swiping = false
}
-class SwipeAndScaleGesture {
+class SwipeAndClickGesture {
// swipePreviewCallback(offsets: Array[Number])
// offsets: the offset vector which the underlying component should move, from the starting position
- // pinchPreviewCallback(offsets: Array[Number], scaling: Number)
- // offsets: the offset vector which the underlying component should move, from the starting position
- // scaling: the scaling factor we should apply to the underlying component, from the starting position
- // swipeEndcallback(sign: 0|-1|1)
+ // swipeEndCallback(sign: 0|-1|1)
// sign: if the swipe does not meet the threshold, 0
// if the swipe meets the threshold in the positive direction, 1
// if the swipe meets the threshold in the negative direction, -1
constructor ({
direction,
- // swipeStartCallback, pinchStartCallback,
- swipePreviewCallback, pinchPreviewCallback,
- swipeEndCallback, pinchEndCallback,
+ // swipeStartCallback
+ swipePreviewCallback,
+ swipeEndCallback,
+ swipeCancelCallback,
+ swipelessClickCallback,
threshold = 30, perpendicularTolerance = 1.0
}) {
const nop = () => { console.log('Warning: Not implemented') }
this.direction = direction
this.swipePreviewCallback = swipePreviewCallback || nop
- this.pinchPreviewCallback = pinchPreviewCallback || nop
this.swipeEndCallback = swipeEndCallback || nop
- this.pinchEndCallback = pinchEndCallback || nop
+ this.swipeCancelCallback = swipeCancelCallback || nop
+ this.swipelessClickCallback = swipelessClickCallback || nop
this.threshold = threshold
this.perpendicularTolerance = perpendicularTolerance
+ this._reset()
+ }
+
+ _reset () {
this._startPos = [0, 0]
- this._startDistance = DISTANCE_MIN
+ this._pointerId = -1
this._swiping = false
+ this._swiped = false
}
start (event) {
console.log('start() called', event)
- if (isSwipeEvent(event)) {
- this._startPos = touchEventCoord(event)
- console.log('start pos:', this._startPos)
- this._swiping = true
- } else if (isScaleEvent(event)) {
- const coords = [...event.targetTouches].map(touchCoord)
- this._startPos = avgCoord(coords)
- this._startDistance = vectorLength(deltaCoord(coords[0], coords[1]))
- if (this._startDistance < DISTANCE_MIN) {
- this._startDistance = DISTANCE_MIN
- }
- this._scalePoints = [...event.targetTouches]
- this._swiping = false
- console.log(
- 'is scale event, start =', this._startPos,
- 'dist =', this._startDistance)
- }
+
+ this._startPos = pointerEventCoord(event)
+ this._pointerId = event.pointerId
+ console.log('start pos:', this._startPos)
+ this._swiping = true
+ this._swiped = false
}
move (event) {
- // console.log('move called', event)
- if (isSwipeEvent(event)) {
- const touch = event.changedTouches[0]
- const delta = deltaCoord(this._startPos, touchCoord(touch))
+ if (this._swiping && this._pointerId === event.pointerId) {
+ this._swiped = true
+
+ const coord = pointerEventCoord(event)
+ const delta = deltaCoord(this._startPos, coord)
this.swipePreviewCallback(delta)
- } else if (isScaleEvent(event)) {
- console.log('is scale event')
- const coords = [...event.targetTouches].map(touchCoord)
- const curPos = avgCoord(coords)
- const curDistance = vectorLength(deltaCoord(coords[0], coords[1]))
- const scaling = curDistance / this._startDistance
- const posDiff = vectorMinus(curPos, this._startPos)
- // const delta = vectorAdd(numProduct((1 - scaling), this._startPos), posDiff)
- const delta = posDiff
- // console.log(
- // 'is scale event, cur =', curPos,
- // 'dist =', curDistance,
- // 'scale =', scaling,
- // 'delta =', delta)
- this.pinchPreviewCallback(delta, scaling)
}
}
- end (event) {
- console.log('end() called', event)
- if (isScaleEventEnd(event)) {
- this.pinchEndCallback()
- }
-
- if (!isSwipeEventEnd(event)) {
- console.log('not swipe event')
+ cancel (event) {
+ if (!this._swiping || this._pointerId !== event.pointerId) {
return
}
+
+ this.swipeCancelCallback()
+ }
+
+ end (event) {
if (!this._swiping) {
console.log('not swiping')
return
}
- this.swiping = false
- console.log('is swipe event')
+ if (this._pointerId !== event.pointerId) {
+ console.log('pointer id does not match')
+ return
+ }
+
+ this._swiping = false
+
+ console.log('end: is swipe event')
// movement too small
- const touch = event.changedTouches[0]
- const delta = deltaCoord(this._startPos, touchCoord(touch))
- this.swipePreviewCallback(delta)
+ const coord = pointerEventCoord(event)
+ const delta = deltaCoord(this._startPos, coord)
const sign = (() => {
if (vectorLength(delta) < this.threshold) {
return 0
}
// movement is opposite from direction
const isPositive = dotProduct(delta, this.direction) > 0
// movement perpendicular to direction is too much
const towardsDir = project(delta, this.direction)
const perpendicularDir = perpendicular(this.direction)
const towardsPerpendicular = project(delta, perpendicularDir)
if (
vectorLength(towardsDir) * this.perpendicularTolerance <
vectorLength(towardsPerpendicular)
) {
return 0
}
return isPositive ? 1 : -1
})()
- this.swipeEndCallback(sign)
+ if (this._swiped) {
+ this.swipeEndCallback(sign)
+ } else {
+ this.swipelessClickCallback()
+ }
+ this._reset()
}
}
const GestureService = {
DIRECTION_LEFT,
DIRECTION_RIGHT,
DIRECTION_UP,
DIRECTION_DOWN,
swipeGesture,
beginSwipe,
updateSwipe,
- SwipeAndScaleGesture
+ SwipeAndClickGesture
}
export default GestureService

File Metadata

Mime Type
text/x-diff
Expires
Sun, Aug 9, 1:19 PM (1 d, 8 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1724609
Default Alt Text
(24 KB)

Event Timeline