mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Add hearts to marketplace assets
This commit is contained in:
@@ -25,6 +25,7 @@ public class SchemaMigration implements ApplicationRunner {
|
|||||||
ensureSessionAttributeUpsertTrigger();
|
ensureSessionAttributeUpsertTrigger();
|
||||||
ensureChannelCanvasColumns();
|
ensureChannelCanvasColumns();
|
||||||
ensureAssetTables();
|
ensureAssetTables();
|
||||||
|
ensureMarketplaceScriptHeartsTable();
|
||||||
ensureAuthorizedClientTable();
|
ensureAuthorizedClientTable();
|
||||||
normalizeAuthorizedClientTimestamps();
|
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() {
|
private void ensureScriptAssetColumns() {
|
||||||
List<String> scriptColumns;
|
List<String> scriptColumns;
|
||||||
List<String> attachmentColumns;
|
List<String> attachmentColumns;
|
||||||
|
|||||||
@@ -46,8 +46,12 @@ public class ScriptMarketplaceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/scripts")
|
@GetMapping("/scripts")
|
||||||
public List<ScriptMarketplaceEntry> listMarketplaceScripts(@RequestParam(value = "query", required = false) String query) {
|
public List<ScriptMarketplaceEntry> listMarketplaceScripts(
|
||||||
return channelDirectoryService.listMarketplaceScripts(query);
|
@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")
|
@GetMapping("/scripts/{scriptId}/logo")
|
||||||
@@ -85,4 +89,16 @@ public class ScriptMarketplaceController {
|
|||||||
.map(ResponseEntity::ok)
|
.map(ResponseEntity::ok)
|
||||||
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to import script"));
|
.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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,5 +5,7 @@ public record ScriptMarketplaceEntry(
|
|||||||
String name,
|
String name,
|
||||||
String description,
|
String description,
|
||||||
String logoUrl,
|
String logoUrl,
|
||||||
String broadcaster
|
String broadcaster,
|
||||||
|
long heartsCount,
|
||||||
|
boolean hearted
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import dev.kruhlmann.imgfloat.model.CanvasEvent;
|
|||||||
import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest;
|
import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest;
|
||||||
import dev.kruhlmann.imgfloat.model.Channel;
|
import dev.kruhlmann.imgfloat.model.Channel;
|
||||||
import dev.kruhlmann.imgfloat.model.CodeAssetRequest;
|
import dev.kruhlmann.imgfloat.model.CodeAssetRequest;
|
||||||
|
import dev.kruhlmann.imgfloat.model.MarketplaceScriptHeart;
|
||||||
import dev.kruhlmann.imgfloat.model.PlaybackRequest;
|
import dev.kruhlmann.imgfloat.model.PlaybackRequest;
|
||||||
import dev.kruhlmann.imgfloat.model.ScriptAsset;
|
import dev.kruhlmann.imgfloat.model.ScriptAsset;
|
||||||
import dev.kruhlmann.imgfloat.model.ScriptAssetAttachment;
|
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.AssetRepository;
|
||||||
import dev.kruhlmann.imgfloat.repository.AudioAssetRepository;
|
import dev.kruhlmann.imgfloat.repository.AudioAssetRepository;
|
||||||
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
|
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
|
||||||
|
import dev.kruhlmann.imgfloat.repository.MarketplaceScriptHeartRepository;
|
||||||
import dev.kruhlmann.imgfloat.repository.ScriptAssetRepository;
|
import dev.kruhlmann.imgfloat.repository.ScriptAssetRepository;
|
||||||
import dev.kruhlmann.imgfloat.repository.ScriptAssetAttachmentRepository;
|
import dev.kruhlmann.imgfloat.repository.ScriptAssetAttachmentRepository;
|
||||||
import dev.kruhlmann.imgfloat.repository.ScriptAssetFileRepository;
|
import dev.kruhlmann.imgfloat.repository.ScriptAssetFileRepository;
|
||||||
@@ -66,6 +68,7 @@ public class ChannelDirectoryService {
|
|||||||
private final ScriptAssetRepository scriptAssetRepository;
|
private final ScriptAssetRepository scriptAssetRepository;
|
||||||
private final ScriptAssetAttachmentRepository scriptAssetAttachmentRepository;
|
private final ScriptAssetAttachmentRepository scriptAssetAttachmentRepository;
|
||||||
private final ScriptAssetFileRepository scriptAssetFileRepository;
|
private final ScriptAssetFileRepository scriptAssetFileRepository;
|
||||||
|
private final MarketplaceScriptHeartRepository marketplaceScriptHeartRepository;
|
||||||
private final SimpMessagingTemplate messagingTemplate;
|
private final SimpMessagingTemplate messagingTemplate;
|
||||||
private final AssetStorageService assetStorageService;
|
private final AssetStorageService assetStorageService;
|
||||||
private final MediaDetectionService mediaDetectionService;
|
private final MediaDetectionService mediaDetectionService;
|
||||||
@@ -83,6 +86,7 @@ public class ChannelDirectoryService {
|
|||||||
ScriptAssetRepository scriptAssetRepository,
|
ScriptAssetRepository scriptAssetRepository,
|
||||||
ScriptAssetAttachmentRepository scriptAssetAttachmentRepository,
|
ScriptAssetAttachmentRepository scriptAssetAttachmentRepository,
|
||||||
ScriptAssetFileRepository scriptAssetFileRepository,
|
ScriptAssetFileRepository scriptAssetFileRepository,
|
||||||
|
MarketplaceScriptHeartRepository marketplaceScriptHeartRepository,
|
||||||
SimpMessagingTemplate messagingTemplate,
|
SimpMessagingTemplate messagingTemplate,
|
||||||
AssetStorageService assetStorageService,
|
AssetStorageService assetStorageService,
|
||||||
MediaDetectionService mediaDetectionService,
|
MediaDetectionService mediaDetectionService,
|
||||||
@@ -98,6 +102,7 @@ public class ChannelDirectoryService {
|
|||||||
this.scriptAssetRepository = scriptAssetRepository;
|
this.scriptAssetRepository = scriptAssetRepository;
|
||||||
this.scriptAssetAttachmentRepository = scriptAssetAttachmentRepository;
|
this.scriptAssetAttachmentRepository = scriptAssetAttachmentRepository;
|
||||||
this.scriptAssetFileRepository = scriptAssetFileRepository;
|
this.scriptAssetFileRepository = scriptAssetFileRepository;
|
||||||
|
this.marketplaceScriptHeartRepository = marketplaceScriptHeartRepository;
|
||||||
this.messagingTemplate = messagingTemplate;
|
this.messagingTemplate = messagingTemplate;
|
||||||
this.assetStorageService = assetStorageService;
|
this.assetStorageService = assetStorageService;
|
||||||
this.mediaDetectionService = mediaDetectionService;
|
this.mediaDetectionService = mediaDetectionService;
|
||||||
@@ -430,7 +435,7 @@ public class ChannelDirectoryService {
|
|||||||
return Optional.of(view);
|
return Optional.of(view);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<ScriptMarketplaceEntry> listMarketplaceScripts(String query) {
|
public List<ScriptMarketplaceEntry> listMarketplaceScripts(String query, String sessionUsername) {
|
||||||
String q = normalizeDescription(query);
|
String q = normalizeDescription(query);
|
||||||
String normalizedQuery = q == null ? null : q.toLowerCase(Locale.ROOT);
|
String normalizedQuery = q == null ? null : q.toLowerCase(Locale.ROOT);
|
||||||
List<ScriptMarketplaceEntry> entries = new ArrayList<>(marketplaceScriptSeedLoader.listEntriesForQuery(normalizedQuery));
|
List<ScriptMarketplaceEntry> entries = new ArrayList<>(marketplaceScriptSeedLoader.listEntriesForQuery(normalizedQuery));
|
||||||
@@ -439,12 +444,7 @@ public class ChannelDirectoryService {
|
|||||||
scripts = scriptAssetRepository.findByIsPublicTrue();
|
scripts = scriptAssetRepository.findByIsPublicTrue();
|
||||||
} catch (DataAccessException ex) {
|
} catch (DataAccessException ex) {
|
||||||
logger.warn("Unable to load marketplace scripts", ex);
|
logger.warn("Unable to load marketplace scripts", ex);
|
||||||
return entries
|
return applyMarketplaceHearts(entries, sessionUsername);
|
||||||
.stream()
|
|
||||||
.sorted(
|
|
||||||
Comparator.comparing(ScriptMarketplaceEntry::name, Comparator.nullsLast(String::compareToIgnoreCase))
|
|
||||||
)
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
if (normalizedQuery != null && !normalizedQuery.isBlank()) {
|
if (normalizedQuery != null && !normalizedQuery.isBlank()) {
|
||||||
scripts =
|
scripts =
|
||||||
@@ -477,15 +477,145 @@ public class ChannelDirectoryService {
|
|||||||
script.getName(),
|
script.getName(),
|
||||||
script.getDescription(),
|
script.getDescription(),
|
||||||
logoUrl,
|
logoUrl,
|
||||||
broadcaster
|
broadcaster,
|
||||||
|
0,
|
||||||
|
false
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.toList()
|
.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
|
return entries
|
||||||
.stream()
|
.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();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,9 @@ public class MarketplaceScriptSeedLoader {
|
|||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
logoPath.isPresent() ? "/api/marketplace/scripts/" + id + "/logo" : null,
|
logoPath.isPresent() ? "/api/marketplace/scripts/" + id + "/logo" : null,
|
||||||
broadcaster
|
broadcaster,
|
||||||
|
0,
|
||||||
|
false
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -302,12 +302,30 @@
|
|||||||
white-space: nowrap;
|
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 {
|
.modal .modal-inner .marketplace-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: flex-end;
|
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,
|
.modal .modal-inner .marketplace-empty,
|
||||||
|
|||||||
@@ -750,7 +750,14 @@ export function createCustomAssetModal({
|
|||||||
marketplaceList.innerHTML = '<div class="marketplace-empty">No scripts found.</div>';
|
marketplaceList.innerHTML = '<div class="marketplace-empty">No scripts found.</div>';
|
||||||
return;
|
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");
|
const card = document.createElement("div");
|
||||||
card.className = "marketplace-card";
|
card.className = "marketplace-card";
|
||||||
|
|
||||||
@@ -775,17 +782,34 @@ export function createCustomAssetModal({
|
|||||||
description.textContent = entry.description || "No description provided.";
|
description.textContent = entry.description || "No description provided.";
|
||||||
const meta = document.createElement("small");
|
const meta = document.createElement("small");
|
||||||
meta.textContent = entry.broadcaster ? `By ${entry.broadcaster}` : "";
|
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(title);
|
||||||
content.appendChild(description);
|
content.appendChild(description);
|
||||||
content.appendChild(meta);
|
content.appendChild(meta);
|
||||||
|
content.appendChild(hearts);
|
||||||
|
|
||||||
const actions = document.createElement("div");
|
const actions = document.createElement("div");
|
||||||
actions.className = "marketplace-actions";
|
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");
|
const importButton = document.createElement("button");
|
||||||
importButton.type = "button";
|
importButton.type = "button";
|
||||||
importButton.className = "primary";
|
importButton.className = "icon-button";
|
||||||
importButton.textContent = "Import";
|
importButton.setAttribute("aria-label", "Import script");
|
||||||
|
importButton.innerHTML = '<i class="icon fa-solid fa-download"></i>';
|
||||||
importButton.addEventListener("click", () => importMarketplaceScript(entry));
|
importButton.addEventListener("click", () => importMarketplaceScript(entry));
|
||||||
|
actions.appendChild(heartButton);
|
||||||
actions.appendChild(importButton);
|
actions.appendChild(importButton);
|
||||||
|
|
||||||
card.appendChild(content);
|
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) {
|
function debounce(fn, wait = 150) {
|
||||||
let timeout;
|
let timeout;
|
||||||
return (...args) => {
|
return (...args) => {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import dev.kruhlmann.imgfloat.model.VisibilityRequest;
|
|||||||
import dev.kruhlmann.imgfloat.repository.AssetRepository;
|
import dev.kruhlmann.imgfloat.repository.AssetRepository;
|
||||||
import dev.kruhlmann.imgfloat.repository.AudioAssetRepository;
|
import dev.kruhlmann.imgfloat.repository.AudioAssetRepository;
|
||||||
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
|
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
|
||||||
|
import dev.kruhlmann.imgfloat.repository.MarketplaceScriptHeartRepository;
|
||||||
import dev.kruhlmann.imgfloat.repository.ScriptAssetRepository;
|
import dev.kruhlmann.imgfloat.repository.ScriptAssetRepository;
|
||||||
import dev.kruhlmann.imgfloat.repository.ScriptAssetAttachmentRepository;
|
import dev.kruhlmann.imgfloat.repository.ScriptAssetAttachmentRepository;
|
||||||
import dev.kruhlmann.imgfloat.repository.ScriptAssetFileRepository;
|
import dev.kruhlmann.imgfloat.repository.ScriptAssetFileRepository;
|
||||||
@@ -61,6 +62,7 @@ class ChannelDirectoryServiceTest {
|
|||||||
private ScriptAssetRepository scriptAssetRepository;
|
private ScriptAssetRepository scriptAssetRepository;
|
||||||
private ScriptAssetAttachmentRepository scriptAssetAttachmentRepository;
|
private ScriptAssetAttachmentRepository scriptAssetAttachmentRepository;
|
||||||
private ScriptAssetFileRepository scriptAssetFileRepository;
|
private ScriptAssetFileRepository scriptAssetFileRepository;
|
||||||
|
private MarketplaceScriptHeartRepository marketplaceScriptHeartRepository;
|
||||||
private SettingsService settingsService;
|
private SettingsService settingsService;
|
||||||
private MarketplaceScriptSeedLoader marketplaceScriptSeedLoader;
|
private MarketplaceScriptSeedLoader marketplaceScriptSeedLoader;
|
||||||
|
|
||||||
@@ -74,6 +76,9 @@ class ChannelDirectoryServiceTest {
|
|||||||
scriptAssetRepository = mock(ScriptAssetRepository.class);
|
scriptAssetRepository = mock(ScriptAssetRepository.class);
|
||||||
scriptAssetAttachmentRepository = mock(ScriptAssetAttachmentRepository.class);
|
scriptAssetAttachmentRepository = mock(ScriptAssetAttachmentRepository.class);
|
||||||
scriptAssetFileRepository = mock(ScriptAssetFileRepository.class);
|
scriptAssetFileRepository = mock(ScriptAssetFileRepository.class);
|
||||||
|
marketplaceScriptHeartRepository = mock(MarketplaceScriptHeartRepository.class);
|
||||||
|
when(marketplaceScriptHeartRepository.countByScriptIds(any())).thenReturn(List.of());
|
||||||
|
when(marketplaceScriptHeartRepository.findByUsernameAndScriptIdIn(anyString(), any())).thenReturn(List.of());
|
||||||
settingsService = mock(SettingsService.class);
|
settingsService = mock(SettingsService.class);
|
||||||
when(settingsService.get()).thenReturn(Settings.defaults());
|
when(settingsService.get()).thenReturn(Settings.defaults());
|
||||||
setupInMemoryPersistence();
|
setupInMemoryPersistence();
|
||||||
@@ -109,6 +114,7 @@ class ChannelDirectoryServiceTest {
|
|||||||
scriptAssetRepository,
|
scriptAssetRepository,
|
||||||
scriptAssetAttachmentRepository,
|
scriptAssetAttachmentRepository,
|
||||||
scriptAssetFileRepository,
|
scriptAssetFileRepository,
|
||||||
|
marketplaceScriptHeartRepository,
|
||||||
messagingTemplate,
|
messagingTemplate,
|
||||||
assetStorageService,
|
assetStorageService,
|
||||||
mediaDetectionService,
|
mediaDetectionService,
|
||||||
@@ -201,7 +207,7 @@ class ChannelDirectoryServiceTest {
|
|||||||
void includesDefaultMarketplaceScript() {
|
void includesDefaultMarketplaceScript() {
|
||||||
when(scriptAssetRepository.findByIsPublicTrue()).thenReturn(List.of());
|
when(scriptAssetRepository.findByIsPublicTrue()).thenReturn(List.of());
|
||||||
|
|
||||||
List<dev.kruhlmann.imgfloat.model.ScriptMarketplaceEntry> entries = service.listMarketplaceScripts(null);
|
List<dev.kruhlmann.imgfloat.model.ScriptMarketplaceEntry> entries = service.listMarketplaceScripts(null, null);
|
||||||
|
|
||||||
assertThat(entries)
|
assertThat(entries)
|
||||||
.anyMatch((entry) -> "rotating-logo".equals(entry.id()));
|
.anyMatch((entry) -> "rotating-logo".equals(entry.id()));
|
||||||
|
|||||||
Reference in New Issue
Block a user