2using System.Net.Http.Json;
4using Dreamine.Identity;
5using Microsoft.AspNetCore.Components.Forms;
87 DefaultRequestHeaders = { {
"User-Agent",
"WeddingThankYou/1.0 (contact: admin@codemaru.co.kr)" } }
207 public IReadOnlyList<PhotoInfo>
Gallery {
get;
private set; } = [];
376 var config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false);
382 var user = await
_userContext.GetCurrentAsync().ConfigureAwait(
false);
390 await
LoadAsync(slug, ct).ConfigureAwait(
false);
426 public async Task<bool>
LoginAsync(
string slug, CancellationToken ct =
default)
428 var config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false);
429 if (config is
null) {
StatusMessage =
"존재하지 않는 슬러그입니다.";
return false; }
431 var user = await
_userContext.GetCurrentAsync().ConfigureAwait(
false);
444 var verification = DreaminePasswordHasher.VerifyPassword(
LoginPassword, config.PasswordHash, out var upgradedHash);
445 IsAuthenticated = verification is not PasswordHashVerificationResult.Failed;
452 if (verification is PasswordHashVerificationResult.SuccessRehashNeeded && upgradedHash is not
null)
454 config.PasswordHash = upgradedHash;
455 await
_tenants.SaveAsync(config, ct).ConfigureAwait(
false);
458 if (user.IsAuthenticated &&
string.IsNullOrWhiteSpace(config.OwnerUserId))
460 config.OwnerUserId = user.Id;
461 config.OwnerProvider = user.Provider;
462 config.OwnerEmail = user.Email;
463 config.OwnerDisplayName = user.DisplayName;
464 config.OwnerLinkedAt = DateTime.Now;
466 await
_tenants.SaveAsync(config, ct).ConfigureAwait(
false);
470 StatusMessage =
"CodeMaru/Dreamine 계정에 대표 관리자로 연결되었습니다. 다음부터는 공용 로그인으로 관리할 수 있습니다.";
472 else if (user.IsAuthenticated)
475 await
_tenants.SaveAsync(config, ct).ConfigureAwait(
false);
479 StatusMessage =
"현재 CodeMaru/Dreamine 계정이 이 감사장의 관리자로 추가되었습니다.";
481 else if (!user.IsAuthenticated &&
string.IsNullOrWhiteSpace(config.OwnerUserId))
483 StatusMessage =
"로그인은 성공했습니다. 공용 계정에 연결하려면 먼저 CodeMaru/Dreamine 로그인을 해주세요.";
540 var config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false);
552 await
LoadAsync(slug, ct).ConfigureAwait(
false);
588 public async Task
LoadAsync(
string slug, CancellationToken ct =
default)
593 Gallery = await
_photos.GetGalleryAsync(slug, ct).ConfigureAwait(
false);
624 var user = await
_userContext.GetCurrentAsync().ConfigureAwait(
false);
627 ?
string.IsNullOrWhiteSpace(user.DisplayName)
629 : $
"{user.DisplayName} ({user.Provider})"
659 if (
Config is
null)
return;
673 Config.PasswordHash = DreaminePasswordHasher.HashPlainTextForStorage(
Config.PasswordHash);
678 catch (Exception ex) {
StatusMessage = $
"저장 오류: {ex.Message}"; }
715 if (
Config is
null)
return;
719 var chapters =
Config.StoryChapters;
720 var index = chapters.FindIndex(x => x.ChapterNumber == chapter.ChapterNumber);
721 var normalized = WeddingStoryChapterDefaults.Clone(chapter);
722 normalized.Label =
string.IsNullOrWhiteSpace(normalized.Label)
723 ? $
"CHAPTER {Math.Max(1, normalized.ChapterNumber):00}"
724 : normalized.Label.Trim();
725 normalized.Title =
string.IsNullOrWhiteSpace(normalized.Title)
727 : normalized.Title.Trim();
731 chapters[index] = normalized;
735 chapters.Add(normalized);
736 Config.StoryChapters = WeddingStoryChapterDefaults.Normalize(chapters);
774 if (
Config is
null)
return;
778 var chapters =
Config.StoryChapters;
779 var nextNumber = chapters.Count == 0 ? 1 : chapters.Max(x => x.ChapterNumber) + 1;
780 chapters.Add(
new StoryChapter
782 ChapterNumber = nextNumber,
783 Label = $
"CHAPTER {nextNumber:00}",
830 if (
Config is
null)
return;
834 if (chapterNumber <= 4)
840 var removed =
Config.StoryChapters.RemoveAll(x => x.ChapterNumber == chapterNumber);
889 var option = WeddingLayoutCatalog.Instance.Find(mode);
896 if (!option.IsImplemented)
902 var access =
new WeddingLayoutAccessState
905 UnlockedLayouts = config.UnlockedLayoutModes
906 .Select(WeddingLayoutCatalog.FromLegacyKey)
907 .Where(x => x != WeddingLayoutMode.Unknown)
911 if (!
new WeddingLayoutAccessPolicy().CanUse(option, access))
913 StatusMessage =
"저장 오류: 프리미엄 레이아웃은 플랜 또는 잠금 해제 후 저장할 수 있습니다.";
946 config.UnlockedThemeKeys ??=
new();
947 if (!WeddingThemeCatalog.IsKnownKey(config.
ThemeName))
953 var option = WeddingThemeCatalog.Instance.Find(config.
ThemeName);
960 if (!option.IsImplemented)
966 var access =
new WeddingThemeAccessState
969 UnlockedThemeKeys = config.UnlockedThemeKeys
970 .Select(WeddingThemeCatalog.NormalizeKey)
971 .Where(WeddingThemeCatalog.IsKnownKey)
975 if (!
new WeddingThemeAccessPolicy().CanUse(option, access))
981 config.ThemeName = option.Key;
1019 if (
Config is
null)
return;
1020 var user = await
_userContext.GetCurrentAsync().ConfigureAwait(
false);
1027 if (
string.Equals(
Config.OwnerUserId, userId, StringComparison.Ordinal))
1033 var removed =
GetAdminUsers(
Config).RemoveAll(x =>
string.Equals(x.UserId, userId, StringComparison.Ordinal));
1091 await
_photos.UploadAsync(slug, file, ct).ConfigureAwait(
false);
1093 Config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false);
1094 Gallery = await
_photos.GetGalleryAsync(slug, ct).ConfigureAwait(
false);
1097 catch (Exception ex) {
StatusMessage = $
"업로드 오류: {ex.Message}"; }
1141 public async Task
UploadHeroAsync(
string slug, IBrowserFile file, CancellationToken ct =
default)
1146 await
_photos.UploadHeroAsync(slug, file, ct).ConfigureAwait(
false);
1147 Config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false);
1150 catch (Exception ex) {
StatusMessage = $
"히어로 업로드 오류: {ex.Message}"; }
1194 public async Task
DeletePhotoAsync(
string slug,
string fileName, CancellationToken ct =
default)
1198 await
_photos.DeleteAsync(slug, fileName, ct).ConfigureAwait(
false);
1199 Config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false);
1200 Gallery = await
_photos.GetGalleryAsync(slug, ct).ConfigureAwait(
false);
1203 catch (Exception ex) {
StatusMessage = $
"삭제 오류: {ex.Message}"; }
1248 if (
Config is
null)
return;
1252 var index =
Config.GalleryFileNames.FindIndex(x =>
string.Equals(x, fileName, StringComparison.OrdinalIgnoreCase));
1253 if (index < 0)
return;
1255 var target = Math.Clamp(index + offset, 0,
Config.GalleryFileNames.Count - 1);
1256 if (index == target)
return;
1258 Config.GalleryFileNames.RemoveAt(index);
1259 Config.GalleryFileNames.Insert(target, fileName);
1264 catch (Exception ex) {
StatusMessage = $
"순서 변경 오류: {ex.Message}"; }
1309 if (
Config is
null)
return;
1313 var index =
Config.GalleryFileNames.FindIndex(x =>
string.Equals(x, fileName, StringComparison.OrdinalIgnoreCase));
1314 if (index < 0)
return;
1316 Config.GalleryFileNames.RemoveAt(index);
1317 Config.GalleryFileNames.Insert(first ? 0 :
Config.GalleryFileNames.Count, fileName);
1322 catch (Exception ex) {
StatusMessage = $
"순서 변경 오류: {ex.Message}"; }
1335 if (
Config is
null)
return;
1336 var existing =
Gallery.Select(x => x.FileName).ToHashSet(StringComparer.OrdinalIgnoreCase);
1337 Config.GalleryFileNames = Config.GalleryFileNames
1338 .Where(existing.Contains)
1339 .Distinct(StringComparer.OrdinalIgnoreCase)
1342 foreach (var photo
in Gallery)
1344 if (!
Config.GalleryFileNames.Contains(photo.FileName, StringComparer.OrdinalIgnoreCase))
1346 Config.GalleryFileNames.Add(photo.FileName);
1396 await
_photos.UploadRoadMapAsync(slug, file, ct).ConfigureAwait(
false);
1397 Config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false);
1400 catch (Exception ex) {
StatusMessage = $
"약도 업로드 오류: {ex.Message}"; }
1430 if (
Config is
null)
return;
1431 var query =
string.IsNullOrWhiteSpace(
Config.VenueAddress) ? Config.VenueName :
Config.VenueAddress;
1432 if (
string.IsNullOrWhiteSpace(query)) {
GeocodeStatus =
"❗ 예식장 이름 또는 주소를 먼저 입력해주세요.";
return; }
1438 var url = $
"https://nominatim.openstreetmap.org/search?q={Uri.EscapeDataString(query)}&format=json&limit=1&accept-language=ko";
1439 var results = await
_geocodeHttp.GetFromJsonAsync<JsonElement[]>(url, ct).ConfigureAwait(
false);
1440 if (results is
null || results.Length == 0)
1445 var item = results[0];
1446 Config.VenueLat =
double.Parse(item.GetProperty(
"lat").GetString()!, System.Globalization.CultureInfo.InvariantCulture);
1447 Config.VenueLng =
double.Parse(item.GetProperty(
"lon").GetString()!, System.Globalization.CultureInfo.InvariantCulture);
1448 GeocodeStatus = $
"✅ 좌표 검색 완료 — {item.GetProperty("display_name
").GetString()}";
1450 catch {
GeocodeStatus =
"❌ 좌표 검색 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요."; }
1464 if (
Config is
null)
return;
1466 var name = Uri.EscapeDataString(
Config.VenueName);
1467 var addr = Uri.EscapeDataString(
1468 string.IsNullOrWhiteSpace(
Config.VenueAddress) ?
Config.VenueName :
Config.VenueAddress);
1469 var lat =
Config.VenueLat;
1470 var lng =
Config.VenueLng;
1471 bool hasCoords = lat != 0 && lng != 0;
1472 var latS = lat.ToString(System.Globalization.CultureInfo.InvariantCulture);
1473 var lngS = lng.ToString(System.Globalization.CultureInfo.InvariantCulture);
1476 Config.MapLinkKakao = hasCoords
1477 ? $
"https://map.kakao.com/link/map/{name},{latS},{lngS}"
1478 : $
"https://map.kakao.com/link/search/{addr}";
1481 Config.MapLinkNaver = hasCoords
1482 ? $
"https://map.naver.com/v5/search/{addr}?c={lngS},{latS},15,0,0,0,dh"
1483 : $
"https://map.naver.com/v5/search/{addr}";
1486 if (!
string.IsNullOrWhiteSpace(
_opts.AtlanAuthKey) && hasCoords)
1487 Config.MapLinkAtlan = $
"http://m.atlan.co.kr/searchPlus/linkAtlan.do?shareType=kakao&coordX={lngS}&coordY={latS}&title={name}&AuthKey={_opts.AtlanAuthKey}";
1490 if (!
string.IsNullOrWhiteSpace(
_opts.TmapAppKey) && hasCoords)
1491 Config.MapLinkTmap = $
"https://apis.openapi.sk.com/tmap/app/routes?appKey={_opts.TmapAppKey}&name={name}&lon={lngS}&lat={latS}";
1771 await
_photos.UploadOgImageAsync(slug, file, ct).ConfigureAwait(
false);
1772 Config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false);
1775 catch (Exception ex) {
StatusMessage = $
"미리보기 이미지 오류: {ex.Message}"; }
1819 public async Task
UploadVideoAsync(
string slug, IBrowserFile file, CancellationToken ct =
default)
1824 await
_photos.UploadVideoAsync(slug, file, ct).ConfigureAwait(
false);
1825 Config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false);
1828 catch (Exception ex) {
StatusMessage = $
"동영상 업로드 오류: {ex.Message}"; }
1872 public async Task
DeleteVideoAsync(
string slug,
string fileName, CancellationToken ct =
default)
1876 await
_photos.DeleteVideoAsync(slug, fileName, ct).ConfigureAwait(
false);
1877 Config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false);
1880 catch (Exception ex) {
StatusMessage = $
"동영상 삭제 오류: {ex.Message}"; }
1923 public async Task
UploadMusicAsync(
string slug, IBrowserFile file, CancellationToken ct =
default)
1928 await
_photos.UploadMusicAsync(slug, file, ct).ConfigureAwait(
false);
1929 Config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false);
1932 catch (Exception ex) {
StatusMessage = $
"음악 업로드 오류: {ex.Message}"; }
1995 user.IsAuthenticated &&
1997 string.Equals(config.
OwnerUserId, user.Id, StringComparison.Ordinal);
2033 if (!user.IsAuthenticated)
return false;
2035 return GetAdminUsers(config).Any(x =>
string.Equals(x.UserId, user.Id, StringComparison.Ordinal));
2072 if (!user.IsAuthenticated)
return;
2075 var existing = users.FirstOrDefault(x =>
string.Equals(x.UserId, user.Id, StringComparison.Ordinal));
2076 if (existing is
null)
2081 Provider = user.Provider,
2083 DisplayName = user.DisplayName,
2085 AddedAt = DateTime.Now
2090 existing.Provider = user.Provider;
2091 existing.Email = user.Email;
2092 existing.DisplayName = user.DisplayName;
2093 if (role ==
"Owner")
2095 existing.Role =
"Owner";
2126 if (!
string.IsNullOrWhiteSpace(config.
OwnerUserId) &&
2127 users.All(x => !
string.Equals(x.UserId, config.
OwnerUserId, StringComparison.Ordinal)))
2136 AddedAt = config.OwnerLinkedAt ?? config.CreatedAt
2140 foreach (var user
in users.Where(x =>
string.Equals(x.UserId, config.
OwnerUserId, StringComparison.Ordinal)))
2142 user.Role =
"Owner";
2146 .OrderByDescending(x =>
string.Equals(x.Role,
"Owner", StringComparison.OrdinalIgnoreCase))
2147 .ThenBy(x => x.AddedAt)
record ThankYouCurrentUser(string Id, string Provider, string Email, string DisplayName)
static void Normalize(TenantConfig config)
static WeddingLayoutMode ResolveLayoutMode(string? legacyStyle)
static IReadOnlyList< ThankYouAdminUser > BuildEffectiveAdminUsers(TenantConfig config)
static bool IsOwnerUser(TenantConfig config, ThankYouCurrentUser user)
async Task DeleteVideoAsync(string slug, string fileName, CancellationToken ct=default)
async Task SaveStoryChapterAsync(StoryChapter chapter, CancellationToken ct=default)
EffectiveMediaPolicy? EffectiveMediaPolicy
async Task UploadVideoAsync(string slug, IBrowserFile file, CancellationToken ct=default)
bool ValidateThemeForSave(TenantConfig config)
async Task LoadAsync(string slug, CancellationToken ct=default)
bool ValidateLayoutForSave(TenantConfig config)
static void EnsureAdminUser(TenantConfig config, ThankYouCurrentUser user, string role)
void NormalizeGalleryFileOrder()
async Task UploadGalleryAsync(string slug, IBrowserFile file, CancellationToken ct=default)
static List< ThankYouAdminUser > GetAdminUsers(TenantConfig config)
async Task UploadRoadMapAsync(string slug, IBrowserFile file, CancellationToken ct=default)
async Task MoveGalleryPhotoAsync(string fileName, int offset, CancellationToken ct=default)
bool IsLinkedToCurrentUser
string GetHeroUrl(string slug, string fileName)
void SetStatusMessage(string message)
async Task DeleteStoryChapterAsync(int chapterNumber, CancellationToken ct=default)
async Task RemoveAdminAsync(string userId, CancellationToken ct=default)
async Task DeletePhotoAsync(string slug, string fileName, CancellationToken ct=default)
static readonly HttpClient _geocodeHttp
async Task InitializeAsync(string slug, CancellationToken ct=default)
readonly WeddingOptions _opts
async Task UploadMusicAsync(string slug, IBrowserFile file, CancellationToken ct=default)
readonly ITenantStore _tenants
static bool IsAdminUser(TenantConfig config, ThankYouCurrentUser user)
string GetOgImageUrl(string slug, string fileName)
string GetMusicUrl(string slug, string fileName)
AdminViewModel(ITenantStore tenants, IPhotoService photos, WeddingOptions opts, ThankYouUserContext userContext, IMediaQuotaPolicyResolver mediaPolicyResolver, ISuperAdminSessionTokenService superAdminTokens)
string GetRoadMapUrl(string slug, string fileName)
readonly ISuperAdminSessionTokenService _superAdminTokens
async Task< bool > LoginAsync(string slug, CancellationToken ct=default)
async Task UploadOgImageAsync(string slug, IBrowserFile file, CancellationToken ct=default)
async Task MoveGalleryPhotoToAsync(string fileName, bool first, CancellationToken ct=default)
IReadOnlyList< PhotoInfo > Gallery
async Task< bool > LoginAsSuperAdminAsync(string slug, string? sessionToken, CancellationToken ct=default)
readonly IMediaQuotaPolicyResolver _mediaPolicyResolver
async Task SaveConfigAsync(CancellationToken ct=default)
async Task AddStoryChapterAsync(CancellationToken ct=default)
string GetVideoUrl(string slug, string fileName)
IReadOnlyList< ThankYouAdminUser > EffectiveAdminUsers
readonly ThankYouUserContext _userContext
async Task GeocodeAsync(CancellationToken ct=default)
async Task RefreshCurrentUserAsync()
string GetThumbUrl(string slug, string fileName)
async Task UploadHeroAsync(string slug, IBrowserFile file, CancellationToken ct=default)
readonly IPhotoService _photos
string GetPhotoUrl(string slug, string fileName)