Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
db804fe
chore : TWA 매니페스트 www 호스트 통일 및 버전(4) 업데이트 (#268)
GulSam00 Jul 7, 2026
421d71c
chore : 푸터 높이 조정 및 트위터 카드 메타데이터 문구 개선 (#268)
GulSam00 Jul 7, 2026
b9bc84d
chore : 빌드 후 버전 업 반영
GulSam00 Jul 10, 2026
c251b4a
chore : 파비콘 및 PWA 아이콘 이미지 교체 (#272)
GulSam00 Jul 12, 2026
8bf9150
Merge pull request #273 from GulSam00/chore/272-changeFavicon
GulSam00 Jul 12, 2026
f8fe442
Merge branch 'develop' into chore/268-playStoreRelease
GulSam00 Jul 12, 2026
1cfe4f4
chore : 버전 6 업
GulSam00 Jul 12, 2026
e9938f0
fix : Footer 높이 변경에 따른 하단 콘텐츠 겹침 수정 (#268)
GulSam00 Jul 12, 2026
7572d63
fix : TWA 도메인을 www.singcode.kr로 통일 (#268)
GulSam00 Jul 12, 2026
e8dd16a
chore : twa-manifest.json 파일 끝 개행 추가 (#268)
GulSam00 Jul 12, 2026
3298c10
chore : crawling 문서 워크플로우 표 포맷팅 정리
GulSam00 Jul 12, 2026
38708d8
Merge pull request #274 from GulSam00/chore/268-playStoreRelease
GulSam00 Jul 12, 2026
7466864
fix : /privacy, /error 경로 로그인 없이 접근 가능하도록 ALLOW_PATHS 추가 (#275)
GulSam00 Jul 12, 2026
da3f47b
feat : 노래 검색 시 song_tags 언어 태그 필터링 기능 추가 (#276)
GulSam00 Jul 12, 2026
8e08238
Merge pull request #277 from GulSam00/fix/275-authMiddleware
GulSam00 Jul 12, 2026
dc66c37
fix : Qodo 리뷰 반영 - languageTag 검증 추가 및 미사용 useSearchSongQuery 삭제 (#276)
GulSam00 Jul 12, 2026
4d4c107
chore : store-assets 이미지 추가, .gitignore 추가해서 깃에는 올리지 않음
GulSam00 Jul 12, 2026
7ea36a0
fix : 언어 태그 필터 국기 이모지가 Windows에서 텍스트로 보이는 문제 수정 - country-flag-icons …
GulSam00 Jul 15, 2026
1229315
Merge pull request #278 from GulSam00/feat/276-songTagsFilter
GulSam00 Jul 15, 2026
cdec407
chore : name 변경. 한 줄에 전부 렌더링.
GulSam00 Jul 15, 2026
98e97b1
Merge pull request #279 from GulSam00/feat/276-songTagsFilter
GulSam00 Jul 15, 2026
744ed53
chore : 버전 2.9.0. 앱 버전 7.
GulSam00 Jul 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions apps/twa/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with th
## Overview

`apps/twa`는 **Singcode PWA를 Google Play Store에 배포하기 위한 Android 빌드 워크스페이스**다.
Bubblewrap CLI가 `https://singcode.kr`의 PWA Manifest를 받아 Android 프로젝트(.aab/.apk)를 생성한다.
Bubblewrap CLI가 `https://www.singcode.kr`의 PWA Manifest를 받아 Android 프로젝트(.aab/.apk)를 생성한다.

- **Application ID**: `kr.singcode.app` — 한 번 결정되면 영구적이다. 변경 불가.
- **PWA URL**: `https://singcode.kr`
- **Manifest URL**: `https://singcode.kr/manifest.webmanifest`
- **Digital Asset Links**: `https://singcode.kr/.well-known/assetlinks.json`
- **PWA URL**: `https://www.singcode.kr`
- **Manifest URL**: `https://www.singcode.kr/manifest.webmanifest`
- **Digital Asset Links**: `https://www.singcode.kr/.well-known/assetlinks.json`

## Prerequisites (로컬 1회성 셋업)

Expand Down Expand Up @@ -64,8 +64,8 @@ pnpm build # bubblewrap build
빌드 전 PWA가 정상인지 확인.

```bash
curl -sI https://singcode.kr/manifest.webmanifest # 200
curl -s https://singcode.kr/manifest.webmanifest | jq . # 유효한 JSON
curl -sI https://www.singcode.kr/manifest.webmanifest # 200
curl -s https://www.singcode.kr/manifest.webmanifest | jq . # 유효한 JSON
```

`manifest.webmanifest`의 `icons[].purpose`에 `any`와 `maskable`이 모두 등록되어 있어야 한다.
Expand All @@ -82,16 +82,16 @@ pnpm --filter twa run init

| 항목 | 값 |
| ----------------- | ------------------------------------------------- |
| Domain | `singcode.kr` |
| Domain | `www.singcode.kr` |
| Application ID | `kr.singcode.app` (영구) |
| Application name | `Singcode - 당신의 노래방 메모장` |
| Launcher name | `Singcode` |
| Display mode | `standalone` |
| Orientation | `portrait` |
| Theme color | `#1a1a2e` |
| Splash background | `#1a1a2e` |
| Icon URL | `https://singcode.kr/icons/icon-512.png` |
| Maskable icon URL | `https://singcode.kr/icons/icon-maskable-512.png` |
| Icon URL | `https://www.singcode.kr/icons/icon-512.png` |
| Maskable icon URL | `https://www.singcode.kr/icons/icon-maskable-512.png` |
| Signing key alias | `singcode` |

### 3. 키스토어 생성 (최초 1회, 사용자 직접 진행)
Expand Down Expand Up @@ -139,10 +139,10 @@ keytool -list -v \
배포 후 검증:

```bash
curl -sI https://singcode.kr/.well-known/assetlinks.json # 200
curl -sI https://www.singcode.kr/.well-known/assetlinks.json # 200
```

[Statement List Tester](https://developers.google.com/digital-asset-links/tools/generator)에서 `https://singcode.kr` + `kr.singcode.app` + SHA-256으로 통과 확인.
[Statement List Tester](https://developers.google.com/digital-asset-links/tools/generator)에서 `https://www.singcode.kr` + `kr.singcode.app` + SHA-256으로 통과 확인.

### 6. `bubblewrap build`

Expand All @@ -168,7 +168,7 @@ adb install -r apps/twa/app-release-signed.apk
체크리스트:

- [ ] 앱 실행 시 **풀스크린** (주소창이 뜨지 않음)
- [ ] `singcode.kr` 정상 로드 (오프라인 splash 후 메인 진입)
- [ ] `www.singcode.kr` 정상 로드 (오프라인 splash 후 메인 진입)
- [ ] 시스템 뒤로가기 동작 정상
- [ ] 카카오 로그인 정상 (브라우저 탭 전환 후 복귀)
- [ ] PWA 아이콘 (`icon-maskable-*`)이 흰 배경 마스킹 없이 표시
Expand All @@ -184,7 +184,7 @@ adb install -r apps/twa/app-release-signed.apk
keytool -printcert -jarfile apps/twa/app-release-signed.apk

# 2. 배포된 assetlinks.json 값 확인
curl -s https://singcode.kr/.well-known/assetlinks.json | jq .
curl -s https://www.singcode.kr/.well-known/assetlinks.json | jq .

# 3. 두 값이 동일한지 비교 — 다르면 assetlinks.json 갱신 후 재배포
```
Expand Down
2 changes: 1 addition & 1 deletion apps/twa/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"description": "Singcode TWA (Trusted Web Activity) Android build",
"scripts": {
"init": "bubblewrap init --manifest=https://singcode.kr/manifest.webmanifest",
"init": "bubblewrap init --manifest=https://www.singcode.kr/manifest.webmanifest",
"build": "bubblewrap build",
"update": "bubblewrap update"
},
Expand Down
20 changes: 10 additions & 10 deletions apps/twa/twa-manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"packageId": "kr.singcode.app",
"host": "singcode.kr",
"host": "www.singcode.kr",
"name": "싱코드(singcode) - 노래방 번호 검색",
"launcherName": "Singcode",
"display": "standalone",
Expand All @@ -13,19 +13,19 @@
"backgroundColor": "#1A1A2E",
"enableNotifications": true,
"startUrl": "/",
"iconUrl": "https://singcode.kr/icons/icon-512.png",
"maskableIconUrl": "https://singcode.kr/icons/icon-maskable-512.png",
"monochromeIconUrl": "https://singcode.kr/icons/icon-512.png",
"iconUrl": "https://www.singcode.kr/icons/icon-512.png",
"maskableIconUrl": "https://www.singcode.kr/icons/icon-maskable-512.png",
"monochromeIconUrl": "https://www.singcode.kr/icons/icon-512.png",
"splashScreenFadeOutDuration": 300,
"signingKey": {
"path": "./android.keystore",
"alias": "singcode"
},
"appVersionName": "3",
"appVersionCode": 3,
"appVersionName": "7",
"appVersionCode": 7,
"shortcuts": [],
"generatorApp": "bubblewrap-cli",
"webManifestUrl": "https://singcode.kr/manifest.webmanifest",
"webManifestUrl": "https://www.singcode.kr/manifest.webmanifest",
"fallbackType": "customtabs",
"features": {},
"alphaDependencies": {
Expand All @@ -34,7 +34,7 @@
"enableSiteSettingsShortcut": true,
"isChromeOSOnly": false,
"isMetaQuest": false,
"fullScopeUrl": "https://singcode.kr/",
"fullScopeUrl": "https://www.singcode.kr/",
"minSdkVersion": 21,
"orientation": "portrait",
"fingerprints": [],
Expand All @@ -44,5 +44,5 @@
"fileHandlers": [],
"launchHandlerClientMode": "",
"displayOverride": [],
"appVersion": "3"
}
"appVersion": "7"
}
2 changes: 2 additions & 0 deletions apps/web/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,5 @@ next-env.d.ts
/public/sw.js.map
/public/swe-worker-*.js
/public/swe-worker-*.js.map

store-assets
3 changes: 2 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "2.8.0",
"version": "2.9.0",
"type": "module",
"private": true,
"scripts": {
Expand Down Expand Up @@ -43,6 +43,7 @@
"axios": "^1.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"country-flag-icons": "^1.6.20",
"date-fns": "^4.1.0",
"es-hangul": "^2.3.8",
"framer-motion": "^12.33.0",
Expand Down
Binary file modified apps/web/public/icons/icon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/web/public/icons/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/web/public/icons/icon-maskable-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/web/public/icons/icon-maskable-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion apps/web/src/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export default function Footer() {
const navPath = pathname.split('/')[1];

return (
<footer className="bg-background fixed bottom-0 flex h-8 w-full max-w-md justify-between border-t">
<footer className="bg-background fixed bottom-0 flex h-10 w-full max-w-md justify-between border-t">
{navigation.map(item => {
const isActive = '/' + navPath === item.href;
const isAnimating = footerAnimateKey === item.key;
Expand Down
85 changes: 75 additions & 10 deletions apps/web/src/app/api/search/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';

import { LANGUAGE_TAGS } from '@/constants/languageTags';
import createClient from '@/lib/supabase/server';
import { ApiResponse } from '@/types/apiRoute';
import { SearchSong, Song } from '@/types/song';
Expand Down Expand Up @@ -56,20 +57,36 @@ function buildColumnOrGroup(columns: string[], pattern: string): string {
return columns.map(column => `${column}.ilike.${pattern}`).join(',');
}

// languageTag가 있으면 song_tags(언어 태그) 조인 필터를 추가한다.
// select 절에 `song_tags!inner(tag_id)`가 함께 포함되어 있어야 한다.
function applyLanguageTagFilter<T extends { eq: (column: string, value: number) => T }>(
query: T,
languageTag?: number,
): T {
return languageTag ? query.eq('song_tags.tag_id', languageTag) : query;
}

// 정확 일치: 전체 구가 한 컬럼과 일치 (랭킹 상위). 띄어쓰기 차이는 무시한다.
function applyExactFilter(baseQuery: any, type: string, searchText: string) {
function applyExactFilter(baseQuery: any, type: string, searchText: string, languageTag?: number) {
if (type === 'number') {
return baseQuery.or(`num_tj.eq.${searchText},num_ky.eq.${searchText}`);
const query = baseQuery.or(`num_tj.eq.${searchText},num_ky.eq.${searchText}`);
return applyLanguageTagFilter(query, languageTag);
}

const phrase = buildPhrasePattern(tokenizeQuery(searchText));
const columns = getSearchColumns(type);

return baseQuery.or(buildColumnOrGroup(columns, phrase));
const query = baseQuery.or(buildColumnOrGroup(columns, phrase));
return applyLanguageTagFilter(query, languageTag);
}

// 부분 일치: 입력한 모든 토큰이 (순서 무관) 포함돼야 매칭(AND) + 정확 일치 결과는 제외.
function applyPartialFilter(baseQuery: any, type: string, searchText: string) {
function applyPartialFilter(
baseQuery: any,
type: string,
searchText: string,
languageTag?: number,
) {
const tokens = tokenizeQuery(searchText);
const phrase = buildPhrasePattern(tokens);
const columns = getSearchColumns(type);
Expand All @@ -90,7 +107,7 @@ function applyPartialFilter(baseQuery: any, type: string, searchText: string) {
query = query.or(`${column}.not.ilike.${phrase},${column}.is.null`);
}

return query;
return applyLanguageTagFilter(query, languageTag);
}

async function executeSearchQueries(
Expand All @@ -101,6 +118,7 @@ async function executeSearchQueries(
order: string,
from: number,
to: number,
languageTag?: number,
): Promise<{ data: DBSong[]; hasNext: boolean } | { error: string }> {
const size = to - from + 1;

Expand All @@ -110,11 +128,17 @@ async function executeSearchQueries(
supabase.from('songs').select(selectClause, { count: 'exact', head: true }),
type,
query,
languageTag,
);
if (exactCountResult.error) return { error: exactCountResult.error.message };
const exactTotal = exactCountResult.count ?? 0;

const exactQuery = applyExactFilter(supabase.from('songs').select(selectClause), type, query);
const exactQuery = applyExactFilter(
supabase.from('songs').select(selectClause),
type,
query,
languageTag,
);
const { data, error } = await exactQuery.order(order).range(from, to);
if (error) return { error: error.message };

Expand All @@ -134,11 +158,13 @@ async function executeSearchQueries(
supabase.from('songs').select(selectClause, { count: 'exact', head: true }),
type,
query,
languageTag,
);
const partialCountQuery = applyPartialFilter(
supabase.from('songs').select(selectClause, { count: 'exact', head: true }),
type,
query,
languageTag,
);

const [exactCountResult, partialCountResult] = await Promise.all([
Expand All @@ -163,6 +189,7 @@ async function executeSearchQueries(
supabase.from('songs').select(selectClause),
type,
query,
languageTag,
);
const { data, error } = await partialQuery.order(order).range(from, to);
if (error) return { error: error.message };
Expand All @@ -175,14 +202,20 @@ async function executeSearchQueries(
supabase.from('songs').select(selectClause),
type,
query,
languageTag,
);
const { data, error } = await partialQuery.order(order).range(partialFrom, partialTo);
if (error) return { error: error.message };
partialData = (data as DBSong[]) ?? [];
} else {
// 현재 페이지가 정확 일치 영역에 포함 (경계에 걸릴 수도 있음)
const exactTo = Math.min(to, exactTotal - 1);
const exactQuery = applyExactFilter(supabase.from('songs').select(selectClause), type, query);
const exactQuery = applyExactFilter(
supabase.from('songs').select(selectClause),
type,
query,
languageTag,
);
const exactResult = await exactQuery.order(order).range(from, exactTo);
if (exactResult.error) return { error: exactResult.error.message };
exactData = (exactResult.data as DBSong[]) ?? [];
Expand All @@ -194,6 +227,7 @@ async function executeSearchQueries(
supabase.from('songs').select(selectClause),
type,
query,
languageTag,
);
const partialResult = await partialQuery.order(order).range(0, partialTo);
if (partialResult.error) return { error: partialResult.error.message };
Expand All @@ -217,6 +251,23 @@ export async function GET(request: Request): Promise<NextResponse<ApiResponse<Se
const order = type === 'all' ? 'title' : type === 'number' ? 'num_tj' : type;
const authenticated = searchParams.get('authenticated') === 'true';

const languageTagParam = searchParams.get('languageTag');
let languageTag: number | undefined;
if (languageTagParam) {
const parsedLanguageTag = Number.parseInt(languageTagParam, 10);
const isValidLanguageTag = LANGUAGE_TAGS.some(tag => tag.id === parsedLanguageTag);
if (!isValidLanguageTag) {
return NextResponse.json(
{
success: false,
error: '유효하지 않은 languageTag 값입니다',
},
{ status: 400 },
);
}
languageTag = parsedLanguageTag;
}

const page = parseInt(searchParams.get('page') || '0', 10);
const size = 20;
const from = page * size;
Expand All @@ -234,11 +285,25 @@ export async function GET(request: Request): Promise<NextResponse<ApiResponse<Se

const supabase = await createClient();

const selectClause = authenticated
const baseSelectClause = authenticated
? `*, thumb_logs(*), tosings(user_id), like_activities(user_id), save_activities(user_id)`
: `*, thumb_logs(*)`;

const result = await executeSearchQueries(supabase, selectClause, query, type, order, from, to);
// languageTag로 필터링할 때만 song_tags를 inner join으로 함께 select한다.
// (그 외에는 태그 없는 곡이 결과에서 누락되지 않도록 조인하지 않는다)
const selectClause = languageTag
? `${baseSelectClause}, song_tags!inner(tag_id)`
: baseSelectClause;

const result = await executeSearchQueries(
supabase,
selectClause,
query,
type,
order,
from,
to,
languageTag,
);

if ('error' in result) {
return NextResponse.json(
Expand Down
Binary file modified apps/web/src/app/favicon.ico
Binary file not shown.
Binary file modified apps/web/src/app/favicon16.ico
Binary file not shown.
Binary file modified apps/web/src/app/favicon32.ico
Binary file not shown.
6 changes: 3 additions & 3 deletions apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ export const metadata: Metadata = {
},
twitter: {
card: 'summary_large_image',
title: 'Singcode - 노래방에서 부를 곡, 기억하지 말고 저장하세요',
description: '노래방 갈 때마다 잊어버리는 곡번호? Singcode가 대신 기억할게요!',
title: 'Singcode - 노래방 번호 검색',
description: 'Singcode에서 빠르고 편하게 노래방 번호 검색하세요. J-POP 검색도 지원합니다.',
images: ['/brand/thumbnail.png'],
},
robots: {
Expand Down Expand Up @@ -106,7 +106,7 @@ export default function RootLayout({
<Header />
<div className="h-full p-4">{children}</div>

<div className="pointer-events-none fixed bottom-8 left-1/2 z-40 w-full max-w-md -translate-x-1/2 px-1 pb-1">
<div className="pointer-events-none fixed bottom-10 left-1/2 z-40 w-full max-w-md -translate-x-1/2 px-1 pb-1">
<div className="pointer-events-auto">
<PromotionBanner />
</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/patch-notes/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default function PatchNotesPage() {
</div>

<ScrollArea className="mt-4 flex-1">
<ol className="relative flex flex-col gap-6 pb-8">
<ol className="relative flex flex-col gap-6 pb-10">
<span
aria-hidden
className="bg-border absolute top-3 bottom-3 left-[5rem] w-px sm:left-[6rem]"
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const metadata: Metadata = {

export default function PrivacyPage() {
return (
<div className="space-y-8 pb-8 text-sm leading-relaxed">
<div className="space-y-8 pb-10 text-sm leading-relaxed">
<div>
<h1 className="mb-2 text-2xl font-bold">Singcode 개인정보처리방침</h1>
<p className="text-muted-foreground">본 방침은 2025년 6월 18일부터 적용됩니다.</p>
Expand Down
Loading
Loading