diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 8bc0394..b58009b 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -14,6 +14,9 @@ on: env: DOTNET_NOLOGO: "true" DOTNET_CLI_TELEMETRY_OPTOUT: "true" + # 릴리스 자산은 공개 배포 저장소로 업로드한다(소스 저장소는 비공개라 익명 다운로드 불가). + # draft 상태는 익명에게 보이지 않으므로 QA 후 수동 Publish 흐름은 그대로다. + RELEASE_REPO: "Video-Downloader/releases" jobs: # ── 관문: 전 플랫폼 빌드 전에 단위+E2E 통과 ── diff --git a/.gitignore b/.gitignore index 6e1a581..fe8d4bf 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,8 @@ CLAUDE.local.md ._* Thumbs.db desktop.ini + +# ── 배포 사이트 산출물 (deploy-site 가 배포 시점에 생성) ───────────── +site/releases.json +.playwright-mcp/ +.wrangler/ diff --git a/build/ci/upload-release-asset.ps1 b/build/ci/upload-release-asset.ps1 index 483a3e3..f65efa5 100644 --- a/build/ci/upload-release-asset.ps1 +++ b/build/ci/upload-release-asset.ps1 @@ -1,6 +1,7 @@ # Forgejo 릴리스(draft)에 산출물을 업로드한다. 릴리스가 없으면 만들고, 동명 자산은 교체한다(재실행 멱등). # 사용: ./build/ci/upload-release-asset.ps1 -Tag v1.0.0 -Files out/a.exe, out/b.exe # 필요 환경: RELEASE_TOKEN + (Actions 기본 제공) GITHUB_SERVER_URL, GITHUB_REPOSITORY +# RELEASE_REPO 를 주면 해당 저장소로 업로드(공개 배포 저장소용), 없으면 현재 저장소 # RELEASE_PRERELEASE=true 면 prerelease 로 생성(리허설용) param( [Parameter(Mandatory)][string]$Tag, @@ -8,7 +9,8 @@ param( ) $ErrorActionPreference = 'Stop' if (-not $env:RELEASE_TOKEN) { throw "RELEASE_TOKEN 환경변수가 없습니다" } -$api = "$($env:GITHUB_SERVER_URL.TrimEnd('/'))/api/v1/repos/$($env:GITHUB_REPOSITORY)" +$repo = if ($env:RELEASE_REPO) { $env:RELEASE_REPO } else { $env:GITHUB_REPOSITORY } +$api = "$($env:GITHUB_SERVER_URL.TrimEnd('/'))/api/v1/repos/$repo" $hdr = @{ Authorization = "token $env:RELEASE_TOKEN" } # draft 릴리스는 tags/ 단건 조회에 안 잡힐 수 있어 목록에서 tag_name 으로 찾는다 diff --git a/build/ci/upload-release-asset.sh b/build/ci/upload-release-asset.sh index 54ece8c..75077ee 100755 --- a/build/ci/upload-release-asset.sh +++ b/build/ci/upload-release-asset.sh @@ -2,12 +2,13 @@ # Forgejo 릴리스(draft)에 산출물을 업로드한다. 릴리스가 없으면 만들고, 동명 자산은 교체한다(재실행 멱등). # 사용: build/ci/upload-release-asset.sh <릴리스태그> <파일...> # 필요 환경: RELEASE_TOKEN + (Actions 기본 제공) GITHUB_SERVER_URL, GITHUB_REPOSITORY +# RELEASE_REPO 를 주면 해당 저장소로 업로드(공개 배포 저장소용), 없으면 현재 저장소 # RELEASE_PRERELEASE=true 면 prerelease 로 생성(리허설용) set -euo pipefail [ $# -ge 2 ] || { echo "사용법: $0 <태그> <파일...>" >&2; exit 2; } [ -n "${RELEASE_TOKEN:-}" ] || { echo "RELEASE_TOKEN 환경변수가 없습니다" >&2; exit 2; } tag="$1"; shift -api="${GITHUB_SERVER_URL%/}/api/v1/repos/${GITHUB_REPOSITORY}" +api="${GITHUB_SERVER_URL%/}/api/v1/repos/${RELEASE_REPO:-$GITHUB_REPOSITORY}" auth="Authorization: token ${RELEASE_TOKEN}" # draft 릴리스는 tags/ 단건 조회에 안 잡힐 수 있어 목록에서 tag_name 으로 찾는다 diff --git a/build/deploy-site.ps1 b/build/deploy-site.ps1 new file mode 100644 index 0000000..a54d59a --- /dev/null +++ b/build/deploy-site.ps1 @@ -0,0 +1,29 @@ +# MEDIABROWSER 사이트(site/)를 Cloudflare Pages 에 배포한다. +# 사용: ./build/deploy-site.ps1 [-Project mediabrowser] +# 필요: .env 의 CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID · node/npx(wrangler) +# 절차: 공개 릴리스 저장소에서 releases.json 생성 → wrangler pages deploy +param([string]$Project = "mediabrowser") + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot + +# .env 로드 (값은 출력하지 않는다) +$envFile = Join-Path $root ".env" +if (-not (Test-Path $envFile)) { throw ".env 가 없습니다. .env.example 을 복사해 채우세요." } +foreach ($line in Get-Content $envFile) { + if ($line -match '^\s*(CLOUDFLARE_API_TOKEN|CLOUDFLARE_ACCOUNT_ID)\s*=\s*(.+)$') { + Set-Item "env:$($Matches[1])" $Matches[2].Trim() + } +} +if (-not $env:CLOUDFLARE_API_TOKEN) { throw ".env 에 CLOUDFLARE_API_TOKEN 이 없습니다." } +if (-not $env:CLOUDFLARE_ACCOUNT_ID) { throw ".env 에 CLOUDFLARE_ACCOUNT_ID 가 없습니다." } + +Write-Host "1) releases.json 생성" -ForegroundColor Cyan +node (Join-Path $root "site\fetch-releases.mjs") +if ($LASTEXITCODE -ne 0) { throw "releases.json 생성 실패" } + +Write-Host "2) Cloudflare Pages 배포 ($Project)" -ForegroundColor Cyan +npx wrangler pages deploy (Join-Path $root "site") --project-name $Project --branch main --commit-dirty=true +if ($LASTEXITCODE -ne 0) { throw "wrangler 배포 실패" } + +Write-Host "완료: https://$Project.pages.dev" -ForegroundColor Green diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index bda5070..a130d7b 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -259,3 +259,25 @@ - 리허설 1차(b05e8bf): test **453/453 통과**(실창 E2E 포함 — 러너가 사용자 세션이라 창 생성 가능) · windows/linux/macos 성공 · android 실패(MSB3030). - 리허설 2차(d98c734, 수정 후): **5/5 잡 전부 성공** — `ci-rehearsal` draft prerelease 자산 7개: Setup x64 57.2MB·arm64 51.5MB / linux tar.gz x64 71.1MB·arm64 68.1MB / osx tar.gz arm64 69.6MB·x64 72.7MB / APK(arm64-v8a 단독) 23.3MB. - 정식 배포: `git tag v1.0.0 && git push origin v1.0.0` → 동일 파이프라인이 `v1.0.0` draft 릴리스 생성 → QA 후 수동 Publish (§7.5 런북 그대로). + +## 12차 (2026-08-18) — MEDIABROWSER 배포 사이트 + 공개 릴리스 저장소 +> 사용자: "릴리즈 페이지 만들자 — 프로모션(패럴랙스·글래스모피즘·라이트) + 릴리스 + 사용 안내, +> wrangler 로 Cloudflare 배포, 파일은 NAS 호스팅, 배포명은 MEDIABROWSER". DEPLOYMENT_PLAN §5-ter 참조. + +- [x] **`site/` 3페이지** — index(프로모션: 히어로 스플릿+벤토+기능 그리드+모바일+플랫폼 밴드, + CSS scroll-driven 패럴랙스 + IntersectionObserver 등장, `prefers-reduced-motion/transparency` 존중, + JS 실패 시에도 내용이 보이는 `html.js` 게이트) · releases(플랫폼 그룹·OS 자동 감지 권장·이전 버전 접힘·빈 상태) + · guide(설치→첫 다운로드→관리→휴대폰 연결→FAQ). Wanted Sans Variable + Phosphor 스프라이트. +- [x] **실앱 자산** — 히어로/가이드의 다운로드 패널 이미지는 `VD_DATA_DIR` 격리 프로필로 띄운 실제 앱 + 창 캡처(밝기 보정). 가짜 목업 없음. +- [x] **공개 릴리스 저장소** — 소스(client)가 비공개라 자산 익명 다운로드 불가 → `Video-Downloader/releases` + (공개) 신설, CI 업로드를 env `RELEASE_REPO` 로 전환. draft 는 익명에 안 보여 QA 후 Publish 흐름 유지. + 생성 직후 저장소 설명이 인코딩 깨짐(EFBFBD)으로 올라간 문제(사용자 보고)는 UTF-8 파일 기반 PATCH 로 교정. +- [x] **배포** — `build/deploy-site.ps1`(releases.json 생성 → wrangler pages deploy). + 라이브: https://mediabrowser-7r4.pages.dev (mediabrowser 서브도메인 선점 → 접미). + +### 검증 (12차) +- Playwright 로 3페이지 전체 스크린샷 검증(1440px): 히어로/벤토/기능/모바일/밴드 렌더, 릴리스 빈 상태, + 샘플 데이터 주입 렌더(플랫폼 4그룹·권장 강조·프리뷰 필), 가이드 목차·FAQ. 라이브 URL 에서 재확인. +- 도중 결함 2건 수정: fullPage 캡처로 드러난 reveal 무한 숨김 위험(no-JS 백지) → `html.js` 게이트, + 패널 캡처의 사용자 세션 노출 위험 → 격리 프로필 재캡처로 교체. diff --git a/docs/DEPLOYMENT_PLAN.md b/docs/DEPLOYMENT_PLAN.md index 5bd0668..c1dca0d 100644 --- a/docs/DEPLOYMENT_PLAN.md +++ b/docs/DEPLOYMENT_PLAN.md @@ -174,10 +174,32 @@ jobs: - **리허설**: workflow_dispatch → `ci-rehearsal` draft prerelease 로 태그 없이 전 파이프라인 검증. - **시크릿**: `RELEASE_TOKEN`(Forgejo PAT, `write:repository`) — 릴리스 생성·자산 업로드. 체크아웃은 잡 자동 토큰(`github.token`). `ANDROID_KEYSTORE/ALIAS/PASS` 등록 전까지 APK 는 디버그 서명. +- **업로드 대상**: 공개 배포 저장소 **`Video-Downloader/releases`**(워크플로 env `RELEASE_REPO`). + 소스 저장소(client)는 비공개라 자산 익명 다운로드가 불가해 분리했다. draft 는 익명에게 보이지 + 않으므로 "빌드 → draft → QA → 수동 Publish" 흐름은 그대로다. Publish 하면 §5-ter 사이트가 노출한다. - **머신 전제**: win = SDK 8/10 + `maui-android` 워크로드 + JDK 17 + Inno Setup 6 · linux/mac = `~/.dotnet` SDK 8.0.424 + 10.0.400 (§0.5). - **갭**: §6 자동 업데이트 v1(`UpdateChecker`)은 GitHub Releases API 를 폴링하므로 GitHub 공개 전까지 미동작 — Forgejo Releases API(`/repos/{owner}/{repo}/releases/latest`, 동일 형태) 대응은 후속 항목. + 대상은 공개 저장소 `Video-Downloader/releases` 가 되어야 한다. + +## 5-ter. 배포 사이트 — MEDIABROWSER (Cloudflare Pages, 2026-08-18 가동) + +제품 배포명은 **MEDIABROWSER** 로 하고, 프로모션/다운로드/사용 안내 3페이지 정적 사이트를 +Cloudflare Pages 에 올렸다. **https://mediabrowser-7r4.pages.dev** (mediabrowser 서브도메인 선점으로 +접미 부여 — 커스텀 도메인 연결은 후속). + +- **소스**: `site/` — `index.html`(프로모션: 라이트 글래스모피즘 + CSS scroll-driven 패럴랙스, + 모션 축소·투명도 축소 미디어쿼리 존중) · `releases.html`(플랫폼별 다운로드) · `guide.html`(사용 안내). + 히어로/가이드의 다운로드 패널 이미지는 격리 프로필(`VD_DATA_DIR`)로 띄운 실제 앱 캡처다. +- **릴리스 데이터**: 브라우저에서 Forgejo API 를 직접 부르면 CORS 에 막히므로, 배포 시점에 + `site/fetch-releases.mjs` 가 공개 저장소 릴리스를 `site/releases.json`(git 무시)으로 굽는다. + published 릴리스만 포함(draft 제외), 최신판 강조 + 이전 버전 접힘 + OS 자동 감지 권장 표시. +- **배포**: `./build/deploy-site.ps1` — .env 의 `CLOUDFLARE_API_TOKEN/ACCOUNT_ID` 로 + `wrangler pages deploy site --project-name mediabrowser`. **릴리스를 Publish 한 뒤 이 스크립트를 + 다시 실행해야 사이트 목록이 갱신된다**(후속: 릴리스 CI 에 사이트 재배포 잡 추가). +- **후속**: 커스텀 도메인 · 설치자/실행 파일의 MEDIABROWSER 개명(제품명 통일) · Obtainium `app.json` + 의 URL 을 공개 저장소로 갱신. ## 6. 업데이트 체계 diff --git a/site/assets/icons.svg b/site/assets/icons.svg new file mode 100644 index 0000000..36386d3 --- /dev/null +++ b/site/assets/icons.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/site/assets/panel-downloads.png b/site/assets/panel-downloads.png new file mode 100644 index 0000000..128a91a Binary files /dev/null and b/site/assets/panel-downloads.png differ diff --git a/site/fetch-releases.mjs b/site/fetch-releases.mjs new file mode 100644 index 0000000..d507aa7 --- /dev/null +++ b/site/fetch-releases.mjs @@ -0,0 +1,35 @@ +// 배포 전에 공개 릴리스 저장소의 릴리스 목록을 releases.json 으로 굽는다. +// (브라우저에서 Forgejo API 를 직접 호출하면 CORS 에 막히므로 빌드 시점에 정적 생성) +// 사용: node site/fetch-releases.mjs +import { writeFileSync } from "node:fs"; + +const API = "https://git.chanpaca.net/api/v1/repos/Video-Downloader/releases/releases?limit=50"; + +const res = await fetch(API, { headers: { Accept: "application/json" } }); +if (!res.ok) throw new Error(`릴리스 API 실패: HTTP ${res.status}`); +const raw = await res.json(); + +const releases = raw + .filter((r) => !r.draft) + .sort((a, b) => new Date(b.published_at) - new Date(a.published_at)) + .map((r) => ({ + tag: r.tag_name, + name: r.name || r.tag_name, + published_at: r.published_at, + prerelease: r.prerelease, + notes: r.body || "", + assets: (r.assets || []).map((a) => ({ + name: a.name, + size: a.size, + url: a.browser_download_url, + })), + })); + +const out = { + generated_at: new Date().toISOString(), + repo_url: "https://git.chanpaca.net/Video-Downloader/releases", + releases, +}; + +writeFileSync(new URL("./releases.json", import.meta.url), JSON.stringify(out, null, 2)); +console.log(`releases.json 생성: 릴리스 ${releases.length}건`); diff --git a/site/guide.html b/site/guide.html new file mode 100644 index 0000000..443a512 --- /dev/null +++ b/site/guide.html @@ -0,0 +1,149 @@ + + + + + +사용 안내 - MEDIABROWSER + + + + + + + + + +
+ +
+

사용 안내

+

설치부터 첫 다운로드, 휴대폰 연결까지. 처음이라면 위에서부터 순서대로 따라오세요.

+
+
+ +
+ + +
+
+

설치

+

다운로드 페이지에서 플랫폼에 맞는 빌드를 받으세요.

+
    +
  • + Windows: 설치자(exe)를 실행하고 안내를 따르면 끝입니다. x64 와 arm64 를 제공합니다.
  • +
  • + macOS 프리뷰: tar.gz 를 풀고 앱을 실행합니다. 아직 서명이 없어 처음 실행 시 보안 확인이 필요할 수 있습니다.
  • +
  • + Linux 프리뷰: tar.gz 를 풀고 실행 파일을 실행합니다.
  • +
  • + Android: APK 를 설치합니다. 데스크톱 앱과 페어링해 쓰는 컴패니언입니다.
  • +
+

다운로드에 필요한 도구(yt-dlp, ffmpeg, deno)는 앱이 필요할 때 자동으로 설치합니다. 따로 준비할 것은 없습니다.

+
+ +
+

첫 다운로드

+

MEDIABROWSER 는 브라우저입니다. 평소처럼 사이트에 들어가 영상을 재생하면 나머지는 앱이 합니다.

+
    +
  • + 주소창에 사이트를 입력해 이동하고, 원하는 영상을 재생합니다.
  • +
  • + 재생이 시작되면 스트림(HLS, DASH, MP4)이 감지되어 오른쪽 다운로드 패널에 나타납니다.
  • +
  • + 목록에서 다운로드를 누르면 로그인 상태 그대로 내려받기가 시작됩니다.
  • +
+
+ 다운로드 패널의 빈 상태 화면. 영상을 재생하면 여기에 나타납니다 라는 안내 문구가 표시되어 있다. +
감지 전의 다운로드 패널. 재생을 시작하면 이 목록이 채워집니다.
+
+
+ +
+

다운로드 관리

+

패널의 탭으로 전체, 진행, 완료, 실패를 나눠 보고, 항목마다 속도와 남은 시간, 세그먼트 진행을 확인합니다.

+
    +
  • + 모두 일시정지 / 모두 재개로 한 번에 멈추고 다시 시작합니다. 항목별 일시정지도 됩니다.
  • +
  • + 예약을 걸면 지정한 시간부터 내려받습니다. 이미 받은 영상은 중복 확인으로 알려줍니다.
  • +
  • + 받은 영상은 라이브러리에서 모아 보고, 재생목록과 즐겨찾기, 아카이브로 정리합니다.
  • +
  • + 목록은 CSV 로 내보내 기록으로 남길 수 있습니다.
  • +
+
+ +
+

휴대폰 연결

+

데스크톱 앱에 내장된 서버가 휴대폰과 라이브러리를 이어줍니다. 같은 네트워크에서 페어링 한 번이면 됩니다.

+
    +
  • + 휴대폰에서 컴패니언 앱을 열고, 데스크톱 앱이 보여주는 QR 코드를 스캔합니다.
  • +
  • + 페어링이 끝나면 라이브러리의 영상을 휴대폰에서 바로 재생하거나 파일로 전송받습니다.
  • +
  • + 연결은 내 네트워크 안에서만 이루어집니다. 외부 서버를 거치지 않습니다.
  • +
+
+ +
+

자주 묻는 질문

+
+
+ ffmpeg 나 yt-dlp 를 따로 설치해야 하나요? +

아니요. 필요한 시점에 앱이 자동으로 내려받아 관리합니다. 설정에서 직접 지정한 경로가 있으면 그걸 우선합니다.

+
+
+ 받은 파일은 어디에 저장되나요? +

기본 저장 폴더에 저장되며, 설정에서 폴더를 바꿀 수 있습니다. 완료 목록에서 파일 위치를 바로 열 수 있습니다.

+
+
+ 다운로드가 중간에 끊기면 처음부터 다시 받나요? +

아니요. 세그먼트 단위로 이어받기를 지원해서 받다 만 지점부터 다시 진행합니다.

+
+
+ 업데이트는 어떻게 하나요? +

설정의 앱 업데이트 항목에서 새 버전을 확인할 수 있고, 다운로드 페이지에서 새 설치자를 받아 덮어 설치하면 됩니다.

+
+
+ 어떤 사이트를 지원하나요? +

표준 스트리밍 형식(HLS, DASH, MP4)을 쓰는 사이트에서 동작합니다. 콘텐츠의 이용 약관과 저작권을 지키는 범위에서 사용해 주세요.

+
+
+
+
+
+ + + + + + diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..b307d9e --- /dev/null +++ b/site/index.html @@ -0,0 +1,156 @@ + + + + + +MEDIABROWSER - 보던 영상, 그 자리에서 내 것으로 + + + + + + + + + +
+ +
+
+

보던 영상,
그 자리에서 내 것으로.

+

MEDIABROWSER 는 재생 중인 미디어를 감지해 내려받는 브라우저입니다. + 받은 영상은 라이브러리로 관리하고, 휴대폰으로 이어봅니다.

+ +
+
+
+ MEDIABROWSER 다운로드 패널. 전체·진행·완료·실패 탭과 모두 일시정지, 모두 재개, CSV 버튼이 있고, 영상을 재생하면 여기에 나타납니다 라는 안내가 보인다. +
앱의 다운로드 패널. 재생을 감지하면 이 목록에 자동으로 쌓입니다.
+
+
+
+
+ +
+
+

주소를 붙여넣는 게 아니라,
그냥 봅니다.

+
+
+
+
+
+ HLSDASHMP4 +
+

재생하면 감지됩니다

+

내장 브라우저에서 영상이 재생되는 순간 스트림을 인식해 다운로드 목록에 올립니다. + 별도 확장이나 주소 복사가 필요 없습니다.

+
+
+
+

로그인 그대로

+

탭의 쿠키와 Referer 를 그대로 물려받아 내려받습니다. 브라우저에서 보이던 영상은 그대로 받아집니다.

+
+
+

끊겨도 이어서

+

병렬 분할 다운로드와 이어받기, 그리고 ffmpeg 리먹스까지 자동으로 처리합니다. AES-128 스트림도 지원합니다.

+
+
+
+
+ +
+
+

다운로드는 관리가 절반입니다.

+
+
+
+

속도와 세그먼트가 보입니다

항목마다 속도, 남은 시간, 세그먼트 맵을 실시간으로 표시합니다.

+
+
+
+

전체 일시정지와 재개

한 번에 멈추고 한 번에 다시 시작합니다. 예약 시간을 걸어둘 수도 있습니다.

+
+
+
+

중복은 미리 알려줍니다

이미 받은 영상을 다시 받으려 하면 내려받기 전에 확인해 줍니다.

+
+
+
+

라이브러리와 아카이브

받은 영상을 모아 보고, 정리하고, 재생목록과 즐겨찾기로 관리합니다.

+
+
+
+
+ +
+
+ +
+

받은 영상,
휴대폰에서 이어보기.

+

데스크톱 앱에 내장된 서버가 라이브러리를 휴대폰으로 스트리밍하거나 전송합니다. + QR 코드 한 번으로 페어링하고, Android 컴패니언 앱으로 어디서든 이어봅니다. iOS 는 준비 중입니다.

+
+
+
+ +
+
+
+

Windows 에서 시작하세요.

+

Windows 는 설치자로 바로 시작할 수 있습니다. + macOS 와 Linux 는 새 셸의 프리뷰 빌드, Android 는 컴패니언 앱을 제공합니다.

+
+
Windows
+
macOS 프리뷰
+
Linux 프리뷰
+
Android
+
+ 다운로드 +
+
+
+ + + + + + diff --git a/site/releases.html b/site/releases.html new file mode 100644 index 0000000..5c0844e --- /dev/null +++ b/site/releases.html @@ -0,0 +1,76 @@ + + + + + +다운로드 - MEDIABROWSER + + + + + + + + + +
+ +
+

다운로드

+

사용하는 플랫폼에 맞는 빌드를 받으세요. 설치가 끝나면 + 사용 안내에서 첫 다운로드까지 3분이면 충분합니다.

+
+
+ +
+ + +
+
+ +

릴리스 목록을 불러오는 중

+

잠시만 기다려 주세요.

+
+
+
+ + + + + + diff --git a/site/site.js b/site/site.js new file mode 100644 index 0000000..a922c9c --- /dev/null +++ b/site/site.js @@ -0,0 +1,131 @@ +// MEDIABROWSER 사이트 공용 스크립트 +// 1) 스크롤 등장(reveal) — IntersectionObserver, 모션 축소 설정이면 즉시 표시 +// 2) releases.html — releases.json(배포 시 생성)을 읽어 릴리스 목록 렌더 + +(function initReveal() { + const items = document.querySelectorAll(".reveal"); + if (!items.length) return; + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !("IntersectionObserver" in window)) { + return; // .js 게이트를 켜지 않으면 CSS 기본값(표시 상태) 그대로 + } + document.documentElement.classList.add("js"); + const io = new IntersectionObserver((entries) => { + for (const e of entries) { + if (e.isIntersecting) { e.target.classList.add("in"); io.unobserve(e.target); } + } + }, { threshold: 0.15, rootMargin: "0px 0px -8% 0px" }); + items.forEach((el) => io.observe(el)); +})(); + +// ── 릴리스 렌더 ────────────────────────────────────────────── +const PLATFORMS = [ + { id: "windows", label: "Windows", icon: "windows-logo", sub: "설치자를 실행하면 됩니다", + match: (n) => /setup.*\.exe$/i.test(n) || /win/i.test(n) }, + { id: "macos", label: "macOS 프리뷰", icon: "apple-logo", sub: "tar.gz 를 풀어 실행하는 프리뷰 빌드", + match: (n) => /osx|darwin|mac/i.test(n) }, + { id: "linux", label: "Linux 프리뷰", icon: "linux-logo", sub: "tar.gz 를 풀어 실행하는 프리뷰 빌드", + match: (n) => /linux/i.test(n) }, + { id: "android", label: "Android", icon: "android-logo", sub: "컴패니언 앱 APK", + match: (n) => /\.apk$/i.test(n) }, +]; + +function detectPreferredAsset(name) { + const ua = navigator.userAgent; + if (/Windows/i.test(ua)) return /setup-x64\.exe$/i.test(name); + if (/Android/i.test(ua)) return /\.apk$/i.test(name); + if (/Mac/i.test(ua)) return /osx-arm64/i.test(name); + if (/Linux/i.test(ua)) return /linux-x64/i.test(name); + return false; +} + +function fmtSize(bytes) { + if (!bytes) return ""; + const mb = bytes / 1048576; + return mb >= 1024 ? (mb / 1024).toFixed(1) + " GB" : mb.toFixed(1) + " MB"; +} + +function fmtDate(iso) { + if (!iso) return ""; + const d = new Date(iso); + return `${d.getFullYear()}년 ${d.getMonth() + 1}월 ${d.getDate()}일`; +} + +function esc(s) { + return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); +} + +function assetRow(a, recommended) { + return `
+
${esc(a.name)}
${fmtSize(a.size)}
+ 받기 +
`; +} + +function groupsHtml(assets) { + const used = new Set(); + const groups = PLATFORMS.map((p) => { + const list = assets.filter((a) => !used.has(a.name) && p.match(a.name)); + list.forEach((a) => used.add(a.name)); + if (!list.length) return ""; + return `
+
${p.label}
+
${p.sub}
+ ${list.map((a) => assetRow(a, detectPreferredAsset(a.name))).join("")} +
`; + }).filter(Boolean); + const rest = assets.filter((a) => !used.has(a.name)); + if (rest.length) { + groups.push(`
기타
그 밖의 파일
+ ${rest.map((a) => assetRow(a, false)).join("")}
`); + } + return `
${groups.join("")}
`; +} + +function releaseCard(r, featured) { + const pill = r.prerelease ? `프리뷰` : `안정판`; + const meta = ` + ${r.notes ? `` : ""} + ${groupsHtml(r.assets)}`; + if (featured) return ``; + return `
${esc(r.name || r.tag)}${pill} + ${fmtDate(r.published_at)} +
${r.notes ? `` : ""}${groupsHtml(r.assets)}
+
`; +} + +function emptyState() { + return `
+ +

첫 공개 릴리스를 준비하고 있어요

+

빌드 파이프라인은 이미 돌아가고 있습니다. 곧 이 자리에서 설치자를 받을 수 있습니다. + 먼저 사용 안내를 둘러보세요.

+
`; +} + +(async function initReleases() { + const root = document.getElementById("release-root"); + if (!root) return; + try { + const res = await fetch("releases.json", { cache: "no-cache" }); + if (!res.ok) throw new Error("HTTP " + res.status); + const data = await res.json(); + const releases = (data.releases || []); + if (!releases.length) { root.innerHTML = emptyState(); return; } + const [latest, ...older] = releases; + let html = releaseCard(latest, true); + if (older.length) { + html += `

이전 버전

+ ${older.map((r) => releaseCard(r, false)).join("")}
`; + } + root.innerHTML = html; + } catch (err) { + root.innerHTML = `
+

목록을 불러오지 못했어요

+

잠시 후 새로고침해 주세요. 급하다면 + 릴리스 저장소에서 직접 받을 수 있습니다.

+
`; + } +})(); diff --git a/site/styles.css b/site/styles.css new file mode 100644 index 0000000..9a1ab76 --- /dev/null +++ b/site/styles.css @@ -0,0 +1,289 @@ +/* MEDIABROWSER 사이트 공용 디자인 시스템 + 라이트 단일 테마(사용자 지정) · 글래스모피즘 · 절제된 스크롤 모션 + 반경 체계: 버튼=필, 카드/패널=20px, 칩/입력=12px */ + +:root { + --bg: #f5f7fc; + --bg-deep: #e9edf8; + --ink: #171a23; + --muted: #5b6273; + --line: rgba(23, 26, 35, 0.08); + --accent: #2050f0; + --accent-deep: #1740c8; + --accent-soft: rgba(32, 80, 240, 0.09); + --glass: rgba(255, 255, 255, 0.58); + --glass-strong: rgba(255, 255, 255, 0.78); + --glass-brd: rgba(255, 255, 255, 0.72); + --shadow-soft: 0 24px 60px -24px rgba(32, 60, 160, 0.18); + --shadow-lift: 0 32px 80px -28px rgba(32, 60, 160, 0.28); + --r-card: 20px; + --r-chip: 12px; + --nav-h: 68px; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +html { scroll-behavior: smooth; } +@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } } + +body { + font-family: "Wanted Sans Variable", "Wanted Sans", system-ui, "Apple SD Gothic Neo", "Malgun Gothic", sans-serif; + background: var(--bg); + color: var(--ink); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + overflow-x: hidden; +} + +::selection { background: var(--accent); color: #fff; } + +.icon { width: 1.25em; height: 1.25em; fill: currentColor; flex: none; vertical-align: -0.25em; } + +/* ── 배경 오브(패럴랙스 레이어) ─────────────────────────────── */ +.orbs { position: absolute; inset: 0; overflow: hidden; pointer-events: none; z-index: -1; } +.orb { position: absolute; border-radius: 50%; filter: blur(70px); opacity: 0.55; } +.orb--a { width: 560px; height: 560px; left: -140px; top: -120px; + background: radial-gradient(circle at 35% 35%, #c9d6ff, #eef1fb 70%); } +.orb--b { width: 480px; height: 480px; right: -120px; top: 160px; + background: radial-gradient(circle at 40% 40%, #d8e4ff, #f0f3fc 70%); } +.orb--c { width: 380px; height: 380px; left: 34%; top: 560px; + background: radial-gradient(circle at 50% 40%, #dfe9ff, #f4f6fd 72%); opacity: 0.45; } + +/* 스크롤 패럴랙스: 지원 브라우저에서만, 모션 축소 설정이면 정지 */ +@media (prefers-reduced-motion: no-preference) { + @supports (animation-timeline: scroll()) { + .orb { animation: orb-drift linear both; animation-timeline: scroll(); } + .orb--a { --drift: 180px; } + .orb--b { --drift: -240px; } + .orb--c { --drift: 120px; } + } +} +@keyframes orb-drift { to { transform: translateY(var(--drift, 0px)); } } + +/* ── 글래스 재질 ────────────────────────────────────────────── */ +.glass { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0.16)), var(--glass); + backdrop-filter: blur(18px) saturate(160%); + -webkit-backdrop-filter: blur(18px) saturate(160%); + border: 1px solid var(--glass-brd); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), var(--shadow-soft); +} +@media (prefers-reduced-transparency: reduce) { + .glass { background: rgba(255, 255, 255, 0.96); backdrop-filter: none; -webkit-backdrop-filter: none; } +} + +/* ── 내비게이션 ─────────────────────────────────────────────── */ +.nav { + position: fixed; top: 14px; left: 50%; transform: translateX(-50%); + width: min(1120px, calc(100% - 32px)); height: var(--nav-h); + display: flex; align-items: center; gap: 28px; padding: 0 22px; + border-radius: 999px; z-index: 50; +} +.nav__brand { + font-weight: 800; letter-spacing: 0.04em; font-size: 15px; + color: var(--ink); text-decoration: none; display: flex; align-items: center; gap: 9px; +} +.nav__mark { + width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; + background: linear-gradient(135deg, var(--accent), #4d79ff); color: #fff; +} +.nav__mark .icon { width: 16px; height: 16px; } +.nav__links { display: flex; gap: 22px; margin-left: auto; } +.nav__links a { color: var(--muted); text-decoration: none; font-size: 14.5px; font-weight: 600; } +.nav__links a:hover, .nav__links a[aria-current="page"] { color: var(--ink); } + +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + border-radius: 999px; font-weight: 700; text-decoration: none; cursor: pointer; + border: 0; white-space: nowrap; + transition: transform 0.18s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.18s, background 0.18s; +} +.btn:active { transform: translateY(1px) scale(0.98); } +.btn--primary { + background: var(--accent); color: #fff; padding: 12px 22px; font-size: 15px; + box-shadow: 0 10px 24px -10px rgba(32, 80, 240, 0.55); +} +.btn--primary:hover { background: var(--accent-deep); } +.btn--ghost { + background: rgba(255, 255, 255, 0.6); color: var(--ink); padding: 12px 22px; font-size: 15px; + border: 1px solid var(--line); +} +.btn--ghost:hover { background: #fff; } +.btn--sm { padding: 9px 16px; font-size: 14px; } + +/* ── 공통 레이아웃 ──────────────────────────────────────────── */ +.wrap { width: min(1120px, calc(100% - 40px)); margin: 0 auto; } +section { position: relative; } +.section-pad { padding: 108px 0; } +h1, h2, h3 { line-height: 1.22; letter-spacing: -0.02em; font-weight: 800; } +.sub { color: var(--muted); font-size: 17px; max-width: 58ch; } + +/* 스크롤 등장 — JS 가 html 에 .js 를 붙였을 때만 숨김(no-JS 에서도 내용이 보이는 점진적 향상) */ +html.js .reveal { opacity: 0; transform: translateY(22px); transition: opacity 0.7s cubic-bezier(0.16, 1, 0.3, 1), transform 0.7s cubic-bezier(0.16, 1, 0.3, 1); } +html.js .reveal.in { opacity: 1; transform: none; } +@media (prefers-reduced-motion: reduce) { html.js .reveal { opacity: 1; transform: none; transition: none; } } + +/* ── 히어로 ─────────────────────────────────────────────────── */ +.hero { padding: calc(var(--nav-h) + 78px) 0 96px; min-height: 86dvh; display: flex; align-items: center; } +.hero__grid { display: grid; grid-template-columns: 7fr 5fr; gap: 56px; align-items: center; } +.hero h1 { font-size: clamp(38px, 5.2vw, 62px); } +.hero .sub { margin-top: 22px; font-size: 18px; } +.hero__cta { display: flex; gap: 14px; margin-top: 34px; } +.hero__visual { position: relative; } +.hero__panel { + border-radius: var(--r-card); padding: 14px; + transform: rotate(1.8deg); +} +.hero__panel img { + display: block; width: 100%; border-radius: 12px; + box-shadow: 0 18px 44px -18px rgba(10, 16, 40, 0.45); +} +.hero__caption { margin-top: 12px; font-size: 13.5px; color: var(--muted); text-align: center; } + +/* ── 벤토 ───────────────────────────────────────────────────── */ +.bento { display: grid; grid-template-columns: 3fr 2fr; grid-template-rows: auto auto; gap: 20px; margin-top: 52px; } +.bento__cell { border-radius: var(--r-card); padding: 34px; position: relative; overflow: hidden; } +.bento__cell--wide { grid-row: span 2; display: flex; flex-direction: column; justify-content: flex-end; min-height: 380px; + background: linear-gradient(160deg, #1b2440 0%, #22346e 55%, #2050f0 130%); color: #fff; } +.bento__cell--wide h3, .bento__cell--wide p { color: #fff; } +.bento__cell--wide p { opacity: 0.82; } +.bento__wave { position: absolute; top: 30px; left: 34px; opacity: 0.9; } +.bento__wave .icon { width: 44px; height: 44px; color: #9db6ff; } +.bento__cell h3 { font-size: 21px; margin-bottom: 10px; } +.bento__cell p { color: var(--muted); font-size: 15.5px; } +.bento__chipline { display: flex; gap: 8px; margin-bottom: 18px; flex-wrap: wrap; } +.chip { + display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; + border-radius: 999px; font-size: 13px; font-weight: 700; + background: rgba(255, 255, 255, 0.14); border: 1px solid rgba(255, 255, 255, 0.22); color: #dfe8ff; +} + +/* ── 기능 그리드(카드 없는 정리) ────────────────────────────── */ +.featlist { display: grid; grid-template-columns: repeat(2, 1fr); gap: 40px 64px; margin-top: 56px; } +.featlist__item { display: flex; gap: 16px; align-items: flex-start; } +.featlist__icon { + width: 42px; height: 42px; flex: none; border-radius: var(--r-chip); display: grid; place-items: center; + background: var(--accent-soft); color: var(--accent); +} +.featlist__icon .icon { width: 21px; height: 21px; } +.featlist__item h3 { font-size: 17px; margin-bottom: 4px; } +.featlist__item p { color: var(--muted); font-size: 15px; } + +/* ── 모바일 섹션 ────────────────────────────────────────────── */ +.mobile__grid { display: grid; grid-template-columns: 5fr 7fr; gap: 56px; align-items: center; } +.mobile__stage { position: relative; min-height: 320px; } +.mobile__card { position: absolute; border-radius: var(--r-card); display: flex; align-items: center; gap: 14px; padding: 20px 24px; } +.mobile__card .icon { width: 30px; height: 30px; color: var(--accent); } +.mobile__card strong { display: block; font-size: 15.5px; } +.mobile__card span { font-size: 13.5px; color: var(--muted); } +.mobile__card--a { top: 8%; left: 4%; transform: rotate(-2.4deg); } +.mobile__card--b { top: 40%; right: 6%; transform: rotate(1.6deg); } +.mobile__card--c { bottom: 4%; left: 14%; transform: rotate(-1deg); } + +/* ── 플랫폼 밴드 ────────────────────────────────────────────── */ +.band { border-radius: 28px; padding: 64px 56px; text-align: center; } +.band h2 { font-size: clamp(28px, 3.6vw, 40px); } +.band .sub { margin: 16px auto 0; } +.band__oses { display: flex; gap: 34px; justify-content: center; margin: 36px 0 40px; color: var(--muted); } +.band__os { display: flex; flex-direction: column; align-items: center; gap: 8px; font-size: 13.5px; font-weight: 600; } +.band__os .icon { width: 34px; height: 34px; color: var(--ink); } +.band__os--soon { opacity: 0.55; } + +/* ── 푸터 ───────────────────────────────────────────────────── */ +footer { padding: 48px 0 60px; } +.footer__row { display: flex; align-items: center; gap: 26px; flex-wrap: wrap; border-top: 1px solid var(--line); padding-top: 32px; } +.footer__row a { color: var(--muted); text-decoration: none; font-size: 14px; font-weight: 600; } +.footer__row a:hover { color: var(--ink); } +.footer__brand { font-weight: 800; letter-spacing: 0.04em; font-size: 14px; } +.footer__copy { margin-left: auto; color: var(--muted); font-size: 13.5px; } + +/* ── 릴리스 페이지 ──────────────────────────────────────────── */ +.pagehead { padding: calc(var(--nav-h) + 74px) 0 30px; } +.pagehead h1 { font-size: clamp(32px, 4.2vw, 48px); } +.pagehead .sub { margin-top: 16px; } + +.rel-layout { display: grid; grid-template-columns: 280px 1fr; gap: 44px; align-items: start; padding-bottom: 110px; } +.rel-rail { position: sticky; top: calc(var(--nav-h) + 30px); display: flex; flex-direction: column; gap: 10px; } +.rel-rail__box { border-radius: var(--r-card); padding: 22px; } +.rel-rail__box h3 { font-size: 15px; margin-bottom: 12px; } +.rel-rail__os { display: flex; align-items: center; gap: 10px; padding: 9px 10px; border-radius: var(--r-chip); + color: var(--ink); text-decoration: none; font-size: 14.5px; font-weight: 600; } +.rel-rail__os:hover { background: rgba(255, 255, 255, 0.75); } +.rel-rail__os .icon { width: 19px; height: 19px; color: var(--muted); } +.rel-rail__note { font-size: 13.5px; color: var(--muted); line-height: 1.55; } +.rel-rail__note a { color: var(--accent); font-weight: 700; text-decoration: none; } + +.rel-featured { border-radius: 24px; padding: 40px; } +.rel-featured__meta { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; } +.rel-featured__tag { font-size: 30px; font-weight: 800; letter-spacing: -0.02em; } +.rel-featured__date { color: var(--muted); font-size: 14.5px; } +.rel-pill { display: inline-flex; padding: 4px 12px; border-radius: 999px; font-size: 12.5px; font-weight: 800; + background: var(--accent-soft); color: var(--accent); } +.rel-pill--pre { background: rgba(214, 122, 20, 0.12); color: #a35a00; } +.rel-featured__notes { margin-top: 18px; color: var(--muted); font-size: 15px; white-space: pre-line; max-width: 70ch; } + +.dl-groups { display: grid; grid-template-columns: repeat(2, 1fr); gap: 18px; margin-top: 30px; } +.dl-group { border-radius: var(--r-card); background: rgba(255, 255, 255, 0.66); border: 1px solid var(--line); padding: 22px; } +.dl-group__head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; font-weight: 800; font-size: 16px; } +.dl-group__head .icon { width: 21px; height: 21px; color: var(--ink); } +.dl-group__sub { font-size: 13.5px; color: var(--muted); margin-bottom: 14px; } +.dl-asset { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 0; } +.dl-asset + .dl-asset { border-top: 1px solid var(--line); } +.dl-asset__name { font-size: 14px; font-weight: 600; overflow-wrap: anywhere; } +.dl-asset__size { color: var(--muted); font-size: 12.5px; } +.dl-asset .btn { flex: none; } +.dl-reco { outline: 2px solid var(--accent); outline-offset: 2px; } + +.rel-old { margin-top: 30px; display: flex; flex-direction: column; gap: 14px; } +.rel-old details { border-radius: var(--r-card); border: 1px solid var(--line); background: rgba(255, 255, 255, 0.55); } +.rel-old summary { cursor: pointer; padding: 18px 22px; font-weight: 700; display: flex; align-items: center; gap: 12px; list-style: none; } +.rel-old summary::-webkit-details-marker { display: none; } +.rel-old summary .rel-featured__date { margin-left: auto; } +.rel-old__body { padding: 0 22px 20px; } + +.rel-empty { border-radius: 24px; padding: 72px 40px; text-align: center; } +.rel-empty .icon { width: 44px; height: 44px; color: var(--accent); } +.rel-empty h2 { font-size: 24px; margin: 18px 0 10px; } +.rel-empty p { color: var(--muted); max-width: 48ch; margin: 0 auto; } + +/* ── 가이드 페이지 ──────────────────────────────────────────── */ +.guide-layout { display: grid; grid-template-columns: 240px 1fr; gap: 52px; align-items: start; padding-bottom: 110px; } +.guide-toc { position: sticky; top: calc(var(--nav-h) + 30px); border-radius: var(--r-card); padding: 22px; } +.guide-toc a { display: block; padding: 7px 10px; border-radius: 10px; color: var(--muted); text-decoration: none; font-size: 14.5px; font-weight: 600; } +.guide-toc a:hover { color: var(--ink); background: rgba(255, 255, 255, 0.75); } +.guide-step { padding: 44px 0; border-top: 1px solid var(--line); } +.guide-step:first-of-type { border-top: 0; padding-top: 10px; } +.guide-step h2 { font-size: 24px; display: flex; align-items: center; gap: 13px; } +.guide-step h2 .featlist__icon { width: 38px; height: 38px; } +.guide-step > p { color: var(--muted); margin-top: 14px; max-width: 66ch; } +.guide-step ul { margin: 16px 0 0 2px; list-style: none; display: flex; flex-direction: column; gap: 9px; } +.guide-step li { display: flex; gap: 10px; color: var(--ink); font-size: 15px; } +.guide-step li .icon { width: 18px; height: 18px; color: var(--accent); margin-top: 4px; } +.guide-step li span b { font-weight: 750; } +.guide-media { margin-top: 24px; border-radius: var(--r-card); padding: 12px; max-width: 460px; } +.guide-media img { display: block; width: 100%; border-radius: 12px; } +.guide-media figcaption { font-size: 13px; color: var(--muted); padding: 10px 6px 2px; } +.guide-faq { margin-top: 18px; display: flex; flex-direction: column; gap: 10px; } +.guide-faq details { border: 1px solid var(--line); border-radius: var(--r-chip); background: rgba(255, 255, 255, 0.55); } +.guide-faq summary { cursor: pointer; padding: 15px 18px; font-weight: 700; font-size: 15px; list-style: none; } +.guide-faq summary::-webkit-details-marker { display: none; } +.guide-faq p { padding: 0 18px 16px; color: var(--muted); font-size: 14.5px; } + +/* ── 모바일 반응형 ──────────────────────────────────────────── */ +@media (max-width: 860px) { + .nav { gap: 14px; } + .nav__links { display: none; } + .hero { min-height: auto; padding-bottom: 64px; } + .hero__grid, .mobile__grid { grid-template-columns: 1fr; gap: 40px; } + .bento { grid-template-columns: 1fr; } + .bento__cell--wide { min-height: 300px; } + .featlist { grid-template-columns: 1fr; gap: 28px; } + .section-pad { padding: 72px 0; } + .band { padding: 48px 24px; } + .band__oses { gap: 22px; flex-wrap: wrap; } + .rel-layout, .guide-layout { grid-template-columns: 1fr; gap: 28px; } + .rel-rail, .guide-toc { position: static; } + .dl-groups { grid-template-columns: 1fr; } + .mobile__stage { min-height: 380px; } + .rel-featured { padding: 26px; } +}