Add hearts to marketplace assets

This commit is contained in:
2026-01-13 23:46:09 +01:00
parent e3580f950d
commit a267f9b5ec
12 changed files with 404 additions and 17 deletions

View File

@@ -25,6 +25,7 @@ public class SchemaMigration implements ApplicationRunner {
ensureSessionAttributeUpsertTrigger();
ensureChannelCanvasColumns();
ensureAssetTables();
ensureMarketplaceScriptHeartsTable();
ensureAuthorizedClientTable();
normalizeAuthorizedClientTimestamps();
}
@@ -169,6 +170,22 @@ public class SchemaMigration implements ApplicationRunner {
}
}
private void ensureMarketplaceScriptHeartsTable() {
try {
jdbcTemplate.execute(
"""
CREATE TABLE IF NOT EXISTS marketplace_script_hearts (
script_id TEXT NOT NULL,
username TEXT NOT NULL,
PRIMARY KEY (script_id, username)
)
"""
);
} catch (DataAccessException ex) {
logger.warn("Unable to ensure marketplace script hearts table", ex);
}
}
private void ensureScriptAssetColumns() {
List<String> scriptColumns;
List<String> attachmentColumns;

View File

@@ -46,8 +46,12 @@ public class ScriptMarketplaceController {
}
@GetMapping("/scripts")
public List<ScriptMarketplaceEntry> listMarketplaceScripts(@RequestParam(value = "query", required = false) String query) {
return channelDirectoryService.listMarketplaceScripts(query);
public List<ScriptMarketplaceEntry> listMarketplaceScripts(
@RequestParam(value = "query", required = false) String query,
OAuth2AuthenticationToken oauthToken
) {
String sessionUsername = oauthToken == null ? null : OauthSessionUser.from(oauthToken).login();
return channelDirectoryService.listMarketplaceScripts(query, sessionUsername);
}
@GetMapping("/scripts/{scriptId}/logo")
@@ -85,4 +89,16 @@ public class ScriptMarketplaceController {
.map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to import script"));
}
@PostMapping("/scripts/{scriptId}/heart")
public ResponseEntity<ScriptMarketplaceEntry> toggleMarketplaceHeart(
@PathVariable("scriptId") String scriptId,
OAuth2AuthenticationToken oauthToken
) {
String sessionUsername = OauthSessionUser.from(oauthToken).login();
return channelDirectoryService
.toggleMarketplaceHeart(scriptId, sessionUsername)
.map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Marketplace script not found"));
}
}

View File

@@ -0,0 +1,44 @@
package dev.kruhlmann.imgfloat.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Table;
@Entity
@Table(name = "marketplace_script_hearts")
@IdClass(MarketplaceScriptHeartId.class)
public class MarketplaceScriptHeart {
@Id
@Column(name = "script_id")
private String scriptId;
@Id
@Column(name = "username")
private String username;
public MarketplaceScriptHeart() {}
public MarketplaceScriptHeart(String scriptId, String username) {
this.scriptId = scriptId;
this.username = username;
}
public String getScriptId() {
return scriptId;
}
public void setScriptId(String scriptId) {
this.scriptId = scriptId;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
}

View File

@@ -0,0 +1,50 @@
package dev.kruhlmann.imgfloat.model;
import java.io.Serializable;
import java.util.Objects;
public class MarketplaceScriptHeartId implements Serializable {
private String scriptId;
private String username;
public MarketplaceScriptHeartId() {}
public MarketplaceScriptHeartId(String scriptId, String username) {
this.scriptId = scriptId;
this.username = username;
}
public String getScriptId() {
return scriptId;
}
public void setScriptId(String scriptId) {
this.scriptId = scriptId;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (other == null || getClass() != other.getClass()) {
return false;
}
MarketplaceScriptHeartId that = (MarketplaceScriptHeartId) other;
return Objects.equals(scriptId, that.scriptId) && Objects.equals(username, that.username);
}
@Override
public int hashCode() {
return Objects.hash(scriptId, username);
}
}

View File

@@ -5,5 +5,7 @@ public record ScriptMarketplaceEntry(
String name,
String description,
String logoUrl,
String broadcaster
String broadcaster,
long heartsCount,
boolean hearted
) {}

View File

@@ -0,0 +1,35 @@
package dev.kruhlmann.imgfloat.repository;
import dev.kruhlmann.imgfloat.model.MarketplaceScriptHeart;
import dev.kruhlmann.imgfloat.model.MarketplaceScriptHeartId;
import java.util.Collection;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface MarketplaceScriptHeartRepository
extends JpaRepository<MarketplaceScriptHeart, MarketplaceScriptHeartId> {
interface ScriptHeartCount {
String getScriptId();
long getHeartCount();
}
@Query(
"""
SELECT h.scriptId AS scriptId, COUNT(h) AS heartCount
FROM MarketplaceScriptHeart h
WHERE h.scriptId IN :scriptIds
GROUP BY h.scriptId
"""
)
List<ScriptHeartCount> countByScriptIds(@Param("scriptIds") Collection<String> scriptIds);
List<MarketplaceScriptHeart> findByUsernameAndScriptIdIn(String username, Collection<String> scriptIds);
boolean existsByScriptIdAndUsername(String scriptId, String username);
long countByScriptId(String scriptId);
void deleteByScriptIdAndUsername(String scriptId, String username);
}

View File

@@ -13,6 +13,7 @@ import dev.kruhlmann.imgfloat.model.CanvasEvent;
import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest;
import dev.kruhlmann.imgfloat.model.Channel;
import dev.kruhlmann.imgfloat.model.CodeAssetRequest;
import dev.kruhlmann.imgfloat.model.MarketplaceScriptHeart;
import dev.kruhlmann.imgfloat.model.PlaybackRequest;
import dev.kruhlmann.imgfloat.model.ScriptAsset;
import dev.kruhlmann.imgfloat.model.ScriptAssetAttachment;
@@ -26,6 +27,7 @@ import dev.kruhlmann.imgfloat.model.VisualAsset;
import dev.kruhlmann.imgfloat.repository.AssetRepository;
import dev.kruhlmann.imgfloat.repository.AudioAssetRepository;
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
import dev.kruhlmann.imgfloat.repository.MarketplaceScriptHeartRepository;
import dev.kruhlmann.imgfloat.repository.ScriptAssetRepository;
import dev.kruhlmann.imgfloat.repository.ScriptAssetAttachmentRepository;
import dev.kruhlmann.imgfloat.repository.ScriptAssetFileRepository;
@@ -66,6 +68,7 @@ public class ChannelDirectoryService {
private final ScriptAssetRepository scriptAssetRepository;
private final ScriptAssetAttachmentRepository scriptAssetAttachmentRepository;
private final ScriptAssetFileRepository scriptAssetFileRepository;
private final MarketplaceScriptHeartRepository marketplaceScriptHeartRepository;
private final SimpMessagingTemplate messagingTemplate;
private final AssetStorageService assetStorageService;
private final MediaDetectionService mediaDetectionService;
@@ -83,6 +86,7 @@ public class ChannelDirectoryService {
ScriptAssetRepository scriptAssetRepository,
ScriptAssetAttachmentRepository scriptAssetAttachmentRepository,
ScriptAssetFileRepository scriptAssetFileRepository,
MarketplaceScriptHeartRepository marketplaceScriptHeartRepository,
SimpMessagingTemplate messagingTemplate,
AssetStorageService assetStorageService,
MediaDetectionService mediaDetectionService,
@@ -98,6 +102,7 @@ public class ChannelDirectoryService {
this.scriptAssetRepository = scriptAssetRepository;
this.scriptAssetAttachmentRepository = scriptAssetAttachmentRepository;
this.scriptAssetFileRepository = scriptAssetFileRepository;
this.marketplaceScriptHeartRepository = marketplaceScriptHeartRepository;
this.messagingTemplate = messagingTemplate;
this.assetStorageService = assetStorageService;
this.mediaDetectionService = mediaDetectionService;
@@ -430,7 +435,7 @@ public class ChannelDirectoryService {
return Optional.of(view);
}
public List<ScriptMarketplaceEntry> listMarketplaceScripts(String query) {
public List<ScriptMarketplaceEntry> listMarketplaceScripts(String query, String sessionUsername) {
String q = normalizeDescription(query);
String normalizedQuery = q == null ? null : q.toLowerCase(Locale.ROOT);
List<ScriptMarketplaceEntry> entries = new ArrayList<>(marketplaceScriptSeedLoader.listEntriesForQuery(normalizedQuery));
@@ -439,12 +444,7 @@ public class ChannelDirectoryService {
scripts = scriptAssetRepository.findByIsPublicTrue();
} catch (DataAccessException ex) {
logger.warn("Unable to load marketplace scripts", ex);
return entries
.stream()
.sorted(
Comparator.comparing(ScriptMarketplaceEntry::name, Comparator.nullsLast(String::compareToIgnoreCase))
)
.toList();
return applyMarketplaceHearts(entries, sessionUsername);
}
if (normalizedQuery != null && !normalizedQuery.isBlank()) {
scripts =
@@ -477,15 +477,145 @@ public class ChannelDirectoryService {
script.getName(),
script.getDescription(),
logoUrl,
broadcaster
broadcaster,
0,
false
);
})
.toList()
);
return applyMarketplaceHearts(entries, sessionUsername);
}
public Optional<ScriptMarketplaceEntry> toggleMarketplaceHeart(String scriptId, String sessionUsername) {
if (scriptId == null || scriptId.isBlank() || sessionUsername == null || sessionUsername.isBlank()) {
return Optional.empty();
}
try {
if (marketplaceScriptHeartRepository.existsByScriptIdAndUsername(scriptId, sessionUsername)) {
marketplaceScriptHeartRepository.deleteByScriptIdAndUsername(scriptId, sessionUsername);
} else {
marketplaceScriptHeartRepository.save(new MarketplaceScriptHeart(scriptId, sessionUsername));
}
} catch (DataAccessException ex) {
logger.warn("Unable to update marketplace heart for {}", scriptId, ex);
return Optional.empty();
}
return loadMarketplaceEntryWithHearts(scriptId, sessionUsername);
}
private Optional<ScriptMarketplaceEntry> loadMarketplaceEntryWithHearts(String scriptId, String sessionUsername) {
Optional<MarketplaceScriptSeedLoader.SeedScript> seedScript = marketplaceScriptSeedLoader.findById(scriptId);
if (seedScript.isPresent()) {
return Optional.of(applyMarketplaceHearts(seedScript.get().entry(), sessionUsername));
}
ScriptAsset script;
try {
script = scriptAssetRepository.findById(scriptId).filter(ScriptAsset::isPublic).orElse(null);
} catch (DataAccessException ex) {
logger.warn("Unable to load marketplace script {}", scriptId, ex);
return Optional.empty();
}
if (script == null) {
return Optional.empty();
}
Asset asset = assetRepository.findById(scriptId).orElse(null);
String broadcaster = asset != null ? asset.getBroadcaster() : "";
String logoUrl = script.getLogoFileId() == null ? null : "/api/marketplace/scripts/" + script.getId() + "/logo";
ScriptMarketplaceEntry entry = new ScriptMarketplaceEntry(
script.getId(),
script.getName(),
script.getDescription(),
logoUrl,
broadcaster,
0,
false
);
return Optional.of(applyMarketplaceHearts(entry, sessionUsername));
}
private ScriptMarketplaceEntry applyMarketplaceHearts(ScriptMarketplaceEntry entry, String sessionUsername) {
if (entry == null || entry.id() == null) {
return entry;
}
long heartCount;
boolean hearted;
try {
heartCount = marketplaceScriptHeartRepository.countByScriptId(entry.id());
hearted =
sessionUsername != null &&
marketplaceScriptHeartRepository.existsByScriptIdAndUsername(entry.id(), sessionUsername);
} catch (DataAccessException ex) {
logger.warn("Unable to load marketplace heart summary for {}", entry.id(), ex);
heartCount = 0;
hearted = false;
}
return new ScriptMarketplaceEntry(
entry.id(),
entry.name(),
entry.description(),
entry.logoUrl(),
entry.broadcaster(),
heartCount,
hearted
);
}
private List<ScriptMarketplaceEntry> applyMarketplaceHearts(
List<ScriptMarketplaceEntry> entries,
String sessionUsername
) {
if (entries == null || entries.isEmpty()) {
return List.of();
}
List<String> scriptIds = entries.stream().map(ScriptMarketplaceEntry::id).filter(Objects::nonNull).toList();
Map<String, Long> counts = new HashMap<>();
Set<String> heartedIds = new HashSet<>();
try {
if (!scriptIds.isEmpty()) {
counts.putAll(
marketplaceScriptHeartRepository
.countByScriptIds(scriptIds)
.stream()
.collect(
Collectors.toMap(
MarketplaceScriptHeartRepository.ScriptHeartCount::getScriptId,
MarketplaceScriptHeartRepository.ScriptHeartCount::getHeartCount
)
)
);
if (sessionUsername != null && !sessionUsername.isBlank()) {
heartedIds.addAll(
marketplaceScriptHeartRepository
.findByUsernameAndScriptIdIn(sessionUsername, scriptIds)
.stream()
.map(MarketplaceScriptHeart::getScriptId)
.collect(Collectors.toSet())
);
}
}
} catch (DataAccessException ex) {
logger.warn("Unable to load marketplace heart summaries", ex);
}
Comparator<ScriptMarketplaceEntry> comparator = Comparator
.comparingLong((ScriptMarketplaceEntry entry) -> counts.getOrDefault(entry.id(), 0L))
.reversed()
.thenComparing(ScriptMarketplaceEntry::name, Comparator.nullsLast(String::compareToIgnoreCase));
return entries
.stream()
.sorted(Comparator.comparing(ScriptMarketplaceEntry::name, Comparator.nullsLast(String::compareToIgnoreCase)))
.map((entry) ->
new ScriptMarketplaceEntry(
entry.id(),
entry.name(),
entry.description(),
entry.logoUrl(),
entry.broadcaster(),
counts.getOrDefault(entry.id(), 0L),
heartedIds.contains(entry.id())
)
)
.sorted(comparator)
.toList();
}

View File

@@ -86,7 +86,9 @@ public class MarketplaceScriptSeedLoader {
name,
description,
logoPath.isPresent() ? "/api/marketplace/scripts/" + id + "/logo" : null,
broadcaster
broadcaster,
0,
false
);
}

View File

@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS marketplace_script_hearts (
script_id TEXT NOT NULL,
username TEXT NOT NULL,
PRIMARY KEY (script_id, username)
);

View File

@@ -302,12 +302,30 @@
white-space: nowrap;
}
.modal .modal-inner .marketplace-hearts {
display: inline-flex;
align-items: center;
gap: 6px;
color: rgba(248, 113, 113, 0.9);
}
.modal .modal-inner .marketplace-hearts i {
font-size: 12px;
}
.modal .modal-inner .marketplace-actions {
display: flex;
align-items: center;
margin-top: auto;
width: 100%;
justify-content: flex-end;
gap: 8px;
}
.modal .modal-inner .marketplace-heart-button.active {
border-color: rgba(248, 113, 113, 0.5);
background: rgba(248, 113, 113, 0.12);
color: #fecdd3;
}
.modal .modal-inner .marketplace-empty,

View File

@@ -750,7 +750,14 @@ export function createCustomAssetModal({
marketplaceList.innerHTML = '<div class="marketplace-empty">No scripts found.</div>';
return;
}
marketplaceEntries.forEach((entry) => {
const sortedEntries = [...marketplaceEntries].sort((a, b) => {
const heartsDelta = (b.heartsCount ?? 0) - (a.heartsCount ?? 0);
if (heartsDelta !== 0) {
return heartsDelta;
}
return (a.name || "").localeCompare(b.name || "", undefined, { sensitivity: "base" });
});
sortedEntries.forEach((entry) => {
const card = document.createElement("div");
card.className = "marketplace-card";
@@ -775,17 +782,34 @@ export function createCustomAssetModal({
description.textContent = entry.description || "No description provided.";
const meta = document.createElement("small");
meta.textContent = entry.broadcaster ? `By ${entry.broadcaster}` : "";
const hearts = document.createElement("small");
hearts.className = "marketplace-hearts";
const heartIcon = document.createElement("i");
heartIcon.className = "fa-solid fa-heart";
const heartCount = document.createElement("span");
heartCount.textContent = String(entry.heartsCount ?? 0);
hearts.appendChild(heartIcon);
hearts.appendChild(heartCount);
content.appendChild(title);
content.appendChild(description);
content.appendChild(meta);
content.appendChild(hearts);
const actions = document.createElement("div");
actions.className = "marketplace-actions";
const heartButton = document.createElement("button");
heartButton.type = "button";
heartButton.className = "icon-button marketplace-heart-button";
heartButton.setAttribute("aria-label", "Heart script");
updateMarketplaceHeartButton(heartButton, entry);
heartButton.addEventListener("click", () => toggleMarketplaceHeart(entry, heartCount));
const importButton = document.createElement("button");
importButton.type = "button";
importButton.className = "primary";
importButton.textContent = "Import";
importButton.className = "icon-button";
importButton.setAttribute("aria-label", "Import script");
importButton.innerHTML = '<i class="icon fa-solid fa-download"></i>';
importButton.addEventListener("click", () => importMarketplaceScript(entry));
actions.appendChild(heartButton);
actions.appendChild(importButton);
card.appendChild(content);
@@ -821,6 +845,44 @@ export function createCustomAssetModal({
});
}
function updateMarketplaceHeartButton(button, entry) {
if (!button || !entry) {
return;
}
button.classList.toggle("active", Boolean(entry.hearted));
button.setAttribute("aria-pressed", entry.hearted ? "true" : "false");
const iconClass = entry.hearted ? "fa-solid fa-heart" : "fa-regular fa-heart";
button.innerHTML = `<i class="icon ${iconClass}"></i>`;
}
function toggleMarketplaceHeart(entry, countElement) {
if (!entry?.id) {
return;
}
fetch(`/api/marketplace/scripts/${entry.id}/heart`, {
method: "POST",
headers: { "Content-Type": "application/json" },
})
.then((response) => {
if (!response.ok) {
throw new Error("Failed to update heart");
}
return response.json();
})
.then((updated) => {
entry.heartsCount = updated.heartsCount ?? entry.heartsCount ?? 0;
entry.hearted = updated.hearted ?? entry.hearted;
if (countElement) {
countElement.textContent = String(entry.heartsCount ?? 0);
}
renderMarketplace();
})
.catch((error) => {
console.error(error);
showToast?.("Unable to update heart. Please try again.", "error");
});
}
function debounce(fn, wait = 150) {
let timeout;
return (...args) => {