WeddingPlatform.Web 1.0.0.0
지도, 갤러리, 방명록, 계좌 안내와 배경음악을 링크 하나에 담는 무료 모바일 청첩장 서비스입니다.
로딩중...
검색중...
일치하는것 없음
WeddingAdminViewModel.cs
이 파일의 문서화 페이지로 가기
1using System.Net.Http;
2using System.Net.Http.Json;
3using System.Text.Json;
4using Dreamine.Identity;
5using Microsoft.AspNetCore.Components.Forms;
6using Wedding.Common;
9
11
20public sealed class WeddingAdminViewModel
21{
30 private readonly ITenantStore _tenants;
39 private readonly IPhotoService _photos;
48 private readonly WeddingOptions _opts;
76
85 private static readonly HttpClient _geocodeHttp = new()
86 {
87 DefaultRequestHeaders = { { "User-Agent", "CodemaruWeddingPlatform/1.0 (contact: admin@codemaru.co.kr)" } }
88 };
89
147 ITenantStore tenants,
148 IPhotoService photos,
149 WeddingOptions opts,
150 WeddingUserContext userContext,
151 IMediaQuotaPolicyResolver mediaPolicyResolver,
152 ISuperAdminSessionTokenService superAdminTokens)
153 {
154 _tenants = tenants;
155 _photos = photos;
156 _opts = opts;
157 _userContext = userContext;
158 _mediaPolicyResolver = mediaPolicyResolver;
159 _superAdminTokens = superAdminTokens;
160 }
161
170 public string MaxVideoSizeLabel { get; private set; } = "최대 200MB";
179 public int MaxVideoCount { get; private set; } = 6;
188 public EffectiveMediaPolicy? EffectiveMediaPolicy { get; private set; }
189
198 public TenantConfig? Config { get; private set; }
207 public IReadOnlyList<PhotoInfo> Gallery { get; private set; } = [];
216 public bool IsLoaded { get; private set; }
225 public bool IsAuthenticated { get; private set; }
234 public bool IsSignedIn { get; private set; }
243 public bool IsLinkedToCurrentUser { get; private set; }
252 public bool IsOwner { get; private set; }
261 public IReadOnlyList<WeddingAdminUser> EffectiveAdminUsers { get; private set; } = [];
270 public string StatusMessage { get; private set; } = "";
279 public string CurrentUserLabel { get; private set; } = "";
288 public bool IsUploading { get; private set; }
297 public bool IsGeocoding { get; private set; }
306 public string GeocodeStatus { get; private set; } = "";
307
316 public string LoginPassword { get; set; } = "";
317
350 public async Task InitializeAsync(string slug, CancellationToken ct = default)
351 {
352 StatusMessage = "";
353 await RefreshCurrentUserAsync().ConfigureAwait(false);
354
355 var config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false);
356 if (config is null)
357 {
358 return;
359 }
360
361 var user = await _userContext.GetCurrentAsync().ConfigureAwait(false);
362 IsOwner = IsOwnerUser(config, user);
363 IsLinkedToCurrentUser = IsAdminUser(config, user);
365
367 {
368 IsAuthenticated = true;
369 await LoadAsync(slug, ct).ConfigureAwait(false);
370 }
371 }
372
405 public async Task<bool> LoginAsync(string slug, CancellationToken ct = default)
406 {
407 var config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false);
408 if (config is null) { StatusMessage = "존재하지 않는 슬러그입니다."; return false; }
409
410 var user = await _userContext.GetCurrentAsync().ConfigureAwait(false);
411 await RefreshCurrentUserAsync().ConfigureAwait(false);
412
413 if (IsAdminUser(config, user))
414 {
415 IsAuthenticated = true;
417 IsOwner = IsOwnerUser(config, user);
419 StatusMessage = "";
420 return true;
421 }
422
423 var verification = DreaminePasswordHasher.VerifyPassword(LoginPassword, config.PasswordHash, out var upgradedHash);
424 IsAuthenticated = verification is not PasswordHashVerificationResult.Failed;
425 if (!IsAuthenticated)
426 {
427 StatusMessage = "비밀번호가 틀렸습니다.";
428 return false;
429 }
430
431 if (verification is PasswordHashVerificationResult.SuccessRehashNeeded && upgradedHash is not null)
432 {
433 config.PasswordHash = upgradedHash;
434 await _tenants.SaveAsync(config, ct).ConfigureAwait(false);
435 }
436
437 if (user.IsAuthenticated && string.IsNullOrWhiteSpace(config.OwnerUserId))
438 {
439 config.OwnerUserId = user.Id;
440 config.OwnerProvider = user.Provider;
441 config.OwnerEmail = user.Email;
442 config.OwnerDisplayName = user.DisplayName;
443 config.OwnerLinkedAt = DateTime.Now;
444 EnsureAdminUser(config, user, "Owner");
445 await _tenants.SaveAsync(config, ct).ConfigureAwait(false);
447 IsOwner = true;
449 StatusMessage = "CodeMaru/Dreamine 계정에 대표 관리자로 연결되었습니다. 다음부터는 공용 로그인으로 관리할 수 있습니다.";
450 }
451 else if (user.IsAuthenticated)
452 {
453 EnsureAdminUser(config, user, "Admin");
454 await _tenants.SaveAsync(config, ct).ConfigureAwait(false);
456 IsOwner = IsOwnerUser(config, user);
458 StatusMessage = "현재 CodeMaru/Dreamine 계정이 이 청첩장의 관리자로 추가되었습니다.";
459 }
460 else if (!user.IsAuthenticated && string.IsNullOrWhiteSpace(config.OwnerUserId))
461 {
462 StatusMessage = "로그인은 성공했습니다. 공용 계정에 연결하려면 먼저 CodeMaru/Dreamine 로그인을 해주세요.";
463 }
464 else
465 {
466 StatusMessage = "";
467 }
468
469 return IsAuthenticated;
470 }
471
512 public async Task<bool> LoginAsSuperAdminAsync(string slug, string? sessionToken, CancellationToken ct = default)
513 {
514 if (!_superAdminTokens.ValidateToken(sessionToken))
515 {
516 return false;
517 }
518
519 var config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false);
520 if (config is null)
521 {
522 StatusMessage = "존재하지 않는 슬러그입니다.";
523 return false;
524 }
525
526 IsAuthenticated = true;
527 IsLinkedToCurrentUser = false;
528 IsOwner = false;
530 StatusMessage = "슈퍼관리자 권한으로 접속했습니다.";
531 await LoadAsync(slug, ct).ConfigureAwait(false);
532 return true;
533 }
534
567 public async Task LoadAsync(string slug, CancellationToken ct = default)
568 {
569 Config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false)
570 ?? new TenantConfig { Slug = slug };
572 Gallery = await _photos.GetGalleryAsync(slug, ct).ConfigureAwait(false);
574
575 EffectiveMediaPolicy = await _mediaPolicyResolver.ResolveAsync(Config, ct).ConfigureAwait(false);
576
577 var effectiveMb = EffectiveMediaPolicy.VideoMaxFileSizeMb;
578 MaxVideoSizeLabel = effectiveMb <= 0 ? "무제한" : $"최대 {effectiveMb}MB";
579
580 MaxVideoCount = EffectiveMediaPolicy.VideoMaxCount;
581
582 IsLoaded = true;
583 }
584
601 private async Task RefreshCurrentUserAsync()
602 {
603 var user = await _userContext.GetCurrentAsync().ConfigureAwait(false);
604 IsSignedIn = user.IsAuthenticated;
605 CurrentUserLabel = user.IsAuthenticated
606 ? string.IsNullOrWhiteSpace(user.DisplayName)
607 ? user.Provider
608 : $"{user.DisplayName} ({user.Provider})"
609 : "";
610 }
611
636 public async Task SaveConfigAsync(CancellationToken ct = default)
637 {
638 if (Config is null) return;
639 try
640 {
642 {
643 return;
644 }
645
647 {
648 return;
649 }
650
652 Config.PasswordHash = DreaminePasswordHasher.HashPlainTextForStorage(Config.PasswordHash);
653 await _tenants.SaveAsync(Config, ct).ConfigureAwait(false);
655 StatusMessage = "설정이 저장되었습니다.";
656 }
657 catch (Exception ex) { StatusMessage = $"저장 오류: {ex.Message}"; }
658 }
659
676 public void SetStatusMessage(string message) => StatusMessage = message;
677
710 public async Task SaveStoryChapterAsync(StoryChapter chapter, CancellationToken ct = default)
711 {
712 if (Config is null) return;
713 try
714 {
716 var chapters = Config.DesignSettings.StoryChapters;
717 var index = chapters.FindIndex(x => x.ChapterNumber == chapter.ChapterNumber);
718 var normalized = WeddingStoryChapterDefaults.Clone(chapter);
719 normalized.Label = string.IsNullOrWhiteSpace(normalized.Label)
720 ? $"CHAPTER {Math.Max(1, normalized.ChapterNumber):00}"
721 : normalized.Label.Trim();
722 normalized.Title = string.IsNullOrWhiteSpace(normalized.Title)
723 ? "스토리"
724 : normalized.Title.Trim();
725
726 if (index >= 0)
727 {
728 chapters[index] = normalized;
729 }
730 else
731 {
732 chapters.Add(normalized);
733 Config.DesignSettings.StoryChapters = WeddingStoryChapterDefaults.Normalize(chapters);
734 }
735
736 await _tenants.SaveAsync(Config, ct).ConfigureAwait(false);
737 StatusMessage = $"{normalized.Label} 챕터가 저장되었습니다.";
738 }
739 catch (Exception ex)
740 {
741 StatusMessage = $"챕터 저장 오류: {ex.Message}";
742 }
743 }
744
769 public async Task AddStoryChapterAsync(CancellationToken ct = default)
770 {
771 if (Config is null) return;
772 try
773 {
775 var chapters = Config.DesignSettings.StoryChapters;
776 var nextNumber = chapters.Count == 0 ? 1 : chapters.Max(x => x.ChapterNumber) + 1;
777 chapters.Add(new StoryChapter
778 {
779 ChapterNumber = nextNumber,
780 Label = $"CHAPTER {nextNumber:00}",
781 Title = "새로운 이야기",
782 });
783
784 await _tenants.SaveAsync(Config, ct).ConfigureAwait(false);
785 StatusMessage = $"CHAPTER {nextNumber:00} 챕터가 추가되었습니다.";
786 }
787 catch (Exception ex)
788 {
789 StatusMessage = $"챕터 추가 오류: {ex.Message}";
790 }
791 }
792
825 public async Task DeleteStoryChapterAsync(int chapterNumber, CancellationToken ct = default)
826 {
827 if (Config is null) return;
828 try
829 {
831 if (chapterNumber <= 4)
832 {
833 StatusMessage = "기본 4개 챕터는 유지됩니다.";
834 return;
835 }
836
837 var removed = Config.DesignSettings.StoryChapters.RemoveAll(x => x.ChapterNumber == chapterNumber);
838 if (removed == 0)
839 {
840 StatusMessage = "삭제할 챕터를 찾을 수 없습니다.";
841 return;
842 }
843
844 await _tenants.SaveAsync(Config, ct).ConfigureAwait(false);
845 StatusMessage = $"CHAPTER {chapterNumber:00} 챕터가 삭제되었습니다.";
846 }
847 catch (Exception ex)
848 {
849 StatusMessage = $"챕터 삭제 오류: {ex.Message}";
850 }
851 }
852
878 {
879 config.DesignSettings ??= new DesignSettings();
880 if (!WeddingLayoutCatalog.IsKnownKey(config.InvitationStyle))
881 {
882 StatusMessage = "저장 오류: 존재하지 않는 레이아웃입니다.";
883 return false;
884 }
885
886 var mode = config.DesignSettings.LayoutMode;
887 if (mode == WeddingLayoutMode.Unknown)
888 {
890 config.DesignSettings.LayoutMode = mode;
891 }
892
893 var option = WeddingLayoutCatalog.Instance.Find(mode);
894 if (option is null)
895 {
896 StatusMessage = "저장 오류: 존재하지 않는 레이아웃입니다.";
897 return false;
898 }
899
900 if (!option.IsImplemented)
901 {
902 StatusMessage = "저장 오류: 아직 준비 중인 레이아웃입니다.";
903 return false;
904 }
905
906 var access = new WeddingLayoutAccessState
907 {
908 HasPremiumPlan = config.HasPremiumPlan,
909 UnlockedLayouts = config.UnlockedLayoutModes
910 .Select(WeddingLayoutCatalog.FromLegacyKey)
911 .Where(x => x != WeddingLayoutMode.Unknown)
912 .ToArray(),
913 };
914
915 if (!new WeddingLayoutAccessPolicy().CanUse(option, access))
916 {
917 StatusMessage = "저장 오류: 프리미엄 레이아웃은 플랜 또는 잠금 해제 후 저장할 수 있습니다.";
918 return false;
919 }
920
921 return true;
922 }
923
949 {
950 config.DesignSettings ??= new DesignSettings();
951 config.UnlockedThemeKeys ??= new();
952 if (!WeddingThemeCatalog.IsKnownKey(config.DesignSettings.ThemeKey))
953 {
954 StatusMessage = "저장 오류: 존재하지 않는 테마입니다.";
955 return false;
956 }
957
958 var option = WeddingThemeCatalog.Instance.Find(config.DesignSettings.ThemeKey);
959 if (option is null)
960 {
961 StatusMessage = "저장 오류: 존재하지 않는 테마입니다.";
962 return false;
963 }
964
965 if (!option.IsImplemented)
966 {
967 StatusMessage = "저장 오류: 아직 준비 중인 테마입니다.";
968 return false;
969 }
970
971 var access = new WeddingThemeAccessState
972 {
973 HasPremiumPlan = config.HasPremiumPlan,
974 UnlockedThemeKeys = config.UnlockedThemeKeys
975 .Select(WeddingThemeCatalog.NormalizeKey)
976 .Where(WeddingThemeCatalog.IsKnownKey)
977 .ToArray(),
978 };
979
980 if (!new WeddingThemeAccessPolicy().CanUse(option, access))
981 {
982 StatusMessage = "저장 오류: 프리미엄 테마는 플랜 또는 잠금 해제 후 저장할 수 있습니다.";
983 return false;
984 }
985
986 config.DesignSettings.ThemeKey = option.Key;
987 config.ThemeName = option.Key;
988 return true;
989 }
990
1023 public async Task RemoveAdminAsync(string userId, CancellationToken ct = default)
1024 {
1025 if (Config is null) return;
1026 var user = await _userContext.GetCurrentAsync().ConfigureAwait(false);
1027 if (!IsOwnerUser(Config, user))
1028 {
1029 StatusMessage = "대표 관리자만 관리자 계정을 삭제할 수 있습니다.";
1030 return;
1031 }
1032
1033 if (string.Equals(Config.OwnerUserId, userId, StringComparison.Ordinal))
1034 {
1035 StatusMessage = "대표 관리자는 삭제할 수 없습니다.";
1036 return;
1037 }
1038
1039 var removed = GetAdminUsers(Config).RemoveAll(x => string.Equals(x.UserId, userId, StringComparison.Ordinal));
1040 if (removed == 0)
1041 {
1042 StatusMessage = "삭제할 관리자를 찾을 수 없습니다.";
1043 return;
1044 }
1045
1046 await _tenants.SaveAsync(Config, ct).ConfigureAwait(false);
1048 StatusMessage = "관리자 계정이 삭제되었습니다.";
1049 }
1050
1091 public async Task UploadGalleryAsync(string slug, IBrowserFile file, CancellationToken ct = default)
1092 {
1093 IsUploading = true;
1094 StatusMessage = "";
1095 try
1096 {
1097 await _photos.UploadAsync(slug, file, ct).ConfigureAwait(false);
1098 // Config도 갱신해야 이후 설정 저장 시 GalleryFileNames가 날아가지 않음
1099 Config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false);
1100 Gallery = await _photos.GetGalleryAsync(slug, ct).ConfigureAwait(false);
1101 StatusMessage = $"{file.Name} 업로드 완료";
1102 }
1103 catch (Exception ex) { StatusMessage = $"업로드 오류: {ex.Message}"; }
1104 finally { IsUploading = false; }
1105 }
1106
1147 public async Task UploadHeroAsync(string slug, IBrowserFile file, CancellationToken ct = default)
1148 {
1149 IsUploading = true;
1150 try
1151 {
1152 await _photos.UploadHeroAsync(slug, file, ct).ConfigureAwait(false);
1153 Config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false);
1154 StatusMessage = "히어로 이미지 업로드 완료";
1155 }
1156 catch (Exception ex) { StatusMessage = $"히어로 업로드 오류: {ex.Message}"; }
1157 finally { IsUploading = false; }
1158 }
1159
1200 public async Task DeletePhotoAsync(string slug, string fileName, CancellationToken ct = default)
1201 {
1202 try
1203 {
1204 await _photos.DeleteAsync(slug, fileName, ct).ConfigureAwait(false);
1205 Config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false);
1206 Gallery = await _photos.GetGalleryAsync(slug, ct).ConfigureAwait(false);
1207 StatusMessage = "삭제 완료";
1208 }
1209 catch (Exception ex) { StatusMessage = $"삭제 오류: {ex.Message}"; }
1210 }
1211
1252 public async Task MoveGalleryPhotoAsync(string fileName, int offset, CancellationToken ct = default)
1253 {
1254 if (Config is null) return;
1255 try
1256 {
1258 var index = Config.GalleryFileNames.FindIndex(x => string.Equals(x, fileName, StringComparison.OrdinalIgnoreCase));
1259 if (index < 0) return;
1260
1261 var target = Math.Clamp(index + offset, 0, Config.GalleryFileNames.Count - 1);
1262 if (index == target) return;
1263
1264 Config.GalleryFileNames.RemoveAt(index);
1265 Config.GalleryFileNames.Insert(target, fileName);
1266 await _tenants.SaveAsync(Config, ct).ConfigureAwait(false);
1267 Gallery = await _photos.GetGalleryAsync(Config.Slug, ct).ConfigureAwait(false);
1268 StatusMessage = "사진 노출 순서가 저장되었습니다.";
1269 }
1270 catch (Exception ex) { StatusMessage = $"순서 변경 오류: {ex.Message}"; }
1271 }
1272
1313 public async Task MoveGalleryPhotoToAsync(string fileName, bool first, CancellationToken ct = default)
1314 {
1315 if (Config is null) return;
1316 try
1317 {
1319 var index = Config.GalleryFileNames.FindIndex(x => string.Equals(x, fileName, StringComparison.OrdinalIgnoreCase));
1320 if (index < 0) return;
1321
1322 Config.GalleryFileNames.RemoveAt(index);
1323 Config.GalleryFileNames.Insert(first ? 0 : Config.GalleryFileNames.Count, fileName);
1324 await _tenants.SaveAsync(Config, ct).ConfigureAwait(false);
1325 Gallery = await _photos.GetGalleryAsync(Config.Slug, ct).ConfigureAwait(false);
1326 StatusMessage = "사진 노출 순서가 저장되었습니다.";
1327 }
1328 catch (Exception ex) { StatusMessage = $"순서 변경 오류: {ex.Message}"; }
1329 }
1330
1340 {
1341 if (Config is null) return;
1342 var existing = Gallery.Select(x => x.FileName).ToHashSet(StringComparer.OrdinalIgnoreCase);
1343 Config.GalleryFileNames = Config.GalleryFileNames
1344 .Where(existing.Contains)
1345 .Distinct(StringComparer.OrdinalIgnoreCase)
1346 .ToList();
1347
1348 foreach (var photo in Gallery)
1349 {
1350 if (!Config.GalleryFileNames.Contains(photo.FileName, StringComparer.OrdinalIgnoreCase))
1351 {
1352 Config.GalleryFileNames.Add(photo.FileName);
1353 }
1354 }
1355 }
1356
1397 public async Task UploadRoadMapAsync(string slug, IBrowserFile file, CancellationToken ct = default)
1398 {
1399 IsUploading = true;
1400 try
1401 {
1402 await _photos.UploadRoadMapAsync(slug, file, ct).ConfigureAwait(false);
1403 Config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false);
1404 StatusMessage = "약도 이미지 업로드 완료";
1405 }
1406 catch (Exception ex) { StatusMessage = $"약도 업로드 오류: {ex.Message}"; }
1407 finally { IsUploading = false; }
1408 }
1409
1434 public async Task GeocodeAsync(CancellationToken ct = default)
1435 {
1436 if (Config is null) return;
1437 var query = string.IsNullOrWhiteSpace(Config.VenueAddress) ? Config.VenueName : Config.VenueAddress;
1438 if (string.IsNullOrWhiteSpace(query)) { GeocodeStatus = "❗ 예식장 이름 또는 주소를 먼저 입력해주세요."; return; }
1439
1440 IsGeocoding = true;
1441 GeocodeStatus = "";
1442 try
1443 {
1444 var url = $"https://nominatim.openstreetmap.org/search?q={Uri.EscapeDataString(query)}&format=json&limit=1&accept-language=ko";
1445 var results = await _geocodeHttp.GetFromJsonAsync<JsonElement[]>(url, ct).ConfigureAwait(false);
1446 if (results is null || results.Length == 0)
1447 {
1448 GeocodeStatus = "❌ 좌표를 찾을 수 없습니다. 주소를 더 자세히 입력해 보세요.";
1449 return;
1450 }
1451 var item = results[0];
1452 Config.VenueLat = double.Parse(item.GetProperty("lat").GetString()!, System.Globalization.CultureInfo.InvariantCulture);
1453 Config.VenueLng = double.Parse(item.GetProperty("lon").GetString()!, System.Globalization.CultureInfo.InvariantCulture);
1454 GeocodeStatus = $"✅ 좌표 검색 완료 — {item.GetProperty("display_name").GetString()}";
1455 }
1456 catch { GeocodeStatus = "❌ 좌표 검색 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요."; }
1457 finally { IsGeocoding = false; }
1458 }
1459
1468 public void GenerateMapLinks()
1469 {
1470 if (Config is null) return;
1471
1472 var name = Uri.EscapeDataString(Config.VenueName);
1473 var addr = Uri.EscapeDataString(
1474 string.IsNullOrWhiteSpace(Config.VenueAddress) ? Config.VenueName : Config.VenueAddress);
1475 var lat = Config.VenueLat;
1476 var lng = Config.VenueLng;
1477 bool hasCoords = lat != 0 && lng != 0;
1478 var latS = lat.ToString(System.Globalization.CultureInfo.InvariantCulture);
1479 var lngS = lng.ToString(System.Globalization.CultureInfo.InvariantCulture);
1480
1481 // 카카오맵 — 좌표 있으면 핀, 없으면 검색
1482 Config.MapLinkKakao = hasCoords
1483 ? $"https://map.kakao.com/link/map/{name},{latS},{lngS}"
1484 : $"https://map.kakao.com/link/search/{addr}";
1485
1486 // 네이버지도 — 좌표 있으면 좌표 검색, 없으면 주소 검색
1487 Config.MapLinkNaver = hasCoords
1488 ? $"https://map.naver.com/v5/search/{addr}?c={lngS},{latS},15,0,0,0,dh"
1489 : $"https://map.naver.com/v5/search/{addr}";
1490
1491 // 아틀란
1492 if (!string.IsNullOrWhiteSpace(_opts.AtlanAuthKey) && hasCoords)
1493 Config.MapLinkAtlan = $"http://m.atlan.co.kr/searchPlus/linkAtlan.do?shareType=kakao&coordX={lng.ToString(System.Globalization.CultureInfo.InvariantCulture)}&coordY={lat.ToString(System.Globalization.CultureInfo.InvariantCulture)}&title={name}&AuthKey={_opts.AtlanAuthKey}";
1494
1495 // T맵
1496 if (!string.IsNullOrWhiteSpace(_opts.TmapAppKey) && hasCoords)
1497 Config.MapLinkTmap = $"https://apis.openapi.sk.com/tmap/app/routes?appKey={_opts.TmapAppKey}&name={name}&lon={lng.ToString(System.Globalization.CultureInfo.InvariantCulture)}&lat={lat.ToString(System.Globalization.CultureInfo.InvariantCulture)}";
1498 }
1499
1532 public string GetThumbUrl(string slug, string fileName) => _photos.GetThumbUrl(slug, fileName);
1565 public string GetPhotoUrl(string slug, string fileName) => _photos.GetPhotoUrl(slug, fileName);
1598 public string GetHeroUrl(string slug, string fileName) => _photos.GetHeroUrl(slug, fileName);
1631 public string GetRoadMapUrl(string slug, string fileName) => _photos.GetRoadMapUrl(slug, fileName);
1664 public string GetMusicUrl(string slug, string fileName) => _photos.GetMusicUrl(slug, fileName);
1697 public string GetOgImageUrl(string slug, string fileName) => _photos.GetHeroUrl(slug, fileName);
1730 public string GetVideoUrl(string slug, string fileName) => _photos.GetVideoUrl(slug, fileName);
1731
1772 public async Task UploadOgImageAsync(string slug, IBrowserFile file, CancellationToken ct = default)
1773 {
1774 IsUploading = true;
1775 try
1776 {
1777 await _photos.UploadOgImageAsync(slug, file, ct).ConfigureAwait(false);
1778 Config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false);
1779 StatusMessage = "미리보기 이미지 업로드 완료";
1780 }
1781 catch (Exception ex) { StatusMessage = $"미리보기 이미지 오류: {ex.Message}"; }
1782 finally { IsUploading = false; }
1783 }
1784
1825 public async Task UploadThankYouOgImageAsync(string slug, IBrowserFile file, CancellationToken ct = default)
1826 {
1827 IsUploading = true;
1828 try
1829 {
1830 await _photos.UploadThankYouOgImageAsync(slug, file, ct).ConfigureAwait(false);
1831 Config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false);
1832 StatusMessage = "감사장 미리보기 이미지 업로드 완료";
1833 }
1834 catch (Exception ex) { StatusMessage = $"감사장 미리보기 이미지 오류: {ex.Message}"; }
1835 finally { IsUploading = false; }
1836 }
1837
1878 public async Task UploadVideoAsync(string slug, IBrowserFile file, CancellationToken ct = default)
1879 {
1880 IsUploading = true;
1881 try
1882 {
1883 await _photos.UploadVideoAsync(slug, file, ct).ConfigureAwait(false);
1884 Config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false);
1885 StatusMessage = "동영상 업로드 완료";
1886 }
1887 catch (Exception ex) { StatusMessage = $"동영상 업로드 오류: {ex.Message}"; }
1888 finally { IsUploading = false; }
1889 }
1890
1931 public async Task DeleteVideoAsync(string slug, string fileName, CancellationToken ct = default)
1932 {
1933 try
1934 {
1935 await _photos.DeleteVideoAsync(slug, fileName, ct).ConfigureAwait(false);
1936 Config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false);
1937 StatusMessage = "동영상 삭제 완료";
1938 }
1939 catch (Exception ex) { StatusMessage = $"동영상 삭제 오류: {ex.Message}"; }
1940 }
1941
1982 public async Task UploadMusicAsync(string slug, IBrowserFile file, CancellationToken ct = default)
1983 {
1984 IsUploading = true;
1985 try
1986 {
1987 await _photos.UploadMusicAsync(slug, file, ct).ConfigureAwait(false);
1988 Config = await _tenants.GetAsync(slug, ct).ConfigureAwait(false);
1989 StatusMessage = "배경 음악 업로드 완료";
1990 }
1991 catch (Exception ex) { StatusMessage = $"음악 업로드 오류: {ex.Message}"; }
1992 finally { IsUploading = false; }
1993 }
1994
2019 private static List<WeddingAdminUser> GetAdminUsers(TenantConfig config) => config.AdminUsers ??= [];
2020
2053 private static bool IsOwnerUser(TenantConfig config, WeddingCurrentUser user) =>
2054 user.IsAuthenticated &&
2055 !string.IsNullOrWhiteSpace(config.OwnerUserId) &&
2056 string.Equals(config.OwnerUserId, user.Id, StringComparison.Ordinal);
2057
2090 private static bool IsAdminUser(TenantConfig config, WeddingCurrentUser user)
2091 {
2092 if (!user.IsAuthenticated) return false;
2093 if (IsOwnerUser(config, user)) return true;
2094 return GetAdminUsers(config).Any(x => string.Equals(x.UserId, user.Id, StringComparison.Ordinal));
2095 }
2096
2129 private static void EnsureAdminUser(TenantConfig config, WeddingCurrentUser user, string role)
2130 {
2131 if (!user.IsAuthenticated) return;
2132
2133 var users = GetAdminUsers(config);
2134 var existing = users.FirstOrDefault(x => string.Equals(x.UserId, user.Id, StringComparison.Ordinal));
2135 if (existing is null)
2136 {
2137 users.Add(new WeddingAdminUser
2138 {
2139 UserId = user.Id,
2140 Provider = user.Provider,
2141 Email = user.Email,
2142 DisplayName = user.DisplayName,
2143 Role = role,
2144 AddedAt = DateTime.Now
2145 });
2146 return;
2147 }
2148
2149 existing.Provider = user.Provider;
2150 existing.Email = user.Email;
2151 existing.DisplayName = user.DisplayName;
2152 if (role == "Owner")
2153 {
2154 existing.Role = "Owner";
2155 }
2156 }
2157
2182 private static IReadOnlyList<WeddingAdminUser> BuildEffectiveAdminUsers(TenantConfig config)
2183 {
2184 var users = GetAdminUsers(config);
2185 if (!string.IsNullOrWhiteSpace(config.OwnerUserId) &&
2186 users.All(x => !string.Equals(x.UserId, config.OwnerUserId, StringComparison.Ordinal)))
2187 {
2188 users.Insert(0, new WeddingAdminUser
2189 {
2190 UserId = config.OwnerUserId,
2191 Provider = config.OwnerProvider,
2192 Email = config.OwnerEmail,
2193 DisplayName = config.OwnerDisplayName,
2194 Role = "Owner",
2195 AddedAt = config.OwnerLinkedAt ?? config.CreatedAt
2196 });
2197 }
2198
2199 foreach (var user in users.Where(x => string.Equals(x.UserId, config.OwnerUserId, StringComparison.Ordinal)))
2200 {
2201 user.Role = "Owner";
2202 }
2203
2204 return users
2205 .OrderByDescending(x => string.Equals(x.Role, "Owner", StringComparison.OrdinalIgnoreCase))
2206 .ThenBy(x => x.AddedAt)
2207 .ToList();
2208 }
2209
2226 private static void NormalizeHeroPanelPosition(TenantConfig config)
2227 {
2229 config.InviteHeroTopVerticalDesktop = NormalizeOption(config.InviteHeroTopVerticalDesktop, ["top", "middle", "bottom"], "top");
2230 config.InviteHeroTopHorizontalDesktop = NormalizeOption(config.InviteHeroTopHorizontalDesktop, ["left", "center", "right"], "center");
2231 config.InviteHeroTopVerticalMobile = NormalizeOption(config.InviteHeroTopVerticalMobile, ["top", "middle", "bottom"], "top");
2232 config.InviteHeroTopHorizontalMobile = NormalizeOption(config.InviteHeroTopHorizontalMobile, ["left", "center", "right"], "center");
2233 config.InviteHeroBottomVerticalDesktop = NormalizeOption(config.InviteHeroBottomVerticalDesktop, ["top", "middle", "bottom"], "bottom");
2234 config.InviteHeroBottomHorizontalDesktop = NormalizeOption(config.InviteHeroBottomHorizontalDesktop, ["left", "center", "right"], "center");
2235 config.InviteHeroBottomVerticalMobile = NormalizeOption(config.InviteHeroBottomVerticalMobile, ["top", "middle", "bottom"], "bottom");
2236 config.InviteHeroBottomHorizontalMobile = NormalizeOption(config.InviteHeroBottomHorizontalMobile, ["left", "center", "right"], "center");
2237 config.HeroPanelVerticalDesktop = NormalizeOption(config.HeroPanelVerticalDesktop, ["top", "middle", "bottom"], "top");
2238 config.HeroPanelHorizontalDesktop = NormalizeOption(config.HeroPanelHorizontalDesktop, ["left", "center", "right"], "center");
2239 config.HeroPanelVerticalMobile = NormalizeOption(config.HeroPanelVerticalMobile, ["top", "middle", "bottom"], "top");
2240 config.HeroPanelHorizontalMobile = NormalizeOption(config.HeroPanelHorizontalMobile, ["left", "center", "right"], "center");
2241 }
2242
2283 private static string NormalizeOption(string? value, string[] allowed, string fallback)
2284 {
2285 var normalized = value?.Trim().ToLowerInvariant();
2286 return !string.IsNullOrWhiteSpace(normalized) && allowed.Contains(normalized) ? normalized : fallback;
2287 }
2288}
record WeddingCurrentUser(string Id, string Provider, string Email, string DisplayName)
static WeddingLayoutMode FromLegacyLayoutKey(string? key)
static void Normalize(TenantConfig config)
IReadOnlyList< WeddingAdminUser > EffectiveAdminUsers
readonly IMediaQuotaPolicyResolver _mediaPolicyResolver
static void EnsureAdminUser(TenantConfig config, WeddingCurrentUser user, string role)
string GetRoadMapUrl(string slug, string fileName)
async Task MoveGalleryPhotoToAsync(string fileName, bool first, CancellationToken ct=default)
static IReadOnlyList< WeddingAdminUser > BuildEffectiveAdminUsers(TenantConfig config)
string GetOgImageUrl(string slug, string fileName)
async Task UploadGalleryAsync(string slug, IBrowserFile file, CancellationToken ct=default)
readonly ISuperAdminSessionTokenService _superAdminTokens
async Task UploadThankYouOgImageAsync(string slug, IBrowserFile file, CancellationToken ct=default)
async Task UploadVideoAsync(string slug, IBrowserFile file, CancellationToken ct=default)
async Task AddStoryChapterAsync(CancellationToken ct=default)
static bool IsOwnerUser(TenantConfig config, WeddingCurrentUser user)
async Task UploadOgImageAsync(string slug, IBrowserFile file, CancellationToken ct=default)
async Task DeleteStoryChapterAsync(int chapterNumber, CancellationToken ct=default)
async Task< bool > LoginAsSuperAdminAsync(string slug, string? sessionToken, CancellationToken ct=default)
async Task LoadAsync(string slug, CancellationToken ct=default)
static string NormalizeOption(string? value, string[] allowed, string fallback)
async Task GeocodeAsync(CancellationToken ct=default)
async Task SaveStoryChapterAsync(StoryChapter chapter, CancellationToken ct=default)
static void NormalizeHeroPanelPosition(TenantConfig config)
static bool IsAdminUser(TenantConfig config, WeddingCurrentUser user)
static List< WeddingAdminUser > GetAdminUsers(TenantConfig config)
async Task UploadRoadMapAsync(string slug, IBrowserFile file, CancellationToken ct=default)
async Task InitializeAsync(string slug, CancellationToken ct=default)
async Task SaveConfigAsync(CancellationToken ct=default)
WeddingAdminViewModel(ITenantStore tenants, IPhotoService photos, WeddingOptions opts, WeddingUserContext userContext, IMediaQuotaPolicyResolver mediaPolicyResolver, ISuperAdminSessionTokenService superAdminTokens)
async Task MoveGalleryPhotoAsync(string fileName, int offset, CancellationToken ct=default)
async Task< bool > LoginAsync(string slug, CancellationToken ct=default)
async Task DeleteVideoAsync(string slug, string fileName, CancellationToken ct=default)
async Task UploadMusicAsync(string slug, IBrowserFile file, CancellationToken ct=default)
async Task DeletePhotoAsync(string slug, string fileName, CancellationToken ct=default)
async Task RemoveAdminAsync(string userId, CancellationToken ct=default)
async Task UploadHeroAsync(string slug, IBrowserFile file, CancellationToken ct=default)