2using System.Net.Http.Json;
4using Dreamine.Identity;
5using Microsoft.AspNetCore.Components.Forms;
87 DefaultRequestHeaders = { {
"User-Agent",
"CodemaruWeddingPlatform/1.0 (contact: admin@codemaru.co.kr)" } }
207 public IReadOnlyList<PhotoInfo>
Gallery {
get;
private set; } = [];
355 var config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false);
361 var user = await
_userContext.GetCurrentAsync().ConfigureAwait(
false);
369 await
LoadAsync(slug, ct).ConfigureAwait(
false);
405 public async Task<bool>
LoginAsync(
string slug, CancellationToken ct =
default)
407 var config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false);
408 if (config is
null) {
StatusMessage =
"존재하지 않는 슬러그입니다.";
return false; }
410 var user = await
_userContext.GetCurrentAsync().ConfigureAwait(
false);
423 var verification = DreaminePasswordHasher.VerifyPassword(
LoginPassword, config.PasswordHash, out var upgradedHash);
424 IsAuthenticated = verification is not PasswordHashVerificationResult.Failed;
431 if (verification is PasswordHashVerificationResult.SuccessRehashNeeded && upgradedHash is not
null)
433 config.PasswordHash = upgradedHash;
434 await
_tenants.SaveAsync(config, ct).ConfigureAwait(
false);
437 if (user.IsAuthenticated &&
string.IsNullOrWhiteSpace(config.OwnerUserId))
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;
445 await
_tenants.SaveAsync(config, ct).ConfigureAwait(
false);
449 StatusMessage =
"CodeMaru/Dreamine 계정에 대표 관리자로 연결되었습니다. 다음부터는 공용 로그인으로 관리할 수 있습니다.";
451 else if (user.IsAuthenticated)
454 await
_tenants.SaveAsync(config, ct).ConfigureAwait(
false);
458 StatusMessage =
"현재 CodeMaru/Dreamine 계정이 이 청첩장의 관리자로 추가되었습니다.";
460 else if (!user.IsAuthenticated &&
string.IsNullOrWhiteSpace(config.OwnerUserId))
462 StatusMessage =
"로그인은 성공했습니다. 공용 계정에 연결하려면 먼저 CodeMaru/Dreamine 로그인을 해주세요.";
519 var config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false);
531 await
LoadAsync(slug, ct).ConfigureAwait(
false);
567 public async Task
LoadAsync(
string slug, CancellationToken ct =
default)
572 Gallery = await
_photos.GetGalleryAsync(slug, ct).ConfigureAwait(
false);
603 var user = await
_userContext.GetCurrentAsync().ConfigureAwait(
false);
606 ?
string.IsNullOrWhiteSpace(user.DisplayName)
608 : $
"{user.DisplayName} ({user.Provider})"
638 if (
Config is
null)
return;
652 Config.PasswordHash = DreaminePasswordHasher.HashPlainTextForStorage(
Config.PasswordHash);
657 catch (Exception ex) {
StatusMessage = $
"저장 오류: {ex.Message}"; }
712 if (
Config is
null)
return;
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)
724 : normalized.Title.Trim();
728 chapters[index] = normalized;
732 chapters.Add(normalized);
733 Config.DesignSettings.StoryChapters = WeddingStoryChapterDefaults.Normalize(chapters);
771 if (
Config is
null)
return;
775 var chapters =
Config.DesignSettings.StoryChapters;
776 var nextNumber = chapters.Count == 0 ? 1 : chapters.Max(x => x.ChapterNumber) + 1;
777 chapters.Add(
new StoryChapter
779 ChapterNumber = nextNumber,
780 Label = $
"CHAPTER {nextNumber:00}",
827 if (
Config is
null)
return;
831 if (chapterNumber <= 4)
837 var removed =
Config.DesignSettings.StoryChapters.RemoveAll(x => x.ChapterNumber == chapterNumber);
887 if (mode == WeddingLayoutMode.Unknown)
890 config.DesignSettings.LayoutMode = mode;
893 var option = WeddingLayoutCatalog.Instance.Find(mode);
900 if (!option.IsImplemented)
906 var access =
new WeddingLayoutAccessState
909 UnlockedLayouts = config.UnlockedLayoutModes
910 .Select(WeddingLayoutCatalog.FromLegacyKey)
911 .Where(x => x != WeddingLayoutMode.Unknown)
915 if (!
new WeddingLayoutAccessPolicy().CanUse(option, access))
917 StatusMessage =
"저장 오류: 프리미엄 레이아웃은 플랜 또는 잠금 해제 후 저장할 수 있습니다.";
951 config.UnlockedThemeKeys ??=
new();
965 if (!option.IsImplemented)
971 var access =
new WeddingThemeAccessState
974 UnlockedThemeKeys = config.UnlockedThemeKeys
975 .Select(WeddingThemeCatalog.NormalizeKey)
976 .Where(WeddingThemeCatalog.IsKnownKey)
980 if (!
new WeddingThemeAccessPolicy().CanUse(option, access))
986 config.DesignSettings.ThemeKey = option.Key;
987 config.ThemeName = option.Key;
1025 if (
Config is
null)
return;
1026 var user = await
_userContext.GetCurrentAsync().ConfigureAwait(
false);
1033 if (
string.Equals(
Config.OwnerUserId, userId, StringComparison.Ordinal))
1039 var removed =
GetAdminUsers(
Config).RemoveAll(x =>
string.Equals(x.UserId, userId, StringComparison.Ordinal));
1097 await
_photos.UploadAsync(slug, file, ct).ConfigureAwait(
false);
1099 Config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false);
1100 Gallery = await
_photos.GetGalleryAsync(slug, ct).ConfigureAwait(
false);
1103 catch (Exception ex) {
StatusMessage = $
"업로드 오류: {ex.Message}"; }
1147 public async Task
UploadHeroAsync(
string slug, IBrowserFile file, CancellationToken ct =
default)
1152 await
_photos.UploadHeroAsync(slug, file, ct).ConfigureAwait(
false);
1153 Config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false);
1156 catch (Exception ex) {
StatusMessage = $
"히어로 업로드 오류: {ex.Message}"; }
1200 public async Task
DeletePhotoAsync(
string slug,
string fileName, CancellationToken ct =
default)
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);
1209 catch (Exception ex) {
StatusMessage = $
"삭제 오류: {ex.Message}"; }
1254 if (
Config is
null)
return;
1258 var index =
Config.GalleryFileNames.FindIndex(x =>
string.Equals(x, fileName, StringComparison.OrdinalIgnoreCase));
1259 if (index < 0)
return;
1261 var target = Math.Clamp(index + offset, 0,
Config.GalleryFileNames.Count - 1);
1262 if (index == target)
return;
1264 Config.GalleryFileNames.RemoveAt(index);
1265 Config.GalleryFileNames.Insert(target, fileName);
1270 catch (Exception ex) {
StatusMessage = $
"순서 변경 오류: {ex.Message}"; }
1315 if (
Config is
null)
return;
1319 var index =
Config.GalleryFileNames.FindIndex(x =>
string.Equals(x, fileName, StringComparison.OrdinalIgnoreCase));
1320 if (index < 0)
return;
1322 Config.GalleryFileNames.RemoveAt(index);
1323 Config.GalleryFileNames.Insert(first ? 0 :
Config.GalleryFileNames.Count, fileName);
1328 catch (Exception ex) {
StatusMessage = $
"순서 변경 오류: {ex.Message}"; }
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)
1348 foreach (var photo
in Gallery)
1350 if (!
Config.GalleryFileNames.Contains(photo.FileName, StringComparer.OrdinalIgnoreCase))
1352 Config.GalleryFileNames.Add(photo.FileName);
1402 await
_photos.UploadRoadMapAsync(slug, file, ct).ConfigureAwait(
false);
1403 Config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false);
1406 catch (Exception ex) {
StatusMessage = $
"약도 업로드 오류: {ex.Message}"; }
1436 if (
Config is
null)
return;
1437 var query =
string.IsNullOrWhiteSpace(
Config.VenueAddress) ? Config.VenueName :
Config.VenueAddress;
1438 if (
string.IsNullOrWhiteSpace(query)) {
GeocodeStatus =
"❗ 예식장 이름 또는 주소를 먼저 입력해주세요.";
return; }
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)
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()}";
1456 catch {
GeocodeStatus =
"❌ 좌표 검색 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요."; }
1470 if (
Config is
null)
return;
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);
1482 Config.MapLinkKakao = hasCoords
1483 ? $
"https://map.kakao.com/link/map/{name},{latS},{lngS}"
1484 : $
"https://map.kakao.com/link/search/{addr}";
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}";
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}";
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)}";
1777 await
_photos.UploadOgImageAsync(slug, file, ct).ConfigureAwait(
false);
1778 Config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false);
1781 catch (Exception ex) {
StatusMessage = $
"미리보기 이미지 오류: {ex.Message}"; }
1830 await
_photos.UploadThankYouOgImageAsync(slug, file, ct).ConfigureAwait(
false);
1831 Config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false);
1834 catch (Exception ex) {
StatusMessage = $
"감사장 미리보기 이미지 오류: {ex.Message}"; }
1878 public async Task
UploadVideoAsync(
string slug, IBrowserFile file, CancellationToken ct =
default)
1883 await
_photos.UploadVideoAsync(slug, file, ct).ConfigureAwait(
false);
1884 Config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false);
1887 catch (Exception ex) {
StatusMessage = $
"동영상 업로드 오류: {ex.Message}"; }
1931 public async Task
DeleteVideoAsync(
string slug,
string fileName, CancellationToken ct =
default)
1935 await
_photos.DeleteVideoAsync(slug, fileName, ct).ConfigureAwait(
false);
1936 Config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false);
1939 catch (Exception ex) {
StatusMessage = $
"동영상 삭제 오류: {ex.Message}"; }
1982 public async Task
UploadMusicAsync(
string slug, IBrowserFile file, CancellationToken ct =
default)
1987 await
_photos.UploadMusicAsync(slug, file, ct).ConfigureAwait(
false);
1988 Config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false);
1991 catch (Exception ex) {
StatusMessage = $
"음악 업로드 오류: {ex.Message}"; }
2054 user.IsAuthenticated &&
2056 string.Equals(config.
OwnerUserId, user.Id, StringComparison.Ordinal);
2092 if (!user.IsAuthenticated)
return false;
2094 return GetAdminUsers(config).Any(x =>
string.Equals(x.UserId, user.Id, StringComparison.Ordinal));
2131 if (!user.IsAuthenticated)
return;
2134 var existing = users.FirstOrDefault(x =>
string.Equals(x.UserId, user.Id, StringComparison.Ordinal));
2135 if (existing is
null)
2140 Provider = user.Provider,
2142 DisplayName = user.DisplayName,
2144 AddedAt = DateTime.Now
2149 existing.Provider = user.Provider;
2150 existing.Email = user.Email;
2151 existing.DisplayName = user.DisplayName;
2152 if (role ==
"Owner")
2154 existing.Role =
"Owner";
2185 if (!
string.IsNullOrWhiteSpace(config.
OwnerUserId) &&
2186 users.All(x => !
string.Equals(x.UserId, config.
OwnerUserId, StringComparison.Ordinal)))
2195 AddedAt = config.OwnerLinkedAt ?? config.CreatedAt
2199 foreach (var user
in users.Where(x =>
string.Equals(x.UserId, config.
OwnerUserId, StringComparison.Ordinal)))
2201 user.Role =
"Owner";
2205 .OrderByDescending(x =>
string.Equals(x.Role,
"Owner", StringComparison.OrdinalIgnoreCase))
2206 .ThenBy(x => x.AddedAt)
2285 var normalized = value?.Trim().ToLowerInvariant();
2286 return !
string.IsNullOrWhiteSpace(normalized) && allowed.Contains(normalized) ? normalized : fallback;
record WeddingCurrentUser(string Id, string Provider, string Email, string DisplayName)
WeddingLayoutMode LayoutMode
static WeddingLayoutMode FromLegacyLayoutKey(string? key)
static void Normalize(TenantConfig config)
string InviteHeroTopVerticalMobile
string InviteHeroTopHorizontalDesktop
string HeroPanelVerticalDesktop
string HeroPanelHorizontalDesktop
string InviteHeroTopHorizontalMobile
string InviteHeroBottomHorizontalMobile
DesignSettings DesignSettings
string InviteHeroBottomVerticalMobile
string HeroPanelHorizontalMobile
string HeroPanelVerticalMobile
string InviteHeroTopVerticalDesktop
string InviteHeroBottomVerticalDesktop
string InviteHeroBottomHorizontalDesktop
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)
bool ValidateLayoutForSave(TenantConfig config)
void NormalizeGalleryFileOrder()
async Task UploadGalleryAsync(string slug, IBrowserFile file, CancellationToken ct=default)
readonly ISuperAdminSessionTokenService _superAdminTokens
readonly IPhotoService _photos
async Task UploadThankYouOgImageAsync(string slug, IBrowserFile file, CancellationToken ct=default)
bool IsLinkedToCurrentUser
string GetMusicUrl(string slug, string fileName)
async Task UploadVideoAsync(string slug, IBrowserFile file, CancellationToken ct=default)
string GetPhotoUrl(string slug, string fileName)
async Task AddStoryChapterAsync(CancellationToken ct=default)
async Task RefreshCurrentUserAsync()
static bool IsOwnerUser(TenantConfig config, WeddingCurrentUser user)
async Task UploadOgImageAsync(string slug, IBrowserFile file, CancellationToken ct=default)
void SetStatusMessage(string message)
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)
bool ValidateThemeForSave(TenantConfig config)
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)
readonly WeddingUserContext _userContext
readonly WeddingOptions _opts
string GetThumbUrl(string slug, string fileName)
string GetVideoUrl(string slug, string fileName)
async Task InitializeAsync(string slug, CancellationToken ct=default)
async Task SaveConfigAsync(CancellationToken ct=default)
EffectiveMediaPolicy? EffectiveMediaPolicy
WeddingAdminViewModel(ITenantStore tenants, IPhotoService photos, WeddingOptions opts, WeddingUserContext userContext, IMediaQuotaPolicyResolver mediaPolicyResolver, ISuperAdminSessionTokenService superAdminTokens)
string GetHeroUrl(string slug, string fileName)
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)
static readonly HttpClient _geocodeHttp
async Task DeletePhotoAsync(string slug, string fileName, CancellationToken ct=default)
readonly ITenantStore _tenants
async Task RemoveAdminAsync(string userId, CancellationToken ct=default)
IReadOnlyList< PhotoInfo > Gallery
async Task UploadHeroAsync(string slug, IBrowserFile file, CancellationToken ct=default)