From 23afcdc94b89684e510633dc7e9c21eb1161e9d5 Mon Sep 17 00:00:00 2001 From: ihateblueb Date: Fri, 9 Jan 2026 20:14:06 -0500 Subject: [PATCH 1/2] Add blur hashes for files --- build.gradle.kts | 4 +- .../remlit/aster/common/model/DriveFile.kt | 1 + .../site/remlit/aster/common/model/User.kt | 3 + .../aster/common/model/generated/Partials.kt | 5 +- .../app/src/routes/settings/account.tsx | 63 ++++++++++++++---- .../app/src/routes/settings/index.tsx | 5 +- .../remlit/aster/db/entity/DriveFileEntity.kt | 1 + .../site/remlit/aster/db/entity/UserEntity.kt | 3 + .../remlit/aster/db/table/DriveFileTable.kt | 2 + .../site/remlit/aster/db/table/UserTable.kt | 5 ++ .../aster/service/CommandLineService.kt | 66 ++++++++++++++++++- .../site/remlit/aster/service/DriveService.kt | 25 ++++++- .../site/remlit/aster/service/SetupService.kt | 12 ++-- .../site/remlit/aster/service/UserService.kt | 9 +++ .../remlit/aster/service/ap/ApActorService.kt | 5 ++ .../site/remlit/aster/util/model/DriveFile.kt | 1 + .../site/remlit/aster/util/model/User.kt | 3 + .../1767983581746_Migration_user_private.sql | 1 + .../1767993018807_Migration_user.sql | 2 + .../1768001001852_Migration_drive_file.sql | 1 + src/main/resources/migrations/_manifest.txt | 3 + 21 files changed, 195 insertions(+), 25 deletions(-) create mode 100644 src/main/resources/migrations/1767983581746_Migration_user_private.sql create mode 100644 src/main/resources/migrations/1767993018807_Migration_user.sql create mode 100644 src/main/resources/migrations/1768001001852_Migration_drive_file.sql diff --git a/build.gradle.kts b/build.gradle.kts index 96a74829..32af3d9c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -76,9 +76,11 @@ dependencies { // authentication implementation("at.favre.lib:bcrypt:0.10.2") + implementation("com.j256.two-factor-auth:two-factor-auth:1.3") // misc - implementation("org.jetbrains.kotlin:kotlin-reflect:2.2.21") + implementation("io.trbl:blurhash:1.0.0") + implementation("org.jetbrains.kotlin:kotlin-reflect:2.3.0") implementation("com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260101.1") implementation("site.remlit:aidx4j:1.0.0") implementation("site.remlit:effekt:0.2.1") diff --git a/common/src/commonMain/kotlin/site/remlit/aster/common/model/DriveFile.kt b/common/src/commonMain/kotlin/site/remlit/aster/common/model/DriveFile.kt index bbabc0cf..99ebeb91 100644 --- a/common/src/commonMain/kotlin/site/remlit/aster/common/model/DriveFile.kt +++ b/common/src/commonMain/kotlin/site/remlit/aster/common/model/DriveFile.kt @@ -12,6 +12,7 @@ data class DriveFile( val type: String, val src: String, val alt: String?, + val blurHash: String?, val sensitive: Boolean, diff --git a/common/src/commonMain/kotlin/site/remlit/aster/common/model/User.kt b/common/src/commonMain/kotlin/site/remlit/aster/common/model/User.kt index acc73210..2d514b41 100644 --- a/common/src/commonMain/kotlin/site/remlit/aster/common/model/User.kt +++ b/common/src/commonMain/kotlin/site/remlit/aster/common/model/User.kt @@ -22,8 +22,11 @@ data class User( val avatar: String? = null, val avatarAlt: String? = null, + val avatarBlurHash: String? = null, + val banner: String? = null, val bannerAlt: String? = null, + val bannerBlurHash: String? = null, val locked: Boolean = false, val suspended: Boolean = false, diff --git a/common/src/commonMain/kotlin/site/remlit/aster/common/model/generated/Partials.kt b/common/src/commonMain/kotlin/site/remlit/aster/common/model/generated/Partials.kt index 2604f8cd..3482fb03 100644 --- a/common/src/commonMain/kotlin/site/remlit/aster/common/model/generated/Partials.kt +++ b/common/src/commonMain/kotlin/site/remlit/aster/common/model/generated/Partials.kt @@ -1,6 +1,6 @@ // Generated by common-generators. // This file should not be edited. -// Generated on 2025-12-30T18:15:44.439522 +// Generated on 2026-01-09T17:41:19.235923 package site.remlit.aster.common.model.generated import kotlin.Boolean @@ -22,6 +22,7 @@ import site.remlit.aster.common.model.type.PolicyType @Serializable public data class PartialDriveFile( public val alt: String?, + public val blurHash: String?, public val createdAt: Instant?, public val id: String?, public val sensitive: Boolean?, @@ -78,8 +79,10 @@ public data class PartialUser( public val automated: Boolean?, public val avatar: String?, public val avatarAlt: String?, + public val avatarBlurHash: String?, public val banner: String?, public val bannerAlt: String?, + public val bannerBlurHash: String?, public val bio: String?, public val birthday: String?, public val createdAt: Instant?, diff --git a/frontend/packages/app/src/routes/settings/account.tsx b/frontend/packages/app/src/routes/settings/account.tsx index 77033f2f..84f826f6 100644 --- a/frontend/packages/app/src/routes/settings/account.tsx +++ b/frontend/packages/app/src/routes/settings/account.tsx @@ -1,6 +1,14 @@ import {createFileRoute, useNavigate} from '@tanstack/react-router' import PageHeader from "../../lib/components/PageHeader.tsx"; -import {IconDeviceDesktop, IconLogout, IconSettings, IconUser} from "@tabler/icons-react"; +import { + IconAuth2fa, + IconDeviceDesktop, IconKey, + IconLockPassword, + IconLogout, IconPassword, + IconSettings, + IconShield, + IconUser +} from "@tabler/icons-react"; import Tab from "../../lib/components/Tab.tsx"; import PageWrapper from "../../lib/components/PageWrapper.tsx"; import Container from "../../lib/components/Container.tsx"; @@ -254,6 +262,27 @@ function RouteComponent() { )} ) + case 1: + return ( + <> + + + + + + + + + + ) } } @@ -276,19 +305,27 @@ function RouteComponent() { - - setTab(0)} - > - General - - + setTab(0)} + > + + General + + setTab(1)} + > + + Security + - + + + {renderTab()} diff --git a/frontend/packages/app/src/routes/settings/index.tsx b/frontend/packages/app/src/routes/settings/index.tsx index 784a9c5d..888d6f0e 100644 --- a/frontend/packages/app/src/routes/settings/index.tsx +++ b/frontend/packages/app/src/routes/settings/index.tsx @@ -1,7 +1,7 @@ import {createFileRoute, useNavigate} from '@tanstack/react-router' import PageHeader from "../../lib/components/PageHeader.tsx"; import PageWrapper from "../../lib/components/PageWrapper.tsx"; -import {IconDeviceDesktop, IconSettings, IconUser} from "@tabler/icons-react"; +import {IconAccessible, IconBrush, IconDeviceDesktop, IconSettings, IconShield, IconUser} from "@tabler/icons-react"; import Tab from "../../lib/components/Tab.tsx"; import Container from "../../lib/components/Container.tsx"; import {useState} from "react"; @@ -64,18 +64,21 @@ function RouteComponent() { selected={tab === 0} onClick={() => setTab(0)} > + General setTab(1)} > + Appearance setTab(2)} > + Accessibility diff --git a/src/main/kotlin/site/remlit/aster/db/entity/DriveFileEntity.kt b/src/main/kotlin/site/remlit/aster/db/entity/DriveFileEntity.kt index a0302998..1335f54d 100644 --- a/src/main/kotlin/site/remlit/aster/db/entity/DriveFileEntity.kt +++ b/src/main/kotlin/site/remlit/aster/db/entity/DriveFileEntity.kt @@ -11,6 +11,7 @@ class DriveFileEntity(id: EntityID) : Entity(id = id) { var type by DriveFileTable.type var src by DriveFileTable.src var alt by DriveFileTable.alt + var blurHash by DriveFileTable.blurHash var sensitive by DriveFileTable.sensitive diff --git a/src/main/kotlin/site/remlit/aster/db/entity/UserEntity.kt b/src/main/kotlin/site/remlit/aster/db/entity/UserEntity.kt index b2430fad..965a65fd 100644 --- a/src/main/kotlin/site/remlit/aster/db/entity/UserEntity.kt +++ b/src/main/kotlin/site/remlit/aster/db/entity/UserEntity.kt @@ -21,8 +21,11 @@ class UserEntity(id: EntityID) : Entity(id) { var avatar by UserTable.avatar var avatarAlt by UserTable.avatarAlt + var avatarBlurHash by UserTable.avatarBlurHash + var banner by UserTable.banner var bannerAlt by UserTable.bannerAlt + var bannerBlurHash by UserTable.bannerBlurHash var locked by UserTable.locked var suspended by UserTable.suspended diff --git a/src/main/kotlin/site/remlit/aster/db/table/DriveFileTable.kt b/src/main/kotlin/site/remlit/aster/db/table/DriveFileTable.kt index 69707ff0..57356748 100644 --- a/src/main/kotlin/site/remlit/aster/db/table/DriveFileTable.kt +++ b/src/main/kotlin/site/remlit/aster/db/table/DriveFileTable.kt @@ -16,6 +16,8 @@ object DriveFileTable : IdTable("drive_file") { val src = varchar("src", length = TEXT_MEDIUM) val alt = varchar("alt", length = TEXT_LONG) .nullable() + val blurHash = varchar("blurHash", length = TEXT_TINY) + .nullable() val sensitive = bool("sensitive") .default(false) diff --git a/src/main/kotlin/site/remlit/aster/db/table/UserTable.kt b/src/main/kotlin/site/remlit/aster/db/table/UserTable.kt index ce37dffc..63a39604 100644 --- a/src/main/kotlin/site/remlit/aster/db/table/UserTable.kt +++ b/src/main/kotlin/site/remlit/aster/db/table/UserTable.kt @@ -37,10 +37,15 @@ object UserTable : IdTable("user") { .nullable() val avatarAlt = varchar("avatarAlt", length = TEXT_LONG) .nullable() + val avatarBlurHash = varchar("avatarBlurHash", length = TEXT_TINY) + .nullable() + val banner = varchar("banner", length = TEXT_MEDIUM) .nullable() val bannerAlt = varchar("bannerAlt", length = TEXT_LONG) .nullable() + val bannerBlurHash = varchar("bannerBlurHash", length = TEXT_TINY) + .nullable() val locked = bool("locked") .default(false) diff --git a/src/main/kotlin/site/remlit/aster/service/CommandLineService.kt b/src/main/kotlin/site/remlit/aster/service/CommandLineService.kt index a668c43d..0e695e00 100644 --- a/src/main/kotlin/site/remlit/aster/service/CommandLineService.kt +++ b/src/main/kotlin/site/remlit/aster/service/CommandLineService.kt @@ -1,12 +1,20 @@ package site.remlit.aster.service +import io.ktor.http.Url +import io.ktor.http.fullPath +import org.jetbrains.exposed.v1.core.and import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.core.neq import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.slf4j.LoggerFactory +import site.remlit.aster.common.util.ifFails +import site.remlit.aster.common.util.orNull import site.remlit.aster.db.Database +import site.remlit.aster.db.entity.DriveFileEntity import site.remlit.aster.db.entity.InviteEntity +import site.remlit.aster.db.entity.UserEntity import site.remlit.aster.db.table.DriveFileTable +import site.remlit.aster.db.table.UserTable import site.remlit.aster.model.Configuration import site.remlit.aster.model.PackageInformation import site.remlit.aster.model.Service @@ -28,7 +36,8 @@ object CommandLineService : Service { logger.info("${PackageInformation.name} ${PackageInformation.version}") logger.info("Run without arguments to start server") logger.info("help Show this page") - logger.info("clean:files Clean up drive files that are no longer in the file storage") + logger.info("files:clean Clean up drive files that are no longer in the file storage") + logger.info("files:generateblurhashes Generate blur hashes for all media") logger.info("migration:generate Generate migrations (for developer use)") logger.info("migration:execute Execute migrations") logger.info("role:list List all roles") @@ -79,7 +88,7 @@ object CommandLineService : Service { return } - "clean:files" -> { + "files:clean" -> { val files = DriveService.getMany(DriveFileTable.id neq "") logger.info("${files.size} drive files found") @@ -108,6 +117,59 @@ object CommandLineService : Service { } } + "files:generateblurhashes" -> { + fun generateBlurHash(url: String): String? { + return orNull { + val url = Url(url) + + println(url) + + if (url.host != Configuration.url.host || !url.fullPath.startsWith("/uploads")) + return null + + return DriveService.generateBlurHash(Path(Configuration.fileStorage.localPath.toString() + + url.fullPath.replace("uploads/", ""))) + } + } + + val files = DriveService.getMany(UserTable.host eq null and + (DriveFileTable.blurHash eq null)) + + files.forEach { file -> + if (!file.type.startsWith("image")) + return@forEach + + val hash = generateBlurHash(file.src) + logger.info("Generated blurhash $hash for file ${file.id}") + + transaction { + DriveFileEntity.findByIdAndUpdate(file.id) { + it.blurHash = hash + } + } + + UserService.getMany(UserTable.avatar eq file.src).forEach { user -> + logger.info("Found avatar for user ${user.id} with same source, adding blurhash") + transaction { + UserEntity.findByIdAndUpdate(user.id.toString()) { + it.avatarBlurHash = hash + } + } + } + + UserService.getMany(UserTable.banner eq file.src).forEach { user -> + logger.info("Found banner for user ${user.id} with same source, adding blurhash") + transaction { + UserEntity.findByIdAndUpdate(user.id.toString()) { + it.bannerBlurHash = hash + } + } + } + } + + return + } + "migration:generate" -> { MigrationService.generate() return diff --git a/src/main/kotlin/site/remlit/aster/service/DriveService.kt b/src/main/kotlin/site/remlit/aster/service/DriveService.kt index 9e8c3d12..a5333cf5 100644 --- a/src/main/kotlin/site/remlit/aster/service/DriveService.kt +++ b/src/main/kotlin/site/remlit/aster/service/DriveService.kt @@ -1,6 +1,7 @@ package site.remlit.aster.service import io.ktor.http.* +import io.trbl.blurhash.BlurHash import org.jetbrains.exposed.v1.core.Op import org.jetbrains.exposed.v1.core.and import org.jetbrains.exposed.v1.core.eq @@ -9,6 +10,7 @@ import org.jetbrains.exposed.v1.jdbc.select import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.transactions.transaction import site.remlit.aster.common.model.DriveFile +import site.remlit.aster.common.util.orNull import site.remlit.aster.db.entity.DriveFileEntity import site.remlit.aster.db.entity.UserEntity import site.remlit.aster.db.table.DriveFileTable @@ -29,6 +31,8 @@ import site.remlit.aster.util.model.fromEntities import site.remlit.aster.util.model.fromEntity import site.remlit.aster.util.sql.arrayContains import java.nio.file.Path +import javax.imageio.ImageIO +import kotlin.io.path.exists import kotlin.io.path.name /** @@ -142,10 +146,12 @@ object DriveService : Service { DriveFileEntity.new(id) { this.type = type.toString() - this.src = - "${Configuration.url.protocol.name}://${Configuration.url.host}/uploads/${user.id}/${path.name}" + this.src = "${Configuration.url.protocol.name}://${Configuration.url.host}/uploads/${user.id}/${path.name}" this.user = user this.alt = alt + this.blurHash = if (type.toString().startsWith("image")) + orNull { generateBlurHash(path) } + else null } val driveFile = getById(id)!! @@ -279,4 +285,19 @@ object DriveService : Service { return newFile } + + /** + * Generate a blur hash for a file + * + * @param path Local path of file + * + * @return Generated blur hash + * */ + @JvmStatic + fun generateBlurHash(path: Path): String { + if (!path.exists()) + throw IllegalArgumentException("Path does not exist") + + return BlurHash.encode(ImageIO.read(path.toFile())) + } } diff --git a/src/main/kotlin/site/remlit/aster/service/SetupService.kt b/src/main/kotlin/site/remlit/aster/service/SetupService.kt index cb782099..a3837def 100644 --- a/src/main/kotlin/site/remlit/aster/service/SetupService.kt +++ b/src/main/kotlin/site/remlit/aster/service/SetupService.kt @@ -1,9 +1,12 @@ package site.remlit.aster.service import at.favre.lib.crypto.bcrypt.BCrypt +import io.ktor.http.Url +import io.ktor.http.fullPath import org.jetbrains.annotations.ApiStatus import org.jetbrains.exposed.v1.core.and import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.neq import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -16,6 +19,7 @@ import site.remlit.aster.db.table.UserTable import site.remlit.aster.model.Configuration import site.remlit.aster.model.KeyType import site.remlit.aster.model.Service +import site.remlit.aster.service.DriveService.generateBlurHash import site.remlit.aster.service.ap.ApIdService import java.nio.file.Files import kotlin.io.path.Path @@ -33,7 +37,7 @@ object SetupService : Service { * Ensure instance is properly set up * */ @ApiStatus.Internal - fun setup() { + internal fun setup() { setupRoles() setupInstanceActor() @@ -44,8 +48,7 @@ object SetupService : Service { /** * Creates admin and mod roles, if they don't already exist * */ - @ApiStatus.Internal - fun setupRoles() { + private fun setupRoles() { val existingAdminRole = RoleService.get(RoleTable.type eq RoleType.Admin) if (existingAdminRole != null) { @@ -89,8 +92,7 @@ object SetupService : Service { /** * Creates instance actor, if it doesn't already exist * */ - @ApiStatus.Internal - fun setupInstanceActor() { + private fun setupInstanceActor() { val existingActor = UserService.get( UserTable.username eq "instance.actor" and (UserTable.host eq null) diff --git a/src/main/kotlin/site/remlit/aster/service/UserService.kt b/src/main/kotlin/site/remlit/aster/service/UserService.kt index c8c7ecd0..8fc07179 100644 --- a/src/main/kotlin/site/remlit/aster/service/UserService.kt +++ b/src/main/kotlin/site/remlit/aster/service/UserService.kt @@ -4,6 +4,7 @@ import org.jetbrains.exposed.v1.core.Op import org.jetbrains.exposed.v1.core.and import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import site.remlit.aster.common.model.DriveFile import site.remlit.aster.common.model.User import site.remlit.aster.db.entity.UserEntity import site.remlit.aster.db.entity.UserPrivateEntity @@ -183,6 +184,7 @@ object UserService : Service { avatar: String? = user.avatar, avatarAlt: String? = user.avatarAlt, + banner: String? = user.banner, bannerAlt: String? = user.bannerAlt, @@ -203,8 +205,15 @@ object UserService : Service { it.avatar = avatar?.ifEmpty { null } it.avatarAlt = avatarAlt?.ifEmpty { null } + it.avatarBlurHash = if (it.avatar != null) + DriveService.getBySrc(it.avatar!!)?.blurHash + else null + it.banner = banner?.ifEmpty { null } it.bannerAlt = bannerAlt?.ifEmpty { null } + it.bannerBlurHash = if (it.banner != null) + DriveService.getBySrc(it.banner!!)?.blurHash + else null it.locked = locked it.automated = automated diff --git a/src/main/kotlin/site/remlit/aster/service/ap/ApActorService.kt b/src/main/kotlin/site/remlit/aster/service/ap/ApActorService.kt index 60f5c3a3..80c1959d 100644 --- a/src/main/kotlin/site/remlit/aster/service/ap/ApActorService.kt +++ b/src/main/kotlin/site/remlit/aster/service/ap/ApActorService.kt @@ -181,10 +181,15 @@ object ApActorService : Service { birthday = null, location = null, + // TODO: Blurhashes for incoming remote actors + avatar = icon?.url, avatarAlt = icon?.summary ?: icon?.name, + avatarBlurHash = null, + banner = image?.url, bannerAlt = image?.summary ?: image?.name, + bannerBlurHash = null, inbox = inbox, outbox = extractString { json["outbox"] }, diff --git a/src/main/kotlin/site/remlit/aster/util/model/DriveFile.kt b/src/main/kotlin/site/remlit/aster/util/model/DriveFile.kt index ff86a47d..86b4f835 100644 --- a/src/main/kotlin/site/remlit/aster/util/model/DriveFile.kt +++ b/src/main/kotlin/site/remlit/aster/util/model/DriveFile.kt @@ -10,6 +10,7 @@ fun DriveFile.Companion.fromEntity(entity: DriveFileEntity): DriveFile = DriveFi type = entity.type, src = entity.src, alt = entity.alt, + blurHash = entity.blurHash, sensitive = entity.sensitive, user = User.fromEntity(entity.user), createdAt = entity.createdAt.toLocalInstant(), diff --git a/src/main/kotlin/site/remlit/aster/util/model/User.kt b/src/main/kotlin/site/remlit/aster/util/model/User.kt index 89015af7..96072272 100644 --- a/src/main/kotlin/site/remlit/aster/util/model/User.kt +++ b/src/main/kotlin/site/remlit/aster/util/model/User.kt @@ -21,8 +21,11 @@ fun User.Companion.fromEntity(entity: UserEntity) = User( avatar = entity.avatar, avatarAlt = entity.avatarAlt, + avatarBlurHash = entity.avatarBlurHash, + banner = entity.banner, bannerAlt = entity.bannerAlt, + bannerBlurHash = entity.bannerBlurHash, locked = entity.locked, suspended = entity.suspended, diff --git a/src/main/resources/migrations/1767983581746_Migration_user_private.sql b/src/main/resources/migrations/1767983581746_Migration_user_private.sql new file mode 100644 index 00000000..5aa43550 --- /dev/null +++ b/src/main/resources/migrations/1767983581746_Migration_user_private.sql @@ -0,0 +1 @@ +ALTER TABLE user_private ADD "totpSecret" VARCHAR(1000) NULL; diff --git a/src/main/resources/migrations/1767993018807_Migration_user.sql b/src/main/resources/migrations/1767993018807_Migration_user.sql new file mode 100644 index 00000000..e2984437 --- /dev/null +++ b/src/main/resources/migrations/1767993018807_Migration_user.sql @@ -0,0 +1,2 @@ +ALTER TABLE "user" ADD "avatarBlurHash" VARCHAR(150) NULL; +ALTER TABLE "user" ADD "bannerBlurHash" VARCHAR(150) NULL; diff --git a/src/main/resources/migrations/1768001001852_Migration_drive_file.sql b/src/main/resources/migrations/1768001001852_Migration_drive_file.sql new file mode 100644 index 00000000..f0cf1087 --- /dev/null +++ b/src/main/resources/migrations/1768001001852_Migration_drive_file.sql @@ -0,0 +1 @@ +ALTER TABLE drive_file ADD "blurHash" VARCHAR(150) NULL; diff --git a/src/main/resources/migrations/_manifest.txt b/src/main/resources/migrations/_manifest.txt index b3b6676e..52b49d84 100644 --- a/src/main/resources/migrations/_manifest.txt +++ b/src/main/resources/migrations/_manifest.txt @@ -68,3 +68,6 @@ 1766426964325_Migration_note_attachment.sql 1767137499095_Migration_note_attachment.sql 1767284655481_Migration_backfill_queue.sql +1767983581746_Migration_user_private.sql +1767993018807_Migration_user.sql +1768001001852_Migration_drive_file.sql From d787b5bc06af915539467659e2e70e5ceb4bab79 Mon Sep 17 00:00:00 2001 From: ihateblueb Date: Sat, 10 Jan 2026 09:33:15 -0500 Subject: [PATCH 2/2] Somewhat ok blurhash implementation in frontend --- frontend/packages/app/package.json | 9 ++- .../app/src/lib/components/Avatar.tsx | 45 +++++++++++- .../app/src/lib/components/DriveFile.scss | 57 ++++++++------- .../app/src/lib/components/DriveFile.tsx | 29 +++++++- .../app/src/lib/components/page/UserPage.scss | 73 ++++++++++--------- .../app/src/lib/components/page/UserPage.tsx | 7 +- frontend/pnpm-lock.yaml | 8 ++ 7 files changed, 155 insertions(+), 73 deletions(-) diff --git a/frontend/packages/app/package.json b/frontend/packages/app/package.json index a43b869f..c903200e 100644 --- a/frontend/packages/app/package.json +++ b/frontend/packages/app/package.json @@ -11,17 +11,18 @@ }, "dependencies": { "@floating-ui/react-dom": "^2.1.6", + "@preact/compat": "^18.3.1", + "@preact/signals": "^2.5.1", "@tabler/icons-react": "^3.33.0", "@tanstack/react-form": "^1.23.0", "@tanstack/react-query": "^5.90.2", "@tanstack/react-router": "^1.132.0", "@tanstack/react-store": "^0.7.0", "@tanstack/router-plugin": "^1.132.0", + "aster-common": "link:../common/build/dist/js/productionLibrary", + "fast-blurhash": "^1.1.4", "mfm-js": "^0.25.0", - "preact": "^10.27.2", - "@preact/compat": "^18.3.1", - "@preact/signals": "^2.5.1", - "aster-common": "link:../common/build/dist/js/productionLibrary" + "preact": "^10.27.2" }, "devDependencies": { "@eslint/js": "^9.37.0", diff --git a/frontend/packages/app/src/lib/components/Avatar.tsx b/frontend/packages/app/src/lib/components/Avatar.tsx index 7bc7be7f..9a1b876a 100644 --- a/frontend/packages/app/src/lib/components/Avatar.tsx +++ b/frontend/packages/app/src/lib/components/Avatar.tsx @@ -2,23 +2,64 @@ import * as Common from 'aster-common' import './Avatar.scss' import {useNavigate} from "@tanstack/react-router"; import localstore from "../utils/localstore.ts"; +import {decodeBlurHash, getBlurHashAverageColor} from "fast-blurhash"; +import {createRef} from "preact"; +import {useEffect, useRef, useState} from "react"; function Avatar( {user, size}: { user: any, size?: undefined | 'xl' | 'lg' | 'md' | 'sm' } ) { const navigate = useNavigate(); + const [useFallback, setUseFallback] = useState(false); let fallback = "/assets/img/avatar.png" + let sizePx = 45; + switch (size) { + case 'xl': sizePx = 55; break; + case 'lg': sizePx = 50; break; + case 'md': sizePx = 35; break; + case 'sm': sizePx = 25; break; + } + + const canvasRef = createRef() + + useEffect(() => { + if (!user.avatarBlurHash) return + + try { + const decoded = decodeBlurHash(user.avatarBlurHash, sizePx, sizePx) + const ctx = canvasRef.current?.getContext('2d'); + const imageData = ctx?.createImageData(sizePx, sizePx); + imageData.data.set(decoded); + ctx.putImageData(imageData, 0, 0); + } catch (_) {} + }) + + function render() { + if (useFallback) { + return ( + + ) + } else { + return ( + {user?.avatarAlt setUseFallback(true)} + /> + ) + } + } + return (
navigate({to: `/${Common.renderHandle(user)}`})} > - {user?.avatarAlt e.currentTarget.src = fallback}/> + {render()}
) diff --git a/frontend/packages/app/src/lib/components/DriveFile.scss b/frontend/packages/app/src/lib/components/DriveFile.scss index b5533970..cb4c3752 100644 --- a/frontend/packages/app/src/lib/components/DriveFile.scss +++ b/frontend/packages/app/src/lib/components/DriveFile.scss @@ -1,36 +1,37 @@ .driveFile { - display: flex; - justify-content: center; - align-items: center; - flex-direction: column; - padding: 12px; - gap: 6px; - box-sizing: border-box; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + padding: 12px; + gap: 6px; + box-sizing: border-box; - border-radius: var(--br-md); + border-radius: var(--br-md); - &:hover { - background-color: var(--bg-3-50); - } + &:hover { + background-color: var(--bg-3-50); + } - img, .preview { - display: flex; - align-items: center; - justify-content: center; + img, .preview { + display: flex; + align-items: center; + justify-content: center; - height: 115px; - width: 115px; - object-fit: cover; + height: 115px; + width: 115px; + object-fit: cover; + overflow: hidden; - border-radius: var(--br-sm); - background-color: var(--bg-3); - color: var(--tx-3); - } + border-radius: var(--br-sm); + background-color: var(--bg-3); + color: var(--tx-3); + } - .name { - max-width: 110px; - text-wrap: wrap; - word-break: break-all; - text-align: center; - } + .name { + max-width: 110px; + text-wrap: wrap; + word-break: break-all; + text-align: center; + } } diff --git a/frontend/packages/app/src/lib/components/DriveFile.tsx b/frontend/packages/app/src/lib/components/DriveFile.tsx index 62421df7..5aa0529c 100644 --- a/frontend/packages/app/src/lib/components/DriveFile.tsx +++ b/frontend/packages/app/src/lib/components/DriveFile.tsx @@ -13,14 +13,41 @@ import { IconVideo } from "@tabler/icons-react"; import * as React from "react"; +import {useEffect, useState} from "react"; +import {decodeBlurHash} from "fast-blurhash"; +import {createRef} from "preact"; function DriveFile({data}: { data: common.DriveFile }) { const [hidden, setHidden] = React.useState(false); + const [useFallback, setUseFallback] = useState(false); + const canvasRef = createRef() + + useEffect(() => { + if (!data.blurHash) return + + try { + const decoded = decodeBlurHash(data.blurHash, 115, 115) + const ctx = canvasRef.current?.getContext('2d'); + const imageData = ctx?.createImageData(115, 115); + imageData.data.set(decoded); + ctx.putImageData(imageData, 0, 0); + } catch (_) {} + }) + function renderPreview() { const type = data.type if (type.startsWith("image")) { - return {data.alt}/ + return ( +
+ {useFallback ? ( + + ) : ( + {data.alt} setUseFallback(true)}/> + )} +
+ ) } else if (type.startsWith("video")) { return
diff --git a/frontend/packages/app/src/lib/components/page/UserPage.scss b/frontend/packages/app/src/lib/components/page/UserPage.scss index 20c49ec8..bcce5702 100644 --- a/frontend/packages/app/src/lib/components/page/UserPage.scss +++ b/frontend/packages/app/src/lib/components/page/UserPage.scss @@ -1,49 +1,50 @@ .userPage { - .userHeader { - --th: 12px; - --nth: calc(var(--th) * -1); + .userHeader { + --th: 12px; + --nth: calc(var(--th) * -1); - --w: calc(100% + (var(--th) * 2)); + --w: calc(100% + (var(--th) * 2)); - box-sizing: border-box; + box-sizing: border-box; - margin: var(--nth) var(--nth) 0 var(--nth); + margin: var(--nth) var(--nth) 0 var(--nth); - width: var(--w); - height: 250px; + width: var(--w); + height: 250px; - background: var(--bg-3); - background-position: center; - background-size: cover; - - box-shadow: inset 0 0 65px 5px var(--bg-2); - } + background: var(--bg-3); + background-position: center; + background-size: cover; - .userIdentity { - margin-top: calc(-1 * (55px + (2 * 12px))); - } + box-shadow: inset 0 0 65px 5px var(--bg-2); + } - --username-shadow: 0 0 5px #00000080, 0 0 10px #00000050, 0 0 15px #000000; + .userIdentity { + margin-top: calc(-1 * (55px + (2 * 12px))); + } - .displayName { - font-size: var(--fs-xxl); - font-weight: 600; - text-shadow: var(--username-shadow); - } + --username-shadow: 0 0 5px #00000080, 0 0 10px #00000050, 0 0 15px #000000; - .username { - font-weight: 400; - text-shadow: var(--username-shadow); - } + .displayName { + color: var(--tx-1); + font-size: var(--fs-xxl); + font-weight: 600; + text-shadow: var(--username-shadow); + } - .underHeader { - margin: 20px 0 20px 0; + .username { + font-weight: 400; + text-shadow: var(--username-shadow); + } - .bio { - &.none { - color: var(--tx-3); - font-style: italic; - } - } - } + .underHeader { + margin: 20px 0 20px 0; + + .bio { + &.none { + color: var(--tx-3); + font-style: italic; + } + } + } } diff --git a/frontend/packages/app/src/lib/components/page/UserPage.tsx b/frontend/packages/app/src/lib/components/page/UserPage.tsx index 66b1cd81..6bbdca8e 100644 --- a/frontend/packages/app/src/lib/components/page/UserPage.tsx +++ b/frontend/packages/app/src/lib/components/page/UserPage.tsx @@ -6,7 +6,7 @@ import {useQuery} from "@tanstack/react-query"; import Loading from "../Loading.tsx"; import Error from "../Error.tsx"; import Avatar from "../Avatar.tsx"; -import {useState} from "react"; +import {useEffect, useState} from "react"; import Container from "../Container.tsx"; import Button from "../Button.tsx"; import Mfm from "../Mfm.tsx"; @@ -31,7 +31,10 @@ function UserPage( return ( <> -
+
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index e9c6c0c4..160207ca 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -54,6 +54,9 @@ importers: aster-common: specifier: link:../../../common/build/dist/js/productionLibrary version: link:../../../common/build/dist/js/productionLibrary + fast-blurhash: + specifier: ^1.1.4 + version: 1.1.4 mfm-js: specifier: ^0.25.0 version: 0.25.0 @@ -1434,6 +1437,9 @@ packages: resolution: {integrity: sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ==} engines: {node: '>=4'} + fast-blurhash@1.1.4: + resolution: {integrity: sha512-xeH121M027hgWHHhHWYYjUmMKl8vCH3PPkXk439ixsP8Bvb/r3UFqg12oMSToD/aSAw8EE6XiTdfZ6M5jaLfzg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -3506,6 +3512,8 @@ snapshots: ext-list: 2.2.2 sort-keys-length: 1.0.1 + fast-blurhash@1.1.4: {} + fast-deep-equal@3.1.3: {} fast-fifo@1.3.2: {}