공개: alpha.4 기준선 갱신
26
.github/workflows/ci.yml
vendored
|
|
@ -33,6 +33,32 @@ jobs:
|
||||||
- name: Run API integration tests
|
- name: Run API integration tests
|
||||||
run: dotnet test tests/PhysOn.Api.IntegrationTests/PhysOn.Api.IntegrationTests.csproj -c Release --no-restore
|
run: dotnet test tests/PhysOn.Api.IntegrationTests/PhysOn.Api.IntegrationTests.csproj -c Release --no-restore
|
||||||
|
|
||||||
|
web:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: src/PhysOn.Web/package-lock.json
|
||||||
|
|
||||||
|
- name: Install web dependencies
|
||||||
|
run: npm ci
|
||||||
|
working-directory: src/PhysOn.Web
|
||||||
|
|
||||||
|
- name: Lint web
|
||||||
|
run: npm run lint
|
||||||
|
working-directory: src/PhysOn.Web
|
||||||
|
|
||||||
|
- name: Build web
|
||||||
|
run: npm run build
|
||||||
|
working-directory: src/PhysOn.Web
|
||||||
|
|
||||||
desktop-windows:
|
desktop-windows:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
|
|
||||||
24
.github/workflows/release-portable.yml
vendored
|
|
@ -43,23 +43,23 @@ jobs:
|
||||||
global-json-file: global.json
|
global-json-file: global.json
|
||||||
|
|
||||||
- name: Restore desktop project
|
- name: Restore desktop project
|
||||||
run: dotnet restore src/VsMessenger.Desktop/VsMessenger.Desktop.csproj
|
run: dotnet restore src/PhysOn.Desktop/PhysOn.Desktop.csproj
|
||||||
|
|
||||||
- name: Publish portable desktop build
|
- name: Publish portable desktop build
|
||||||
run: dotnet publish src/VsMessenger.Desktop/VsMessenger.Desktop.csproj -c Release -r win-x64 --self-contained true -o out/win-x64
|
run: dotnet publish src/PhysOn.Desktop/PhysOn.Desktop.csproj -c Release -r win-x64 --self-contained true -o out/win-x64
|
||||||
|
|
||||||
- name: Create portable ZIP
|
- name: Create portable ZIP
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: Compress-Archive -Path out/win-x64/* -DestinationPath out/VsMessenger-win-x64.zip
|
run: Compress-Archive -Path out/win-x64/* -DestinationPath out/KoTalk-windows-x64.zip
|
||||||
|
|
||||||
- name: Upload Windows artifact
|
- name: Upload Windows artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: windows-portable
|
name: windows-portable
|
||||||
path: out/VsMessenger-win-x64.zip
|
path: out/KoTalk-windows-x64.zip
|
||||||
|
|
||||||
build-android:
|
build-android:
|
||||||
if: ${{ hashFiles('src/VsMessenger.Mobile.Android/*.csproj') != '' }}
|
if: ${{ hashFiles('src/PhysOn.Mobile.Android/*.csproj') != '' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -81,11 +81,11 @@ jobs:
|
||||||
run: dotnet workload install android
|
run: dotnet workload install android
|
||||||
|
|
||||||
- name: Restore Android project
|
- name: Restore Android project
|
||||||
run: dotnet restore src/VsMessenger.Mobile.Android/VsMessenger.Mobile.Android.csproj
|
run: dotnet restore src/PhysOn.Mobile.Android/PhysOn.Mobile.Android.csproj
|
||||||
|
|
||||||
- name: Publish Android APK
|
- name: Publish Android APK
|
||||||
run: |
|
run: |
|
||||||
dotnet publish src/VsMessenger.Mobile.Android/VsMessenger.Mobile.Android.csproj \
|
dotnet publish src/PhysOn.Mobile.Android/PhysOn.Mobile.Android.csproj \
|
||||||
-c Release \
|
-c Release \
|
||||||
-f net8.0-android \
|
-f net8.0-android \
|
||||||
-p:AndroidPackageFormat=apk \
|
-p:AndroidPackageFormat=apk \
|
||||||
|
|
@ -96,13 +96,13 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
apk_path="$(find out/android -type f -name '*.apk' | head -n 1)"
|
apk_path="$(find out/android -type f -name '*.apk' | head -n 1)"
|
||||||
test -n "$apk_path"
|
test -n "$apk_path"
|
||||||
cp "$apk_path" out/VsMessenger-android-universal.apk
|
cp "$apk_path" out/KoTalk-android-universal.apk
|
||||||
|
|
||||||
- name: Upload Android artifact
|
- name: Upload Android artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: android-apk
|
name: android-apk
|
||||||
path: out/VsMessenger-android-universal.apk
|
path: out/KoTalk-android-universal.apk
|
||||||
|
|
||||||
assemble-release:
|
assemble-release:
|
||||||
if: ${{ always() && needs.build-windows.result == 'success' && (needs.build-android.result == 'success' || needs.build-android.result == 'skipped') }}
|
if: ${{ always() && needs.build-windows.result == 'success' && (needs.build-android.result == 'success' || needs.build-android.result == 'skipped') }}
|
||||||
|
|
@ -148,12 +148,12 @@ jobs:
|
||||||
prepare_args=(
|
prepare_args=(
|
||||||
--version "$VERSION_INPUT"
|
--version "$VERSION_INPUT"
|
||||||
--channel "$channel"
|
--channel "$channel"
|
||||||
--windows-zip incoming/windows/VsMessenger-win-x64.zip
|
--windows-zip incoming/windows/KoTalk-windows-x64.zip
|
||||||
--force
|
--force
|
||||||
)
|
)
|
||||||
|
|
||||||
if [[ -f incoming/android/VsMessenger-android-universal.apk ]]; then
|
if [[ -f incoming/android/KoTalk-android-universal.apk ]]; then
|
||||||
prepare_args+=(--android-apk incoming/android/VsMessenger-android-universal.apk)
|
prepare_args+=(--android-apk incoming/android/KoTalk-android-universal.apk)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
./scripts/release/release-prepare-assets.sh \
|
./scripts/release/release-prepare-assets.sh \
|
||||||
|
|
|
||||||
16
CHANGELOG.md
|
|
@ -8,6 +8,13 @@
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- 공개 원격용 버전 태그 생성 스크립트 `scripts/release/release-create-tag.sh`
|
||||||
|
- GitHub 릴리즈 게시 스크립트 `scripts/release/release-publish-github.sh`
|
||||||
|
- 제2·제3 공개 원격 순차 게시 스크립트 `scripts/release/release-publish-public.sh`
|
||||||
|
|
||||||
|
- `artifacts/builds/2026.04.16-alpha.4/` Windows 포터블 산출물과 체크섬 추가
|
||||||
|
- `download-vstalk.phy.kr` HTTPS 미러를 실제 latest ZIP/manifest 경로로 활성화
|
||||||
|
|
||||||
- `KoTalk` 공개 브랜드 기준과 다운로드/릴리즈 표면 정리
|
- `KoTalk` 공개 브랜드 기준과 다운로드/릴리즈 표면 정리
|
||||||
- 공개 가입 전략을 `1회성 인증 중심`으로 재정의한 기획 문서 보강
|
- 공개 가입 전략을 `1회성 인증 중심`으로 재정의한 기획 문서 보강
|
||||||
- Apache-2.0 기준의 라이선스/상표/기여 정책 정리
|
- Apache-2.0 기준의 라이선스/상표/기여 정책 정리
|
||||||
|
|
@ -46,6 +53,15 @@
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- 공개 원격 배포 정책을 `public/* 브랜치 + 버전 태그 + 릴리즈 페이지 + 자산` 기준으로 고정
|
||||||
|
- Gitea/GitHub 릴리즈 게시 스크립트가 지정 원격 기준으로 동작하고 최신 스크린샷 자산도 함께 첨부하도록 확장
|
||||||
|
- 비-`origin` 원격에 대한 로컬 pre-push 가드가 `public/*` 브랜치와 `refs/tags/*`를 함께 허용하도록 조정
|
||||||
|
|
||||||
|
- 데스크톱·웹 UI의 설명형 카피를 더 줄이고 말풍선/칩/버튼 라운드를 2px 기준으로 축소
|
||||||
|
- 웹 앱 버전을 `0.1.0-alpha.4`로 올리고 최신 캡처 자산을 재생성
|
||||||
|
- 다운로드 스크립트, 릴리즈 워크플로, Caddy 예시 설정을 `download-vstalk.phy.kr`와 `KoTalk-*` 자산명 기준으로 정렬
|
||||||
|
- 다운로드 미러 상태를 DNS/HTTPS 정상 동작 기준으로 문서에 반영
|
||||||
|
|
||||||
- 공개 브랜드를 `KoTalk`로 정리하고 공개 문서의 직접적·내부지향 표현을 제거
|
- 공개 브랜드를 `KoTalk`로 정리하고 공개 문서의 직접적·내부지향 표현을 제거
|
||||||
- README를 대중용 첫인상 기준으로 다시 구성하고 다운로드는 공식 미러와 저장소 릴리즈를 함께 표기
|
- README를 대중용 첫인상 기준으로 다시 구성하고 다운로드는 공식 미러와 저장소 릴리즈를 함께 표기
|
||||||
- 보안/신뢰 문서에서 운영 힌트와 공유 접근값 노출을 줄이고 공개 범위를 재정의
|
- 보안/신뢰 문서에서 운영 힌트와 공유 접근값 노출을 줄이고 공개 범위를 재정의
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
| Public brand | `KoTalk` |
|
| Public brand | `KoTalk` |
|
||||||
| Stage | `Alpha` |
|
| Stage | `Alpha` |
|
||||||
| Most usable surface | Mobile web live + Windows build |
|
| Most usable surface | Mobile web live + Windows build |
|
||||||
| Biggest current gap | Android 실빌드와 다운로드 미러 정합성 |
|
| Biggest current gap | Android 실빌드와 데스크톱 멀티윈도우 완성도 |
|
||||||
| Signup direction | 공개형 1회성 인증 중심으로 재설계 중 |
|
| Signup direction | 공개형 1회성 인증 중심으로 재설계 중 |
|
||||||
| Tone of this repo | 현재 동작 범위와 남은 갭을 함께 적는 제품형 저장소 |
|
| Tone of this repo | 현재 동작 범위와 남은 갭을 함께 적는 제품형 저장소 |
|
||||||
|
|
||||||
|
|
@ -30,7 +30,7 @@ KoTalk는 아직 모든 플랫폼이 완성된 상태는 아니지만, “문서
|
||||||
| Windows desktop | 저장소 빌드 / 릴리즈 산출물 | Buildable | 핵심 메시징 루프 검증 가능 |
|
| Windows desktop | 저장소 빌드 / 릴리즈 산출물 | Buildable | 핵심 메시징 루프 검증 가능 |
|
||||||
| Mobile web | [vstalk.phy.kr](https://vstalk.phy.kr) | Live | 가입, 대화, 검색, 보관 1차 흐름 제공 |
|
| Mobile web | [vstalk.phy.kr](https://vstalk.phy.kr) | Live | 가입, 대화, 검색, 보관 1차 흐름 제공 |
|
||||||
| Android | 저장소 릴리즈 예정 | In progress | 문서와 배포 구조 우선 정리 중 |
|
| Android | 저장소 릴리즈 예정 | In progress | 문서와 배포 구조 우선 정리 중 |
|
||||||
| Official mirror | [download-vstalk.phy.kr](https://download-vstalk.phy.kr) | Normalizing | 2026-04-16 기준 DNS/HTTPS 정합성 확인 필요 |
|
| Official mirror | [download-vstalk.phy.kr](https://download-vstalk.phy.kr) | Live | Windows latest와 version manifest를 HTTPS로 제공 |
|
||||||
|
|
||||||
## Verified Now
|
## Verified Now
|
||||||
|
|
||||||
|
|
@ -67,7 +67,6 @@ KoTalk는 아직 모든 플랫폼이 완성된 상태는 아니지만, “문서
|
||||||
## In Progress
|
## In Progress
|
||||||
|
|
||||||
- Android 첫 실사용 빌드
|
- Android 첫 실사용 빌드
|
||||||
- 공개 다운로드 미러 정합성
|
|
||||||
- 릴리즈 페이지와 미러 간 latest 라우트 통합
|
- 릴리즈 페이지와 미러 간 latest 라우트 통합
|
||||||
- 검색 범위 확장
|
- 검색 범위 확장
|
||||||
- 파일 전송
|
- 파일 전송
|
||||||
|
|
@ -80,7 +79,7 @@ KoTalk는 아직 모든 플랫폼이 완성된 상태는 아니지만, “문서
|
||||||
- Android 실사용 빌드는 아직 제공되지 않습니다.
|
- Android 실사용 빌드는 아직 제공되지 않습니다.
|
||||||
- 파일 전송은 미구현입니다.
|
- 파일 전송은 미구현입니다.
|
||||||
- 검색은 전역 파일/링크/사람 범위까지 확장되지 않았습니다.
|
- 검색은 전역 파일/링크/사람 범위까지 확장되지 않았습니다.
|
||||||
- 공식 다운로드 미러는 DNS/HTTPS 정상화가 끝나야 안정 채널로 표기할 수 있습니다.
|
- 공식 다운로드 미러는 현재 Windows latest와 version manifest 기준으로 동작합니다.
|
||||||
- 데스크톱 멀티 윈도우는 방향은 잡혀 있지만, 실제 생산성 흐름은 더 다듬어야 합니다.
|
- 데스크톱 멀티 윈도우는 방향은 잡혀 있지만, 실제 생산성 흐름은 더 다듬어야 합니다.
|
||||||
|
|
||||||
## Download And Release Paths
|
## Download And Release Paths
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
<a href="문서/README.md"><img alt="docs" src="https://img.shields.io/badge/docs-master%20plan%20%2B%20atlas-111827"></a>
|
<a href="문서/README.md"><img alt="docs" src="https://img.shields.io/badge/docs-master%20plan%20%2B%20atlas-111827"></a>
|
||||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-Apache--2.0-1F2937"></a>
|
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-Apache--2.0-1F2937"></a>
|
||||||
<a href="https://vstalk.phy.kr"><img alt="web" src="https://img.shields.io/badge/live-vstalk.phy.kr-0F766E"></a>
|
<a href="https://vstalk.phy.kr"><img alt="web" src="https://img.shields.io/badge/live-vstalk.phy.kr-0F766E"></a>
|
||||||
<a href="https://download-vstalk.phy.kr"><img alt="download" src="https://img.shields.io/badge/download-mirror%20reserved-F59E0B"></a>
|
<a href="https://download-vstalk.phy.kr"><img alt="download" src="https://img.shields.io/badge/download-mirror%20live-166534"></a>
|
||||||
<a href="PROJECT_STATUS.md"><img alt="verified" src="https://img.shields.io/badge/verified-2026--04--16-6B7280"></a>
|
<a href="PROJECT_STATUS.md"><img alt="verified" src="https://img.shields.io/badge/verified-2026--04--16-6B7280"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -108,7 +108,7 @@ KoTalk는 이 배경을 리스크 문구로 숨기지 않고, 왜 이 프로젝
|
||||||
| Windows desktop | 저장소 빌드와 버전별 산출물 | Buildable | 핵심 메시징 루프와 데스크톱 레이아웃 실험 진행 중 |
|
| Windows desktop | 저장소 빌드와 버전별 산출물 | Buildable | 핵심 메시징 루프와 데스크톱 레이아웃 실험 진행 중 |
|
||||||
| Mobile web | [vstalk.phy.kr](https://vstalk.phy.kr) | Live | 가입, 대화, 검색, 보관 1차 흐름 검증 |
|
| Mobile web | [vstalk.phy.kr](https://vstalk.phy.kr) | Live | 가입, 대화, 검색, 보관 1차 흐름 검증 |
|
||||||
| Android | 저장소 릴리즈 예정 | In progress | 문서와 배포 구조 우선 정리 중 |
|
| Android | 저장소 릴리즈 예정 | In progress | 문서와 배포 구조 우선 정리 중 |
|
||||||
| Official download mirror | [download-vstalk.phy.kr](https://download-vstalk.phy.kr) | Normalizing | 2026-04-16 기준 DNS/HTTPS 정합성 점검 진행 중 |
|
| Official download mirror | [download-vstalk.phy.kr](https://download-vstalk.phy.kr) | Live | Windows latest와 version manifest를 HTTPS로 제공 |
|
||||||
|
|
||||||
## Architecture Snapshot
|
## Architecture Snapshot
|
||||||
|
|
||||||
|
|
|
||||||
23
RELEASING.md
|
|
@ -14,8 +14,8 @@ KoTalk의 릴리즈는 단순한 파일 업로드가 아니라, 산출물과 공
|
||||||
|
|
||||||
## Current Note
|
## Current Note
|
||||||
|
|
||||||
2026-04-16 기준 [download-vstalk.phy.kr](https://download-vstalk.phy.kr)의 DNS/HTTPS 정합성은 재점검이 필요합니다.
|
2026-04-16 기준 [download-vstalk.phy.kr](https://download-vstalk.phy.kr)는 DNS와 HTTPS가 정상입니다.
|
||||||
그래서 현재는 저장소 릴리즈 경로를 함께 유지하는 것을 원칙으로 둡니다.
|
현재는 Windows latest와 version manifest를 제공하고, 저장소 릴리즈 경로를 함께 유지합니다.
|
||||||
|
|
||||||
## Minimum Release Contract
|
## Minimum Release Contract
|
||||||
|
|
||||||
|
|
@ -24,6 +24,8 @@ KoTalk의 릴리즈는 단순한 파일 업로드가 아니라, 산출물과 공
|
||||||
3. [CHANGELOG.md](CHANGELOG.md)에 의미 있는 변경이 기록돼야 합니다.
|
3. [CHANGELOG.md](CHANGELOG.md)에 의미 있는 변경이 기록돼야 합니다.
|
||||||
4. 최신 스크린샷이 현재 UI를 대표해야 합니다.
|
4. 최신 스크린샷이 현재 UI를 대표해야 합니다.
|
||||||
5. 다운로드 경로와 릴리즈 링크가 함께 갱신돼야 합니다.
|
5. 다운로드 경로와 릴리즈 링크가 함께 갱신돼야 합니다.
|
||||||
|
6. 공개 원격은 `브랜치 + 태그 + 릴리즈 페이지 + 자산`을 한 세트로 맞춥니다.
|
||||||
|
7. 공개 릴리즈 페이지에는 산출물과 최신 스크린샷을 함께 게시합니다.
|
||||||
|
|
||||||
## Platform Policy
|
## Platform Policy
|
||||||
|
|
||||||
|
|
@ -31,6 +33,23 @@ KoTalk의 릴리즈는 단순한 파일 업로드가 아니라, 산출물과 공
|
||||||
- Mobile web: 라이브 반영이 있으면 스크린샷과 상태 문서를 함께 갱신합니다.
|
- Mobile web: 라이브 반영이 있으면 스크린샷과 상태 문서를 함께 갱신합니다.
|
||||||
- Android: APK 공개 시 공식 미러와 저장소 릴리즈를 함께 맞춥니다.
|
- Android: APK 공개 시 공식 미러와 저장소 릴리즈를 함께 맞춥니다.
|
||||||
|
|
||||||
|
## Public Release Sequence
|
||||||
|
|
||||||
|
1. 내부 기준선에서 산출물과 스크린샷을 먼저 고정합니다.
|
||||||
|
2. `public/*` 브랜치에 공개 가능한 이력을 정리합니다.
|
||||||
|
3. 같은 기준선에 버전 태그를 생성합니다.
|
||||||
|
4. 제2 공개 레포에 브랜치와 태그를 푸시합니다.
|
||||||
|
5. 제2 공개 레포 릴리즈 페이지에 자산과 노트를 게시합니다.
|
||||||
|
6. 명시적 요청이 있을 때만 같은 태그와 자산을 제3 공개 레포에 게시합니다.
|
||||||
|
7. `download-vstalk.phy.kr`는 최신 포인터만 유지합니다.
|
||||||
|
|
||||||
|
## Release Scripts
|
||||||
|
|
||||||
|
- 공개 기준 태그 생성: [`scripts/create-release-tag.sh`](scripts/create-release-tag.sh)
|
||||||
|
- 제2 공개 레포 릴리즈 게시: [`scripts/publish-gitea-release.sh`](scripts/publish-gitea-release.sh)
|
||||||
|
- 제3 GitHub 릴리즈 게시: [`scripts/publish-github-release.sh`](scripts/publish-github-release.sh)
|
||||||
|
- 공개 브랜치/태그/릴리즈 순차 게시: [`scripts/publish-public-release.sh`](scripts/publish-public-release.sh)
|
||||||
|
|
||||||
## Related Docs
|
## Related Docs
|
||||||
|
|
||||||
- 배포 골격: [deploy/README.md](deploy/README.md)
|
- 배포 골격: [deploy/README.md](deploy/README.md)
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,18 @@
|
||||||
root * /srv/download
|
root * /srv/download
|
||||||
redir /windows /windows/latest 302
|
redir /windows /windows/latest 302
|
||||||
redir /android /android/latest 302
|
redir /android /android/latest 302
|
||||||
redir /windows/latest /windows/latest/VsMessenger-win-x64.zip 302
|
redir /windows/latest /latest/KoTalk-windows-x64.zip 302
|
||||||
redir /android/latest /android/latest/VsMessenger-android-universal.apk 302
|
redir /android/latest /latest/KoTalk-android-universal.apk 302
|
||||||
redir /latest /latest/version.json 302
|
redir /latest /latest/version.json 302
|
||||||
header {
|
header {
|
||||||
|
Content-Security-Policy "default-src 'none'; frame-ancestors 'none'; base-uri 'none'"
|
||||||
|
Cross-Origin-Resource-Policy "same-origin"
|
||||||
|
Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
|
||||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
X-Content-Type-Options "nosniff"
|
X-Content-Type-Options "nosniff"
|
||||||
X-Frame-Options "DENY"
|
X-Frame-Options "DENY"
|
||||||
Referrer-Policy "strict-origin-when-cross-origin"
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
X-Robots-Tag "noindex, nofollow"
|
||||||
}
|
}
|
||||||
file_server
|
file_server
|
||||||
}
|
}
|
||||||
|
|
@ -22,6 +26,10 @@
|
||||||
{$WEBAPP_HOST:vstalk.phy.kr} {
|
{$WEBAPP_HOST:vstalk.phy.kr} {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
header {
|
header {
|
||||||
|
Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' https: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
|
||||||
|
Cross-Origin-Opener-Policy "same-origin"
|
||||||
|
Cross-Origin-Resource-Policy "same-origin"
|
||||||
|
Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
|
||||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
X-Content-Type-Options "nosniff"
|
X-Content-Type-Options "nosniff"
|
||||||
X-Frame-Options "DENY"
|
X-Frame-Options "DENY"
|
||||||
|
|
@ -41,6 +49,9 @@
|
||||||
{$API_HOST} {
|
{$API_HOST} {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
header {
|
header {
|
||||||
|
Content-Security-Policy "default-src 'none'; frame-ancestors 'none'; base-uri 'none'"
|
||||||
|
Cross-Origin-Resource-Policy "same-origin"
|
||||||
|
Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
|
||||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
X-Content-Type-Options "nosniff"
|
X-Content-Type-Options "nosniff"
|
||||||
Referrer-Policy "strict-origin-when-cross-origin"
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 55 KiB |
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
- 같은 버전 번호 아래에 Windows와 Android 산출물을 병렬로 보관합니다.
|
- 같은 버전 번호 아래에 Windows와 Android 산출물을 병렬로 보관합니다.
|
||||||
- `latest/`는 최신 포인터, `releases/<version>/`는 불변 이력으로 구분합니다.
|
- `latest/`는 최신 포인터, `releases/<version>/`는 불변 이력으로 구분합니다.
|
||||||
- 원격 Forge Releases는 버전별 원본 저장소, `download-vs-messanger.phy.kr`는 최종 사용자용 다운로드 미러로 사용합니다.
|
- 원격 Forge Releases는 버전별 원본 저장소, `download-vstalk.phy.kr`는 최종 사용자용 다운로드 미러로 사용합니다.
|
||||||
|
|
||||||
## 목표 구조
|
## 목표 구조
|
||||||
|
|
||||||
|
|
@ -19,11 +19,11 @@ release-assets/
|
||||||
SHA256SUMS.txt
|
SHA256SUMS.txt
|
||||||
screenshots/
|
screenshots/
|
||||||
windows/
|
windows/
|
||||||
VsMessenger-win-x64.zip
|
KoTalk-windows-x64.zip
|
||||||
SHA256SUMS.txt
|
SHA256SUMS.txt
|
||||||
version.json
|
version.json
|
||||||
android/
|
android/
|
||||||
VsMessenger-android-universal.apk
|
KoTalk-android-universal.apk
|
||||||
SHA256SUMS.txt
|
SHA256SUMS.txt
|
||||||
version.json
|
version.json
|
||||||
releases/
|
releases/
|
||||||
|
|
@ -34,11 +34,11 @@ release-assets/
|
||||||
screenshots/
|
screenshots/
|
||||||
windows/
|
windows/
|
||||||
x64/
|
x64/
|
||||||
VsMessenger-win-x64-v0.2.0-alpha.1.zip
|
KoTalk-windows-x64-v0.2.0-alpha.1.zip
|
||||||
SHA256SUMS.txt
|
SHA256SUMS.txt
|
||||||
android/
|
android/
|
||||||
universal/
|
universal/
|
||||||
VsMessenger-android-universal-v0.2.0-alpha.1.apk
|
KoTalk-android-universal-v0.2.0-alpha.1.apk
|
||||||
SHA256SUMS.txt
|
SHA256SUMS.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -52,11 +52,11 @@ release-assets/
|
||||||
|
|
||||||
## 다운로드 경로 규칙
|
## 다운로드 경로 규칙
|
||||||
|
|
||||||
- 최신 Windows: `https://download-vs-messanger.phy.kr/windows/latest`
|
- 최신 Windows: `https://download-vstalk.phy.kr/windows/latest`
|
||||||
- 최신 Android: `https://download-vs-messanger.phy.kr/android/latest`
|
- 최신 Android: `https://download-vstalk.phy.kr/android/latest`
|
||||||
- 전체 최신 메타데이터: `https://download-vs-messanger.phy.kr/latest/version.json`
|
- 전체 최신 메타데이터: `https://download-vstalk.phy.kr/latest/version.json`
|
||||||
- 버전별 Windows: `https://download-vs-messanger.phy.kr/releases/<version>/windows/x64/...`
|
- 버전별 Windows: `https://download-vstalk.phy.kr/releases/<version>/windows/x64/...`
|
||||||
- 버전별 Android: `https://download-vs-messanger.phy.kr/releases/<version>/android/universal/...`
|
- 버전별 Android: `https://download-vstalk.phy.kr/releases/<version>/android/universal/...`
|
||||||
|
|
||||||
## 생성 스크립트
|
## 생성 스크립트
|
||||||
|
|
||||||
|
|
@ -68,8 +68,8 @@ release-assets/
|
||||||
./scripts/release/release-prepare-assets.sh \
|
./scripts/release/release-prepare-assets.sh \
|
||||||
--version v0.2.0-alpha.1 \
|
--version v0.2.0-alpha.1 \
|
||||||
--channel alpha \
|
--channel alpha \
|
||||||
--windows-zip artifacts/release/VsMessenger-win-x64-v0.2.0-alpha.1.zip \
|
--windows-zip artifacts/release/KoTalk-windows-x64-v0.2.0-alpha.1.zip \
|
||||||
--android-apk artifacts/release/VsMessenger-android-universal-v0.2.0-alpha.1.apk \
|
--android-apk artifacts/release/KoTalk-android-universal-v0.2.0-alpha.1.apk \
|
||||||
--screenshots artifacts/screenshots \
|
--screenshots artifacts/screenshots \
|
||||||
--force
|
--force
|
||||||
```
|
```
|
||||||
|
|
@ -78,16 +78,20 @@ release-assets/
|
||||||
|
|
||||||
- VPS 다운로드 미러 업로드: `scripts/release/release-upload-assets.sh`
|
- VPS 다운로드 미러 업로드: `scripts/release/release-upload-assets.sh`
|
||||||
- Forge Releases 게시: `scripts/release/release-publish-forge.sh`
|
- Forge Releases 게시: `scripts/release/release-publish-forge.sh`
|
||||||
|
- GitHub Releases 게시: `scripts/release/release-publish-github.sh`
|
||||||
|
- 공개 원격 전체 게시: `scripts/release/release-publish-public.sh`
|
||||||
|
- 공개 태그 생성: `scripts/release/release-create-tag.sh`
|
||||||
|
|
||||||
두 채널은 목적이 다릅니다.
|
두 채널은 목적이 다릅니다.
|
||||||
|
|
||||||
- Forge Releases: 버전별 원본 보관
|
- Forge Releases: 버전별 원본 보관
|
||||||
- 다운로드 미러: 최신 포인터와 빠른 정적 다운로드
|
- 다운로드 미러: 최신 포인터와 빠른 정적 다운로드
|
||||||
- 모바일 웹앱: `release-assets/`가 아니라 `vstalk.phy.kr` 배포 트랙에서 별도 운영
|
- 모바일 웹앱: `release-assets/`가 아니라 `vstalk.phy.kr` 배포 트랙에서 별도 운영
|
||||||
|
- 공개 원격 릴리즈 페이지에는 ZIP/APK뿐 아니라 `screenshots/` 아래 최신 캡처도 함께 게시합니다.
|
||||||
|
|
||||||
## 운영 메모
|
## 운영 메모
|
||||||
|
|
||||||
- 생성된 버전별 산출물은 기본적으로 Git 추적 대상이 아닙니다.
|
- 생성된 버전별 산출물은 워크트리에 유지하며, 최신 로컬 검수와 서버 업로드 기준으로 사용합니다.
|
||||||
- 공개 릴리즈마다 `RELEASE_NOTES.ko.md`, `SHA256SUMS.txt`, `version.json`을 함께 갱신합니다.
|
- 공개 릴리즈마다 `RELEASE_NOTES.ko.md`, `SHA256SUMS.txt`, `version.json`을 함께 갱신합니다.
|
||||||
- 같은 버전에서 Windows만 있고 Android가 아직 없을 수는 있지만, 장기 원칙은 `같은 버전 아래 두 플랫폼 병렬 게시`입니다.
|
- 같은 버전에서 Windows만 있고 Android가 아직 없을 수는 있지만, 장기 원칙은 `같은 버전 아래 두 플랫폼 병렬 게시`입니다.
|
||||||
- 모바일 웹앱 정적 산출물은 `release-assets/`가 아니라 `/srv/vs-messanger/webapp/releases/<version>`에 배포합니다.
|
- 모바일 웹앱 정적 산출물은 `release-assets/`가 아니라 `/srv/vs-messanger/webapp/releases/<version>`에 배포합니다.
|
||||||
|
|
|
||||||
4
scripts/capture_kotalk_desktop_screenshots.sh
Executable file
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
"$SCRIPT_DIR/ci/capture-kotalk-desktop-screenshots.sh" "$@"
|
||||||
206
scripts/ci/capture-kotalk-desktop-screenshots.sh
Executable file
|
|
@ -0,0 +1,206 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
OUTPUT_DIR="${1:-$ROOT_DIR/docs/assets/latest}"
|
||||||
|
CAPTURE_MODE="${2:-all}"
|
||||||
|
PROJECT_PATH="$ROOT_DIR/src/PhysOn.Desktop/PhysOn.Desktop.csproj"
|
||||||
|
DOTNET_BIN="${DOTNET_BIN:-$HOME/.dotnet/dotnet}"
|
||||||
|
|
||||||
|
if [[ ! -x "$DOTNET_BIN" ]]; then
|
||||||
|
echo "dotnet not found at $DOTNET_BIN" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||||
|
|
||||||
|
capture_mode() {
|
||||||
|
local mode="$1"
|
||||||
|
local output_path="$2"
|
||||||
|
local main_title="$3"
|
||||||
|
local detached_title="${4:-}"
|
||||||
|
local data_home="$TMP_DIR/$mode-data"
|
||||||
|
local config_home="$TMP_DIR/$mode-config"
|
||||||
|
local cache_home="$TMP_DIR/$mode-cache"
|
||||||
|
local runtime_home="$TMP_DIR/$mode-runtime"
|
||||||
|
local tree_path="$TMP_DIR/$mode-tree.txt"
|
||||||
|
local log_path="$TMP_DIR/$mode.log"
|
||||||
|
mkdir -p "$data_home" "$config_home" "$cache_home" "$runtime_home"
|
||||||
|
|
||||||
|
env \
|
||||||
|
XDG_DATA_HOME="$data_home" \
|
||||||
|
XDG_CONFIG_HOME="$config_home" \
|
||||||
|
XDG_CACHE_HOME="$cache_home" \
|
||||||
|
XDG_RUNTIME_DIR="$runtime_home" \
|
||||||
|
DOTNET_BIN="$DOTNET_BIN" \
|
||||||
|
PROJECT_PATH="$PROJECT_PATH" \
|
||||||
|
TREE_PATH="$tree_path" \
|
||||||
|
LOG_PATH="$log_path" \
|
||||||
|
OUTPUT_PATH="$output_path" \
|
||||||
|
MAIN_TITLE="$main_title" \
|
||||||
|
DETACHED_TITLE="$detached_title" \
|
||||||
|
MODE="$mode" \
|
||||||
|
xvfb-run -a bash -lc '
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
refresh_tree() {
|
||||||
|
xwininfo -root -tree >"$TREE_PATH" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
find_window_id() {
|
||||||
|
local title="$1"
|
||||||
|
python3 - "$TREE_PATH" "$title" <<'"'"'PY'"'"'
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
tree_path, title = sys.argv[1], sys.argv[2]
|
||||||
|
pattern = re.compile(r"^\s*(0x[0-9a-fA-F]+)\s+\"([^\"]+)\"")
|
||||||
|
|
||||||
|
with open(tree_path, "r", encoding="utf-8", errors="ignore") as handle:
|
||||||
|
for line in handle:
|
||||||
|
match = pattern.match(line)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
window_id, window_title = match.groups()
|
||||||
|
if window_title == title:
|
||||||
|
print(window_id)
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_window() {
|
||||||
|
local title="$1"
|
||||||
|
local attempts="${2:-40}"
|
||||||
|
local pause="${3:-0.5}"
|
||||||
|
local window_id=""
|
||||||
|
|
||||||
|
for _ in $(seq 1 "$attempts"); do
|
||||||
|
refresh_tree
|
||||||
|
if window_id="$(find_window_id "$title" 2>/dev/null)"; then
|
||||||
|
echo "$window_id"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep "$pause"
|
||||||
|
done
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
window_geometry() {
|
||||||
|
local window_id="$1"
|
||||||
|
python3 - "$window_id" <<'"'"'PY'"'"'
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
window_id = sys.argv[1]
|
||||||
|
output = subprocess.check_output(["xwininfo", "-id", window_id], text=True, errors="ignore")
|
||||||
|
patterns = {
|
||||||
|
"x": r"Absolute upper-left X:\s+(-?\d+)",
|
||||||
|
"y": r"Absolute upper-left Y:\s+(-?\d+)",
|
||||||
|
"w": r"Width:\s+(\d+)",
|
||||||
|
"h": r"Height:\s+(\d+)",
|
||||||
|
}
|
||||||
|
|
||||||
|
values = {}
|
||||||
|
for key, pattern in patterns.items():
|
||||||
|
match = re.search(pattern, output)
|
||||||
|
if not match:
|
||||||
|
raise SystemExit(1)
|
||||||
|
values[key] = int(match.group(1))
|
||||||
|
|
||||||
|
if values["w"] < 240 or values["h"] < 240:
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
print(values["x"], values["y"], values["w"], values["h"])
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_geometry() {
|
||||||
|
local window_id="$1"
|
||||||
|
local attempts="${2:-30}"
|
||||||
|
local pause="${3:-0.4}"
|
||||||
|
local geometry=""
|
||||||
|
local previous=""
|
||||||
|
|
||||||
|
for _ in $(seq 1 "$attempts"); do
|
||||||
|
if geometry="$(window_geometry "$window_id" 2>/dev/null)"; then
|
||||||
|
if [[ "$geometry" == "$previous" ]]; then
|
||||||
|
echo "$geometry"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
previous="$geometry"
|
||||||
|
fi
|
||||||
|
sleep "$pause"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -n "$previous" ]]; then
|
||||||
|
echo "$previous"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
capture_window() {
|
||||||
|
local window_id="$1"
|
||||||
|
local target_path="$2"
|
||||||
|
local root_capture="${target_path%.*}-root.${target_path##*.}"
|
||||||
|
local geometry
|
||||||
|
read -r crop_x crop_y crop_w crop_h < <(wait_for_geometry "$window_id")
|
||||||
|
import -window root "$root_capture"
|
||||||
|
convert "$root_capture" -crop "${crop_w}x${crop_h}+${crop_x}+${crop_y}" +repage "$target_path"
|
||||||
|
rm -f "$root_capture"
|
||||||
|
}
|
||||||
|
|
||||||
|
create_conversation_fallback() {
|
||||||
|
local source_path="$1"
|
||||||
|
local target_path="$2"
|
||||||
|
convert "$source_path" -gravity east -crop 58%x84%+0+0 +repage "$target_path"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "$MODE" == "sample" ]]; then
|
||||||
|
export KOTALK_DESKTOP_SAMPLE_MODE=1
|
||||||
|
export KOTALK_DESKTOP_OPEN_SAMPLE_WINDOW=1
|
||||||
|
fi
|
||||||
|
export XDG_DATA_HOME="$XDG_DATA_HOME"
|
||||||
|
export XDG_CONFIG_HOME="$XDG_CONFIG_HOME"
|
||||||
|
export XDG_CACHE_HOME="$XDG_CACHE_HOME"
|
||||||
|
export XDG_RUNTIME_DIR="$XDG_RUNTIME_DIR"
|
||||||
|
|
||||||
|
"$DOTNET_BIN" run --project "$PROJECT_PATH" -c Debug >"$LOG_PATH" 2>&1 &
|
||||||
|
app_pid=$!
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
kill "$app_pid" >/dev/null 2>&1 || true
|
||||||
|
wait "$app_pid" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
main_id="$(wait_for_window "$MAIN_TITLE" 60 0.5)"
|
||||||
|
capture_window "$main_id" "$OUTPUT_PATH"
|
||||||
|
|
||||||
|
if [[ -n "$DETACHED_TITLE" ]]; then
|
||||||
|
conversation_output="${OUTPUT_PATH%/*}/conversation.${OUTPUT_PATH##*.}"
|
||||||
|
if detached_id="$(wait_for_window "$DETACHED_TITLE" 12 0.5 2>/dev/null)"; then
|
||||||
|
capture_window "$detached_id" "$conversation_output"
|
||||||
|
else
|
||||||
|
create_conversation_fallback "$OUTPUT_PATH" "$conversation_output"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "$CAPTURE_MODE" == "all" || "$CAPTURE_MODE" == "onboarding" ]]; then
|
||||||
|
capture_mode "onboarding" "$OUTPUT_DIR/onboarding.png" "KoTalk"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$CAPTURE_MODE" == "all" || "$CAPTURE_MODE" == "sample" ]]; then
|
||||||
|
capture_mode "sample" "$OUTPUT_DIR/hero-shell.png" "KoTalk" "제품 운영"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Desktop screenshots written to $OUTPUT_DIR"
|
||||||
5
scripts/create-release-tag.sh
Executable file
|
|
@ -0,0 +1,5 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
exec "$script_dir/release/release-create-tag.sh" "$@"
|
||||||
5
scripts/publish-github-release.sh
Executable file
|
|
@ -0,0 +1,5 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
exec "$script_dir/release/release-publish-github.sh" "$@"
|
||||||
5
scripts/publish-public-release.sh
Executable file
|
|
@ -0,0 +1,5 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
exec "$script_dir/release/release-publish-public.sh" "$@"
|
||||||
102
scripts/release/release-create-tag.sh
Executable file
|
|
@ -0,0 +1,102 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
./scripts/release/release-create-tag.sh --version 2026.04.16-alpha.4 [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--ref <git-ref> Target ref or commit. Default: public/main
|
||||||
|
--message <text> Annotated tag message
|
||||||
|
--remote <name> Push tag to a remote after creation
|
||||||
|
--force Replace an existing local tag
|
||||||
|
--dry-run Print what would happen
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
version=""
|
||||||
|
target_ref="public/main"
|
||||||
|
message=""
|
||||||
|
remote_name=""
|
||||||
|
force="false"
|
||||||
|
dry_run="false"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--version)
|
||||||
|
version="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--ref)
|
||||||
|
target_ref="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--message)
|
||||||
|
message="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--remote)
|
||||||
|
remote_name="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--force)
|
||||||
|
force="true"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
dry_run="true"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown argument: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "$version" ]]; then
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
cd "$repo_root"
|
||||||
|
|
||||||
|
target_commit="$(git rev-parse "$target_ref")"
|
||||||
|
tag_message="${message:-Release $version}"
|
||||||
|
|
||||||
|
echo "Tag: $version"
|
||||||
|
echo "Target ref: $target_ref"
|
||||||
|
echo "Target commit: $target_commit"
|
||||||
|
[[ -n "$remote_name" ]] && echo "Remote: $remote_name"
|
||||||
|
|
||||||
|
if [[ "$dry_run" == "true" ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if git rev-parse -q --verify "refs/tags/$version" >/dev/null; then
|
||||||
|
if [[ "$force" != "true" ]]; then
|
||||||
|
echo "Tag already exists: $version" >&2
|
||||||
|
echo "Use --force to replace it." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
git tag -d "$version" >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
git tag -a "$version" "$target_commit" -m "$tag_message"
|
||||||
|
|
||||||
|
if [[ -n "$remote_name" ]]; then
|
||||||
|
push_args=()
|
||||||
|
if [[ "$force" == "true" ]]; then
|
||||||
|
push_args+=(--force)
|
||||||
|
fi
|
||||||
|
git push "${push_args[@]}" "$remote_name" "refs/tags/$version:refs/tags/$version"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Created annotated tag $version at $target_commit"
|
||||||
|
|
@ -16,7 +16,7 @@ Options:
|
||||||
--force Overwrite an existing release folder
|
--force Overwrite an existing release folder
|
||||||
|
|
||||||
Environment:
|
Environment:
|
||||||
DOWNLOAD_BASE_URL Defaults to https://download-vs-messanger.phy.kr
|
DOWNLOAD_BASE_URL Defaults to https://download-vstalk.phy.kr
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,7 +95,7 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
release_root="$repo_root/release-assets/releases/$version"
|
release_root="$repo_root/release-assets/releases/$version"
|
||||||
latest_root="$repo_root/release-assets/latest"
|
latest_root="$repo_root/release-assets/latest"
|
||||||
template_path="$repo_root/release-assets/templates/RELEASE_NOTES.ko.md"
|
template_path="$repo_root/release-assets/templates/RELEASE_NOTES.ko.md"
|
||||||
download_base_url="${DOWNLOAD_BASE_URL:-https://download-vs-messanger.phy.kr}"
|
download_base_url="${DOWNLOAD_BASE_URL:-https://download-vstalk.phy.kr}"
|
||||||
published_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
published_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||||
|
|
||||||
derive_release_url() {
|
derive_release_url() {
|
||||||
|
|
@ -179,8 +179,8 @@ EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
if [[ -n "$windows_zip" ]]; then
|
if [[ -n "$windows_zip" ]]; then
|
||||||
windows_release_name="VsMessenger-win-x64-$version.zip"
|
windows_release_name="KoTalk-windows-x64-$version.zip"
|
||||||
windows_latest_name="VsMessenger-win-x64.zip"
|
windows_latest_name="KoTalk-windows-x64.zip"
|
||||||
windows_release_dir="$release_root/windows/x64"
|
windows_release_dir="$release_root/windows/x64"
|
||||||
windows_latest_dir="$latest_root/windows"
|
windows_latest_dir="$latest_root/windows"
|
||||||
mkdir -p "$windows_release_dir" "$windows_latest_dir"
|
mkdir -p "$windows_release_dir" "$windows_latest_dir"
|
||||||
|
|
@ -229,8 +229,8 @@ EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "$android_apk" ]]; then
|
if [[ -n "$android_apk" ]]; then
|
||||||
android_release_name="VsMessenger-android-universal-$version.apk"
|
android_release_name="KoTalk-android-universal-$version.apk"
|
||||||
android_latest_name="VsMessenger-android-universal.apk"
|
android_latest_name="KoTalk-android-universal.apk"
|
||||||
android_release_dir="$release_root/android/universal"
|
android_release_dir="$release_root/android/universal"
|
||||||
android_latest_dir="$latest_root/android"
|
android_latest_dir="$latest_root/android"
|
||||||
mkdir -p "$android_release_dir" "$android_latest_dir"
|
mkdir -p "$android_release_dir" "$android_latest_dir"
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,23 @@ Usage:
|
||||||
./scripts/release/release-publish-forge.sh --version v0.2.0-alpha.1 [options]
|
./scripts/release/release-publish-forge.sh --version v0.2.0-alpha.1 [options]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
|
--remote <name> Git remote name. Default: public-stage
|
||||||
--base-url <url> Forge base URL. Example: https://forge.example.com
|
--base-url <url> Forge base URL. Example: https://forge.example.com
|
||||||
--repo <owner/name> Repository in owner/name form
|
--repo <owner/name> Repository in owner/name form
|
||||||
--token <token> Gitea API token
|
--token <token> Gitea API token
|
||||||
|
--target-commitish <ref> Branch or commit to associate when creating the tag
|
||||||
|
--notes <path> Release notes markdown file
|
||||||
--dry-run Print planned uploads without calling the API
|
--dry-run Print planned uploads without calling the API
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
version=""
|
version=""
|
||||||
|
remote_name="public-stage"
|
||||||
base_url=""
|
base_url=""
|
||||||
repo_full_name=""
|
repo_full_name=""
|
||||||
token="${FORGE_RELEASE_TOKEN:-${GITEA_RELEASE_TOKEN:-}}"
|
token="${FORGE_RELEASE_TOKEN:-${GITEA_RELEASE_TOKEN:-}}"
|
||||||
|
target_commitish=""
|
||||||
|
notes_path=""
|
||||||
dry_run="false"
|
dry_run="false"
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
|
|
@ -30,6 +36,10 @@ while [[ $# -gt 0 ]]; do
|
||||||
base_url="${2:-}"
|
base_url="${2:-}"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--remote)
|
||||||
|
remote_name="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
--repo)
|
--repo)
|
||||||
repo_full_name="${2:-}"
|
repo_full_name="${2:-}"
|
||||||
shift 2
|
shift 2
|
||||||
|
|
@ -38,6 +48,14 @@ while [[ $# -gt 0 ]]; do
|
||||||
token="${2:-}"
|
token="${2:-}"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--target-commitish)
|
||||||
|
target_commitish="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--notes)
|
||||||
|
notes_path="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
--dry-run)
|
--dry-run)
|
||||||
dry_run="true"
|
dry_run="true"
|
||||||
shift
|
shift
|
||||||
|
|
@ -67,20 +85,24 @@ if [[ ! -d "$release_root" ]]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
origin_url="$(git -C "$repo_root" remote get-url origin 2>/dev/null || true)"
|
remote_url="$(git -C "$repo_root" remote get-url "$remote_name" 2>/dev/null || true)"
|
||||||
|
|
||||||
if [[ -z "$base_url" ]]; then
|
if [[ -z "$base_url" ]]; then
|
||||||
if [[ "$origin_url" =~ ^(https?://[^/]+)/(.+)\.git$ ]]; then
|
if [[ "$remote_url" =~ ^(https?://[^/]+)(/open-source/projects)/([^/]+)/([^/]+?)(\.git)?$ ]]; then
|
||||||
|
base_url="${BASH_REMATCH[1]}${BASH_REMATCH[2]}"
|
||||||
|
elif [[ "$remote_url" =~ ^(https?://[^/]+)/(.+?)(\.git)?$ ]]; then
|
||||||
base_url="${BASH_REMATCH[1]}"
|
base_url="${BASH_REMATCH[1]}"
|
||||||
elif [[ "$origin_url" =~ ^git@([^:]+):(.+)\.git$ ]]; then
|
elif [[ "$remote_url" =~ ^git@([^:]+):(.+)\.git$ ]]; then
|
||||||
base_url="https://${BASH_REMATCH[1]}"
|
base_url="https://${BASH_REMATCH[1]}"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "$repo_full_name" ]]; then
|
if [[ -z "$repo_full_name" ]]; then
|
||||||
if [[ "$origin_url" =~ ^https?://[^/]+/(.+)\.git$ ]]; then
|
if [[ "$remote_url" =~ ^https?://[^/]+/open-source/projects/([^/]+)/([^/]+?)(\.git)?$ ]]; then
|
||||||
|
repo_full_name="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}"
|
||||||
|
elif [[ "$remote_url" =~ ^https?://[^/]+/(.+?)(\.git)?$ ]]; then
|
||||||
repo_full_name="${BASH_REMATCH[1]}"
|
repo_full_name="${BASH_REMATCH[1]}"
|
||||||
elif [[ "$origin_url" =~ ^git@[^:]+:(.+)\.git$ ]]; then
|
elif [[ "$remote_url" =~ ^git@[^:]+:(.+)\.git$ ]]; then
|
||||||
repo_full_name="${BASH_REMATCH[1]}"
|
repo_full_name="${BASH_REMATCH[1]}"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
@ -90,8 +112,47 @@ if [[ -z "$base_url" || -z "$repo_full_name" ]]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$dry_run" != "true" && -z "$token" ]]; then
|
if [[ -z "$notes_path" ]]; then
|
||||||
echo "A Gitea API token is required. Use --token or FORGE_RELEASE_TOKEN." >&2
|
notes_path="$release_root/RELEASE_NOTES.ko.md"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$notes_path" && ! -f "$notes_path" ]]; then
|
||||||
|
echo "Release notes file not found: $notes_path" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$target_commitish" ]]; then
|
||||||
|
target_commitish="main"
|
||||||
|
fi
|
||||||
|
|
||||||
|
basic_auth_user=""
|
||||||
|
basic_auth_password=""
|
||||||
|
|
||||||
|
if [[ -z "$token" ]]; then
|
||||||
|
for fallback_path in \
|
||||||
|
"$repo_root/.workspace-secrets/${remote_name}.token" \
|
||||||
|
"$repo_root/.workspace-secrets/forge-release.token"; do
|
||||||
|
if [[ -f "$fallback_path" ]]; then
|
||||||
|
token="$(tr -d '\r\n' < "$fallback_path")"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$token" && -n "$remote_url" ]]; then
|
||||||
|
credential_output="$(printf 'url=%s\n\n' "$remote_url" | git credential fill 2>/dev/null || true)"
|
||||||
|
if [[ -n "$credential_output" ]]; then
|
||||||
|
while IFS='=' read -r key value; do
|
||||||
|
case "$key" in
|
||||||
|
username) basic_auth_user="$value" ;;
|
||||||
|
password) basic_auth_password="$value" ;;
|
||||||
|
esac
|
||||||
|
done <<< "$credential_output"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$dry_run" != "true" && -z "$token" && ( -z "$basic_auth_user" || -z "$basic_auth_password" ) ]]; then
|
||||||
|
echo "A Gitea API token or basic credential is required. Use --token, FORGE_RELEASE_TOKEN, a local secret file, or configured git credentials." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -108,7 +169,7 @@ esac
|
||||||
|
|
||||||
mapfile -t asset_files < <(
|
mapfile -t asset_files < <(
|
||||||
find "$release_root" -type f \
|
find "$release_root" -type f \
|
||||||
\( -name '*.zip' -o -name '*.apk' -o -name 'RELEASE_NOTES.ko.md' -o -name 'SHA256SUMS.txt' -o -name 'version.json' \) \
|
\( -name '*.zip' -o -name '*.apk' -o -name '*.png' -o -name '*.jpg' -o -name '*.jpeg' -o -name 'RELEASE_NOTES.ko.md' -o -name 'SHA256SUMS.txt' -o -name 'version.json' \) \
|
||||||
| sort
|
| sort
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -118,7 +179,9 @@ if [[ ${#asset_files[@]} -eq 0 ]]; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Forge release target: $base_url/$repo_full_name"
|
echo "Forge release target: $base_url/$repo_full_name"
|
||||||
|
echo "Remote: $remote_name"
|
||||||
echo "Version: $version"
|
echo "Version: $version"
|
||||||
|
echo "Target commitish: $target_commitish"
|
||||||
printf 'Assets:\n'
|
printf 'Assets:\n'
|
||||||
printf ' - %s\n' "${asset_files[@]#$release_root/}"
|
printf ' - %s\n' "${asset_files[@]#$release_root/}"
|
||||||
|
|
||||||
|
|
@ -126,11 +189,16 @@ if [[ "$dry_run" == "true" ]]; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
auth_header="Authorization: token $token"
|
auth_args=()
|
||||||
|
if [[ -n "$token" ]]; then
|
||||||
|
auth_args=(-H "Authorization: token $token")
|
||||||
|
else
|
||||||
|
auth_args=(-u "$basic_auth_user:$basic_auth_password")
|
||||||
|
fi
|
||||||
tmp_response="$(mktemp)"
|
tmp_response="$(mktemp)"
|
||||||
trap 'rm -f "$tmp_response"' EXIT
|
trap 'rm -f "$tmp_response"' EXIT
|
||||||
|
|
||||||
existing_status="$(curl -sS -o "$tmp_response" -w '%{http_code}' -H "$auth_header" "$tag_api")"
|
existing_status="$(curl -sS -o "$tmp_response" -w '%{http_code}' "${auth_args[@]}" "$tag_api")"
|
||||||
if [[ "$existing_status" == "200" ]]; then
|
if [[ "$existing_status" == "200" ]]; then
|
||||||
existing_release_id="$(python3 - <<'PY' "$tmp_response"
|
existing_release_id="$(python3 - <<'PY' "$tmp_response"
|
||||||
import json, sys
|
import json, sys
|
||||||
|
|
@ -138,17 +206,17 @@ with open(sys.argv[1], "r", encoding="utf-8") as fh:
|
||||||
print(json.load(fh)["id"])
|
print(json.load(fh)["id"])
|
||||||
PY
|
PY
|
||||||
)"
|
)"
|
||||||
curl -sS -X DELETE -H "$auth_header" "${release_api}/${existing_release_id}" >/dev/null
|
curl -sS -X DELETE "${auth_args[@]}" "${release_api}/${existing_release_id}" >/dev/null
|
||||||
fi
|
fi
|
||||||
|
|
||||||
release_body=$'Windows와 Android 클라이언트 산출물을 병렬로 정리한 릴리즈입니다.\n\n'
|
release_body="$(cat "$notes_path")"
|
||||||
release_body+=$'동일 버전 번호 아래 OS별 자산을 함께 게시하며, 최신 다운로드 채널은 download-vs-messanger.phy.kr에서 운영합니다.'
|
|
||||||
|
|
||||||
create_payload="$(python3 - <<'PY' "$version" "$release_body" "$pre_release"
|
create_payload="$(python3 - <<'PY' "$version" "$release_body" "$pre_release" "$target_commitish"
|
||||||
import json, sys
|
import json, sys
|
||||||
version, body, prerelease = sys.argv[1], sys.argv[2], sys.argv[3] == "true"
|
version, body, prerelease, target_commitish = sys.argv[1], sys.argv[2], sys.argv[3] == "true", sys.argv[4]
|
||||||
print(json.dumps({
|
print(json.dumps({
|
||||||
"tag_name": version,
|
"tag_name": version,
|
||||||
|
"target_commitish": target_commitish,
|
||||||
"name": version,
|
"name": version,
|
||||||
"body": body,
|
"body": body,
|
||||||
"draft": False,
|
"draft": False,
|
||||||
|
|
@ -159,7 +227,7 @@ PY
|
||||||
|
|
||||||
create_status="$(curl -sS -o "$tmp_response" -w '%{http_code}' \
|
create_status="$(curl -sS -o "$tmp_response" -w '%{http_code}' \
|
||||||
-X POST \
|
-X POST \
|
||||||
-H "$auth_header" \
|
"${auth_args[@]}" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d "$create_payload" \
|
-d "$create_payload" \
|
||||||
"$release_api")"
|
"$release_api")"
|
||||||
|
|
@ -181,7 +249,7 @@ for asset in "${asset_files[@]}"; do
|
||||||
name="$(basename "$asset")"
|
name="$(basename "$asset")"
|
||||||
curl -sS \
|
curl -sS \
|
||||||
-X POST \
|
-X POST \
|
||||||
-H "$auth_header" \
|
"${auth_args[@]}" \
|
||||||
-H 'Content-Type: application/octet-stream' \
|
-H 'Content-Type: application/octet-stream' \
|
||||||
--data-binary @"$asset" \
|
--data-binary @"$asset" \
|
||||||
"${release_api}/${release_id}/assets?name=${name}" >/dev/null
|
"${release_api}/${release_id}/assets?name=${name}" >/dev/null
|
||||||
|
|
|
||||||
226
scripts/release/release-publish-github.sh
Executable file
|
|
@ -0,0 +1,226 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
./scripts/release/release-publish-github.sh --version 2026.04.16-alpha.4 [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--remote <name> Git remote name. Default: github-public
|
||||||
|
--repo <owner/name> GitHub repository in owner/name form
|
||||||
|
--token <token> GitHub token
|
||||||
|
--target-commitish <ref> Branch or commit to associate when creating the tag
|
||||||
|
--notes <path> Release notes markdown file
|
||||||
|
--dry-run Print planned uploads without calling the API
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
version=""
|
||||||
|
remote_name="github-public"
|
||||||
|
repo_full_name=""
|
||||||
|
token="${GITHUB_RELEASE_TOKEN:-${GITHUB_TOKEN:-}}"
|
||||||
|
target_commitish=""
|
||||||
|
notes_path=""
|
||||||
|
dry_run="false"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--version)
|
||||||
|
version="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--remote)
|
||||||
|
remote_name="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--repo)
|
||||||
|
repo_full_name="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--token)
|
||||||
|
token="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--target-commitish)
|
||||||
|
target_commitish="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--notes)
|
||||||
|
notes_path="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
dry_run="true"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown argument: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "$version" ]]; then
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
release_root="$repo_root/release-assets/releases/$version"
|
||||||
|
|
||||||
|
if [[ ! -d "$release_root" ]]; then
|
||||||
|
echo "Release bundle not found: $release_root" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
remote_url="$(git -C "$repo_root" remote get-url "$remote_name" 2>/dev/null || true)"
|
||||||
|
if [[ -z "$repo_full_name" ]]; then
|
||||||
|
if [[ "$remote_url" =~ ^https?://github\.com/(.+?)(\.git)?$ ]]; then
|
||||||
|
repo_full_name="${BASH_REMATCH[1]}"
|
||||||
|
elif [[ "$remote_url" =~ ^git@github\.com:(.+)\.git$ ]]; then
|
||||||
|
repo_full_name="${BASH_REMATCH[1]}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$repo_full_name" ]]; then
|
||||||
|
echo "Unable to infer GitHub repository name from remote: $remote_name" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$token" && -f "$repo_root/.workspace-secrets/github-public.pat" ]]; then
|
||||||
|
token="$(tr -d '\r\n' < "$repo_root/.workspace-secrets/github-public.pat")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$dry_run" != "true" && -z "$token" ]]; then
|
||||||
|
echo "A GitHub token is required. Use --token, GITHUB_RELEASE_TOKEN, or GITHUB_TOKEN." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$notes_path" ]]; then
|
||||||
|
notes_path="$release_root/RELEASE_NOTES.ko.md"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$notes_path" && ! -f "$notes_path" ]]; then
|
||||||
|
echo "Release notes file not found: $notes_path" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$target_commitish" ]]; then
|
||||||
|
target_commitish="main"
|
||||||
|
fi
|
||||||
|
|
||||||
|
pre_release="false"
|
||||||
|
case "$version" in
|
||||||
|
*alpha*|*beta*|*rc*)
|
||||||
|
pre_release="true"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
mapfile -t asset_files < <(
|
||||||
|
find "$release_root" -type f \
|
||||||
|
\( -name '*.zip' -o -name '*.apk' -o -name '*.png' -o -name '*.jpg' -o -name '*.jpeg' -o -name 'RELEASE_NOTES.ko.md' -o -name 'SHA256SUMS.txt' -o -name 'version.json' \) \
|
||||||
|
| sort
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ ${#asset_files[@]} -eq 0 ]]; then
|
||||||
|
echo "No release assets found in $release_root" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "GitHub release target: https://github.com/$repo_full_name"
|
||||||
|
echo "Remote: $remote_name"
|
||||||
|
echo "Version: $version"
|
||||||
|
echo "Target commitish: $target_commitish"
|
||||||
|
printf 'Assets:\n'
|
||||||
|
printf ' - %s\n' "${asset_files[@]#$release_root/}"
|
||||||
|
|
||||||
|
if [[ "$dry_run" == "true" ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
api_root="https://api.github.com/repos/${repo_full_name}"
|
||||||
|
release_api="${api_root}/releases"
|
||||||
|
tag_api="${release_api}/tags/${version}"
|
||||||
|
auth_header="Authorization: Bearer $token"
|
||||||
|
accept_header="Accept: application/vnd.github+json"
|
||||||
|
api_version_header="X-GitHub-Api-Version: 2022-11-28"
|
||||||
|
tmp_response="$(mktemp)"
|
||||||
|
trap 'rm -f "$tmp_response"' EXIT
|
||||||
|
|
||||||
|
existing_status="$(curl -sS -o "$tmp_response" -w '%{http_code}' \
|
||||||
|
-H "$auth_header" \
|
||||||
|
-H "$accept_header" \
|
||||||
|
-H "$api_version_header" \
|
||||||
|
"$tag_api")"
|
||||||
|
|
||||||
|
if [[ "$existing_status" == "200" ]]; then
|
||||||
|
existing_release_id="$(python3 - <<'PY' "$tmp_response"
|
||||||
|
import json, sys
|
||||||
|
with open(sys.argv[1], "r", encoding="utf-8") as fh:
|
||||||
|
print(json.load(fh)["id"])
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
curl -sS -X DELETE \
|
||||||
|
-H "$auth_header" \
|
||||||
|
-H "$accept_header" \
|
||||||
|
-H "$api_version_header" \
|
||||||
|
"${release_api}/${existing_release_id}" >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
release_body="$(cat "$notes_path")"
|
||||||
|
create_payload="$(python3 - <<'PY' "$version" "$release_body" "$pre_release" "$target_commitish"
|
||||||
|
import json, sys
|
||||||
|
version, body, prerelease, target_commitish = sys.argv[1], sys.argv[2], sys.argv[3] == "true", sys.argv[4]
|
||||||
|
print(json.dumps({
|
||||||
|
"tag_name": version,
|
||||||
|
"target_commitish": target_commitish,
|
||||||
|
"name": version,
|
||||||
|
"body": body,
|
||||||
|
"draft": False,
|
||||||
|
"prerelease": prerelease,
|
||||||
|
"make_latest": "false" if prerelease else "true",
|
||||||
|
}, ensure_ascii=False))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
create_status="$(curl -sS -o "$tmp_response" -w '%{http_code}' \
|
||||||
|
-X POST \
|
||||||
|
-H "$auth_header" \
|
||||||
|
-H "$accept_header" \
|
||||||
|
-H "$api_version_header" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "$create_payload" \
|
||||||
|
"$release_api")"
|
||||||
|
|
||||||
|
if [[ "$create_status" != "201" ]]; then
|
||||||
|
echo "Failed to create GitHub release. HTTP $create_status" >&2
|
||||||
|
cat "$tmp_response" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
upload_url="$(python3 - <<'PY' "$tmp_response"
|
||||||
|
import json, sys
|
||||||
|
with open(sys.argv[1], "r", encoding="utf-8") as fh:
|
||||||
|
print(json.load(fh)["upload_url"].split("{", 1)[0])
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
for asset in "${asset_files[@]}"; do
|
||||||
|
name="$(basename "$asset")"
|
||||||
|
curl -sS \
|
||||||
|
-X POST \
|
||||||
|
-H "$auth_header" \
|
||||||
|
-H "$accept_header" \
|
||||||
|
-H "$api_version_header" \
|
||||||
|
-H 'Content-Type: application/octet-stream' \
|
||||||
|
--data-binary @"$asset" \
|
||||||
|
"${upload_url}?name=${name}" >/dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Published GitHub release $version with ${#asset_files[@]} assets."
|
||||||
181
scripts/release/release-publish-public.sh
Executable file
|
|
@ -0,0 +1,181 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
./scripts/release/release-publish-public.sh --version 2026.04.16-alpha.4 [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--branch <name> Source branch to publish. Default: public/main
|
||||||
|
--target-branch <name> Target branch name on public remotes. Default: main
|
||||||
|
--stage-remote <name> Stage remote. Default: public-stage
|
||||||
|
--github-remote <name> GitHub remote. Default: github-public
|
||||||
|
--notes <path> Release notes markdown file
|
||||||
|
--skip-github Publish to stage only
|
||||||
|
--skip-stage Publish to GitHub only
|
||||||
|
--force-tag Replace an existing local or remote tag
|
||||||
|
--dry-run Print the planned sequence only
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
version=""
|
||||||
|
branch_name="public/main"
|
||||||
|
target_branch="main"
|
||||||
|
stage_remote="public-stage"
|
||||||
|
github_remote="github-public"
|
||||||
|
notes_path=""
|
||||||
|
skip_github="false"
|
||||||
|
skip_stage="false"
|
||||||
|
force_tag="false"
|
||||||
|
dry_run="false"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--version)
|
||||||
|
version="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--branch)
|
||||||
|
branch_name="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--target-branch)
|
||||||
|
target_branch="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--stage-remote)
|
||||||
|
stage_remote="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--github-remote)
|
||||||
|
github_remote="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--notes)
|
||||||
|
notes_path="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--skip-github)
|
||||||
|
skip_github="true"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--skip-stage)
|
||||||
|
skip_stage="true"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--force-tag)
|
||||||
|
force_tag="true"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
dry_run="true"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown argument: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "$version" ]]; then
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
cd "$repo_root"
|
||||||
|
|
||||||
|
if [[ -z "$notes_path" ]]; then
|
||||||
|
notes_path="$repo_root/release-assets/releases/$version/RELEASE_NOTES.ko.md"
|
||||||
|
fi
|
||||||
|
|
||||||
|
target_commit="$(git rev-parse "$branch_name")"
|
||||||
|
|
||||||
|
echo "Version: $version"
|
||||||
|
echo "Source branch: $branch_name"
|
||||||
|
echo "Target branch: $target_branch"
|
||||||
|
echo "Target commit: $target_commit"
|
||||||
|
[[ "$skip_stage" != "true" ]] && echo "Stage remote: $stage_remote"
|
||||||
|
[[ "$skip_github" != "true" ]] && echo "GitHub remote: $github_remote"
|
||||||
|
|
||||||
|
tag_args=()
|
||||||
|
if [[ "$force_tag" == "true" ]]; then
|
||||||
|
tag_args+=(--force)
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$repo_root/scripts/release/release-create-tag.sh" \
|
||||||
|
--version "$version" \
|
||||||
|
--ref "$branch_name" \
|
||||||
|
"${tag_args[@]}" \
|
||||||
|
--dry-run
|
||||||
|
|
||||||
|
if [[ "$dry_run" == "true" ]]; then
|
||||||
|
if [[ "$skip_stage" != "true" ]]; then
|
||||||
|
echo "DRY RUN: ALLOW_PUBLIC_PUSH=1 git push $stage_remote refs/heads/$branch_name:refs/heads/$target_branch"
|
||||||
|
echo "DRY RUN: ALLOW_PUBLIC_PUSH=1 git push $stage_remote refs/tags/$version:refs/tags/$version"
|
||||||
|
"$repo_root/scripts/release/release-publish-forge.sh" \
|
||||||
|
--remote "$stage_remote" \
|
||||||
|
--version "$version" \
|
||||||
|
--target-commitish "$target_branch" \
|
||||||
|
--notes "$notes_path" \
|
||||||
|
--dry-run
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$skip_github" != "true" ]]; then
|
||||||
|
echo "DRY RUN: ALLOW_PUBLIC_PUSH=1 git push $github_remote refs/heads/$branch_name:refs/heads/$target_branch"
|
||||||
|
echo "DRY RUN: ALLOW_PUBLIC_PUSH=1 git push $github_remote refs/tags/$version:refs/tags/$version"
|
||||||
|
"$repo_root/scripts/release/release-publish-github.sh" \
|
||||||
|
--remote "$github_remote" \
|
||||||
|
--version "$version" \
|
||||||
|
--target-commitish "$target_branch" \
|
||||||
|
--notes "$notes_path" \
|
||||||
|
--dry-run
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$repo_root/scripts/release/release-create-tag.sh" \
|
||||||
|
--version "$version" \
|
||||||
|
--ref "$branch_name" \
|
||||||
|
"${tag_args[@]}"
|
||||||
|
|
||||||
|
push_branch() {
|
||||||
|
local remote_name="$1"
|
||||||
|
ALLOW_PUBLIC_PUSH=1 git push "$remote_name" "refs/heads/$branch_name:refs/heads/$target_branch"
|
||||||
|
}
|
||||||
|
|
||||||
|
push_tag() {
|
||||||
|
local remote_name="$1"
|
||||||
|
local force_args=()
|
||||||
|
if [[ "$force_tag" == "true" ]]; then
|
||||||
|
force_args+=(--force)
|
||||||
|
fi
|
||||||
|
ALLOW_PUBLIC_PUSH=1 git push "${force_args[@]}" "$remote_name" "refs/tags/$version:refs/tags/$version"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "$skip_stage" != "true" ]]; then
|
||||||
|
push_branch "$stage_remote"
|
||||||
|
push_tag "$stage_remote"
|
||||||
|
"$repo_root/scripts/release/release-publish-forge.sh" \
|
||||||
|
--remote "$stage_remote" \
|
||||||
|
--version "$version" \
|
||||||
|
--target-commitish "$target_branch" \
|
||||||
|
--notes "$notes_path"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$skip_github" != "true" ]]; then
|
||||||
|
push_branch "$github_remote"
|
||||||
|
push_tag "$github_remote"
|
||||||
|
"$repo_root/scripts/release/release-publish-github.sh" \
|
||||||
|
--remote "$github_remote" \
|
||||||
|
--version "$version" \
|
||||||
|
--target-commitish "$target_branch" \
|
||||||
|
--notes "$notes_path"
|
||||||
|
fi
|
||||||
|
|
@ -26,9 +26,22 @@ public partial class App : Application
|
||||||
DataContext = viewModel,
|
DataContext = viewModel,
|
||||||
};
|
};
|
||||||
|
|
||||||
_ = viewModel.InitializeAsync();
|
_ = InitializeDesktopAsync(viewModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task InitializeDesktopAsync(MainWindowViewModel viewModel)
|
||||||
|
{
|
||||||
|
await viewModel.InitializeAsync();
|
||||||
|
|
||||||
|
if (string.Equals(
|
||||||
|
Environment.GetEnvironmentVariable("KOTALK_DESKTOP_OPEN_SAMPLE_WINDOW"),
|
||||||
|
"1",
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
await viewModel.OpenDetachedConversationFromShortcutAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,5 @@ namespace PhysOn.Desktop.Models;
|
||||||
public sealed record DesktopWorkspaceLayout(
|
public sealed record DesktopWorkspaceLayout(
|
||||||
bool IsCompactDensity,
|
bool IsCompactDensity,
|
||||||
bool IsInspectorVisible,
|
bool IsInspectorVisible,
|
||||||
bool IsConversationPaneCollapsed);
|
bool IsConversationPaneCollapsed,
|
||||||
|
double ConversationPaneWidth = 348);
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,12 @@ public partial class ConversationWindowViewModel : ViewModelBase, IAsyncDisposab
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
|
if (string.Equals(Environment.GetEnvironmentVariable("KOTALK_DESKTOP_SAMPLE_MODE"), "1", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
LoadSampleConversation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await LoadMessagesAsync();
|
await LoadMessagesAsync();
|
||||||
|
|
||||||
try
|
try
|
||||||
|
|
@ -78,6 +84,47 @@ public partial class ConversationWindowViewModel : ViewModelBase, IAsyncDisposab
|
||||||
partial void OnErrorTextChanged(string? value) => OnPropertyChanged(nameof(HasErrorText));
|
partial void OnErrorTextChanged(string? value) => OnPropertyChanged(nameof(HasErrorText));
|
||||||
partial void OnConversationTitleChanged(string value) => OnPropertyChanged(nameof(ConversationGlyph));
|
partial void OnConversationTitleChanged(string value) => OnPropertyChanged(nameof(ConversationGlyph));
|
||||||
|
|
||||||
|
private void LoadSampleConversation()
|
||||||
|
{
|
||||||
|
Messages.Clear();
|
||||||
|
StatusText = "●";
|
||||||
|
ErrorText = null;
|
||||||
|
|
||||||
|
foreach (var item in new[]
|
||||||
|
{
|
||||||
|
new MessageRowViewModel
|
||||||
|
{
|
||||||
|
MessageId = "detached-1",
|
||||||
|
SenderName = "민지",
|
||||||
|
Text = "이 창은 대화를 따로 두고 확인할 수 있게 분리했습니다.",
|
||||||
|
MetaText = "09:10",
|
||||||
|
IsMine = false,
|
||||||
|
ServerSequence = 1
|
||||||
|
},
|
||||||
|
new MessageRowViewModel
|
||||||
|
{
|
||||||
|
MessageId = "detached-2",
|
||||||
|
SenderName = _launchContext.DisplayName,
|
||||||
|
Text = "검수하면서도 메인 받은함은 그대로 둘 수 있어요.",
|
||||||
|
MetaText = "09:11",
|
||||||
|
IsMine = true,
|
||||||
|
ServerSequence = 2
|
||||||
|
},
|
||||||
|
new MessageRowViewModel
|
||||||
|
{
|
||||||
|
MessageId = "detached-3",
|
||||||
|
SenderName = "민지",
|
||||||
|
Text = "작업용 대화만 따로 띄워두기엔 이 구성이 훨씬 낫네요.",
|
||||||
|
MetaText = "09:12",
|
||||||
|
IsMine = false,
|
||||||
|
ServerSequence = 3
|
||||||
|
}
|
||||||
|
})
|
||||||
|
{
|
||||||
|
Messages.Add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task LoadMessagesAsync()
|
private async Task LoadMessagesAsync()
|
||||||
{
|
{
|
||||||
if (IsBusy)
|
if (IsBusy)
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,9 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||||
ShowAllConversationsCommand = new RelayCommand(() => SelectedListFilter = "all");
|
ShowAllConversationsCommand = new RelayCommand(() => SelectedListFilter = "all");
|
||||||
ShowUnreadConversationsCommand = new RelayCommand(() => SelectedListFilter = "unread");
|
ShowUnreadConversationsCommand = new RelayCommand(() => SelectedListFilter = "unread");
|
||||||
ShowPinnedConversationsCommand = new RelayCommand(() => SelectedListFilter = "pinned");
|
ShowPinnedConversationsCommand = new RelayCommand(() => SelectedListFilter = "pinned");
|
||||||
|
ApplyAckDraftCommand = new RelayCommand(() => ApplyQuickDraft("확인했습니다."));
|
||||||
|
ApplyShareDraftCommand = new RelayCommand(() => ApplyQuickDraft("공유드립니다.\n- "));
|
||||||
|
ApplyTaskDraftCommand = new RelayCommand(() => ApplyQuickDraft("할 일\n- "));
|
||||||
ToggleCompactModeCommand = new RelayCommand(() => IsCompactDensity = !IsCompactDensity);
|
ToggleCompactModeCommand = new RelayCommand(() => IsCompactDensity = !IsCompactDensity);
|
||||||
ToggleInspectorCommand = new RelayCommand(() => IsInspectorVisible = !IsInspectorVisible);
|
ToggleInspectorCommand = new RelayCommand(() => IsInspectorVisible = !IsInspectorVisible);
|
||||||
ToggleConversationPaneCommand = new RelayCommand(() => IsConversationPaneCollapsed = !IsConversationPaneCollapsed);
|
ToggleConversationPaneCommand = new RelayCommand(() => IsConversationPaneCollapsed = !IsConversationPaneCollapsed);
|
||||||
|
|
@ -80,6 +83,9 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||||
public IRelayCommand ShowAllConversationsCommand { get; }
|
public IRelayCommand ShowAllConversationsCommand { get; }
|
||||||
public IRelayCommand ShowUnreadConversationsCommand { get; }
|
public IRelayCommand ShowUnreadConversationsCommand { get; }
|
||||||
public IRelayCommand ShowPinnedConversationsCommand { get; }
|
public IRelayCommand ShowPinnedConversationsCommand { get; }
|
||||||
|
public IRelayCommand ApplyAckDraftCommand { get; }
|
||||||
|
public IRelayCommand ApplyShareDraftCommand { get; }
|
||||||
|
public IRelayCommand ApplyTaskDraftCommand { get; }
|
||||||
public IRelayCommand ToggleCompactModeCommand { get; }
|
public IRelayCommand ToggleCompactModeCommand { get; }
|
||||||
public IRelayCommand ToggleInspectorCommand { get; }
|
public IRelayCommand ToggleInspectorCommand { get; }
|
||||||
public IRelayCommand ToggleConversationPaneCommand { get; }
|
public IRelayCommand ToggleConversationPaneCommand { get; }
|
||||||
|
|
@ -102,7 +108,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||||
[ObservableProperty] private string selectedListFilter = "all";
|
[ObservableProperty] private string selectedListFilter = "all";
|
||||||
[ObservableProperty] private string composerText = string.Empty;
|
[ObservableProperty] private string composerText = string.Empty;
|
||||||
[ObservableProperty] private string selectedConversationTitle = "KoTalk";
|
[ObservableProperty] private string selectedConversationTitle = "KoTalk";
|
||||||
[ObservableProperty] private string selectedConversationSubtitle = "대화를 열면 바로 이어집니다.";
|
[ObservableProperty] private string selectedConversationSubtitle = "준비";
|
||||||
[ObservableProperty] private ConversationRowViewModel? selectedConversation;
|
[ObservableProperty] private ConversationRowViewModel? selectedConversation;
|
||||||
[ObservableProperty] private bool hasErrorText;
|
[ObservableProperty] private bool hasErrorText;
|
||||||
[ObservableProperty] private bool hasFilteredConversations;
|
[ObservableProperty] private bool hasFilteredConversations;
|
||||||
|
|
@ -110,6 +116,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||||
[ObservableProperty] private bool isCompactDensity = true;
|
[ObservableProperty] private bool isCompactDensity = true;
|
||||||
[ObservableProperty] private bool isInspectorVisible;
|
[ObservableProperty] private bool isInspectorVisible;
|
||||||
[ObservableProperty] private bool isConversationPaneCollapsed;
|
[ObservableProperty] private bool isConversationPaneCollapsed;
|
||||||
|
[ObservableProperty] private double conversationPaneWidthValue = 348;
|
||||||
[ObservableProperty] private int detachedWindowCount;
|
[ObservableProperty] private int detachedWindowCount;
|
||||||
|
|
||||||
public bool ShowOnboarding => !IsAuthenticated;
|
public bool ShowOnboarding => !IsAuthenticated;
|
||||||
|
|
@ -153,26 +160,26 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||||
public string DetachedWindowActionGlyph => HasDetachedWindows ? DetachedWindowBadgeText : "↗";
|
public string DetachedWindowActionGlyph => HasDetachedWindows ? DetachedWindowBadgeText : "↗";
|
||||||
public bool HasDetachedWindows => DetachedWindowCount > 0;
|
public bool HasDetachedWindows => DetachedWindowCount > 0;
|
||||||
public bool IsConversationPaneExpanded => !IsConversationPaneCollapsed;
|
public bool IsConversationPaneExpanded => !IsConversationPaneCollapsed;
|
||||||
public double ConversationPaneWidth => IsConversationPaneCollapsed ? 0 : (IsCompactDensity ? 296 : 340);
|
public double ConversationPaneWidth => IsConversationPaneCollapsed ? 0 : ConversationPaneWidthValue;
|
||||||
public double InspectorPaneWidth => IsInspectorVisible ? (IsCompactDensity ? 92 : 108) : 0;
|
public double InspectorPaneWidth => IsInspectorVisible ? (IsCompactDensity ? 92 : 108) : 0;
|
||||||
public Thickness ConversationRowPadding => IsCompactDensity ? new Thickness(6, 6) : new Thickness(8, 7);
|
public Thickness ConversationRowPadding => IsCompactDensity ? new Thickness(6, 5) : new Thickness(8, 6);
|
||||||
public Thickness MessageBubblePadding => IsCompactDensity ? new Thickness(10, 8) : new Thickness(12, 10);
|
public Thickness MessageBubblePadding => IsCompactDensity ? new Thickness(10, 7) : new Thickness(12, 9);
|
||||||
public double ConversationAvatarSize => IsCompactDensity ? 26 : 30;
|
public double ConversationAvatarSize => IsCompactDensity ? 28 : 32;
|
||||||
public double ComposerMinHeight => IsCompactDensity ? 48 : 58;
|
public double ComposerMinHeight => IsCompactDensity ? 48 : 58;
|
||||||
public string ComposerCounterText => $"{ComposerText.Trim().Length}";
|
public string ComposerCounterText => $"{ComposerText.Trim().Length}";
|
||||||
public string SearchWatermark => "대화 검색";
|
public string SearchWatermark => "검색";
|
||||||
public string InspectorStatusText => HasDetachedWindows
|
public string InspectorStatusText => HasDetachedWindows
|
||||||
? $"{RealtimeStatusGlyph} {DetachedWindowBadgeText}"
|
? $"{RealtimeStatusGlyph} {DetachedWindowBadgeText}"
|
||||||
: RealtimeStatusGlyph;
|
: RealtimeStatusGlyph;
|
||||||
public string WorkspaceModeText => HasDetachedWindows ? $"분리 창 {DetachedWindowBadgeText}" : "단일 창";
|
public string WorkspaceModeText => HasDetachedWindows ? $"분리 창 {DetachedWindowBadgeText}" : "단일 창";
|
||||||
public string StatusSummaryText => string.IsNullOrWhiteSpace(StatusLine) ? RealtimeStatusText : StatusLine;
|
public string StatusSummaryText => string.IsNullOrWhiteSpace(StatusLine) ? RealtimeStatusText : StatusLine;
|
||||||
public string ComposerPlaceholderText => HasSelectedConversation ? "메시지 보내기" : "왼쪽에서 대화를 고르세요";
|
public string ComposerPlaceholderText => HasSelectedConversation ? "메시지" : "대화 선택";
|
||||||
public string ComposerActionText => Messages.Count == 0 ? "시작" : "보내기";
|
public string ComposerActionText => Messages.Count == 0 ? "시작" : "보내기";
|
||||||
public bool ShowMessageEmptyState => Messages.Count == 0;
|
public bool ShowMessageEmptyState => Messages.Count == 0;
|
||||||
public string MessageEmptyStateTitle => HasSelectedConversation ? "첫 메시지부터 시작" : "대화를 먼저 고르세요";
|
public string MessageEmptyStateTitle => HasSelectedConversation ? "첫 메시지" : "대화 선택";
|
||||||
public string MessageEmptyStateText => HasSelectedConversation
|
public string MessageEmptyStateText => HasSelectedConversation
|
||||||
? "짧게 한 줄만 남겨도 바로 이어집니다."
|
? "짧게 남기세요."
|
||||||
: "받은함에서 대화를 고르거나 창으로 분리해 집중할 수 있습니다.";
|
: "목록에서 선택";
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
|
|
@ -270,6 +277,11 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||||
OnPropertyChanged(nameof(InspectorPaneWidth));
|
OnPropertyChanged(nameof(InspectorPaneWidth));
|
||||||
_ = PersistWorkspaceLayoutAsync();
|
_ = PersistWorkspaceLayoutAsync();
|
||||||
}
|
}
|
||||||
|
partial void OnConversationPaneWidthValueChanged(double value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(ConversationPaneWidth));
|
||||||
|
_ = PersistWorkspaceLayoutAsync();
|
||||||
|
}
|
||||||
partial void OnIsConversationPaneCollapsedChanged(bool value)
|
partial void OnIsConversationPaneCollapsedChanged(bool value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(ConversationPaneGlyph));
|
OnPropertyChanged(nameof(ConversationPaneGlyph));
|
||||||
|
|
@ -292,7 +304,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||||
{
|
{
|
||||||
UpdateSelectedConversationState(value?.ConversationId);
|
UpdateSelectedConversationState(value?.ConversationId);
|
||||||
SelectedConversationTitle = value?.Title ?? "KoTalk";
|
SelectedConversationTitle = value?.Title ?? "KoTalk";
|
||||||
SelectedConversationSubtitle = value?.Subtitle ?? "받은함과 대화를 한 화면에서 관리합니다.";
|
SelectedConversationSubtitle = value?.Subtitle ?? "대화";
|
||||||
OnPropertyChanged(nameof(SelectedConversationGlyph));
|
OnPropertyChanged(nameof(SelectedConversationGlyph));
|
||||||
OnPropertyChanged(nameof(HasSelectedConversation));
|
OnPropertyChanged(nameof(HasSelectedConversation));
|
||||||
OnPropertyChanged(nameof(HasSelectedConversationUnread));
|
OnPropertyChanged(nameof(HasSelectedConversationUnread));
|
||||||
|
|
@ -317,7 +329,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||||
$"desktop-{Environment.MachineName.ToLowerInvariant()}",
|
$"desktop-{Environment.MachineName.ToLowerInvariant()}",
|
||||||
"windows",
|
"windows",
|
||||||
Environment.MachineName,
|
Environment.MachineName,
|
||||||
"0.1.0"));
|
"0.1.0-alpha.4"));
|
||||||
|
|
||||||
var response = await _apiClient.RegisterAlphaQuickAsync(apiBaseUrl, request, CancellationToken.None);
|
var response = await _apiClient.RegisterAlphaQuickAsync(apiBaseUrl, request, CancellationToken.None);
|
||||||
ApiBaseUrl = apiBaseUrl;
|
ApiBaseUrl = apiBaseUrl;
|
||||||
|
|
@ -370,7 +382,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||||
SelectedListFilter = "all";
|
SelectedListFilter = "all";
|
||||||
SelectedConversation = null;
|
SelectedConversation = null;
|
||||||
SelectedConversationTitle = "KoTalk";
|
SelectedConversationTitle = "KoTalk";
|
||||||
SelectedConversationSubtitle = "대화를 열면 바로 이어집니다.";
|
SelectedConversationSubtitle = "준비";
|
||||||
NotifyConversationMetricsChanged();
|
NotifyConversationMetricsChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -381,10 +393,18 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Messages.Clear();
|
||||||
|
NotifyMessageStateChanged();
|
||||||
|
|
||||||
await RunBusyAsync(async () =>
|
await RunBusyAsync(async () =>
|
||||||
{
|
{
|
||||||
var items = await _apiClient.GetMessagesAsync(_session.ApiBaseUrl, _session.AccessToken, value.ConversationId, CancellationToken.None);
|
var items = await _apiClient.GetMessagesAsync(_session.ApiBaseUrl, _session.AccessToken, value.ConversationId, CancellationToken.None);
|
||||||
|
|
||||||
|
if (!string.Equals(SelectedConversation?.ConversationId, value.ConversationId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Messages.Clear();
|
Messages.Clear();
|
||||||
foreach (var item in items.Items)
|
foreach (var item in items.Items)
|
||||||
{
|
{
|
||||||
|
|
@ -667,6 +687,27 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||||
OnPropertyChanged(nameof(PinnedConversationCount));
|
OnPropertyChanged(nameof(PinnedConversationCount));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void UpdateConversationPaneWidth(double width)
|
||||||
|
{
|
||||||
|
if (IsConversationPaneCollapsed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var clamped = Math.Clamp(Math.Round(width), 280, 480);
|
||||||
|
if (Math.Abs(clamped - ConversationPaneWidthValue) > 1)
|
||||||
|
{
|
||||||
|
ConversationPaneWidthValue = clamped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyQuickDraft(string template)
|
||||||
|
{
|
||||||
|
ComposerText = string.IsNullOrWhiteSpace(ComposerText)
|
||||||
|
? template
|
||||||
|
: ComposerText.TrimEnd() + Environment.NewLine + template;
|
||||||
|
}
|
||||||
|
|
||||||
private void LoadSampleWorkspace()
|
private void LoadSampleWorkspace()
|
||||||
{
|
{
|
||||||
Conversations.Clear();
|
Conversations.Clear();
|
||||||
|
|
@ -676,6 +717,13 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||||
CurrentUserDisplayName = "이안";
|
CurrentUserDisplayName = "이안";
|
||||||
DisplayName = "이안";
|
DisplayName = "이안";
|
||||||
InviteCode = string.Empty;
|
InviteCode = string.Empty;
|
||||||
|
_currentUserId = "sample-user";
|
||||||
|
_session = new DesktopSession(
|
||||||
|
DefaultApiBaseUrl,
|
||||||
|
"sample-access",
|
||||||
|
"sample-refresh",
|
||||||
|
CurrentUserDisplayName,
|
||||||
|
"sample-ops");
|
||||||
RealtimeState = RealtimeConnectionState.Connected;
|
RealtimeState = RealtimeConnectionState.Connected;
|
||||||
RealtimeStatusText = "연결됨";
|
RealtimeStatusText = "연결됨";
|
||||||
StatusLine = "준비";
|
StatusLine = "준비";
|
||||||
|
|
@ -683,6 +731,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||||
IsCompactDensity = true;
|
IsCompactDensity = true;
|
||||||
IsInspectorVisible = false;
|
IsInspectorVisible = false;
|
||||||
IsConversationPaneCollapsed = false;
|
IsConversationPaneCollapsed = false;
|
||||||
|
ConversationPaneWidthValue = 348;
|
||||||
DetachedWindowCount = 1;
|
DetachedWindowCount = 1;
|
||||||
ErrorText = null;
|
ErrorText = null;
|
||||||
|
|
||||||
|
|
@ -691,8 +740,8 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||||
{
|
{
|
||||||
ConversationId = "sample-ops",
|
ConversationId = "sample-ops",
|
||||||
Title = "제품 운영",
|
Title = "제품 운영",
|
||||||
Subtitle = "스크린샷 기준으로 레이아웃을 다시 정리했습니다.",
|
Subtitle = "레이아웃 검수 메모를 확인해 주세요.",
|
||||||
LastMessageText = "스크린샷 기준으로 레이아웃을 다시 정리했습니다.",
|
LastMessageText = "레이아웃 검수 메모를 확인해 주세요.",
|
||||||
MetaText = FormatConversationMeta(now.AddMinutes(-5), 2),
|
MetaText = FormatConversationMeta(now.AddMinutes(-5), 2),
|
||||||
UnreadCount = 2,
|
UnreadCount = 2,
|
||||||
IsPinned = true,
|
IsPinned = true,
|
||||||
|
|
@ -703,8 +752,8 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||||
{
|
{
|
||||||
ConversationId = "sample-review",
|
ConversationId = "sample-review",
|
||||||
Title = "디자인 리뷰",
|
Title = "디자인 리뷰",
|
||||||
Subtitle = "오후 2시에 검수 포인트만 다시 볼게요.",
|
Subtitle = "오후 2시에 포인트만 다시 볼게요.",
|
||||||
LastMessageText = "오후 2시에 검수 포인트만 다시 볼게요.",
|
LastMessageText = "오후 2시에 포인트만 다시 볼게요.",
|
||||||
MetaText = FormatConversationMeta(now.AddMinutes(-22), 0),
|
MetaText = FormatConversationMeta(now.AddMinutes(-22), 0),
|
||||||
UnreadCount = 0,
|
UnreadCount = 0,
|
||||||
IsPinned = false,
|
IsPinned = false,
|
||||||
|
|
@ -715,14 +764,38 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||||
{
|
{
|
||||||
ConversationId = "sample-friends",
|
ConversationId = "sample-friends",
|
||||||
Title = "주말 약속",
|
Title = "주말 약속",
|
||||||
Subtitle = "토요일 브런치 장소만 정하면 끝.",
|
Subtitle = "브런치 장소만 정하면 끝.",
|
||||||
LastMessageText = "토요일 브런치 장소만 정하면 끝.",
|
LastMessageText = "브런치 장소만 정하면 끝.",
|
||||||
MetaText = FormatConversationMeta(now.AddMinutes(-54), 0),
|
MetaText = FormatConversationMeta(now.AddMinutes(-54), 0),
|
||||||
UnreadCount = 0,
|
UnreadCount = 0,
|
||||||
IsPinned = false,
|
IsPinned = false,
|
||||||
LastReadSequence = 3,
|
LastReadSequence = 3,
|
||||||
SortKey = now.AddMinutes(-54)
|
SortKey = now.AddMinutes(-54)
|
||||||
});
|
});
|
||||||
|
Conversations.Add(new ConversationRowViewModel
|
||||||
|
{
|
||||||
|
ConversationId = "sample-team",
|
||||||
|
Title = "운영 팀",
|
||||||
|
Subtitle = "오후 공유본만 마지막으로 확인해 주세요.",
|
||||||
|
LastMessageText = "오후 공유본만 마지막으로 확인해 주세요.",
|
||||||
|
MetaText = FormatConversationMeta(now.AddHours(-2), 1),
|
||||||
|
UnreadCount = 1,
|
||||||
|
IsPinned = false,
|
||||||
|
LastReadSequence = 7,
|
||||||
|
SortKey = now.AddHours(-2)
|
||||||
|
});
|
||||||
|
Conversations.Add(new ConversationRowViewModel
|
||||||
|
{
|
||||||
|
ConversationId = "sample-files",
|
||||||
|
Title = "자료 모음",
|
||||||
|
Subtitle = "최신 캡처와 빌드 경로를 정리해 두었습니다.",
|
||||||
|
LastMessageText = "최신 캡처와 빌드 경로를 정리해 두었습니다.",
|
||||||
|
MetaText = FormatConversationMeta(now.AddHours(-5), 0),
|
||||||
|
UnreadCount = 0,
|
||||||
|
IsPinned = true,
|
||||||
|
LastReadSequence = 9,
|
||||||
|
SortKey = now.AddHours(-5)
|
||||||
|
});
|
||||||
|
|
||||||
NotifyConversationMetricsChanged();
|
NotifyConversationMetricsChanged();
|
||||||
RefreshConversationFilter("sample-ops");
|
RefreshConversationFilter("sample-ops");
|
||||||
|
|
@ -735,7 +808,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||||
{
|
{
|
||||||
MessageId = "sample-msg-1",
|
MessageId = "sample-msg-1",
|
||||||
SenderName = "민지",
|
SenderName = "민지",
|
||||||
Text = "회의 전에 이슈만 짧게 정리해 주세요.",
|
Text = "회의 전에 레이아웃 이슈만 짧게 정리해 주세요.",
|
||||||
MetaText = "08:54",
|
MetaText = "08:54",
|
||||||
IsMine = false,
|
IsMine = false,
|
||||||
ServerSequence = 13
|
ServerSequence = 13
|
||||||
|
|
@ -744,7 +817,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||||
{
|
{
|
||||||
MessageId = "sample-msg-2",
|
MessageId = "sample-msg-2",
|
||||||
SenderName = "이안",
|
SenderName = "이안",
|
||||||
Text = "레이아웃 구조를 다시 줄였습니다. 우측 빈 패널도 없앴어요.",
|
Text = "리스트 폭을 다시 줄이고 우측 빈 패널도 없앴어요.",
|
||||||
MetaText = "08:56",
|
MetaText = "08:56",
|
||||||
IsMine = true,
|
IsMine = true,
|
||||||
ServerSequence = 14
|
ServerSequence = 14
|
||||||
|
|
@ -753,7 +826,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||||
{
|
{
|
||||||
MessageId = "sample-msg-3",
|
MessageId = "sample-msg-3",
|
||||||
SenderName = "민지",
|
SenderName = "민지",
|
||||||
Text = "좋아요. 지금 화면이면 바로 검수할 수 있겠네요.",
|
Text = "좋아요. 지금 화면이면 검수하기 좋겠네요.",
|
||||||
MetaText = "08:58",
|
MetaText = "08:58",
|
||||||
IsMine = false,
|
IsMine = false,
|
||||||
ServerSequence = 15
|
ServerSequence = 15
|
||||||
|
|
@ -762,7 +835,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||||
{
|
{
|
||||||
MessageId = "sample-msg-4",
|
MessageId = "sample-msg-4",
|
||||||
SenderName = "이안",
|
SenderName = "이안",
|
||||||
Text = "스크린샷 기준으로 레이아웃도 바로 수정했습니다.",
|
Text = "스크린샷 기준으로 밀도도 같이 맞췄습니다.",
|
||||||
MetaText = "09:05",
|
MetaText = "09:05",
|
||||||
IsMine = true,
|
IsMine = true,
|
||||||
ServerSequence = 16
|
ServerSequence = 16
|
||||||
|
|
@ -771,7 +844,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||||
{
|
{
|
||||||
MessageId = "sample-msg-5",
|
MessageId = "sample-msg-5",
|
||||||
SenderName = "민지",
|
SenderName = "민지",
|
||||||
Text = "좋아요. 바로 확인 가능한 흐름으로 정리됐어요.",
|
Text = "좋아요. 확인 흐름이 더 짧아졌어요.",
|
||||||
MetaText = "09:06",
|
MetaText = "09:06",
|
||||||
IsMine = false,
|
IsMine = false,
|
||||||
ServerSequence = 17
|
ServerSequence = 17
|
||||||
|
|
@ -780,7 +853,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||||
{
|
{
|
||||||
MessageId = "sample-msg-6",
|
MessageId = "sample-msg-6",
|
||||||
SenderName = "이안",
|
SenderName = "이안",
|
||||||
Text = "분리 창도 한 번에 열리도록 남겨 두었습니다.",
|
Text = "분리 창은 상단 액션으로 남겨 두었습니다.",
|
||||||
MetaText = "09:07",
|
MetaText = "09:07",
|
||||||
IsMine = true,
|
IsMine = true,
|
||||||
ServerSequence = 18
|
ServerSequence = 18
|
||||||
|
|
@ -793,6 +866,42 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||||
MetaText = "09:08",
|
MetaText = "09:08",
|
||||||
IsMine = false,
|
IsMine = false,
|
||||||
ServerSequence = 19
|
ServerSequence = 19
|
||||||
|
},
|
||||||
|
new MessageRowViewModel
|
||||||
|
{
|
||||||
|
MessageId = "sample-msg-8",
|
||||||
|
SenderName = "이안",
|
||||||
|
Text = "검색과 필터는 한 줄 안에서 끝나도록 다시 정리할게요.",
|
||||||
|
MetaText = "09:10",
|
||||||
|
IsMine = true,
|
||||||
|
ServerSequence = 20
|
||||||
|
},
|
||||||
|
new MessageRowViewModel
|
||||||
|
{
|
||||||
|
MessageId = "sample-msg-9",
|
||||||
|
SenderName = "민지",
|
||||||
|
Text = "좋아요. 설명보다 눌리는 구조가 더 중요해요.",
|
||||||
|
MetaText = "09:11",
|
||||||
|
IsMine = false,
|
||||||
|
ServerSequence = 21
|
||||||
|
},
|
||||||
|
new MessageRowViewModel
|
||||||
|
{
|
||||||
|
MessageId = "sample-msg-10",
|
||||||
|
SenderName = "이안",
|
||||||
|
Text = "작성창도 짧은 액션만 남기고 텍스트는 줄였습니다.",
|
||||||
|
MetaText = "09:12",
|
||||||
|
IsMine = true,
|
||||||
|
ServerSequence = 22
|
||||||
|
},
|
||||||
|
new MessageRowViewModel
|
||||||
|
{
|
||||||
|
MessageId = "sample-msg-11",
|
||||||
|
SenderName = "민지",
|
||||||
|
Text = "이제 목록과 대화가 한 화면에서 훨씬 빠르게 읽히네요.",
|
||||||
|
MetaText = "09:13",
|
||||||
|
IsMine = false,
|
||||||
|
ServerSequence = 23
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
|
|
@ -1021,6 +1130,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||||
IsCompactDensity = true;
|
IsCompactDensity = true;
|
||||||
IsInspectorVisible = false;
|
IsInspectorVisible = false;
|
||||||
IsConversationPaneCollapsed = false;
|
IsConversationPaneCollapsed = false;
|
||||||
|
ConversationPaneWidthValue = 348;
|
||||||
StatusLine = "초기화";
|
StatusLine = "초기화";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1029,6 +1139,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||||
IsCompactDensity = layout.IsCompactDensity;
|
IsCompactDensity = layout.IsCompactDensity;
|
||||||
IsInspectorVisible = layout.IsInspectorVisible;
|
IsInspectorVisible = layout.IsInspectorVisible;
|
||||||
IsConversationPaneCollapsed = layout.IsConversationPaneCollapsed;
|
IsConversationPaneCollapsed = layout.IsConversationPaneCollapsed;
|
||||||
|
ConversationPaneWidthValue = Math.Clamp(layout.ConversationPaneWidth, 280, 480);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task PersistWorkspaceLayoutAsync()
|
private Task PersistWorkspaceLayoutAsync()
|
||||||
|
|
@ -1036,7 +1147,8 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||||
return _workspaceLayoutStore.SaveAsync(new DesktopWorkspaceLayout(
|
return _workspaceLayoutStore.SaveAsync(new DesktopWorkspaceLayout(
|
||||||
IsCompactDensity,
|
IsCompactDensity,
|
||||||
IsInspectorVisible,
|
IsInspectorVisible,
|
||||||
IsConversationPaneCollapsed));
|
IsConversationPaneCollapsed,
|
||||||
|
ConversationPaneWidthValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
|
|
|
||||||
|
|
@ -3,95 +3,98 @@
|
||||||
xmlns:vm="using:PhysOn.Desktop.ViewModels"
|
xmlns:vm="using:PhysOn.Desktop.ViewModels"
|
||||||
x:Class="PhysOn.Desktop.Views.ConversationWindow"
|
x:Class="PhysOn.Desktop.Views.ConversationWindow"
|
||||||
x:DataType="vm:ConversationWindowViewModel"
|
x:DataType="vm:ConversationWindowViewModel"
|
||||||
Width="460"
|
Width="404"
|
||||||
Height="760"
|
Height="748"
|
||||||
MinWidth="360"
|
MinWidth="340"
|
||||||
MinHeight="520"
|
MinHeight="520"
|
||||||
Background="#F6F7F8"
|
Background="#F3F4F6"
|
||||||
Title="{Binding ConversationTitle}">
|
Title="{Binding ConversationTitle}">
|
||||||
|
|
||||||
<Window.Styles>
|
<Window.Styles>
|
||||||
<Style Selector="Border.surface">
|
<Style Selector="Border.surface">
|
||||||
<Setter Property="CornerRadius" Value="10" />
|
<Setter Property="CornerRadius" Value="2" />
|
||||||
<Setter Property="Background" Value="#FFFFFF" />
|
<Setter Property="Background" Value="#FFFFFF" />
|
||||||
<Setter Property="BorderBrush" Value="#E6E8EB" />
|
<Setter Property="BorderBrush" Value="#E5E7EB" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Border.soft">
|
<Style Selector="Border.muted">
|
||||||
<Setter Property="CornerRadius" Value="8" />
|
<Setter Property="CornerRadius" Value="2" />
|
||||||
<Setter Property="Background" Value="#F7F8F9" />
|
<Setter Property="Background" Value="#F7F8FA" />
|
||||||
<Setter Property="BorderBrush" Value="#ECEEF1" />
|
<Setter Property="BorderBrush" Value="#E8EAEE" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.icon">
|
|
||||||
<Setter Property="CornerRadius" Value="9" />
|
|
||||||
<Setter Property="Padding" Value="9,7" />
|
|
||||||
<Setter Property="Background" Value="#FFFFFF" />
|
|
||||||
<Setter Property="BorderBrush" Value="#D9DDE2" />
|
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Button.primary">
|
|
||||||
<Setter Property="CornerRadius" Value="9" />
|
|
||||||
<Setter Property="Padding" Value="12,9" />
|
|
||||||
<Setter Property="Background" Value="#111418" />
|
|
||||||
<Setter Property="Foreground" Value="#FFFFFF" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="TextBlock.caption">
|
|
||||||
<Setter Property="FontSize" Value="12" />
|
|
||||||
<Setter Property="Foreground" Value="#6A7380" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="TextBlock.body">
|
|
||||||
<Setter Property="FontSize" Value="13" />
|
|
||||||
<Setter Property="Foreground" Value="#151A20" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Border.bubble">
|
<Style Selector="Border.bubble">
|
||||||
<Setter Property="CornerRadius" Value="10" />
|
<Setter Property="CornerRadius" Value="2" />
|
||||||
<Setter Property="Padding" Value="8,7" />
|
<Setter Property="Padding" Value="9,7" />
|
||||||
<Setter Property="Margin" Value="0,0,0,5" />
|
<Setter Property="Margin" Value="0,0,0,6" />
|
||||||
<Setter Property="Background" Value="#F6F7F8" />
|
<Setter Property="Background" Value="#F7F8FA" />
|
||||||
<Setter Property="BorderBrush" Value="#EAEDF0" />
|
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="HorizontalAlignment" Value="Left" />
|
<Setter Property="HorizontalAlignment" Value="Left" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Border.bubble.mine">
|
<Style Selector="Border.bubble.mine">
|
||||||
<Setter Property="Background" Value="#ECEFF3" />
|
<Setter Property="Background" Value="#EEF1F4" />
|
||||||
<Setter Property="HorizontalAlignment" Value="Right" />
|
<Setter Property="HorizontalAlignment" Value="Right" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<Style Selector="Button.icon">
|
||||||
|
<Setter Property="CornerRadius" Value="2" />
|
||||||
|
<Setter Property="Padding" Value="8,6" />
|
||||||
|
<Setter Property="Background" Value="#F7F8FA" />
|
||||||
|
<Setter Property="BorderBrush" Value="#D9DDE2" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.primary">
|
||||||
|
<Setter Property="CornerRadius" Value="2" />
|
||||||
|
<Setter Property="Padding" Value="11,8" />
|
||||||
|
<Setter Property="Background" Value="#111418" />
|
||||||
|
<Setter Property="Foreground" Value="#FFFFFF" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.caption">
|
||||||
|
<Setter Property="FontSize" Value="11.5" />
|
||||||
|
<Setter Property="Foreground" Value="#69727D" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.body">
|
||||||
|
<Setter Property="FontSize" Value="13" />
|
||||||
|
<Setter Property="Foreground" Value="#111418" />
|
||||||
|
</Style>
|
||||||
</Window.Styles>
|
</Window.Styles>
|
||||||
|
|
||||||
<Grid Margin="16" RowDefinitions="Auto,Auto,*,Auto" RowSpacing="10">
|
<Grid Margin="10" RowDefinitions="Auto,Auto,*,Auto" RowSpacing="8">
|
||||||
<Border Classes="surface" Padding="12">
|
<Border Classes="surface" Padding="10">
|
||||||
<Grid ColumnDefinitions="42,*,Auto" ColumnSpacing="12">
|
<Grid ColumnDefinitions="40,*,Auto,Auto" ColumnSpacing="10">
|
||||||
<Border Width="42" Height="42" Classes="soft">
|
<Border Width="40" Height="40" Classes="muted">
|
||||||
<TextBlock HorizontalAlignment="Center"
|
<TextBlock HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Text="{Binding ConversationGlyph}"
|
Text="{Binding ConversationGlyph}"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
Foreground="#151A20" />
|
Foreground="#111418" />
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<StackPanel Grid.Column="1" Spacing="2">
|
<StackPanel Grid.Column="1" Spacing="2">
|
||||||
<TextBlock Text="{Binding ConversationTitle}"
|
<TextBlock Text="{Binding ConversationTitle}"
|
||||||
FontSize="15"
|
FontSize="14.5"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
Foreground="#151A20"
|
Foreground="#111418"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock Text="{Binding ConversationSubtitle}"
|
||||||
|
Classes="caption"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextTrimming="CharacterEllipsis" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8">
|
<Border Grid.Column="2" Classes="muted" Padding="8,4">
|
||||||
<Border Classes="soft" Padding="8,5">
|
|
||||||
<TextBlock Text="{Binding StatusText}" Classes="caption" />
|
<TextBlock Text="{Binding StatusText}" Classes="caption" />
|
||||||
</Border>
|
</Border>
|
||||||
<Button Classes="icon"
|
|
||||||
|
<Button Grid.Column="3"
|
||||||
|
Classes="icon"
|
||||||
Command="{Binding ReloadCommand}"
|
Command="{Binding ReloadCommand}"
|
||||||
Content="↻" />
|
Content="↻" />
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Border Grid.Row="1"
|
<Border Grid.Row="1"
|
||||||
Classes="surface"
|
Classes="surface"
|
||||||
Padding="10"
|
Padding="9"
|
||||||
IsVisible="{Binding HasErrorText}">
|
IsVisible="{Binding HasErrorText}">
|
||||||
<TextBlock Text="{Binding ErrorText}"
|
<TextBlock Text="{Binding ErrorText}"
|
||||||
Foreground="#C62828"
|
Foreground="#C62828"
|
||||||
|
|
@ -99,8 +102,8 @@
|
||||||
TextWrapping="Wrap" />
|
TextWrapping="Wrap" />
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Border Grid.Row="2" Classes="surface" Padding="12">
|
<Border Grid.Row="2" Classes="surface" Padding="8">
|
||||||
<ScrollViewer Name="MessagesScrollViewer">
|
<ScrollViewer Name="MessagesScrollViewer" MaxWidth="360" HorizontalAlignment="Center">
|
||||||
<ItemsControl ItemsSource="{Binding Messages}">
|
<ItemsControl ItemsSource="{Binding Messages}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:MessageRowViewModel">
|
<DataTemplate x:DataType="vm:MessageRowViewModel">
|
||||||
|
|
@ -123,17 +126,18 @@
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Border Grid.Row="3" Classes="surface" Padding="10">
|
<Border Grid.Row="3" Classes="muted" Padding="8" MaxWidth="360" HorizontalAlignment="Center">
|
||||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10">
|
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10">
|
||||||
<TextBox Name="ComposerTextBox"
|
<TextBox Name="ComposerTextBox"
|
||||||
PlaceholderText="메시지"
|
PlaceholderText="메시지"
|
||||||
AcceptsReturn="True"
|
AcceptsReturn="True"
|
||||||
TextWrapping="Wrap"
|
TextWrapping="Wrap"
|
||||||
MinHeight="46"
|
MinHeight="44"
|
||||||
Text="{Binding ComposerText}"
|
Text="{Binding ComposerText}"
|
||||||
KeyDown="ComposerTextBox_OnKeyDown" />
|
KeyDown="ComposerTextBox_OnKeyDown" />
|
||||||
<Button Grid.Column="1"
|
<Button Grid.Column="1"
|
||||||
Classes="primary"
|
Classes="primary"
|
||||||
|
MinWidth="72"
|
||||||
Command="{Binding SendMessageCommand}"
|
Command="{Binding SendMessageCommand}"
|
||||||
Content="보내기" />
|
Content="보내기" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,9 @@
|
||||||
Title="KoTalk"
|
Title="KoTalk"
|
||||||
Width="1440"
|
Width="1440"
|
||||||
Height="900"
|
Height="900"
|
||||||
MinWidth="1040"
|
MinWidth="980"
|
||||||
MinHeight="680"
|
MinHeight="640"
|
||||||
Background="#F6F7F8">
|
Background="#F3F4F6">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<vm:MainWindowViewModel />
|
<vm:MainWindowViewModel />
|
||||||
|
|
@ -22,39 +22,73 @@
|
||||||
|
|
||||||
<Window.Styles>
|
<Window.Styles>
|
||||||
<Style Selector="Border.surface">
|
<Style Selector="Border.surface">
|
||||||
<Setter Property="CornerRadius" Value="10" />
|
<Setter Property="CornerRadius" Value="2" />
|
||||||
<Setter Property="Background" Value="#FFFFFF" />
|
<Setter Property="Background" Value="#FFFFFF" />
|
||||||
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
<Setter Property="BorderBrush" Value="#E5E7EB" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Border.surface-muted">
|
<Style Selector="Border.surface-muted">
|
||||||
<Setter Property="CornerRadius" Value="8" />
|
<Setter Property="CornerRadius" Value="2" />
|
||||||
<Setter Property="Background" Value="#F5F6F7" />
|
<Setter Property="Background" Value="#F7F8FA" />
|
||||||
<Setter Property="BorderBrush" Value="#E8EBEF" />
|
<Setter Property="BorderBrush" Value="#E8EAEE" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Border.rail-surface">
|
<Style Selector="Border.rail-surface">
|
||||||
<Setter Property="CornerRadius" Value="10" />
|
<Setter Property="CornerRadius" Value="2" />
|
||||||
<Setter Property="Background" Value="#FFFFFF" />
|
<Setter Property="Background" Value="#FFFFFF" />
|
||||||
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
<Setter Property="BorderBrush" Value="#E5E7EB" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Border.dashboard-card">
|
<Style Selector="Border.row-card">
|
||||||
<Setter Property="CornerRadius" Value="8" />
|
<Setter Property="CornerRadius" Value="2" />
|
||||||
<Setter Property="Background" Value="#F7F8F9" />
|
|
||||||
<Setter Property="BorderBrush" Value="#E8EBEF" />
|
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
|
||||||
<Setter Property="Padding" Value="12,10" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Border.empty-card">
|
|
||||||
<Setter Property="CornerRadius" Value="10" />
|
|
||||||
<Setter Property="Background" Value="#FFFFFF" />
|
<Setter Property="Background" Value="#FFFFFF" />
|
||||||
|
<Setter Property="BorderBrush" Value="#ECEFF3" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.row-card.active">
|
||||||
|
<Setter Property="Background" Value="#F3F5F7" />
|
||||||
|
<Setter Property="BorderBrush" Value="#D7DCE3" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.inline-alert">
|
||||||
|
<Setter Property="CornerRadius" Value="2" />
|
||||||
|
<Setter Property="Background" Value="#FFFFFF" />
|
||||||
|
<Setter Property="BorderBrush" Value="#D14B3F" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="Padding" Value="10,8" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.status-chip">
|
||||||
|
<Setter Property="CornerRadius" Value="2" />
|
||||||
|
<Setter Property="Background" Value="#F7F8FA" />
|
||||||
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="Padding" Value="18" />
|
<Setter Property="Padding" Value="8,3" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.unread-badge">
|
||||||
|
<Setter Property="CornerRadius" Value="2" />
|
||||||
|
<Setter Property="Background" Value="#111418" />
|
||||||
|
<Setter Property="Padding" Value="6,1" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.message-bubble">
|
||||||
|
<Setter Property="CornerRadius" Value="2" />
|
||||||
|
<Setter Property="Background" Value="#F7F8FA" />
|
||||||
|
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="MaxWidth" Value="560" />
|
||||||
|
<Setter Property="Margin" Value="0,0,0,6" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Left" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.message-bubble.mine">
|
||||||
|
<Setter Property="Background" Value="#EEF1F4" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Right" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.message-bubble.pending">
|
||||||
|
<Setter Property="Opacity" Value="0.72" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.message-bubble.failed">
|
||||||
|
<Setter Property="BorderBrush" Value="#C9392C" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TextBlock.display-title">
|
<Style Selector="TextBlock.display-title">
|
||||||
<Setter Property="FontSize" Value="28" />
|
<Setter Property="FontSize" Value="30" />
|
||||||
<Setter Property="FontWeight" Value="SemiBold" />
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
<Setter Property="Foreground" Value="#111418" />
|
<Setter Property="Foreground" Value="#111418" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
@ -69,68 +103,68 @@
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TextBlock.caption">
|
<Style Selector="TextBlock.caption">
|
||||||
<Setter Property="FontSize" Value="11.5" />
|
<Setter Property="FontSize" Value="11.5" />
|
||||||
<Setter Property="Foreground" Value="#6A7480" />
|
<Setter Property="Foreground" Value="#69727D" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TextBlock.eyebrow">
|
<Style Selector="TextBlock.eyebrow">
|
||||||
<Setter Property="FontSize" Value="11" />
|
<Setter Property="FontSize" Value="10.5" />
|
||||||
<Setter Property="FontWeight" Value="SemiBold" />
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
<Setter Property="Foreground" Value="#6A7480" />
|
<Setter Property="Foreground" Value="#69727D" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TextBox.input">
|
<Style Selector="TextBox.input">
|
||||||
<Setter Property="CornerRadius" Value="10" />
|
<Setter Property="CornerRadius" Value="2" />
|
||||||
<Setter Property="Padding" Value="11,8" />
|
<Setter Property="Padding" Value="11,8" />
|
||||||
<Setter Property="Background" Value="#FFFFFF" />
|
<Setter Property="Background" Value="#FFFFFF" />
|
||||||
<Setter Property="BorderBrush" Value="#D9DEE4" />
|
<Setter Property="BorderBrush" Value="#D8DDE4" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="FontSize" Value="13" />
|
<Setter Property="FontSize" Value="13" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TextBox.search-input">
|
<Style Selector="TextBox.search-input">
|
||||||
<Setter Property="CornerRadius" Value="9" />
|
<Setter Property="CornerRadius" Value="2" />
|
||||||
<Setter Property="Padding" Value="10,7" />
|
<Setter Property="Padding" Value="9,7" />
|
||||||
<Setter Property="Background" Value="#F6F7F8" />
|
<Setter Property="Background" Value="#F7F8FA" />
|
||||||
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
<Setter Property="BorderBrush" Value="#E5E7EB" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="FontSize" Value="13" />
|
<Setter Property="FontSize" Value="13" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.primary-button">
|
<Style Selector="Button.primary-button">
|
||||||
<Setter Property="CornerRadius" Value="10" />
|
<Setter Property="CornerRadius" Value="2" />
|
||||||
<Setter Property="Padding" Value="14,10" />
|
<Setter Property="Padding" Value="12,9" />
|
||||||
<Setter Property="Background" Value="#111418" />
|
<Setter Property="Background" Value="#111418" />
|
||||||
<Setter Property="Foreground" Value="#FFFFFF" />
|
<Setter Property="Foreground" Value="#FFFFFF" />
|
||||||
<Setter Property="FontWeight" Value="SemiBold" />
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.secondary-button">
|
<Style Selector="Button.secondary-button">
|
||||||
<Setter Property="CornerRadius" Value="10" />
|
<Setter Property="CornerRadius" Value="2" />
|
||||||
<Setter Property="Padding" Value="13,10" />
|
<Setter Property="Padding" Value="10,8" />
|
||||||
<Setter Property="Background" Value="#FFFFFF" />
|
<Setter Property="Background" Value="#FFFFFF" />
|
||||||
<Setter Property="Foreground" Value="#1E252C" />
|
<Setter Property="Foreground" Value="#1E252C" />
|
||||||
<Setter Property="BorderBrush" Value="#D9DEE4" />
|
<Setter Property="BorderBrush" Value="#D8DDE4" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.icon-button">
|
<Style Selector="Button.icon-button">
|
||||||
<Setter Property="Height" Value="34" />
|
<Setter Property="Height" Value="30" />
|
||||||
<Setter Property="MinWidth" Value="34" />
|
<Setter Property="MinWidth" Value="30" />
|
||||||
<Setter Property="CornerRadius" Value="9" />
|
<Setter Property="CornerRadius" Value="2" />
|
||||||
<Setter Property="Padding" Value="10,0" />
|
<Setter Property="Padding" Value="8,0" />
|
||||||
<Setter Property="Background" Value="#F7F8F9" />
|
<Setter Property="Background" Value="#F7F8FA" />
|
||||||
<Setter Property="Foreground" Value="#1E252C" />
|
<Setter Property="Foreground" Value="#1E252C" />
|
||||||
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
<Setter Property="BorderBrush" Value="#E5E7EB" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="FontSize" Value="12" />
|
<Setter Property="FontSize" Value="12" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.icon-button.compact">
|
<Style Selector="Button.icon-button.compact">
|
||||||
<Setter Property="MinWidth" Value="28" />
|
<Setter Property="MinWidth" Value="28" />
|
||||||
<Setter Property="Height" Value="28" />
|
<Setter Property="Height" Value="28" />
|
||||||
<Setter Property="CornerRadius" Value="8" />
|
<Setter Property="CornerRadius" Value="2" />
|
||||||
<Setter Property="Padding" Value="8,0" />
|
<Setter Property="Padding" Value="6,0" />
|
||||||
<Setter Property="FontSize" Value="11" />
|
<Setter Property="FontSize" Value="11" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.filter-button">
|
<Style Selector="Button.filter-button">
|
||||||
<Setter Property="CornerRadius" Value="999" />
|
<Setter Property="CornerRadius" Value="2" />
|
||||||
<Setter Property="Padding" Value="10,6" />
|
<Setter Property="Padding" Value="9,5" />
|
||||||
<Setter Property="Background" Value="#F7F8F9" />
|
<Setter Property="Background" Value="#F7F8FA" />
|
||||||
<Setter Property="Foreground" Value="#69727D" />
|
<Setter Property="Foreground" Value="#69727D" />
|
||||||
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
<Setter Property="BorderBrush" Value="#E5E7EB" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="FontSize" Value="12" />
|
<Setter Property="FontSize" Value="12" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
@ -140,13 +174,13 @@
|
||||||
<Setter Property="BorderBrush" Value="#111418" />
|
<Setter Property="BorderBrush" Value="#111418" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.rail-button">
|
<Style Selector="Button.rail-button">
|
||||||
<Setter Property="MinWidth" Value="48" />
|
<Setter Property="Width" Value="38" />
|
||||||
<Setter Property="Height" Value="52" />
|
<Setter Property="Height" Value="38" />
|
||||||
<Setter Property="CornerRadius" Value="10" />
|
<Setter Property="CornerRadius" Value="2" />
|
||||||
<Setter Property="Padding" Value="0" />
|
<Setter Property="Padding" Value="0" />
|
||||||
<Setter Property="Background" Value="#FFFFFF" />
|
<Setter Property="Background" Value="#FFFFFF" />
|
||||||
<Setter Property="Foreground" Value="#1E252C" />
|
<Setter Property="Foreground" Value="#1E252C" />
|
||||||
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
<Setter Property="BorderBrush" Value="#E5E7EB" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="FontSize" Value="13" />
|
<Setter Property="FontSize" Value="13" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
@ -161,194 +195,85 @@
|
||||||
<Setter Property="Padding" Value="0" />
|
<Setter Property="Padding" Value="0" />
|
||||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Border.row-card">
|
<Style Selector="Button.quick-button">
|
||||||
<Setter Property="CornerRadius" Value="9" />
|
<Setter Property="CornerRadius" Value="2" />
|
||||||
|
<Setter Property="Padding" Value="9,5" />
|
||||||
<Setter Property="Background" Value="#FFFFFF" />
|
<Setter Property="Background" Value="#FFFFFF" />
|
||||||
<Setter Property="BorderBrush" Value="#EEF1F4" />
|
<Setter Property="Foreground" Value="#4A5560" />
|
||||||
|
<Setter Property="BorderBrush" Value="#E5E7EB" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
</Style>
|
<Setter Property="FontSize" Value="11.5" />
|
||||||
<Style Selector="Border.row-card.active">
|
|
||||||
<Setter Property="Background" Value="#F3F5F7" />
|
|
||||||
<Setter Property="BorderBrush" Value="#D8DDE4" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Border.status-chip">
|
|
||||||
<Setter Property="CornerRadius" Value="999" />
|
|
||||||
<Setter Property="Padding" Value="8,4" />
|
|
||||||
<Setter Property="Background" Value="#F7F8F9" />
|
|
||||||
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Border.inline-alert">
|
|
||||||
<Setter Property="CornerRadius" Value="9" />
|
|
||||||
<Setter Property="Background" Value="#FFFFFF" />
|
|
||||||
<Setter Property="BorderBrush" Value="#C9392C" />
|
|
||||||
<Setter Property="BorderThickness" Value="1,0,0,0" />
|
|
||||||
<Setter Property="Padding" Value="12,10" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Border.avatar-badge">
|
|
||||||
<Setter Property="CornerRadius" Value="10" />
|
|
||||||
<Setter Property="Background" Value="#F3F4F6" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Border.unread-badge">
|
|
||||||
<Setter Property="CornerRadius" Value="999" />
|
|
||||||
<Setter Property="Background" Value="#111418" />
|
|
||||||
<Setter Property="Padding" Value="7,2" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Border.message-bubble">
|
|
||||||
<Setter Property="CornerRadius" Value="10" />
|
|
||||||
<Setter Property="Background" Value="#F7F8F9" />
|
|
||||||
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
|
||||||
<Setter Property="MaxWidth" Value="680" />
|
|
||||||
<Setter Property="Margin" Value="0,0,0,6" />
|
|
||||||
<Setter Property="HorizontalAlignment" Value="Left" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Border.message-bubble.mine">
|
|
||||||
<Setter Property="Background" Value="#EEF1F4" />
|
|
||||||
<Setter Property="HorizontalAlignment" Value="Right" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Border.message-bubble.pending">
|
|
||||||
<Setter Property="Opacity" Value="0.72" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Border.message-bubble.failed">
|
|
||||||
<Setter Property="BorderBrush" Value="#C9392C" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="TextBlock.message-text">
|
|
||||||
<Setter Property="FontSize" Value="13" />
|
|
||||||
<Setter Property="Foreground" Value="#111418" />
|
|
||||||
<Setter Property="TextWrapping" Value="Wrap" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="TextBlock.message-meta">
|
|
||||||
<Setter Property="FontSize" Value="11" />
|
|
||||||
<Setter Property="Foreground" Value="#69727D" />
|
|
||||||
</Style>
|
</Style>
|
||||||
</Window.Styles>
|
</Window.Styles>
|
||||||
|
|
||||||
<Grid Margin="20">
|
<Grid Margin="12">
|
||||||
<Grid IsVisible="{Binding ShowOnboarding}"
|
<Grid IsVisible="{Binding ShowOnboarding}"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
MaxWidth="1120"
|
MaxWidth="380">
|
||||||
ColumnDefinitions="1.2*,420"
|
<Border Classes="surface" Padding="22">
|
||||||
ColumnSpacing="18">
|
<StackPanel Spacing="14">
|
||||||
<Border Grid.Column="0" Classes="surface" Padding="28">
|
<StackPanel Spacing="8">
|
||||||
<Grid RowDefinitions="Auto,Auto,*" RowSpacing="18">
|
<TextBlock Text="KO · TALK" Classes="eyebrow" />
|
||||||
<StackPanel Spacing="10">
|
<Border Width="50" Height="50" Classes="surface-muted" HorizontalAlignment="Left">
|
||||||
<TextBlock Text="KTOP" Classes="eyebrow" />
|
|
||||||
<StackPanel Spacing="6">
|
|
||||||
<Border Width="60" Height="60" Classes="surface-muted" HorizontalAlignment="Left">
|
|
||||||
<TextBlock HorizontalAlignment="Center"
|
<TextBlock HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Text="KO"
|
Text="KO"
|
||||||
FontSize="20"
|
FontSize="18"
|
||||||
FontWeight="Bold"
|
FontWeight="Bold"
|
||||||
Foreground="#111418" />
|
Foreground="#111418" />
|
||||||
</Border>
|
</Border>
|
||||||
<TextBlock Text="KoTalk" Classes="display-title" />
|
<TextBlock Text="KoTalk" Classes="display-title" />
|
||||||
<TextBlock Text="열면 바로 이어지는 대화 워크스페이스" Classes="body" />
|
|
||||||
</StackPanel>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="*,*,*" ColumnSpacing="10">
|
<TextBox Classes="input"
|
||||||
<Border Grid.Column="0" Classes="dashboard-card">
|
|
||||||
<StackPanel Spacing="4">
|
|
||||||
<TextBlock Text="빠른 입장" Classes="eyebrow" />
|
|
||||||
<TextBlock Text="표시 이름 + 참여 키" Classes="section-title" />
|
|
||||||
<TextBlock Text="필수 입력만 바로 보입니다." Classes="caption" TextWrapping="Wrap" />
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
<Border Grid.Column="1" Classes="dashboard-card">
|
|
||||||
<StackPanel Spacing="4">
|
|
||||||
<TextBlock Text="대화 집중" Classes="eyebrow" />
|
|
||||||
<TextBlock Text="받은함과 대화만 남김" Classes="section-title" />
|
|
||||||
<TextBlock Text="불필요한 빈 패널을 줄였습니다." Classes="caption" TextWrapping="Wrap" />
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
<Border Grid.Column="2" Classes="dashboard-card">
|
|
||||||
<StackPanel Spacing="4">
|
|
||||||
<TextBlock Text="멀티 윈도우" Classes="eyebrow" />
|
|
||||||
<TextBlock Text="대화별 분리 창" Classes="section-title" />
|
|
||||||
<TextBlock Text="작업 흐름을 끊지 않고 분리합니다." Classes="caption" TextWrapping="Wrap" />
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Border Grid.Row="2" Classes="surface-muted" Padding="16">
|
|
||||||
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12">
|
|
||||||
<Border Width="36" Height="36" Classes="surface">
|
|
||||||
<TextBlock HorizontalAlignment="Center"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
Text="·"
|
|
||||||
FontWeight="Bold"
|
|
||||||
Foreground="#111418" />
|
|
||||||
</Border>
|
|
||||||
<StackPanel Grid.Column="1" Spacing="4">
|
|
||||||
<TextBlock Text="세션은 이 기기에만 남깁니다." Classes="section-title" />
|
|
||||||
<TextBlock Text="서버 주소는 기본으로 숨겨 두고, 변경이 필요할 때만 고급 설정에서 엽니다." Classes="caption" TextWrapping="Wrap" />
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<Border Grid.Column="1" Classes="surface" Padding="22">
|
|
||||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*" RowSpacing="10">
|
|
||||||
<StackPanel Spacing="4">
|
|
||||||
<TextBlock Text="입장" Classes="section-title" />
|
|
||||||
<TextBlock Text="필수 입력만 먼저 받습니다." Classes="caption" />
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<TextBox Grid.Row="1"
|
|
||||||
Classes="input"
|
|
||||||
PlaceholderText="표시 이름"
|
PlaceholderText="표시 이름"
|
||||||
Text="{Binding DisplayName}" />
|
Text="{Binding DisplayName}" />
|
||||||
|
|
||||||
<TextBox Grid.Row="2"
|
<TextBox Classes="input"
|
||||||
Classes="input"
|
|
||||||
PlaceholderText="참여 키"
|
PlaceholderText="참여 키"
|
||||||
Text="{Binding InviteCode}" />
|
Text="{Binding InviteCode}" />
|
||||||
|
|
||||||
<Grid Grid.Row="3" ColumnDefinitions="Auto,*" ColumnSpacing="10">
|
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="10">
|
||||||
<Button Classes="secondary-button"
|
<Button Classes="secondary-button"
|
||||||
HorizontalAlignment="Left"
|
|
||||||
Command="{Binding ToggleAdvancedSettingsCommand}"
|
Command="{Binding ToggleAdvancedSettingsCommand}"
|
||||||
Content="{Binding AdvancedSettingsButtonText}" />
|
Content="{Binding AdvancedSettingsButtonText}" />
|
||||||
<TextBox Grid.Column="1"
|
<TextBox Grid.Column="1"
|
||||||
Classes="input"
|
Classes="input"
|
||||||
IsVisible="{Binding ShowAdvancedSettings}"
|
IsVisible="{Binding ShowAdvancedSettings}"
|
||||||
PlaceholderText="기본 서버를 바꾸는 경우만 입력"
|
PlaceholderText="서버를 바꿀 때만 입력"
|
||||||
Text="{Binding ApiBaseUrl}" />
|
Text="{Binding ApiBaseUrl}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<StackPanel Grid.Row="4" VerticalAlignment="Bottom" Spacing="10">
|
<CheckBox Content="유지" IsChecked="{Binding RememberSession}" />
|
||||||
<CheckBox Content="이 기기에서 이어서 열기" IsChecked="{Binding RememberSession}" />
|
|
||||||
<Border Classes="inline-alert" IsVisible="{Binding HasErrorText}">
|
<Border Classes="inline-alert" IsVisible="{Binding HasErrorText}">
|
||||||
<TextBlock Text="{Binding ErrorText}"
|
<TextBlock Text="{Binding ErrorText}"
|
||||||
Classes="caption"
|
Classes="caption"
|
||||||
Foreground="#C9392C"
|
Foreground="#C9392C"
|
||||||
TextWrapping="Wrap" />
|
TextWrapping="Wrap" />
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Button Classes="primary-button"
|
<Button Classes="primary-button"
|
||||||
HorizontalAlignment="Stretch"
|
HorizontalAlignment="Stretch"
|
||||||
Command="{Binding SignInCommand}"
|
Command="{Binding SignInCommand}"
|
||||||
Content="대화 열기" />
|
Content="열기" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid IsVisible="{Binding ShowShell}">
|
<Grid IsVisible="{Binding ShowShell}">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="72" />
|
<ColumnDefinition Width="56" />
|
||||||
<ColumnDefinition Width="{Binding ConversationPaneWidth}" />
|
<ColumnDefinition Width="{Binding ConversationPaneWidth}" />
|
||||||
<ColumnDefinition Width="8" />
|
<ColumnDefinition Width="6" />
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<Border Grid.Column="0" Classes="rail-surface" Padding="10">
|
<Border Grid.Column="0" Classes="rail-surface" Padding="8">
|
||||||
<Grid RowDefinitions="Auto,*,Auto">
|
<Grid RowDefinitions="Auto,*,Auto">
|
||||||
<StackPanel Spacing="12">
|
<StackPanel Spacing="8">
|
||||||
<Border Width="48" Height="48" Classes="surface-muted">
|
<Border Width="38" Height="38" Classes="surface-muted">
|
||||||
<TextBlock HorizontalAlignment="Center"
|
<TextBlock HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Text="KO"
|
Text="KO"
|
||||||
|
|
@ -357,33 +282,12 @@
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Button Classes="rail-button active" ToolTip.Tip="받은함">
|
<Button Classes="rail-button active" ToolTip.Tip="받은함">
|
||||||
<StackPanel Spacing="2" HorizontalAlignment="Center">
|
<TextBlock Text="⌂" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
<TextBlock Text="⌂" HorizontalAlignment="Center" />
|
|
||||||
<TextBlock Text="대화" Classes="caption" HorizontalAlignment="Center" />
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button Classes="rail-button"
|
|
||||||
ToolTip.Tip="대화를 분리 창으로 열기"
|
|
||||||
Command="{Binding DetachConversationCommand}">
|
|
||||||
<StackPanel Spacing="2" HorizontalAlignment="Center">
|
|
||||||
<TextBlock Text="{Binding DetachedWindowActionGlyph}" HorizontalAlignment="Center" />
|
|
||||||
<TextBlock Text="분리" Classes="caption" HorizontalAlignment="Center" />
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button Classes="rail-button"
|
|
||||||
ToolTip.Tip="대화 목록 접기"
|
|
||||||
Command="{Binding ToggleConversationPaneCommand}">
|
|
||||||
<StackPanel Spacing="2" HorizontalAlignment="Center">
|
|
||||||
<TextBlock Text="{Binding PaneActionGlyph}" HorizontalAlignment="Center" />
|
|
||||||
<TextBlock Text="목록" Classes="caption" HorizontalAlignment="Center" />
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Grid.Row="2" Spacing="10">
|
<StackPanel Grid.Row="2" Spacing="8">
|
||||||
<Border Width="48" Height="48" Classes="surface-muted">
|
<Border Width="38" Height="38" Classes="surface-muted">
|
||||||
<TextBlock HorizontalAlignment="Center"
|
<TextBlock HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Text="{Binding CurrentUserMonogram}"
|
Text="{Binding CurrentUserMonogram}"
|
||||||
|
|
@ -393,29 +297,21 @@
|
||||||
<Button Classes="rail-button"
|
<Button Classes="rail-button"
|
||||||
ToolTip.Tip="로그아웃"
|
ToolTip.Tip="로그아웃"
|
||||||
Command="{Binding SignOutCommand}">
|
Command="{Binding SignOutCommand}">
|
||||||
<StackPanel Spacing="2" HorizontalAlignment="Center">
|
<TextBlock Text="⎋" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
<TextBlock Text="⎋" HorizontalAlignment="Center" />
|
|
||||||
<TextBlock Text="종료" Classes="caption" HorizontalAlignment="Center" />
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Border Grid.Column="1"
|
<Border x:Name="ConversationPaneHost"
|
||||||
|
Grid.Column="1"
|
||||||
Classes="surface"
|
Classes="surface"
|
||||||
Padding="14"
|
Padding="10"
|
||||||
IsVisible="{Binding IsConversationPaneExpanded}">
|
IsVisible="{Binding IsConversationPaneExpanded}"
|
||||||
<Grid RowDefinitions="Auto,Auto,*,Auto" RowSpacing="12">
|
SizeChanged="ConversationPaneHost_OnSizeChanged">
|
||||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10">
|
<Grid RowDefinitions="Auto,Auto,Auto,*" RowSpacing="8">
|
||||||
<StackPanel Spacing="3">
|
<Grid ColumnDefinitions="*" ColumnSpacing="10">
|
||||||
<TextBlock Text="받은함" Classes="section-title" />
|
<TextBlock Text="받은함" Classes="section-title" />
|
||||||
<TextBlock Text="{Binding CurrentUserDisplayName}" Classes="caption" />
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<Border Grid.Column="1" Classes="status-chip" VerticalAlignment="Center">
|
|
||||||
<TextBlock Text="{Binding StatusSummaryText}" Classes="caption" />
|
|
||||||
</Border>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
<Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
||||||
|
|
@ -423,7 +319,6 @@
|
||||||
Classes="search-input"
|
Classes="search-input"
|
||||||
PlaceholderText="{Binding SearchWatermark}"
|
PlaceholderText="{Binding SearchWatermark}"
|
||||||
Text="{Binding ConversationSearchText}" />
|
Text="{Binding ConversationSearchText}" />
|
||||||
|
|
||||||
<Button Grid.Column="1"
|
<Button Grid.Column="1"
|
||||||
Classes="icon-button"
|
Classes="icon-button"
|
||||||
ToolTip.Tip="밀도 전환"
|
ToolTip.Tip="밀도 전환"
|
||||||
|
|
@ -436,8 +331,7 @@
|
||||||
Content="↻" />
|
Content="↻" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<StackPanel Grid.Row="2" Spacing="10">
|
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="6">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
|
||||||
<Button Classes="filter-button"
|
<Button Classes="filter-button"
|
||||||
Classes.selected="{Binding IsAllFilterSelected}"
|
Classes.selected="{Binding IsAllFilterSelected}"
|
||||||
Command="{Binding ShowAllConversationsCommand}">
|
Command="{Binding ShowAllConversationsCommand}">
|
||||||
|
|
@ -464,19 +358,19 @@
|
||||||
</Button>
|
</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Grid>
|
<Grid Grid.Row="3">
|
||||||
<Border Classes="empty-card"
|
<Border Classes="surface-muted"
|
||||||
|
Padding="18"
|
||||||
IsVisible="{Binding ShowConversationEmptyState}">
|
IsVisible="{Binding ShowConversationEmptyState}">
|
||||||
<StackPanel Spacing="10">
|
<StackPanel Spacing="10">
|
||||||
<TextBlock Text="{Binding ConversationEmptyStateText}" Classes="section-title" />
|
<TextBlock Text="{Binding ConversationEmptyStateText}" Classes="section-title" />
|
||||||
<TextBlock Text="필터를 바꾸거나 새로고침으로 다시 맞춰 보세요." Classes="caption" />
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
<Button Classes="secondary-button"
|
<Button Classes="secondary-button"
|
||||||
Command="{Binding ShowAllConversationsCommand}"
|
Command="{Binding ShowAllConversationsCommand}"
|
||||||
Content="전체 보기" />
|
Content="전체" />
|
||||||
<Button Classes="secondary-button"
|
<Button Classes="secondary-button"
|
||||||
Command="{Binding ReloadCommand}"
|
Command="{Binding ReloadCommand}"
|
||||||
Content="다시 확인" />
|
Content="새로고침" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
@ -485,17 +379,20 @@
|
||||||
<ItemsControl ItemsSource="{Binding FilteredConversations}">
|
<ItemsControl ItemsSource="{Binding FilteredConversations}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:ConversationRowViewModel">
|
<DataTemplate x:DataType="vm:ConversationRowViewModel">
|
||||||
<Grid Margin="0,0,0,6" ColumnDefinitions="*,Auto" ColumnSpacing="6">
|
|
||||||
<Button Classes="row-button"
|
<Button Classes="row-button"
|
||||||
|
Margin="0,0,0,6"
|
||||||
Command="{Binding $parent[Window].DataContext.SelectConversationCommand}"
|
Command="{Binding $parent[Window].DataContext.SelectConversationCommand}"
|
||||||
CommandParameter="{Binding .}">
|
CommandParameter="{Binding .}">
|
||||||
<Border Classes="row-card"
|
<Border Classes="row-card"
|
||||||
Classes.active="{Binding IsSelected}"
|
Classes.active="{Binding IsSelected}"
|
||||||
Padding="{Binding $parent[Window].DataContext.ConversationRowPadding}">
|
Padding="{Binding $parent[Window].DataContext.ConversationRowPadding}">
|
||||||
<Grid ColumnDefinitions="34,*,Auto" RowDefinitions="Auto,Auto" ColumnSpacing="8" RowSpacing="2">
|
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||||
<Border Width="34"
|
RowDefinitions="Auto,Auto"
|
||||||
Height="34"
|
ColumnSpacing="9"
|
||||||
Classes="avatar-badge">
|
RowSpacing="2">
|
||||||
|
<Border Width="{Binding $parent[Window].DataContext.ConversationAvatarSize}"
|
||||||
|
Height="{Binding $parent[Window].DataContext.ConversationAvatarSize}"
|
||||||
|
Classes="surface-muted">
|
||||||
<TextBlock HorizontalAlignment="Center"
|
<TextBlock HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Text="{Binding AvatarText}"
|
Text="{Binding AvatarText}"
|
||||||
|
|
@ -529,49 +426,31 @@
|
||||||
<Border Classes="unread-badge"
|
<Border Classes="unread-badge"
|
||||||
IsVisible="{Binding HasUnread}">
|
IsVisible="{Binding HasUnread}">
|
||||||
<TextBlock Text="{Binding UnreadBadgeText}"
|
<TextBlock Text="{Binding UnreadBadgeText}"
|
||||||
FontSize="11"
|
FontSize="10.5"
|
||||||
Foreground="#FFFFFF" />
|
Foreground="#FFFFFF" />
|
||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
</Button>
|
</Button>
|
||||||
<Button Grid.Column="1"
|
|
||||||
Classes="icon-button compact"
|
|
||||||
ToolTip.Tip="분리 창으로 열기"
|
|
||||||
Command="{Binding $parent[Window].DataContext.DetachConversationRowCommand}"
|
|
||||||
CommandParameter="{Binding .}"
|
|
||||||
Content="↗" />
|
|
||||||
</Grid>
|
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</Grid>
|
</Grid>
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<Grid Grid.Row="3" ColumnDefinitions="Auto,Auto" ColumnSpacing="8">
|
|
||||||
<Border Classes="status-chip">
|
|
||||||
<TextBlock Text="{Binding RealtimeStatusText}" Classes="caption" />
|
|
||||||
</Border>
|
|
||||||
<Border Grid.Column="1" Classes="status-chip">
|
|
||||||
<TextBlock Text="{Binding WorkspaceModeText}" Classes="caption" />
|
|
||||||
</Border>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<GridSplitter Grid.Column="2"
|
<GridSplitter Grid.Column="2"
|
||||||
Width="6"
|
Width="6"
|
||||||
IsVisible="{Binding IsConversationPaneExpanded}"
|
IsVisible="{Binding IsConversationPaneExpanded}"
|
||||||
Background="#E4E7EB"
|
Background="#E5E7EB"
|
||||||
ResizeDirection="Columns"
|
ResizeDirection="Columns"
|
||||||
ShowsPreview="True" />
|
ShowsPreview="True" />
|
||||||
|
|
||||||
<Border Grid.Column="3" Classes="surface" Padding="14">
|
<Border Grid.Column="3" Classes="surface" Padding="10">
|
||||||
<Grid RowDefinitions="Auto,Auto,*,Auto" RowSpacing="12">
|
<Grid RowDefinitions="Auto,*,Auto" RowSpacing="10">
|
||||||
<Grid ColumnDefinitions="*,Auto,Auto,Auto" ColumnSpacing="8">
|
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto" ColumnSpacing="8">
|
||||||
<Grid Grid.Column="0" ColumnDefinitions="Auto,*" ColumnSpacing="10">
|
|
||||||
<Border Width="38" Height="38" Classes="surface-muted">
|
<Border Width="38" Height="38" Classes="surface-muted">
|
||||||
<TextBlock HorizontalAlignment="Center"
|
<TextBlock HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
|
|
@ -579,57 +458,48 @@
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
Foreground="#111418" />
|
Foreground="#111418" />
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<StackPanel Grid.Column="1" Spacing="2">
|
<StackPanel Grid.Column="1" Spacing="2">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
<TextBlock Text="{Binding SelectedConversationTitle}"
|
<TextBlock Text="{Binding SelectedConversationTitle}"
|
||||||
Classes="section-title"
|
Classes="section-title"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<Border Classes="status-chip" IsVisible="{Binding SelectedConversationIsPinned}">
|
||||||
|
<TextBlock Text="고정" Classes="caption" />
|
||||||
|
</Border>
|
||||||
|
<Border Classes="unread-badge" IsVisible="{Binding HasSelectedConversationUnread}">
|
||||||
|
<TextBlock Text="{Binding SelectedConversationUnreadBadgeText}"
|
||||||
|
FontSize="10.5"
|
||||||
|
Foreground="#FFFFFF" />
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
<TextBlock Text="{Binding SelectedConversationSubtitle}"
|
<TextBlock Text="{Binding SelectedConversationSubtitle}"
|
||||||
Classes="caption"
|
Classes="caption"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextTrimming="CharacterEllipsis" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Border Grid.Column="1" Classes="status-chip" VerticalAlignment="Center">
|
|
||||||
<TextBlock Text="{Binding RealtimeStatusText}" Classes="caption" />
|
|
||||||
</Border>
|
|
||||||
<Button Grid.Column="2"
|
<Button Grid.Column="2"
|
||||||
Classes="icon-button"
|
Classes="icon-button"
|
||||||
ToolTip.Tip="분리 창으로 열기"
|
ToolTip.Tip="목록 접기"
|
||||||
|
Command="{Binding ToggleConversationPaneCommand}"
|
||||||
|
Content="{Binding PaneActionGlyph}" />
|
||||||
|
<Button Grid.Column="3"
|
||||||
|
Classes="icon-button"
|
||||||
|
ToolTip.Tip="분리"
|
||||||
Command="{Binding DetachConversationCommand}"
|
Command="{Binding DetachConversationCommand}"
|
||||||
Content="↗" />
|
Content="↗" />
|
||||||
<Button Grid.Column="3"
|
<Button Grid.Column="4"
|
||||||
Classes="icon-button"
|
Classes="icon-button"
|
||||||
ToolTip.Tip="새로고침"
|
ToolTip.Tip="새로고침"
|
||||||
Command="{Binding ReloadCommand}"
|
Command="{Binding ReloadCommand}"
|
||||||
Content="↻" />
|
Content="↻" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="*,*,*" ColumnSpacing="8">
|
<Grid Grid.Row="1">
|
||||||
<Border Grid.Column="0" Classes="dashboard-card">
|
|
||||||
<StackPanel Spacing="3">
|
|
||||||
<TextBlock Text="안읽음" Classes="eyebrow" />
|
|
||||||
<TextBlock Text="{Binding UnreadConversationCount}" Classes="section-title" />
|
|
||||||
<TextBlock Text="지금 바로 볼 대화" Classes="caption" />
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
<Border Grid.Column="1" Classes="dashboard-card">
|
|
||||||
<StackPanel Spacing="3">
|
|
||||||
<TextBlock Text="고정" Classes="eyebrow" />
|
|
||||||
<TextBlock Text="{Binding PinnedConversationCount}" Classes="section-title" />
|
|
||||||
<TextBlock Text="자주 여는 대화" Classes="caption" />
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
<Border Grid.Column="2" Classes="dashboard-card">
|
|
||||||
<StackPanel Spacing="3">
|
|
||||||
<TextBlock Text="창 상태" Classes="eyebrow" />
|
|
||||||
<TextBlock Text="{Binding WorkspaceModeText}" Classes="section-title" />
|
|
||||||
<TextBlock Text="분리 창을 바로 열 수 있습니다." Classes="caption" />
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid Grid.Row="2" RowDefinitions="Auto,*" RowSpacing="10">
|
|
||||||
<Border Classes="inline-alert"
|
<Border Classes="inline-alert"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Top"
|
||||||
|
Margin="0,0,0,8"
|
||||||
IsVisible="{Binding HasErrorText}">
|
IsVisible="{Binding HasErrorText}">
|
||||||
<TextBlock Text="{Binding ErrorText}"
|
<TextBlock Text="{Binding ErrorText}"
|
||||||
Classes="caption"
|
Classes="caption"
|
||||||
|
|
@ -637,8 +507,10 @@
|
||||||
TextWrapping="Wrap" />
|
TextWrapping="Wrap" />
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Grid Grid.Row="1">
|
<ScrollViewer Name="MessagesScrollViewer"
|
||||||
<ScrollViewer Name="MessagesScrollViewer">
|
Margin="0,12,0,0"
|
||||||
|
MaxWidth="860"
|
||||||
|
HorizontalAlignment="Center">
|
||||||
<ItemsControl ItemsSource="{Binding Messages}">
|
<ItemsControl ItemsSource="{Binding Messages}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:MessageRowViewModel">
|
<DataTemplate x:DataType="vm:MessageRowViewModel">
|
||||||
|
|
@ -647,13 +519,16 @@
|
||||||
Classes.pending="{Binding IsPending}"
|
Classes.pending="{Binding IsPending}"
|
||||||
Classes.failed="{Binding IsFailed}"
|
Classes.failed="{Binding IsFailed}"
|
||||||
Padding="{Binding $parent[Window].DataContext.MessageBubblePadding}">
|
Padding="{Binding $parent[Window].DataContext.MessageBubblePadding}">
|
||||||
<StackPanel Spacing="5">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Text="{Binding SenderName}"
|
<TextBlock Text="{Binding SenderName}"
|
||||||
Classes="caption"
|
Classes="caption"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
IsVisible="{Binding ShowSenderName}" />
|
IsVisible="{Binding ShowSenderName}" />
|
||||||
<TextBlock Text="{Binding Text}" Classes="message-text" />
|
<TextBlock Text="{Binding Text}"
|
||||||
<TextBlock Text="{Binding MetaText}" Classes="message-meta" />
|
FontSize="13"
|
||||||
|
Foreground="#111418"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<TextBlock Text="{Binding MetaText}" Classes="caption" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
|
@ -661,10 +536,11 @@
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
<Border Classes="empty-card"
|
<Border Classes="surface-muted"
|
||||||
Width="340"
|
Width="280"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
|
Padding="14"
|
||||||
IsVisible="{Binding ShowMessageEmptyState}">
|
IsVisible="{Binding ShowMessageEmptyState}">
|
||||||
<StackPanel Spacing="10">
|
<StackPanel Spacing="10">
|
||||||
<TextBlock Text="{Binding MessageEmptyStateTitle}" Classes="section-title" />
|
<TextBlock Text="{Binding MessageEmptyStateTitle}" Classes="section-title" />
|
||||||
|
|
@ -680,12 +556,32 @@
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Border Grid.Row="3" Classes="surface-muted" Padding="10">
|
<Border Grid.Row="2"
|
||||||
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto" ColumnSpacing="10" RowSpacing="8">
|
Classes="surface-muted"
|
||||||
|
Padding="8"
|
||||||
|
MaxWidth="860"
|
||||||
|
HorizontalAlignment="Center">
|
||||||
|
<Grid RowDefinitions="Auto,Auto" RowSpacing="6">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button Classes="quick-button"
|
||||||
|
ToolTip.Tip="확인"
|
||||||
|
Command="{Binding ApplyAckDraftCommand}"
|
||||||
|
Content="↺" />
|
||||||
|
<Button Classes="quick-button"
|
||||||
|
ToolTip.Tip="공유"
|
||||||
|
Command="{Binding ApplyShareDraftCommand}"
|
||||||
|
Content="⇡" />
|
||||||
|
<Button Classes="quick-button"
|
||||||
|
ToolTip.Tip="할 일"
|
||||||
|
Command="{Binding ApplyTaskDraftCommand}"
|
||||||
|
Content="☑" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Grid Grid.Row="1"
|
||||||
|
ColumnDefinitions="*,Auto"
|
||||||
|
ColumnSpacing="10">
|
||||||
<TextBox Name="ComposerTextBox"
|
<TextBox Name="ComposerTextBox"
|
||||||
Grid.RowSpan="2"
|
|
||||||
Classes="input"
|
Classes="input"
|
||||||
PlaceholderText="{Binding ComposerPlaceholderText}"
|
PlaceholderText="{Binding ComposerPlaceholderText}"
|
||||||
AcceptsReturn="True"
|
AcceptsReturn="True"
|
||||||
|
|
@ -694,15 +590,15 @@
|
||||||
Text="{Binding ComposerText}"
|
Text="{Binding ComposerText}"
|
||||||
KeyDown="ComposerTextBox_OnKeyDown" />
|
KeyDown="ComposerTextBox_OnKeyDown" />
|
||||||
|
|
||||||
<Button Grid.Column="1"
|
<StackPanel Grid.Column="1"
|
||||||
Classes="primary-button"
|
Spacing="6"
|
||||||
VerticalAlignment="Stretch"
|
HorizontalAlignment="Right">
|
||||||
|
<Button Classes="primary-button"
|
||||||
|
MinWidth="74"
|
||||||
Command="{Binding SendMessageCommand}"
|
Command="{Binding SendMessageCommand}"
|
||||||
Content="{Binding ComposerActionText}" />
|
Content="{Binding ComposerActionText}" />
|
||||||
|
</StackPanel>
|
||||||
<Border Grid.Row="1" Grid.Column="1" Classes="status-chip" HorizontalAlignment="Right">
|
</Grid>
|
||||||
<TextBlock Text="{Binding ComposerCounterText}" Classes="caption" />
|
|
||||||
</Border>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,14 @@ public partial class MainWindow : Window
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ConversationPaneHost_OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is MainWindowViewModel viewModel && sender is Control control && control.IsVisible)
|
||||||
|
{
|
||||||
|
viewModel.UpdateConversationPaneWidth(control.Bounds.Width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected override void OnClosed(EventArgs e)
|
protected override void OnClosed(EventArgs e)
|
||||||
{
|
{
|
||||||
if (_boundViewModel is not null)
|
if (_boundViewModel is not null)
|
||||||
|
|
|
||||||
4
src/PhysOn.Web/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "physon-web",
|
"name": "physon-web",
|
||||||
"version": "0.1.0-alpha.2",
|
"version": "0.1.0-alpha.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "physon-web",
|
"name": "physon-web",
|
||||||
"version": "0.1.0-alpha.2",
|
"version": "0.1.0-alpha.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "physon-web",
|
"name": "physon-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0-alpha.2",
|
"version": "0.1.0-alpha.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,11 @@
|
||||||
|
|
||||||
.onboarding {
|
.onboarding {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 16px 16px calc(20px + env(safe-area-inset-bottom));
|
padding: 12px 12px calc(16px + env(safe-area-inset-bottom));
|
||||||
|
max-width: 460px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding__chrome,
|
.onboarding__chrome,
|
||||||
|
|
@ -25,7 +27,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-lockup,
|
.brand-lockup,
|
||||||
|
|
@ -33,7 +35,7 @@
|
||||||
.chat-appbar__leading {
|
.chat-appbar__leading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,7 +51,7 @@
|
||||||
.appbar__title h2,
|
.appbar__title h2,
|
||||||
.chat-appbar__title strong {
|
.chat-appbar__title strong {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 17px;
|
font-size: 16px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
@ -84,9 +86,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-mark {
|
.brand-mark {
|
||||||
width: 42px;
|
width: 38px;
|
||||||
height: 42px;
|
height: 38px;
|
||||||
border-radius: 10px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-mark svg,
|
.brand-mark svg,
|
||||||
|
|
@ -105,9 +107,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-mark--small {
|
.brand-mark--small {
|
||||||
width: 36px;
|
width: 32px;
|
||||||
height: 36px;
|
height: 32px;
|
||||||
border-radius: 10px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-badge,
|
.surface-badge,
|
||||||
|
|
@ -117,10 +119,10 @@
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
min-height: 32px;
|
min-height: 28px;
|
||||||
padding: 0 12px;
|
padding: 0 10px;
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: 999px;
|
border-radius: 2px;
|
||||||
background: var(--surface-base);
|
background: var(--surface-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,13 +130,13 @@
|
||||||
.onboarding__panel,
|
.onboarding__panel,
|
||||||
.pane {
|
.pane {
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: 12px;
|
border-radius: 2px;
|
||||||
background: var(--surface-base);
|
background: var(--surface-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding__hero,
|
.onboarding__hero,
|
||||||
.onboarding__panel {
|
.onboarding__panel {
|
||||||
padding: 20px 18px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding__hero {
|
.onboarding__hero {
|
||||||
|
|
@ -197,7 +199,7 @@
|
||||||
|
|
||||||
.panel__heading {
|
.panel__heading {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel__label {
|
.panel__label {
|
||||||
|
|
@ -207,8 +209,8 @@
|
||||||
|
|
||||||
.onboarding__form {
|
.onboarding__form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
margin-top: 18px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
|
|
@ -227,10 +229,10 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border: 1px solid var(--border-strong);
|
border: 1px solid var(--border-strong);
|
||||||
border-radius: 10px;
|
border-radius: 2px;
|
||||||
background: var(--surface-base);
|
background: var(--surface-base);
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
padding: 13px 14px;
|
padding: 11px 12px;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -262,8 +264,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-button {
|
.primary-button {
|
||||||
min-height: 48px;
|
min-height: 42px;
|
||||||
border-radius: 12px;
|
border-radius: 2px;
|
||||||
background: var(--text-strong);
|
background: var(--text-strong);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|
@ -271,9 +273,9 @@
|
||||||
|
|
||||||
.secondary-button,
|
.secondary-button,
|
||||||
.ghost-button {
|
.ghost-button {
|
||||||
min-height: 44px;
|
min-height: 38px;
|
||||||
padding: 0 14px;
|
padding: 0 12px;
|
||||||
border-radius: 12px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary-button {
|
.secondary-button {
|
||||||
|
|
@ -298,7 +300,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-top: 14px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-text {
|
.status-text {
|
||||||
|
|
@ -309,13 +311,13 @@
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-bottom: calc(72px + env(safe-area-inset-bottom));
|
padding-bottom: calc(66px + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
.pane {
|
.pane {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto auto auto auto 1fr;
|
grid-template-rows: auto auto auto auto 1fr;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 100svh;
|
min-height: 100svh;
|
||||||
|
|
@ -324,11 +326,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.pane--list {
|
.pane--list {
|
||||||
padding: 18px 16px 0;
|
padding: 12px 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pane--chat {
|
.pane--chat {
|
||||||
padding: 18px 16px 14px;
|
padding: 12px;
|
||||||
border-left: 0;
|
border-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -339,13 +341,13 @@
|
||||||
.appbar__actions {
|
.appbar__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-strip {
|
.toolbar-strip {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
|
|
@ -358,7 +360,7 @@
|
||||||
.toolbar-strip__group {
|
.toolbar-strip__group {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -373,10 +375,10 @@
|
||||||
.icon-button {
|
.icon-button {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 38px;
|
width: 34px;
|
||||||
height: 38px;
|
height: 34px;
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: 12px;
|
border-radius: 2px;
|
||||||
background: var(--surface-base);
|
background: var(--surface-base);
|
||||||
color: var(--text-soft);
|
color: var(--text-soft);
|
||||||
}
|
}
|
||||||
|
|
@ -386,16 +388,16 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-button--soft {
|
.icon-button--soft {
|
||||||
width: 34px;
|
width: 30px;
|
||||||
height: 34px;
|
height: 30px;
|
||||||
border-radius: 10px;
|
border-radius: 2px;
|
||||||
background: var(--surface-muted);
|
background: var(--surface-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-chip {
|
.status-chip {
|
||||||
color: var(--text-soft);
|
color: var(--text-soft);
|
||||||
min-height: 34px;
|
min-height: 28px;
|
||||||
padding: 0 10px;
|
padding: 0 8px;
|
||||||
background: var(--surface-muted);
|
background: var(--surface-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -408,7 +410,7 @@
|
||||||
.status-dot {
|
.status-dot {
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
border-radius: 999px;
|
border-radius: 2px;
|
||||||
background: #9ca3af;
|
background: #9ca3af;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -429,10 +431,10 @@
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
padding: 0 14px;
|
padding: 0 12px;
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: 12px;
|
border-radius: 2px;
|
||||||
background: var(--surface-muted);
|
background: var(--surface-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -444,14 +446,14 @@
|
||||||
|
|
||||||
.search-field input {
|
.search-field input {
|
||||||
border: 0;
|
border: 0;
|
||||||
padding: 12px 0;
|
padding: 10px 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-grid {
|
.quick-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-grid--triple {
|
.quick-grid--triple {
|
||||||
|
|
@ -460,11 +462,11 @@
|
||||||
|
|
||||||
.mini-panel {
|
.mini-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
min-height: 84px;
|
min-height: 72px;
|
||||||
padding: 14px 12px;
|
padding: 11px 10px;
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: 14px;
|
border-radius: 2px;
|
||||||
background: var(--surface-base);
|
background: var(--surface-base);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
@ -494,21 +496,25 @@
|
||||||
.conversation-list {
|
.conversation-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding-bottom: 16px;
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-list--saved {
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation-row {
|
.conversation-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 9px 10px;
|
padding: 8px 9px;
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: 10px;
|
border-radius: 2px;
|
||||||
background: var(--surface-base);
|
background: var(--surface-base);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
@ -520,6 +526,11 @@
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-results--discovery,
|
||||||
|
.search-results--matches {
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
.saved-section__header {
|
.saved-section__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -543,11 +554,11 @@
|
||||||
|
|
||||||
.search-result {
|
.search-result {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 11px 12px;
|
padding: 9px 10px;
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: 10px;
|
border-radius: 2px;
|
||||||
background: var(--surface-base);
|
background: var(--surface-base);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
@ -593,16 +604,16 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 38px;
|
width: 34px;
|
||||||
height: 38px;
|
height: 34px;
|
||||||
border-radius: 10px;
|
border-radius: 2px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: -0.03em;
|
letter-spacing: -0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar--header {
|
.avatar--header {
|
||||||
width: 34px;
|
width: 30px;
|
||||||
height: 34px;
|
height: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation-row__body,
|
.conversation-row__body,
|
||||||
|
|
@ -666,7 +677,7 @@
|
||||||
min-width: 22px;
|
min-width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
border-radius: 999px;
|
border-radius: 2px;
|
||||||
background: var(--text-strong);
|
background: var(--text-strong);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
|
@ -677,7 +688,7 @@
|
||||||
.row-pin {
|
.row-pin {
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
border-radius: 999px;
|
border-radius: 2px;
|
||||||
background: var(--text-muted);
|
background: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -691,8 +702,8 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
min-height: 32px;
|
min-height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-overview span {
|
.chat-overview span {
|
||||||
|
|
@ -710,9 +721,9 @@
|
||||||
.mini-pill {
|
.mini-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 30px;
|
min-height: 26px;
|
||||||
padding: 0 12px;
|
padding: 0 9px;
|
||||||
border-radius: 999px;
|
border-radius: 2px;
|
||||||
background: var(--surface-muted);
|
background: var(--surface-muted);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
}
|
}
|
||||||
|
|
@ -720,10 +731,10 @@
|
||||||
.message-stream {
|
.message-stream {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 4px 0 12px;
|
padding: 2px 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-stream--empty {
|
.message-stream--empty {
|
||||||
|
|
@ -744,10 +755,10 @@
|
||||||
.empty-panel,
|
.empty-panel,
|
||||||
.profile-card {
|
.profile-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
padding: 12px;
|
padding: 10px;
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: 10px;
|
border-radius: 2px;
|
||||||
background: var(--surface-base);
|
background: var(--surface-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -789,9 +800,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar--profile {
|
.avatar--profile {
|
||||||
width: 44px;
|
width: 40px;
|
||||||
height: 44px;
|
height: 40px;
|
||||||
border-radius: 12px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card__body {
|
.profile-card__body {
|
||||||
|
|
@ -845,9 +856,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-bubble__body {
|
.message-bubble__body {
|
||||||
padding: 11px 12px;
|
padding: 9px 10px;
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: 10px;
|
border-radius: 2px;
|
||||||
background: var(--surface-muted);
|
background: var(--surface-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -868,8 +879,8 @@
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
grid-template-rows: auto auto;
|
grid-template-rows: auto auto;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
padding-top: 10px;
|
padding-top: 8px;
|
||||||
border-top: 1px solid var(--border-subtle);
|
border-top: 1px solid var(--border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -877,7 +888,7 @@
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer__field {
|
.composer__field {
|
||||||
|
|
@ -885,7 +896,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer textarea {
|
.composer textarea {
|
||||||
min-height: 48px;
|
min-height: 44px;
|
||||||
max-height: 120px;
|
max-height: 120px;
|
||||||
resize: none;
|
resize: none;
|
||||||
}
|
}
|
||||||
|
|
@ -893,9 +904,9 @@
|
||||||
.send-button {
|
.send-button {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 48px;
|
width: 44px;
|
||||||
height: 48px;
|
height: 44px;
|
||||||
border-radius: 10px;
|
border-radius: 2px;
|
||||||
background: var(--text-strong);
|
background: var(--text-strong);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
@ -903,8 +914,8 @@
|
||||||
.bottom-bar {
|
.bottom-bar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
|
padding: 8px 12px calc(8px + env(safe-area-inset-bottom));
|
||||||
border-top: 1px solid var(--border-subtle);
|
border-top: 1px solid var(--border-subtle);
|
||||||
background: rgba(255, 255, 255, 0.98);
|
background: rgba(255, 255, 255, 0.98);
|
||||||
}
|
}
|
||||||
|
|
@ -923,16 +934,18 @@
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
align-content: center;
|
align-content: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
min-height: 46px;
|
min-height: 42px;
|
||||||
padding: 6px 4px;
|
padding: 6px 4px;
|
||||||
border-radius: 12px;
|
border-radius: 2px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-button span {
|
.nav-button span {
|
||||||
font-size: 11px;
|
display: none;
|
||||||
|
font-size: 10px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-button--active {
|
.nav-button--active {
|
||||||
|
|
@ -940,14 +953,18 @@
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-button--active span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-button--active::before {
|
.nav-button--active::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 4px;
|
||||||
left: 18%;
|
left: 22%;
|
||||||
width: 64%;
|
width: 56%;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
border-radius: 999px;
|
border-radius: 2px;
|
||||||
background: var(--text-strong);
|
background: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -959,7 +976,7 @@
|
||||||
max-width: calc(100vw - 32px);
|
max-width: calc(100vw - 32px);
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
border: 1px solid var(--border-strong);
|
border: 1px solid var(--border-strong);
|
||||||
border-radius: 12px;
|
border-radius: 2px;
|
||||||
background: rgba(255, 255, 255, 0.97);
|
background: rgba(255, 255, 255, 0.97);
|
||||||
color: var(--text-soft);
|
color: var(--text-soft);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|
@ -995,37 +1012,56 @@
|
||||||
|
|
||||||
@media (min-width: 900px) {
|
@media (min-width: 900px) {
|
||||||
.onboarding {
|
.onboarding {
|
||||||
max-width: 1200px;
|
max-width: 500px;
|
||||||
margin: 0 auto;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
grid-template-columns: minmax(0, 1.2fr) minmax(360px, 400px);
|
padding: 18px;
|
||||||
align-items: stretch;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell {
|
.shell {
|
||||||
max-width: 1480px;
|
max-width: 1320px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
grid-template-columns: 88px 340px minmax(640px, 1fr);
|
grid-template-columns: 64px minmax(272px, 340px) minmax(0, 1fr);
|
||||||
gap: 18px;
|
gap: 10px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
padding: 18px;
|
padding: 10px;
|
||||||
padding-bottom: 18px;
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pane {
|
.pane {
|
||||||
min-height: calc(100svh - 36px);
|
min-height: calc(100svh - 20px);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border-radius: 14px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pane--list {
|
.pane--list {
|
||||||
padding-bottom: 16px;
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pane--chat {
|
.pane--chat {
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pane--search .search-results--discovery,
|
||||||
|
.pane--search .search-results--matches,
|
||||||
|
.pane--saved .conversation-list--saved {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
align-content: start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane--search .saved-section,
|
||||||
|
.pane--saved .saved-section {
|
||||||
|
align-content: start;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane--me .profile-card {
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
.pane--hidden {
|
.pane--hidden {
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
|
|
@ -1034,13 +1070,13 @@
|
||||||
position: sticky;
|
position: sticky;
|
||||||
inset: 0 auto auto 0;
|
inset: 0 auto auto 0;
|
||||||
width: auto;
|
width: auto;
|
||||||
height: calc(100svh - 36px);
|
height: calc(100svh - 20px);
|
||||||
padding: 12px 10px;
|
padding: 8px 6px;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
gap: 10px;
|
gap: 6px;
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: 14px;
|
border-radius: 2px;
|
||||||
background: var(--surface-base);
|
background: var(--surface-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1055,17 +1091,21 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-button {
|
.nav-button {
|
||||||
min-height: 58px;
|
min-height: 48px;
|
||||||
padding: 8px 4px;
|
padding: 7px 4px;
|
||||||
border-radius: 12px;
|
border-radius: 2px;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-bar--shell .nav-button span {
|
.bottom-bar--shell .nav-button span {
|
||||||
display: block;
|
display: none;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bottom-bar--shell .nav-button--active span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.appbar__title span,
|
.appbar__title span,
|
||||||
.chat-appbar__title span {
|
.chat-appbar__title span {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
@ -1073,21 +1113,22 @@
|
||||||
|
|
||||||
.appbar,
|
.appbar,
|
||||||
.chat-appbar {
|
.chat-appbar {
|
||||||
min-height: 48px;
|
min-height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-strip {
|
.toolbar-strip {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation-list {
|
.conversation-list {
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-results,
|
.search-results,
|
||||||
.saved-section,
|
.saved-section,
|
||||||
.saved-section__body {
|
.saved-section__body {
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ type IconName =
|
||||||
| 'group'
|
| 'group'
|
||||||
|
|
||||||
const DEFAULT_API_BASE_URL = import.meta.env.VITE_API_BASE_URL?.trim() ?? ''
|
const DEFAULT_API_BASE_URL = import.meta.env.VITE_API_BASE_URL?.trim() ?? ''
|
||||||
const APP_VERSION = 'web-0.1.0-alpha.3'
|
const APP_VERSION = 'web-0.1.0-alpha.4'
|
||||||
|
|
||||||
const CONNECTION_LABEL: Record<ConnectionState, string> = {
|
const CONNECTION_LABEL: Record<ConnectionState, string> = {
|
||||||
idle: '준비 중',
|
idle: '준비 중',
|
||||||
|
|
@ -81,6 +81,18 @@ function sortConversations(items: ConversationSummaryDto[]): ConversationSummary
|
||||||
return [...items].sort(compareConversations)
|
return [...items].sort(compareConversations)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dedupeConversationsById(items: ConversationSummaryDto[]): ConversationSummaryDto[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
return items.filter((item) => {
|
||||||
|
if (seen.has(item.conversationId)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(item.conversationId)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function upsertConversation(
|
function upsertConversation(
|
||||||
items: ConversationSummaryDto[],
|
items: ConversationSummaryDto[],
|
||||||
nextConversation: ConversationSummaryDto,
|
nextConversation: ConversationSummaryDto,
|
||||||
|
|
@ -415,10 +427,6 @@ function App() {
|
||||||
messages: messageMatches.slice(0, 8),
|
messages: messageMatches.slice(0, 8),
|
||||||
}
|
}
|
||||||
}, [conversations, messagesByConversation, normalizedSearchQuery])
|
}, [conversations, messagesByConversation, normalizedSearchQuery])
|
||||||
const savedConversations = useMemo(
|
|
||||||
() => conversations.filter((conversation) => conversation.isPinned || conversation.unreadCount > 0),
|
|
||||||
[conversations],
|
|
||||||
)
|
|
||||||
const replyNeededConversations = useMemo(
|
const replyNeededConversations = useMemo(
|
||||||
() => conversations.filter((conversation) => conversation.unreadCount > 0).slice(0, 4),
|
() => conversations.filter((conversation) => conversation.unreadCount > 0).slice(0, 4),
|
||||||
[conversations],
|
[conversations],
|
||||||
|
|
@ -431,6 +439,24 @@ function App() {
|
||||||
() => conversations.slice(0, 4),
|
() => conversations.slice(0, 4),
|
||||||
[conversations],
|
[conversations],
|
||||||
)
|
)
|
||||||
|
const savedReplyQueue = replyNeededConversations
|
||||||
|
const savedPinnedQueue = useMemo(
|
||||||
|
() => dedupeConversationsById(pinnedConversations.filter((conversation) => !replyNeededConversations.some((item) => item.conversationId === conversation.conversationId))),
|
||||||
|
[pinnedConversations, replyNeededConversations],
|
||||||
|
)
|
||||||
|
const savedRecentQueue = useMemo(
|
||||||
|
() => dedupeConversationsById(
|
||||||
|
recentConversations.filter((conversation) =>
|
||||||
|
!replyNeededConversations.some((item) => item.conversationId === conversation.conversationId) &&
|
||||||
|
!pinnedConversations.some((item) => item.conversationId === conversation.conversationId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
[pinnedConversations, recentConversations, replyNeededConversations],
|
||||||
|
)
|
||||||
|
const savedConversations = useMemo(
|
||||||
|
() => dedupeConversationsById([...savedReplyQueue, ...savedPinnedQueue, ...savedRecentQueue]),
|
||||||
|
[savedPinnedQueue, savedRecentQueue, savedReplyQueue],
|
||||||
|
)
|
||||||
const searchResultTotal = searchResults.conversations.length + searchResults.messages.length
|
const searchResultTotal = searchResults.conversations.length + searchResults.messages.length
|
||||||
const primaryResumeConversation = selectedConversation ?? conversations[0] ?? null
|
const primaryResumeConversation = selectedConversation ?? conversations[0] ?? null
|
||||||
|
|
||||||
|
|
@ -958,11 +984,11 @@ function App() {
|
||||||
const destinationMeta: Record<BottomDestination, { title: string; subtitle: string }> = {
|
const destinationMeta: Record<BottomDestination, { title: string; subtitle: string }> = {
|
||||||
inbox: {
|
inbox: {
|
||||||
title: '받은함',
|
title: '받은함',
|
||||||
subtitle: me?.displayName ? `${me.displayName}` : '최근 대화',
|
subtitle: me?.displayName ? `${me.displayName}` : '최근',
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
title: '검색',
|
title: '검색',
|
||||||
subtitle: '대화 다시 찾기',
|
subtitle: '다시 찾기',
|
||||||
},
|
},
|
||||||
saved: {
|
saved: {
|
||||||
title: '보관',
|
title: '보관',
|
||||||
|
|
@ -970,7 +996,7 @@ function App() {
|
||||||
},
|
},
|
||||||
me: {
|
me: {
|
||||||
title: '내 공간',
|
title: '내 공간',
|
||||||
subtitle: '세션과 기기',
|
subtitle: '세션',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const activeDestinationMeta = destinationMeta[bottomDestination]
|
const activeDestinationMeta = destinationMeta[bottomDestination]
|
||||||
|
|
@ -1038,41 +1064,19 @@ function App() {
|
||||||
</span>
|
</span>
|
||||||
<div className="brand-lockup__text">
|
<div className="brand-lockup__text">
|
||||||
<strong>KoTalk</strong>
|
<strong>KoTalk</strong>
|
||||||
<span>가볍게 이어지는 대화</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="surface-badge">WEB</span>
|
<span className="surface-badge">WEB</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="onboarding__hero">
|
|
||||||
<p className="eyebrow">KO · TALK</p>
|
|
||||||
<h1>
|
|
||||||
열면
|
|
||||||
<br />
|
|
||||||
바로 대화.
|
|
||||||
</h1>
|
|
||||||
<p className="onboarding__summary">표시 이름과 참여 키만 넣고 바로 시작합니다.</p>
|
|
||||||
|
|
||||||
<div className="summary-strip summary-strip--hero" aria-label="핵심 특성">
|
|
||||||
<span className="summary-chip"><Icon name="spark" /> 빠른 시작</span>
|
|
||||||
<span className="summary-chip"><Icon name="chat" /> 작업 대화</span>
|
|
||||||
<span className="summary-chip"><Icon name="session" /> 기기 기억</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="onboarding__panel">
|
<section className="onboarding__panel">
|
||||||
<div className="panel__heading">
|
|
||||||
<span className="panel__label">Join</span>
|
|
||||||
<h2>시작</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form className="onboarding__form" onSubmit={handleRegister}>
|
<form className="onboarding__form" onSubmit={handleRegister}>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>표시 이름</span>
|
|
||||||
<input
|
<input
|
||||||
|
aria-label="표시 이름"
|
||||||
autoComplete="nickname"
|
autoComplete="nickname"
|
||||||
maxLength={20}
|
maxLength={20}
|
||||||
placeholder="이름"
|
placeholder="표시 이름"
|
||||||
required
|
required
|
||||||
value={displayName}
|
value={displayName}
|
||||||
onChange={(event) => setDisplayName(event.target.value)}
|
onChange={(event) => setDisplayName(event.target.value)}
|
||||||
|
|
@ -1080,10 +1084,10 @@ function App() {
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>참여 키</span>
|
|
||||||
<input
|
<input
|
||||||
|
aria-label="참여 키"
|
||||||
autoCapitalize="characters"
|
autoCapitalize="characters"
|
||||||
placeholder="참여 키 입력"
|
placeholder="참여 키"
|
||||||
required
|
required
|
||||||
value={inviteCode}
|
value={inviteCode}
|
||||||
onChange={(event) => setInviteCode(event.target.value.toUpperCase())}
|
onChange={(event) => setInviteCode(event.target.value.toUpperCase())}
|
||||||
|
|
@ -1096,10 +1100,10 @@ function App() {
|
||||||
|
|
||||||
{showAdvanced ? (
|
{showAdvanced ? (
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>서버 주소</span>
|
|
||||||
<input
|
<input
|
||||||
|
aria-label="서버 주소"
|
||||||
inputMode="url"
|
inputMode="url"
|
||||||
placeholder="기본 서버를 바꾸는 경우만 입력"
|
placeholder="서버 주소"
|
||||||
value={apiBaseUrl}
|
value={apiBaseUrl}
|
||||||
onChange={(event) => setApiBaseUrl(event.target.value)}
|
onChange={(event) => setApiBaseUrl(event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1107,15 +1111,10 @@ function App() {
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<button className="primary-button" disabled={registering} type="submit">
|
<button className="primary-button" disabled={registering} type="submit">
|
||||||
{registering ? '입장 중...' : '대화 열기'}
|
{registering ? '입장 중...' : '열기'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="panel__foot">
|
|
||||||
<span>세션은 이 기기에만</span>
|
|
||||||
<span>서버는 고급에서만</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{statusMessage ? <p className="status-text">{statusMessage}</p> : null}
|
{statusMessage ? <p className="status-text">{statusMessage}</p> : null}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -1156,7 +1155,7 @@ function App() {
|
||||||
</button>
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section className={`pane pane--list ${mobileView === 'chat' ? 'pane--hidden' : ''}`}>
|
<section className={`pane pane--list pane--${bottomDestination} ${mobileView === 'chat' ? 'pane--hidden' : ''}`}>
|
||||||
<header className="appbar">
|
<header className="appbar">
|
||||||
<div className="appbar__leading">
|
<div className="appbar__leading">
|
||||||
<span className="brand-mark brand-mark--small" aria-hidden="true">
|
<span className="brand-mark brand-mark--small" aria-hidden="true">
|
||||||
|
|
@ -1190,32 +1189,6 @@ function App() {
|
||||||
{bottomDestination === 'inbox' ? (
|
{bottomDestination === 'inbox' ? (
|
||||||
<>
|
<>
|
||||||
<div className="toolbar-strip" aria-label="받은함 빠른 막대">
|
<div className="toolbar-strip" aria-label="받은함 빠른 막대">
|
||||||
<div className="toolbar-strip__group">
|
|
||||||
<span className={`status-chip status-chip--${connectionState}`}>
|
|
||||||
<span className="status-dot" aria-hidden="true" />
|
|
||||||
{CONNECTION_DESCRIPTION[connectionState]}
|
|
||||||
</span>
|
|
||||||
<span className="status-panel__time">{formatRelativeConnection(storedSession?.savedAt ?? null)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="toolbar-strip__group toolbar-strip__group--actions">
|
|
||||||
<button
|
|
||||||
className="icon-button icon-button--soft"
|
|
||||||
type="button"
|
|
||||||
aria-label="검색 열기"
|
|
||||||
onClick={() => openDestination('search')}
|
|
||||||
>
|
|
||||||
<Icon name="search" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="icon-button icon-button--soft"
|
|
||||||
type="button"
|
|
||||||
aria-label="내 공간 열기"
|
|
||||||
onClick={() => openDestination('me')}
|
|
||||||
>
|
|
||||||
<Icon name="me" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={`summary-chip ${listFilter === 'all' ? 'summary-chip--active' : ''}`}
|
className={`summary-chip ${listFilter === 'all' ? 'summary-chip--active' : ''}`}
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -1237,6 +1210,24 @@ function App() {
|
||||||
>
|
>
|
||||||
<Icon name="pin" /> {pinnedTotal}
|
<Icon name="pin" /> {pinnedTotal}
|
||||||
</button>
|
</button>
|
||||||
|
<div className="toolbar-strip__group toolbar-strip__group--actions">
|
||||||
|
<button
|
||||||
|
className="icon-button icon-button--soft"
|
||||||
|
type="button"
|
||||||
|
aria-label="검색"
|
||||||
|
onClick={() => openDestination('search')}
|
||||||
|
>
|
||||||
|
<Icon name="search" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="icon-button icon-button--soft"
|
||||||
|
type="button"
|
||||||
|
aria-label="새로고침"
|
||||||
|
onClick={handleReconnect}
|
||||||
|
>
|
||||||
|
<Icon name="refresh" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="conversation-list">
|
<div className="conversation-list">
|
||||||
|
|
@ -1290,7 +1281,7 @@ function App() {
|
||||||
<button
|
<button
|
||||||
className="icon-button icon-button--soft"
|
className="icon-button icon-button--soft"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="최근 대화 열기"
|
aria-label="최근 대화"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (primaryResumeConversation) {
|
if (primaryResumeConversation) {
|
||||||
selectConversation(primaryResumeConversation.conversationId, 'chat')
|
selectConversation(primaryResumeConversation.conversationId, 'chat')
|
||||||
|
|
@ -1326,13 +1317,41 @@ function App() {
|
||||||
|
|
||||||
<div className="conversation-list">
|
<div className="conversation-list">
|
||||||
{!normalizedSearchQuery ? (
|
{!normalizedSearchQuery ? (
|
||||||
<p className="empty-state empty-state--inline">대화와 메시지를 다시 찾아보세요.</p>
|
<div className="search-results search-results--discovery">
|
||||||
|
{recentConversations.length > 0 ? (
|
||||||
|
<section className="saved-section">
|
||||||
|
<div className="saved-section__header">
|
||||||
|
<strong>최근</strong>
|
||||||
|
<span>{recentConversations.length}개</span>
|
||||||
|
</div>
|
||||||
|
<div className="saved-section__body">{renderConversationRows(recentConversations)}</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
{replyNeededConversations.length > 0 ? (
|
||||||
|
<section className="saved-section">
|
||||||
|
<div className="saved-section__header">
|
||||||
|
<strong>안읽음</strong>
|
||||||
|
<span>{replyNeededConversations.length}개</span>
|
||||||
|
</div>
|
||||||
|
<div className="saved-section__body">{renderConversationRows(replyNeededConversations)}</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
{pinnedConversations.length > 0 ? (
|
||||||
|
<section className="saved-section">
|
||||||
|
<div className="saved-section__header">
|
||||||
|
<strong>고정</strong>
|
||||||
|
<span>{pinnedConversations.length}개</span>
|
||||||
|
</div>
|
||||||
|
<div className="saved-section__body">{renderConversationRows(pinnedConversations)}</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{normalizedSearchQuery && searchResultTotal === 0 ? (
|
{normalizedSearchQuery && searchResultTotal === 0 ? (
|
||||||
<p className="empty-state empty-state--inline">결과가 없습니다. 다른 단어로 다시 찾아보세요.</p>
|
<p className="empty-state empty-state--inline">결과가 없습니다. 다른 단어로 다시 찾아보세요.</p>
|
||||||
) : null}
|
) : null}
|
||||||
{normalizedSearchQuery && searchResultTotal > 0 ? (
|
{normalizedSearchQuery && searchResultTotal > 0 ? (
|
||||||
<div className="search-results">
|
<div className="search-results search-results--matches">
|
||||||
{searchResults.messages.length > 0 ? (
|
{searchResults.messages.length > 0 ? (
|
||||||
<section className="saved-section">
|
<section className="saved-section">
|
||||||
<div className="saved-section__header">
|
<div className="saved-section__header">
|
||||||
|
|
@ -1365,42 +1384,42 @@ function App() {
|
||||||
{bottomDestination === 'saved' ? (
|
{bottomDestination === 'saved' ? (
|
||||||
<>
|
<>
|
||||||
<div className="toolbar-strip toolbar-strip--utility" aria-label="보관함 요약">
|
<div className="toolbar-strip toolbar-strip--utility" aria-label="보관함 요약">
|
||||||
<span className="status-chip"><Icon name="spark" /> {replyNeededConversations.length}</span>
|
<span className="status-chip"><Icon name="spark" /> {savedReplyQueue.length}</span>
|
||||||
<span className="status-chip"><Icon name="pin" /> {pinnedConversations.length}</span>
|
<span className="status-chip"><Icon name="pin" /> {savedPinnedQueue.length}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="conversation-list">
|
<div className="conversation-list conversation-list--saved">
|
||||||
{savedConversations.length === 0 ? (
|
{savedConversations.length === 0 ? (
|
||||||
<p className="empty-state empty-state--inline">지금 보관된 후속 작업이 없습니다.</p>
|
<p className="empty-state empty-state--inline">지금 보관된 후속 작업이 없습니다.</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{replyNeededConversations.length > 0 ? (
|
{savedReplyQueue.length > 0 ? (
|
||||||
<section className="saved-section">
|
<section className="saved-section">
|
||||||
<div className="saved-section__header">
|
<div className="saved-section__header">
|
||||||
<strong>답장</strong>
|
<strong>답장</strong>
|
||||||
<span>{replyNeededConversations.length}개</span>
|
<span>{savedReplyQueue.length}개</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="saved-section__body">{renderConversationRows(replyNeededConversations)}</div>
|
<div className="saved-section__body">{renderConversationRows(savedReplyQueue)}</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{pinnedConversations.length > 0 ? (
|
{savedPinnedQueue.length > 0 ? (
|
||||||
<section className="saved-section">
|
<section className="saved-section">
|
||||||
<div className="saved-section__header">
|
<div className="saved-section__header">
|
||||||
<strong>고정</strong>
|
<strong>중요</strong>
|
||||||
<span>{pinnedConversations.length}개</span>
|
<span>{savedPinnedQueue.length}개</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="saved-section__body">{renderConversationRows(pinnedConversations)}</div>
|
<div className="saved-section__body">{renderConversationRows(savedPinnedQueue)}</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{recentConversations.length > 0 ? (
|
{savedRecentQueue.length > 0 ? (
|
||||||
<section className="saved-section">
|
<section className="saved-section">
|
||||||
<div className="saved-section__header">
|
<div className="saved-section__header">
|
||||||
<strong>최근</strong>
|
<strong>최근</strong>
|
||||||
<span>{recentConversations.length}개</span>
|
<span>{savedRecentQueue.length}개</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="saved-section__body">{renderConversationRows(recentConversations)}</div>
|
<div className="saved-section__body">{renderConversationRows(savedRecentQueue)}</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1481,8 +1500,7 @@ function App() {
|
||||||
|
|
||||||
{loadingConversationId !== selectedConversation.conversationId && selectedMessages.length === 0 ? (
|
{loadingConversationId !== selectedConversation.conversationId && selectedMessages.length === 0 ? (
|
||||||
<div className="empty-panel empty-panel--chat">
|
<div className="empty-panel empty-panel--chat">
|
||||||
<strong>{selectedConversation.memberCount <= 1 ? '첫 메모부터 시작' : '첫 메시지부터 시작'}</strong>
|
<strong>{selectedConversation.memberCount <= 1 ? '첫 메모' : '첫 메시지'}</strong>
|
||||||
<p>짧게 한 줄만 남겨도 대화가 바로 이어집니다.</p>
|
|
||||||
<div className="empty-panel__actions">
|
<div className="empty-panel__actions">
|
||||||
<button
|
<button
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
|
|
@ -1527,13 +1545,13 @@ function App() {
|
||||||
<form className="composer" onSubmit={handleSendMessage}>
|
<form className="composer" onSubmit={handleSendMessage}>
|
||||||
<div className="composer-shortcuts" aria-label="빠른 입력">
|
<div className="composer-shortcuts" aria-label="빠른 입력">
|
||||||
<button className="ghost-button" type="button" onClick={() => applyQuickDraft('확인 후 답드릴게요.')}>
|
<button className="ghost-button" type="button" onClick={() => applyQuickDraft('확인 후 답드릴게요.')}>
|
||||||
확인 후 회신
|
확인
|
||||||
</button>
|
</button>
|
||||||
<button className="ghost-button" type="button" onClick={() => applyQuickDraft('자료 공유드립니다.\n- ')}>
|
<button className="ghost-button" type="button" onClick={() => applyQuickDraft('자료 공유드립니다.\n- ')}>
|
||||||
자료 공유
|
공유
|
||||||
</button>
|
</button>
|
||||||
<button className="ghost-button" type="button" onClick={() => applyQuickDraft('잠시 뒤 다시 말씀드릴게요.')}>
|
<button className="ghost-button" type="button" onClick={() => applyQuickDraft('잠시 뒤 다시 말씀드릴게요.')}>
|
||||||
잠시 후
|
나중
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<label className="composer__field">
|
<label className="composer__field">
|
||||||
|
|
|
||||||