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 change: 1 addition & 0 deletions apps/app-frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@types/three": "^0.172.0",
"@vueuse/core": "^11.1.0",
"dayjs": "^1.11.10",
"fabric": "^7.4.0",
"floating-vue": "^5.2.2",
"fuse.js": "^6.6.2",
"intl-messageformat": "^10.7.7",
Expand Down
8 changes: 8 additions & 0 deletions apps/app-frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ChevronLeftIcon,
ChevronRightIcon,
CompassIcon,
ImagesIcon,
LogInIcon,
LogOutIcon,
NewspaperIcon,
Expand Down Expand Up @@ -534,6 +535,10 @@ const messages = defineMessages({
id: 'app.nav.modrinth-hosting',
defaultMessage: 'Modrinth Hosting',
},
screenshots: {
id: 'app.nav.screenshots',
defaultMessage: 'Screenshots',
},
createNewInstance: {
id: 'app.nav.create-new-instance',
defaultMessage: 'Create new instance',
Expand Down Expand Up @@ -1790,6 +1795,9 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<NavButton v-tooltip.right="formatMessage(appMessages.skinSelectorLabel)" to="/skins">
<ShirtIcon />
</NavButton>
<NavButton v-tooltip.right="formatMessage(messages.screenshots)" to="/screenshots">
<ImagesIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.modrinthHosting)"
to="/hosting/manage"
Expand Down
192 changes: 192 additions & 0 deletions apps/app-frontend/src/components/ui/screenshots-page/card.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
<script setup lang="ts">
import { KeyboardSensor, PointerSensor, useDraggable } from '@dnd-kit/vue'
import { CheckIcon, ClipboardCopyIcon, EditIcon, MoreHorizontalIcon } from '@modrinth/assets'
import { defineMessages, IconButton, useFormatDateTime, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'

import type { InstanceScreenshot } from '@/helpers/instance'

const props = defineProps<{
screenshot: InstanceScreenshot
selectionKey: string
selected: boolean
selectionActive: boolean
activeDragged: boolean
canDrag: boolean
showInstanceName: boolean
highlighted: boolean
}>()

const emit = defineEmits<{
(e: 'activate', event: MouseEvent | KeyboardEvent): void
(e: 'copy' | 'edit' | 'show-original' | 'toggle-selection'): void
(e: 'more', event: MouseEvent): void
}>()

const card = ref<HTMLElement>()
const loaded = ref(false)
const { formatMessage } = useVIntl()
const formatTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
const messages = defineMessages({
select: { id: 'app.screenshots.select', defaultMessage: 'Select {name}' },
deselect: { id: 'app.screenshots.deselect', defaultMessage: 'Deselect {name}' },
copy: { id: 'app.screenshots.copy', defaultMessage: 'Copy image' },
edit: { id: 'app.screenshots.edit', defaultMessage: 'Edit screenshot' },
edited: { id: 'app.screenshots.edited', defaultMessage: 'Edited' },
moreActions: { id: 'app.screenshots.more-actions', defaultMessage: 'More actions' },
instanceAndTime: {
id: 'app.screenshots.card.instance-and-time',
defaultMessage: '{instance} · {time}',
},
})

const sensors = [
PointerSensor.configure({
preventActivation: () => false,
}),
KeyboardSensor,
]

useDraggable({
id: computed(() => `screenshot:${props.selectionKey}`),
element: card,
disabled: computed(() => !props.canDrag),
sensors,
data: computed(() => ({
selectionKey: props.selectionKey,
instanceId: props.screenshot.instance_id,
})),
})

function activate(event: MouseEvent | KeyboardEvent) {
if (event instanceof KeyboardEvent) {
if (event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' ')) {
return
}
event.preventDefault()
}
emit('activate', event)
}
</script>

<template>
<article
ref="card"
role="button"
tabindex="0"
class="group relative aspect-video min-w-0 cursor-pointer overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 p-0 text-left shadow-sm transition hover:border-brand focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand"
:class="{
'!border-contrast': selected,
'!border-brand ring-2 ring-brand animate-pulse': highlighted,
'opacity-50': activeDragged,
'cursor-grab active:cursor-grabbing': canDrag,
}"
data-screenshot-card
:data-screenshot-id="screenshot.id"
:data-selection-key="selectionKey"
:aria-label="
selectionActive
? formatMessage(selected ? messages.deselect : messages.select, {
name: screenshot.file_name,
})
: screenshot.file_name
"
:aria-pressed="selectionActive ? selected : undefined"
@click="activate"
@contextmenu.prevent.stop="emit('more', $event)"
@keydown="activate"
>
<button
v-if="screenshot.original_screenshot_id"
type="button"
class="absolute left-2 top-2 z-[3] flex cursor-pointer items-center gap-1 rounded-full border border-solid border-surface-5 bg-surface-1/90 px-2 py-1 text-xs font-semibold text-contrast shadow-sm backdrop-blur hover:bg-surface-2"
@click.stop="emit('show-original')"
>
<EditIcon class="size-3.5" />
<span>{{ formatMessage(messages.edited) }}</span>
</button>
<button
type="button"
class="selection-button group/selection absolute right-0.5 top-0 z-[2] flex size-[50px] cursor-pointer items-start justify-center border-0 bg-transparent p-0 pt-4"
:aria-label="
formatMessage(selected ? messages.deselect : messages.select, {
name: screenshot.file_name,
})
"
:aria-pressed="selected"
@click.stop="emit('toggle-selection')"
>
<span
class="relative flex size-6 items-center justify-center rounded-full opacity-0 transition-opacity duration-200 ease-out group-hover:opacity-100 group-focus-within:opacity-100 group-hover/selection:brightness-125"
:class="
selected ? 'border-0 !opacity-100' : 'border-2 border-solid border-primary bg-transparent'
"
>
<span v-if="selected" class="absolute inset-0 rounded-full bg-contrast" />
<CheckIcon v-if="selected" class="relative size-4 invert [stroke-width:3]" />
</span>
</button>
<div v-if="!loaded" class="absolute inset-0 animate-pulse bg-surface-3" />
<img
:src="screenshot.url"
:alt="screenshot.file_name"
loading="lazy"
draggable="false"
class="h-full w-full object-cover transition duration-200 group-hover:scale-[1.02]"
:class="loaded ? 'opacity-100' : 'opacity-0'"
@load="loaded = true"
/>
<div
class="absolute inset-x-0 bottom-0 flex items-end justify-between gap-2 bg-gradient-to-t from-surface-1 to-transparent p-3 pt-[120px] text-contrast opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100"
>
<div class="min-w-0">
<div v-tooltip="screenshot.file_name" class="truncate text-sm font-semibold">
{{ screenshot.file_name }}
</div>
<div class="truncate text-xs text-secondary">
{{
showInstanceName
? formatMessage(messages.instanceAndTime, {
instance: screenshot.instance_name,
time: formatTime(screenshot.created_at),
})
: formatTime(screenshot.created_at)
}}
</div>
</div>
<div
v-if="!selectionActive"
class="flex shrink-0 translate-y-1 gap-1 opacity-0 transition group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:opacity-100"
@click.stop
>
<IconButton
v-tooltip="formatMessage(messages.edit)"
:label="formatMessage(messages.edit)"
type="quiet"
class="bg-surface-2 text-contrast hover:bg-surface-3"
@click="emit('edit')"
>
<EditIcon />
</IconButton>
<IconButton
v-tooltip="formatMessage(messages.copy)"
:label="formatMessage(messages.copy)"
type="quiet"
class="bg-surface-2 text-contrast hover:bg-surface-3"
@click="emit('copy')"
>
<ClipboardCopyIcon />
</IconButton>
<IconButton
v-tooltip="formatMessage(messages.moreActions)"
:label="formatMessage(messages.moreActions)"
type="quiet"
class="bg-surface-2 text-contrast hover:bg-surface-3"
@click="emit('more', $event)"
>
<MoreHorizontalIcon />
</IconButton>
</div>
</div>
</article>
</template>
103 changes: 103 additions & 0 deletions apps/app-frontend/src/components/ui/screenshots-page/censor-object.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { ScreenshotCensorMode, ScreenshotEditorSourceRect } from './editor-types'

const BLUR_DOWNSAMPLE = 0.25
const BLUR_SIGMA = 3
const BLUR_RADIUS = Math.ceil(BLUR_SIGMA * 3)
const BLUR_KERNEL = createGaussianKernel()

export function renderCensorRegion(
source: CanvasImageSource,
sourceRect: ScreenshotEditorSourceRect,
mode: ScreenshotCensorMode,
solidColor: string,
) {
const width = Math.max(1, Math.round(sourceRect.width))
const height = Math.max(1, Math.round(sourceRect.height))
const output = document.createElement('canvas')
output.width = width
output.height = height
const outputContext = output.getContext('2d')
if (!outputContext) throw new Error('Could not create censor canvas')

if (mode === 'solid') {
outputContext.fillStyle = solidColor
outputContext.fillRect(0, 0, width, height)
return output
}

const sampleWidth = Math.max(1, Math.round(width * BLUR_DOWNSAMPLE))
const sampleHeight = Math.max(1, Math.round(height * BLUR_DOWNSAMPLE))
const sample = document.createElement('canvas')
sample.width = sampleWidth
sample.height = sampleHeight
const sampleContext = sample.getContext('2d')
if (!sampleContext) throw new Error('Could not create blur canvas')

sampleContext.drawImage(
source,
sourceRect.left,
sourceRect.top,
sourceRect.width,
sourceRect.height,
0,
0,
sampleWidth,
sampleHeight,
)
const blurredPixels = gaussianBlur(
sampleContext.getImageData(0, 0, sampleWidth, sampleHeight),
sampleWidth,
sampleHeight,
)
sampleContext.putImageData(blurredPixels, 0, 0)
outputContext.imageSmoothingEnabled = true
outputContext.imageSmoothingQuality = 'high'
outputContext.drawImage(sample, 0, 0, sampleWidth, sampleHeight, 0, 0, width, height)
return output
}

function createGaussianKernel() {
const kernel = new Float32Array(BLUR_RADIUS * 2 + 1)
let total = 0
for (let index = -BLUR_RADIUS; index <= BLUR_RADIUS; index++) {
const weight = Math.exp(-(index * index) / (2 * BLUR_SIGMA * BLUR_SIGMA))
kernel[index + BLUR_RADIUS] = weight
total += weight
}
for (let index = 0; index < kernel.length; index++) kernel[index] /= total
return kernel
}

function gaussianBlur(imageData: ImageData, width: number, height: number) {
const horizontal = new Float32Array(imageData.data.length)
const output = new ImageData(width, height)

for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
for (let offset = -BLUR_RADIUS; offset <= BLUR_RADIUS; offset++) {
const sampleX = Math.max(0, Math.min(width - 1, x + offset))
const sourceIndex = (y * width + sampleX) * 4
const weight = BLUR_KERNEL[offset + BLUR_RADIUS]!
for (let channel = 0; channel < 4; channel++) {
horizontal[(y * width + x) * 4 + channel] +=
imageData.data[sourceIndex + channel]! * weight
}
}
}
}

for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
for (let offset = -BLUR_RADIUS; offset <= BLUR_RADIUS; offset++) {
const sampleY = Math.max(0, Math.min(height - 1, y + offset))
const sourceIndex = (sampleY * width + x) * 4
const weight = BLUR_KERNEL[offset + BLUR_RADIUS]!
for (let channel = 0; channel < 4; channel++) {
output.data[(y * width + x) * 4 + channel] += horizontal[sourceIndex + channel]! * weight
}
}
}
}

return output
}
Loading
Loading