This commit is contained in:
@@ -25,7 +25,7 @@
|
|||||||
- Upload / direct download flow is implemented and broadly usable.
|
- Upload / direct download flow is implemented and broadly usable.
|
||||||
- Search is implemented end-to-end and now refactored into source-specific collectors.
|
- Search is implemented end-to-end and now refactored into source-specific collectors.
|
||||||
- Search remains the main unstable subsystem.
|
- Search remains the main unstable subsystem.
|
||||||
- Envato metadata and preview extraction are much stronger than before.
|
- Envato metadata and preview extraction are much stronger than before, including additional hydration-data preview fallback.
|
||||||
- Artgrid metadata fidelity is improved, but stable public hover-video preview extraction is still not solved.
|
- Artgrid metadata fidelity is improved, but stable public hover-video preview extraction is still not solved.
|
||||||
- Frontend now logs more useful API and debug information than earlier versions.
|
- Frontend now logs more useful API and debug information than earlier versions.
|
||||||
- A local self-test workflow now exists and should be run before container builds or pushes.
|
- A local self-test workflow now exists and should be run before container builds or pushes.
|
||||||
@@ -182,10 +182,13 @@
|
|||||||
- detach source on `mouseleave`
|
- detach source on `mouseleave`
|
||||||
- Added in-app result viewer modal for search results:
|
- Added in-app result viewer modal for search results:
|
||||||
- results now open in a modal instead of directly opening a new tab
|
- results now open in a modal instead of directly opening a new tab
|
||||||
- modal shows embedded site iframe, external open button, source summary, and full AI note
|
- modal now prefers internal preview media over embedded third-party iframes to avoid embed blocking
|
||||||
|
- external open button remains available
|
||||||
- Google Video results can now jump directly into the existing direct-download preview / crop flow from the result viewer
|
- Google Video results can now jump directly into the existing direct-download preview / crop flow from the result viewer
|
||||||
- Gemini reason generation is now intended to be Korean-first for readability
|
- Gemini reason generation is now intended to be Korean-first for readability
|
||||||
- Gemini Vision evaluation now covers all ranked results instead of only a top subset
|
- Gemini Vision evaluation now covers all ranked results instead of only a top subset
|
||||||
|
- Search results now prioritize AI note text visually ahead of source summary
|
||||||
|
- Search query order and final top results now include light randomness so repeated searches are less static
|
||||||
|
|
||||||
## Current Features Implemented
|
## Current Features Implemented
|
||||||
- [x] Project folder structure
|
- [x] Project folder structure
|
||||||
@@ -212,7 +215,7 @@
|
|||||||
## Important Current Constraints / Known Problems
|
## Important Current Constraints / Known Problems
|
||||||
- Search backend quality is still the most fragile subsystem.
|
- Search backend quality is still the most fragile subsystem.
|
||||||
- Search relevance is still heuristic-heavy and not yet benchmarked against a durable real-query set.
|
- Search relevance is still heuristic-heavy and not yet benchmarked against a durable real-query set.
|
||||||
- Embedded result viewer uses an iframe, so some third-party sites may still block embedding with `X-Frame-Options` / CSP.
|
- Embedded third-party result viewing is no longer relied on because many providers block iframe embedding with `X-Frame-Options` / CSP.
|
||||||
- Artgrid hover-video preview is still partial / unresolved:
|
- Artgrid hover-video preview is still partial / unresolved:
|
||||||
- provided Artgrid HTML snapshots and downloaded asset bundles did not expose a stable public preview mp4/m3u8 URL
|
- provided Artgrid HTML snapshots and downloaded asset bundles did not expose a stable public preview mp4/m3u8 URL
|
||||||
- public HTML often only exposes title / description / thumbnail / canonical URL
|
- public HTML often only exposes title / description / thumbnail / canonical URL
|
||||||
@@ -251,6 +254,29 @@
|
|||||||
- promise rejections
|
- promise rejections
|
||||||
- backend debug broadcasts
|
- backend debug broadcasts
|
||||||
|
|
||||||
|
## Recent Change Log
|
||||||
|
- Date: `2026-03-16`
|
||||||
|
- What changed:
|
||||||
|
- Envato preview extraction now also inspects `INITIAL_HYDRATION_DATA` when direct page meta / JSON-LD preview URLs are missing.
|
||||||
|
- Search result cards and result modal now surface AI note before source summary text.
|
||||||
|
- Google Video direct download action moved into the AI note area and now seeds Zone C input before opening the shared preview/download modal.
|
||||||
|
- Result modal no longer depends on third-party iframe embedding and instead shows internal preview media plus external-open fallback.
|
||||||
|
- Search flow now shuffles collector query order and lightly randomizes the top merged results to reduce identical repeated outputs.
|
||||||
|
- Why it changed:
|
||||||
|
- Some Envato items still missed preview URLs.
|
||||||
|
- Third-party iframe embedding was failing for blocked sites and creating a poor modal experience.
|
||||||
|
- The user wanted AI note to be the primary explanatory text and Google Video download action to be more obvious.
|
||||||
|
- Repeated searches returning the same ordering made the discovery experience feel too static.
|
||||||
|
- How it was verified:
|
||||||
|
- `go test ./...`
|
||||||
|
- `python3 -m py_compile worker/downloader.py scripts/mock_searxng.py`
|
||||||
|
- `bash scripts/selftest.sh`
|
||||||
|
- added unit coverage for Envato hydration preview extraction
|
||||||
|
- What is still risky or incomplete:
|
||||||
|
- Envato hydration structure could change again, so this fallback is still heuristic.
|
||||||
|
- Full browser-level validation of the revised result modal and button placement was not fully reproducible in this environment.
|
||||||
|
- Search randomness currently changes ordering and query traversal, but does not guarantee materially different source pools if upstream SearXNG returns a narrow candidate set.
|
||||||
|
|
||||||
## Current Environment Variables
|
## Current Environment Variables
|
||||||
- `APP_ROOT`
|
- `APP_ROOT`
|
||||||
- `APP_ADDR`
|
- `APP_ADDR`
|
||||||
|
|||||||
@@ -333,6 +333,7 @@ func (a *App) searchMedia(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
merged := services.MergeRecommendations(recommended, scored, 20)
|
merged := services.MergeRecommendations(recommended, scored, 20)
|
||||||
|
merged = services.RandomizeTopRecommendations(merged, 8)
|
||||||
warning := ""
|
warning := ""
|
||||||
if geminiErr != nil {
|
if geminiErr != nil {
|
||||||
warning = geminiErr.Error()
|
warning = geminiErr.Error()
|
||||||
|
|||||||
+56
-1
@@ -1,9 +1,11 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -63,6 +65,7 @@ func (s *SearchService) SearchMedia(queries []string, enabledPlatforms map[strin
|
|||||||
var lastErr error
|
var lastErr error
|
||||||
|
|
||||||
baseQueries := limitQueries(queries, 6)
|
baseQueries := limitQueries(queries, 6)
|
||||||
|
shuffleStrings(baseQueries)
|
||||||
primaryQueries := baseQueries[:minInt(len(baseQueries), 3)]
|
primaryQueries := baseQueries[:minInt(len(baseQueries), 3)]
|
||||||
runSearchPass := func(bases []string, onlyMissing bool) {
|
runSearchPass := func(bases []string, onlyMissing bool) {
|
||||||
for _, base := range bases {
|
for _, base := range bases {
|
||||||
@@ -80,7 +83,9 @@ func (s *SearchService) SearchMedia(queries []string, enabledPlatforms map[strin
|
|||||||
if onlyMissing && sourceCounts[collector.Name()] > 0 {
|
if onlyMissing && sourceCounts[collector.Name()] > 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, searchQuery := range collector.BuildQueries(base) {
|
searchQueries := collector.BuildQueries(base)
|
||||||
|
shuffleStrings(searchQueries)
|
||||||
|
for _, searchQuery := range searchQueries {
|
||||||
if sourceCounts[collector.Name()] >= collector.MaxResults() {
|
if sourceCounts[collector.Name()] >= collector.MaxResults() {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -201,6 +206,7 @@ func (s *SearchService) enrichEnvato(result SearchResult) SearchResult {
|
|||||||
extractJSONLDValue(html, "contentUrl"),
|
extractJSONLDValue(html, "contentUrl"),
|
||||||
extractMetaContent(html, "twitter:player:stream"),
|
extractMetaContent(html, "twitter:player:stream"),
|
||||||
extractVideoPreviewURL(html),
|
extractVideoPreviewURL(html),
|
||||||
|
extractEnvatoPreviewFromHydration(html),
|
||||||
deriveEnvatoPreviewFromThumbnail(pageThumbnail),
|
deriveEnvatoPreviewFromThumbnail(pageThumbnail),
|
||||||
deriveEnvatoPreviewFromThumbnail(result.ThumbnailURL),
|
deriveEnvatoPreviewFromThumbnail(result.ThumbnailURL),
|
||||||
)
|
)
|
||||||
@@ -801,6 +807,45 @@ func deriveEnvatoPreviewFromThumbnail(thumbnail string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractEnvatoPreviewFromHydration(html string) string {
|
||||||
|
encoded := extractWindowAssignedValue(html, "INITIAL_HYDRATION_DATA")
|
||||||
|
if encoded == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
urls := collectURLs(string(decoded))
|
||||||
|
return pickBestEnvatoPreviewURL(urls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractWindowAssignedValue(html, variable string) string {
|
||||||
|
pattern := regexp.MustCompile(`window\.` + regexp.QuoteMeta(variable) + `\s*=\s*"([^"]+)"`)
|
||||||
|
matches := pattern.FindStringSubmatch(html)
|
||||||
|
if len(matches) == 2 {
|
||||||
|
return matches[1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickBestEnvatoPreviewURL(urls []string) string {
|
||||||
|
for _, item := range urls {
|
||||||
|
lower := strings.ToLower(item)
|
||||||
|
if strings.Contains(lower, "video-previews.elements.envatousercontent.com") && strings.Contains(lower, "watermarked_preview") && strings.HasSuffix(lower, ".mp4") {
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range urls {
|
||||||
|
lower := strings.ToLower(item)
|
||||||
|
if strings.Contains(lower, "envatousercontent.com") && strings.Contains(lower, "watermarked_preview") && strings.HasSuffix(lower, ".mp4") {
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func newBrowserRequest(method, target, accept string) (*http.Request, error) {
|
func newBrowserRequest(method, target, accept string) (*http.Request, error) {
|
||||||
req, err := http.NewRequest(method, target, nil)
|
req, err := http.NewRequest(method, target, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -867,6 +912,16 @@ func limitQueries(queries []string, limit int) []string {
|
|||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shuffleStrings(values []string) {
|
||||||
|
if len(values) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||||
|
rng.Shuffle(len(values), func(i, j int) {
|
||||||
|
values[i], values[j] = values[j], values[i]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func htmlUnescape(text string) string {
|
func htmlUnescape(text string) string {
|
||||||
replacer := strings.NewReplacer("&", "&", """, `"`, "'", "'", "<", "<", ">", ">")
|
replacer := strings.NewReplacer("&", "&", """, `"`, "'", "'", "<", "<", ">", ">")
|
||||||
return replacer.Replace(text)
|
return replacer.Replace(text)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -23,6 +24,16 @@ func TestDeriveEnvatoPreviewFromThumbnail(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractEnvatoPreviewFromHydration(t *testing.T) {
|
||||||
|
payload := `{"contentUrl":"https://video-previews.elements.envatousercontent.com/example/watermarked_preview/watermarked_preview.mp4"}`
|
||||||
|
html := `<script>window.INITIAL_HYDRATION_DATA="` + base64.StdEncoding.EncodeToString([]byte(payload)) + `";window.INITIAL_HYDRATION_DATA_ENCODED=true;</script>`
|
||||||
|
got := extractEnvatoPreviewFromHydration(html)
|
||||||
|
want := "https://video-previews.elements.envatousercontent.com/example/watermarked_preview/watermarked_preview.mp4"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("expected %q, got %q", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIsUsefulGoogleVideoResultRejectsMusicResults(t *testing.T) {
|
func TestIsUsefulGoogleVideoResultRejectsMusicResults(t *testing.T) {
|
||||||
result := SearchResult{
|
result := SearchResult{
|
||||||
Title: "Couple Friendly Sad Bgm Movie Best Bgm",
|
Title: "Couple Friendly Sad Bgm Movie Best Bgm",
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const GeminiFallbackReason = "Gemini Vision 응답이 부족해 키워드 기준으로 보강된 결과입니다."
|
const GeminiFallbackReason = "Gemini Vision 응답이 부족해 키워드 기준으로 보강된 결과입니다."
|
||||||
@@ -193,6 +195,21 @@ func BuildFallbackRecommendations(ranked []SearchResult, limit int, reason strin
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RandomizeTopRecommendations(items []AIRecommendation, window int) []AIRecommendation {
|
||||||
|
if len(items) < 2 || window < 2 {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := min(window, len(items))
|
||||||
|
shuffled := make([]AIRecommendation, len(items))
|
||||||
|
copy(shuffled, items)
|
||||||
|
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||||
|
rng.Shuffle(limit, func(i, j int) {
|
||||||
|
shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
|
||||||
|
})
|
||||||
|
return shuffled
|
||||||
|
}
|
||||||
|
|
||||||
func MergeRecommendations(recommended []AIRecommendation, ranked []SearchResult, limit int) []AIRecommendation {
|
func MergeRecommendations(recommended []AIRecommendation, ranked []SearchResult, limit int) []AIRecommendation {
|
||||||
merged := make([]AIRecommendation, 0, min(limit, len(ranked)))
|
merged := make([]AIRecommendation, 0, min(limit, len(ranked)))
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
|
|||||||
+39
-6
@@ -42,10 +42,13 @@ const resultModalTitle = document.getElementById("resultModalTitle");
|
|||||||
const resultModalSource = document.getElementById("resultModalSource");
|
const resultModalSource = document.getElementById("resultModalSource");
|
||||||
const resultModalSnippet = document.getElementById("resultModalSnippet");
|
const resultModalSnippet = document.getElementById("resultModalSnippet");
|
||||||
const resultModalReason = document.getElementById("resultModalReason");
|
const resultModalReason = document.getElementById("resultModalReason");
|
||||||
const resultModalFrame = document.getElementById("resultModalFrame");
|
|
||||||
const resultModalOpenExternal = document.getElementById("resultModalOpenExternal");
|
const resultModalOpenExternal = document.getElementById("resultModalOpenExternal");
|
||||||
const resultModalDownload = document.getElementById("resultModalDownload");
|
const resultModalDownload = document.getElementById("resultModalDownload");
|
||||||
const closeResultModal = document.getElementById("closeResultModal");
|
const closeResultModal = document.getElementById("closeResultModal");
|
||||||
|
const resultModalMediaFrame = document.getElementById("resultModalMediaFrame");
|
||||||
|
const resultModalVideo = document.getElementById("resultModalVideo");
|
||||||
|
const resultModalThumbnail = document.getElementById("resultModalThumbnail");
|
||||||
|
const resultModalEmbedNotice = document.getElementById("resultModalEmbedNotice");
|
||||||
|
|
||||||
let pendingDownload = null;
|
let pendingDownload = null;
|
||||||
let cropStart = 0;
|
let cropStart = 0;
|
||||||
@@ -345,6 +348,15 @@ function hideModal(element) {
|
|||||||
setHidden(element, true);
|
setHidden(element, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetResultModalMedia() {
|
||||||
|
resultModalVideo.pause();
|
||||||
|
detachVideoSource(resultModalVideo);
|
||||||
|
resultModalMediaFrame.style.aspectRatio = "";
|
||||||
|
setHidden(resultModalVideo, true, "");
|
||||||
|
setHidden(resultModalThumbnail, true, "");
|
||||||
|
setHidden(resultModalEmbedNotice, false, "");
|
||||||
|
}
|
||||||
|
|
||||||
function renderResults(results) {
|
function renderResults(results) {
|
||||||
searchResults.innerHTML = "";
|
searchResults.innerHTML = "";
|
||||||
if (!results.length) {
|
if (!results.length) {
|
||||||
@@ -359,8 +371,8 @@ function renderResults(results) {
|
|||||||
image.src = item.thumbnailUrl || PREVIEW_PLACEHOLDER;
|
image.src = item.thumbnailUrl || PREVIEW_PLACEHOLDER;
|
||||||
image.alt = item.title;
|
image.alt = item.title;
|
||||||
node.querySelector("h3").textContent = item.title;
|
node.querySelector("h3").textContent = item.title;
|
||||||
node.querySelector(".result-snippet").textContent = item.snippet || item.reason || item.source || "";
|
node.querySelector(".result-snippet").textContent = item.reason || item.snippet || item.source || "";
|
||||||
node.querySelector(".result-reason").textContent = item.reason ? `AI 노트: ${item.reason}` : "";
|
node.querySelector(".result-reason").textContent = item.snippet ? `Source: ${item.snippet}` : "";
|
||||||
node.querySelector(".source-badge").textContent = item.source;
|
node.querySelector(".source-badge").textContent = item.source;
|
||||||
node.addEventListener("click", () => openResultModal(item));
|
node.addEventListener("click", () => openResultModal(item));
|
||||||
previewVideo.poster = item.thumbnailUrl || "";
|
previewVideo.poster = item.thumbnailUrl || "";
|
||||||
@@ -385,6 +397,7 @@ function renderResults(results) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function prepareDirectDownload(targetUrl) {
|
async function prepareDirectDownload(targetUrl) {
|
||||||
|
downloadUrl.value = targetUrl;
|
||||||
downloadResult.textContent = "checking duplicate history...";
|
downloadResult.textContent = "checking duplicate history...";
|
||||||
const dup = await api(`/api/history/check?url=${encodeURIComponent(targetUrl)}`);
|
const dup = await api(`/api/history/check?url=${encodeURIComponent(targetUrl)}`);
|
||||||
let force = false;
|
let force = false;
|
||||||
@@ -410,12 +423,22 @@ function openResultModal(item) {
|
|||||||
activeResultItem = item;
|
activeResultItem = item;
|
||||||
resultModalTitle.textContent = item.title || "Untitled";
|
resultModalTitle.textContent = item.title || "Untitled";
|
||||||
resultModalSource.textContent = item.source || "";
|
resultModalSource.textContent = item.source || "";
|
||||||
resultModalSnippet.textContent = item.snippet || "원본 페이지에서 사용할 수 있는 설명이 없습니다.";
|
|
||||||
resultModalReason.textContent = item.reason || "AI 노트가 없습니다.";
|
resultModalReason.textContent = item.reason || "AI 노트가 없습니다.";
|
||||||
resultModalFrame.src = item.link || "about:blank";
|
resultModalSnippet.textContent = item.snippet || "원본 페이지에서 사용할 수 있는 설명이 없습니다.";
|
||||||
resultModalOpenExternal.href = item.link || "#";
|
resultModalOpenExternal.href = item.link || "#";
|
||||||
const canDirectDownload = item.source === "Google Video" && item.link;
|
const canDirectDownload = item.source === "Google Video" && item.link;
|
||||||
resultModalDownload.classList.toggle("hidden", !canDirectDownload);
|
resultModalDownload.classList.toggle("hidden", !canDirectDownload);
|
||||||
|
resetResultModalMedia();
|
||||||
|
if (item.previewVideoUrl) {
|
||||||
|
attachVideoSource(resultModalVideo, item.previewVideoUrl);
|
||||||
|
setHidden(resultModalVideo, false, "");
|
||||||
|
setHidden(resultModalEmbedNotice, true, "");
|
||||||
|
} else if (item.thumbnailUrl) {
|
||||||
|
resultModalThumbnail.src = item.thumbnailUrl;
|
||||||
|
resultModalThumbnail.alt = item.title || "";
|
||||||
|
setHidden(resultModalThumbnail, false, "");
|
||||||
|
setHidden(resultModalEmbedNotice, true, "");
|
||||||
|
}
|
||||||
showModal(resultModal);
|
showModal(resultModal);
|
||||||
logEvent("result:modal:open", { title: item.title, source: item.source, link: item.link });
|
logEvent("result:modal:open", { title: item.title, source: item.source, link: item.link });
|
||||||
}
|
}
|
||||||
@@ -425,7 +448,7 @@ function closeResultViewer() {
|
|||||||
logEvent("result:modal:close", { title: activeResultItem?.title || "" });
|
logEvent("result:modal:close", { title: activeResultItem?.title || "" });
|
||||||
}
|
}
|
||||||
activeResultItem = null;
|
activeResultItem = null;
|
||||||
resultModalFrame.src = "about:blank";
|
resetResultModalMedia();
|
||||||
hideModal(resultModal);
|
hideModal(resultModal);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,6 +663,16 @@ previewThumbnail.addEventListener("load", () => {
|
|||||||
previewMediaFrame.style.aspectRatio = `${previewThumbnail.naturalWidth} / ${previewThumbnail.naturalHeight}`;
|
previewMediaFrame.style.aspectRatio = `${previewThumbnail.naturalWidth} / ${previewThumbnail.naturalHeight}`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
resultModalVideo.addEventListener("loadedmetadata", () => {
|
||||||
|
if (resultModalVideo.videoWidth > 0 && resultModalVideo.videoHeight > 0) {
|
||||||
|
resultModalMediaFrame.style.aspectRatio = `${resultModalVideo.videoWidth} / ${resultModalVideo.videoHeight}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resultModalThumbnail.addEventListener("load", () => {
|
||||||
|
if (!resultModalVideo.src && resultModalThumbnail.naturalWidth > 0 && resultModalThumbnail.naturalHeight > 0) {
|
||||||
|
resultModalMediaFrame.style.aspectRatio = `${resultModalThumbnail.naturalWidth} / ${resultModalThumbnail.naturalHeight}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
for (const button of platformToggles) {
|
for (const button of platformToggles) {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
const platform = button.dataset.platformToggle;
|
const platform = button.dataset.platformToggle;
|
||||||
|
|||||||
+14
-6
@@ -158,23 +158,31 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<a id="resultModalOpenExternal" target="_blank" rel="noreferrer" class="rounded-full border border-white/10 px-3 py-2 text-xs uppercase tracking-[0.2em] text-zinc-300">Open</a>
|
<a id="resultModalOpenExternal" target="_blank" rel="noreferrer" class="rounded-full border border-white/10 px-3 py-2 text-xs uppercase tracking-[0.2em] text-zinc-300">Open</a>
|
||||||
<button id="resultModalDownload" type="button" class="hidden rounded-full border border-white/10 px-3 py-2 text-xs uppercase tracking-[0.2em] text-zinc-300">Direct Download</button>
|
|
||||||
<button id="closeResultModal" type="button" class="rounded-full border border-white/10 px-3 py-2 text-xs uppercase tracking-[0.2em] text-zinc-300">Close</button>
|
<button id="closeResultModal" type="button" class="rounded-full border border-white/10 px-3 py-2 text-xs uppercase tracking-[0.2em] text-zinc-300">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid min-h-0 flex-1 gap-0 lg:grid-cols-[1.5fr_0.85fr]">
|
<div class="grid min-h-0 flex-1 gap-0 lg:grid-cols-[1.5fr_0.85fr]">
|
||||||
<div class="min-h-0 border-b border-white/10 lg:border-b-0 lg:border-r">
|
<div class="min-h-0 border-b border-white/10 lg:border-b-0 lg:border-r">
|
||||||
<iframe id="resultModalFrame" class="h-full w-full bg-white" referrerpolicy="no-referrer"></iframe>
|
<div id="resultModalMediaFrame" class="flex h-full min-h-[320px] items-center justify-center overflow-hidden bg-black/40 p-4">
|
||||||
|
<video id="resultModalVideo" class="hidden max-h-full w-full rounded-2xl bg-black object-contain" controls playsinline></video>
|
||||||
|
<img id="resultModalThumbnail" class="hidden max-h-full w-full rounded-2xl object-contain" alt="" />
|
||||||
|
<div id="resultModalEmbedNotice" class="max-w-md rounded-3xl border border-white/10 bg-white/[0.03] p-6 text-center text-sm leading-7 text-zinc-300">
|
||||||
|
외부 사이트 임베딩 차단 문제를 피하기 위해 내부 미리보기만 표시합니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-h-0 overflow-auto px-5 py-5">
|
<div class="min-h-0 overflow-auto px-5 py-5">
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
<div>
|
|
||||||
<p class="text-xs uppercase tracking-[0.25em] text-zinc-500">Source Summary</p>
|
|
||||||
<p id="resultModalSnippet" class="mt-2 text-sm leading-7 text-zinc-300"></p>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs uppercase tracking-[0.25em] text-zinc-500">AI Note</p>
|
<p class="text-xs uppercase tracking-[0.25em] text-zinc-500">AI Note</p>
|
||||||
<p id="resultModalReason" class="mt-2 whitespace-pre-wrap text-sm leading-7 text-zinc-200"></p>
|
<p id="resultModalReason" class="mt-2 whitespace-pre-wrap text-sm leading-7 text-zinc-200"></p>
|
||||||
|
<button id="resultModalDownload" type="button" class="mt-4 hidden w-full rounded-2xl border border-white bg-white px-4 py-3 text-sm font-medium text-black transition hover:bg-zinc-200">
|
||||||
|
Direct Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-[0.25em] text-zinc-500">Source Summary</p>
|
||||||
|
<p id="resultModalSnippet" class="mt-2 text-sm leading-7 text-zinc-300"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user