Portfolio.Web 1.0.0.0
.NET, Blazor, WPF와 서비스 운영 경험을 프로젝트·이력·기술 스택 단위로 보여주는 개발자 포트폴리오입니다.
로딩중...
검색중...
일치하는것 없음
PortfolioAdminViewModel.cs
이 파일의 문서화 페이지로 가기
1using System.IO;
2using Dreamine.Identity;
3using Microsoft.AspNetCore.Components.Forms;
6
8
18{
36 private readonly IProjectStore _projects;
45 private readonly IResumeStore _resumes;
54 private readonly IContactStore _contacts;
63 private readonly IMediaService _media;
73
82 public bool IsAuthenticated { get; private set; }
91 public bool IsLoaded { get; private set; }
100 public bool IsSignedIn { get; private set; }
109 public bool IsLinkedToCurrentUser { get; private set; }
118 public bool IsOwner { get; private set; }
127 public IReadOnlyList<PortfolioAdminUser> EffectiveAdminUsers { get; private set; } = [];
136 public string CurrentUserLabel { get; private set; } = "";
145 public string LoginPassword { get; set; } = "";
154 public string StatusMessage { get; set; } = "";
163 public bool IsUploading { get; set; }
164
173 public PortfolioConfig? Config { get; private set; }
182 public List<ProjectItem> Projects { get; private set; } = [];
191 public ResumeInfo Resume { get; private set; } = new();
200 public List<ContactMessage> Messages { get; private set; } = [];
201
202 // 프로젝트 편집
211 public ProjectItem? EditingProject { get; private set; }
220 public List<IBrowserFile> PendingFiles { get; } = [];
229 public List<IBrowserFile> PendingVideos { get; } = [];
230
231 // 새 스킬 그룹 편집
240 public string NewSkillCategory { get; set; } = "";
249 public string NewSkillNames { get; set; } = "";
250
251 // 경력 편집
260 public ExperienceItem? EditingExp { get; private set; }
269 public EducationItem? EditingEdu { get; private set; }
270
328 IPortfolioTenantStore tenants,
329 IProjectStore projects,
330 IResumeStore resumes,
331 IContactStore contacts,
332 IMediaService media,
333 PortfolioUserContext userContext)
334 {
335 _tenants = tenants;
336 _projects = projects;
337 _resumes = resumes;
338 _contacts = contacts;
339 _media = media;
340 _userContext = userContext;
341 }
342
367 public async Task InitializeAsync(string slug)
368 {
369 StatusMessage = "";
370 await RefreshCurrentUserAsync().ConfigureAwait(false);
371
372 var cfg = await _tenants.GetAsync(slug).ConfigureAwait(false);
373 if (cfg is null)
374 {
375 return;
376 }
377
378 var user = await _userContext.GetCurrentAsync().ConfigureAwait(false);
379 IsOwner = IsOwnerUser(cfg, user);
382
384 {
385 IsAuthenticated = true;
386 await LoadAsync(slug).ConfigureAwait(false);
387 }
388 }
389
414 public async Task<bool> LoginAsync(string slug)
415 {
416 var cfg = await _tenants.GetAsync(slug);
417 if (cfg == null) { StatusMessage = "❌ 존재하지 않는 포트폴리오입니다."; return false; }
418
419 var user = await _userContext.GetCurrentAsync().ConfigureAwait(false);
420 await RefreshCurrentUserAsync().ConfigureAwait(false);
421
422 if (IsAdminUser(cfg, user))
423 {
424 IsAuthenticated = true;
426 IsOwner = IsOwnerUser(cfg, user);
428 StatusMessage = "";
429 return true;
430 }
431
432 var verification = DreaminePasswordHasher.VerifyPassword(LoginPassword, cfg.PasswordHash, out var upgradedHash);
433 if (verification is PasswordHashVerificationResult.Failed) { StatusMessage = "❌ 비밀번호가 틀렸습니다."; return false; }
434 IsAuthenticated = true;
435
436 if (verification is PasswordHashVerificationResult.SuccessRehashNeeded && upgradedHash is not null)
437 {
438 cfg.PasswordHash = upgradedHash;
439 await _tenants.SaveAsync(cfg).ConfigureAwait(false);
440 }
441
442 if (user.IsAuthenticated && string.IsNullOrWhiteSpace(cfg.OwnerUserId))
443 {
444 cfg.OwnerUserId = user.Id;
445 cfg.OwnerProvider = user.Provider;
446 cfg.OwnerEmail = user.Email;
447 cfg.OwnerDisplayName = user.DisplayName;
448 cfg.OwnerLinkedAt = DateTime.Now;
449 EnsureAdminUser(cfg, user, "Owner");
450 await _tenants.SaveAsync(cfg).ConfigureAwait(false);
452 IsOwner = true;
454 StatusMessage = "✅ CodeMaru/Dreamine 계정에 대표 관리자로 연결되었습니다.";
455 }
456 else if (user.IsAuthenticated)
457 {
458 EnsureAdminUser(cfg, user, "Admin");
459 await _tenants.SaveAsync(cfg).ConfigureAwait(false);
461 IsOwner = IsOwnerUser(cfg, user);
463 StatusMessage = "✅ 현재 CodeMaru/Dreamine 계정이 이 포트폴리오의 관리자로 추가되었습니다.";
464 }
465 else if (string.IsNullOrWhiteSpace(cfg.OwnerUserId))
466 {
467 StatusMessage = "✅ 로그인 성공. 공용 계정에 연결하려면 먼저 CodeMaru/Dreamine 로그인을 해주세요.";
468 }
469 else
470 {
471 StatusMessage = "";
472 }
473
474 return true;
475 }
476
501 public async Task LoadAsync(string slug)
502 {
503 Config = await _tenants.GetAsync(slug);
504 if (Config == null) return;
506 Projects = await _projects.GetAllAsync(slug);
507 Resume = await _resumes.GetAsync(slug);
508 Messages = await _contacts.GetAllAsync(slug);
509 // 기존 데이터 마이그레이션: ImageFileName → WorkImages
510 foreach (var p in Projects.Where(p =>
511 !string.IsNullOrWhiteSpace(p.ImageFileName) && p.WorkImages.Length == 0))
512 p.WorkImages = [p.ImageFileName!];
513 IsLoaded = true;
514 }
515
516 // ── 프로젝트 ────────────────────────────────────────────────
525 public void Logout() { IsAuthenticated = false; IsLoaded = false; LoginPassword = ""; StatusMessage = ""; EditingProject = null; }
526
535 public void NewProject() => EditingProject = new ProjectItem();
552 public void EditProject(ProjectItem p) { EditingProject = p.ShallowClone(); PendingFiles.Clear(); PendingVideos.Clear(); }
561 public void CancelEdit() { EditingProject = null; PendingFiles.Clear(); PendingVideos.Clear(); }
562
587 public async Task SaveProjectAsync(string slug)
588 {
589 if (EditingProject == null) return;
590 StatusMessage = "";
591 if (string.IsNullOrWhiteSpace(EditingProject.Title)) { StatusMessage = "❌ 제목을 입력하세요."; return; }
592
593 if (PendingFiles.Count > 0 || PendingVideos.Count > 0)
594 {
595 IsUploading = true;
596 foreach (var f in PendingFiles)
597 {
598 var saved = await _media.SaveAsync(slug, EditingProject.Id, f);
599 if (EditingProject.Category == ProjectCategory.Work)
600 {
601 if (!EditingProject.WorkImages.Contains(saved))
602 EditingProject.WorkImages = [.. EditingProject.WorkImages, saved];
603 if (EditingProject.ImageFileName == null)
604 EditingProject.ImageFileName = saved;
605 }
606 else
607 {
608 // Personal / Public: WorkImages에 모아두고 첫 번째를 커버로
609 if (!EditingProject.WorkImages.Contains(saved))
610 EditingProject.WorkImages = [.. EditingProject.WorkImages, saved];
611 EditingProject.ImageFileName ??= saved;
612 }
613 }
614 foreach (var v in PendingVideos)
615 {
616 var saved = await _media.SaveVideoAsync(slug, EditingProject.Id, v);
617 if (!EditingProject.VideoFileNames.Contains(saved))
618 EditingProject.VideoFileNames = [.. EditingProject.VideoFileNames, saved];
619 }
620 PendingFiles.Clear();
621 PendingVideos.Clear();
622 IsUploading = false;
623 }
624
625 // ImageFileName이 있지만 WorkImages가 비어있으면 자동 포함
626 if (!string.IsNullOrWhiteSpace(EditingProject.ImageFileName) &&
627 !EditingProject.WorkImages.Contains(EditingProject.ImageFileName))
628 EditingProject.WorkImages = [EditingProject.ImageFileName, .. EditingProject.WorkImages];
629
630 await _projects.SaveAsync(slug, EditingProject);
631 Projects = await _projects.GetAllAsync(slug);
632 EditingProject = null;
633 StatusMessage = "✅ 저장되었습니다.";
634 }
635
668 public async Task DeleteProjectAsync(string slug, string projectId)
669 {
670 await _projects.DeleteAsync(slug, projectId);
671 Projects = await _projects.GetAllAsync(slug);
672 StatusMessage = "✅ 삭제 완료.";
673 }
674
715 public async Task DeleteProjectMediaAsync(string slug, string projectId, string fileName)
716 {
717 await _media.DeleteAsync(slug, projectId, fileName);
718 if (EditingProject != null)
719 EditingProject.WorkImages = EditingProject.WorkImages.Where(f => f != fileName).ToArray();
720 }
721
754 public async Task DeleteProjectCoverAsync(string slug, string projectId)
755 {
756 if (EditingProject == null || string.IsNullOrWhiteSpace(EditingProject.ImageFileName)) return;
757 var fn = EditingProject.ImageFileName;
758 if (!fn.StartsWith('/'))
759 await _media.DeleteAsync(slug, projectId, fn);
760 EditingProject.ImageFileName = null;
761 }
762
803 public async Task DeleteProjectVideoAsync(string slug, string projectId, string fileName)
804 {
805 await _media.DeleteAsync(slug, projectId, fileName);
806 if (EditingProject != null)
807 EditingProject.VideoFileNames = EditingProject.VideoFileNames.Where(f => f != fileName).ToArray();
808 }
809
850 public string GetMediaUrl(string slug, string projectId, string fileName) =>
851 _media.GetMediaUrl(slug, projectId, fileName);
852
893 public string GetImageUrl(string slug, string projectId, string fileName) =>
894 IsExternalUrl(fileName) ? fileName : _media.GetMediaUrl(slug, projectId, fileName);
895
936 public string GetVideoUrl(string slug, string projectId, string fileName) =>
937 IsExternalUrl(fileName) ? fileName : _media.GetMediaUrl(slug, projectId, fileName);
938
963 private static bool IsExternalUrl(string url) =>
964 url.StartsWith('/') || url.StartsWith("http://") || url.StartsWith("https://");
965
998 public string GetProfileImageUrl(string slug, string fileName) =>
999 _media.GetProfileImageUrl(slug, fileName);
1000
1001 // ── Resume ──────────────────────────────────────────────────
1026 public async Task SaveResumeAsync(string slug)
1027 {
1028 await _resumes.SaveAsync(slug, Resume);
1029 StatusMessage = "✅ Resume 저장 완료.";
1030 }
1031
1040 public void AddSkillGroup()
1041 {
1042 if (string.IsNullOrWhiteSpace(NewSkillCategory)) return;
1043 var skills = NewSkillNames.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
1044 Resume.SkillGroups.Add(new SkillGroup { Category = NewSkillCategory.Trim(), Skills = skills });
1045 NewSkillCategory = "";
1046 NewSkillNames = "";
1047 }
1048
1065 public void RemoveSkillGroup(SkillGroup g) => Resume.SkillGroups.Remove(g);
1066
1075 public void NewExperience() => EditingExp = new ExperienceItem();
1101 public void CancelExp() => EditingExp = null;
1110 public void SaveExperience()
1111 {
1112 if (EditingExp == null) return;
1113 if (!Resume.Experiences.Contains(EditingExp))
1114 Resume.Experiences.Add(EditingExp);
1115 EditingExp = null;
1116 }
1117
1133 public void RemoveExperience(ExperienceItem e) => Resume.Experiences.Remove(e);
1134
1143 public void NewEducation() => EditingEdu = new EducationItem();
1169 public void CancelEdu() => EditingEdu = null;
1178 public void SaveEducation()
1179 {
1180 if (EditingEdu == null) return;
1181 if (!Resume.Educations.Contains(EditingEdu))
1182 Resume.Educations.Add(EditingEdu);
1183 EditingEdu = null;
1184 }
1185
1201 public void RemoveEducation(EducationItem e) => Resume.Educations.Remove(e);
1202
1203 // ── 설정 ────────────────────────────────────────────────────
1220 public async Task SaveConfigAsync()
1221 {
1222 if (Config == null) return;
1223 Config.PasswordHash = DreaminePasswordHasher.HashPlainTextForStorage(Config.PasswordHash);
1224 await _tenants.SaveAsync(Config);
1226 StatusMessage = "✅ 설정이 저장되었습니다.";
1227 }
1228
1253 public async Task RemoveAdminAsync(string userId)
1254 {
1255 if (Config is null) return;
1256 var user = await _userContext.GetCurrentAsync().ConfigureAwait(false);
1257 if (!IsOwnerUser(Config, user))
1258 {
1259 StatusMessage = "❌ 대표 관리자만 관리자 계정을 삭제할 수 있습니다.";
1260 return;
1261 }
1262
1263 if (string.Equals(Config.OwnerUserId, userId, StringComparison.Ordinal))
1264 {
1265 StatusMessage = "❌ 대표 관리자는 삭제할 수 없습니다.";
1266 return;
1267 }
1268
1269 var removed = GetAdminUsers(Config).RemoveAll(x => string.Equals(x.UserId, userId, StringComparison.Ordinal));
1270 if (removed == 0)
1271 {
1272 StatusMessage = "❌ 삭제할 관리자를 찾을 수 없습니다.";
1273 return;
1274 }
1275
1276 await _tenants.SaveAsync(Config).ConfigureAwait(false);
1278 StatusMessage = "✅ 관리자 계정이 삭제되었습니다.";
1279 }
1280
1329 public async Task<bool> ChangePasswordAsync(string slug, string current, string next, string confirm)
1330 {
1331 if (Config == null) { StatusMessage = "❌ 설정을 먼저 불러오세요."; return false; }
1332 if (!DreaminePasswordHasher.VerifyPassword(current, Config.PasswordHash)) { StatusMessage = "❌ 현재 비밀번호가 틀렸습니다."; return false; }
1333 if (next.Length < 8) { StatusMessage = "❌ 새 비밀번호는 8자 이상이어야 합니다."; return false; }
1334 if (next != confirm) { StatusMessage = "❌ 새 비밀번호가 일치하지 않습니다."; return false; }
1335 Config.PasswordHash = DreaminePasswordHasher.HashPassword(next);
1336 await _tenants.SaveAsync(Config);
1337 StatusMessage = "✅ 비밀번호가 변경되었습니다.";
1338 return true;
1339 }
1340
1373 public async Task UploadProfileImageAsync(string slug, IBrowserFile file)
1374 {
1375 if (Config == null) return;
1376 IsUploading = true;
1377 var saved = await _media.SaveProfileImageAsync(slug, file);
1378 Config.ProfileImageFileName = saved;
1379 IsUploading = false;
1380 }
1381
1382 // ── 연락처 메시지 ────────────────────────────────────────────
1415 public async Task MarkReadAsync(string slug, string msgId)
1416 {
1417 await _contacts.MarkReadAsync(slug, msgId);
1418 Messages = await _contacts.GetAllAsync(slug);
1419 }
1420
1453 public async Task DeleteMessageAsync(string slug, string msgId)
1454 {
1455 await _contacts.DeleteAsync(slug, msgId);
1456 Messages = await _contacts.GetAllAsync(slug);
1457 StatusMessage = "✅ 메시지 삭제 완료.";
1458 }
1459
1460 // ── 계정 삭제 ────────────────────────────────────────────────
1485 public async Task DeleteSelfAsync(string slug)
1486 {
1487 await _tenants.DeleteAsync(slug);
1488 IsAuthenticated = false;
1489 }
1490
1507 private async Task RefreshCurrentUserAsync()
1508 {
1509 var user = await _userContext.GetCurrentAsync().ConfigureAwait(false);
1510 IsSignedIn = user.IsAuthenticated;
1511 CurrentUserLabel = user.IsAuthenticated
1512 ? string.IsNullOrWhiteSpace(user.DisplayName)
1513 ? user.Provider
1514 : $"{user.DisplayName} ({user.Provider})"
1515 : "";
1516 }
1517
1542 private static List<PortfolioAdminUser> GetAdminUsers(PortfolioConfig config) => config.AdminUsers ??= [];
1543
1576 private static bool IsOwnerUser(PortfolioConfig config, PortfolioCurrentUser user) =>
1577 user.IsAuthenticated &&
1578 !string.IsNullOrWhiteSpace(config.OwnerUserId) &&
1579 string.Equals(config.OwnerUserId, user.Id, StringComparison.Ordinal);
1580
1613 private static bool IsAdminUser(PortfolioConfig config, PortfolioCurrentUser user)
1614 {
1615 if (!user.IsAuthenticated) return false;
1616 if (IsOwnerUser(config, user)) return true;
1617 return GetAdminUsers(config).Any(x => string.Equals(x.UserId, user.Id, StringComparison.Ordinal));
1618 }
1619
1652 private static void EnsureAdminUser(PortfolioConfig config, PortfolioCurrentUser user, string role)
1653 {
1654 if (!user.IsAuthenticated) return;
1655
1656 var users = GetAdminUsers(config);
1657 var existing = users.FirstOrDefault(x => string.Equals(x.UserId, user.Id, StringComparison.Ordinal));
1658 if (existing is null)
1659 {
1660 users.Add(new PortfolioAdminUser
1661 {
1662 UserId = user.Id,
1663 Provider = user.Provider,
1664 Email = user.Email,
1665 DisplayName = user.DisplayName,
1666 Role = role,
1667 AddedAt = DateTime.Now
1668 });
1669 return;
1670 }
1671
1672 existing.Provider = user.Provider;
1673 existing.Email = user.Email;
1674 existing.DisplayName = user.DisplayName;
1675 if (role == "Owner")
1676 {
1677 existing.Role = "Owner";
1678 }
1679 }
1680
1705 private static IReadOnlyList<PortfolioAdminUser> BuildEffectiveAdminUsers(PortfolioConfig config)
1706 {
1707 var users = GetAdminUsers(config);
1708 if (!string.IsNullOrWhiteSpace(config.OwnerUserId) &&
1709 users.All(x => !string.Equals(x.UserId, config.OwnerUserId, StringComparison.Ordinal)))
1710 {
1711 users.Insert(0, new PortfolioAdminUser
1712 {
1713 UserId = config.OwnerUserId,
1714 Provider = config.OwnerProvider,
1715 Email = config.OwnerEmail,
1716 DisplayName = config.OwnerDisplayName,
1717 Role = "Owner",
1718 AddedAt = config.OwnerLinkedAt ?? config.CreatedAt
1719 });
1720 }
1721
1722 foreach (var user in users.Where(x => string.Equals(x.UserId, config.OwnerUserId, StringComparison.Ordinal)))
1723 {
1724 user.Role = "Owner";
1725 }
1726
1727 return users
1728 .OrderByDescending(x => string.Equals(x.Role, "Owner", StringComparison.OrdinalIgnoreCase))
1729 .ThenBy(x => x.AddedAt)
1730 .ToList();
1731 }
1732}
1733
1742internal static class ProjectItemExtensions
1743{
1768 internal static ProjectItem ShallowClone(this ProjectItem p) => new()
1769 {
1770 Id = p.Id, Title = p.Title, Description = p.Description,
1771 Tags = [.. p.Tags], Year = p.Year, Emoji = p.Emoji,
1772 ImageFileName = p.ImageFileName, GitHub = p.GitHub,
1773 LiveUrl = p.LiveUrl, DocUrl = p.DocUrl, Category = p.Category,
1774 SortOrder = p.SortOrder, VideoFileNames = [.. p.VideoFileNames], WorkImages = [.. p.WorkImages],
1775 WorkSubtitle = p.WorkSubtitle, WorkBullets = [.. p.WorkBullets],
1776 WorkPeriod = p.WorkPeriod, WorkRole = p.WorkRole,
1777 WorkTech = p.WorkTech, WorkCompany = p.WorkCompany,
1778 };
1779}
record PortfolioCurrentUser(string Id, string Provider, string Email, string DisplayName)
string GetVideoUrl(string slug, string projectId, string fileName)
PortfolioAdminViewModel(IPortfolioTenantStore tenants, IProjectStore projects, IResumeStore resumes, IContactStore contacts, IMediaService media, PortfolioUserContext userContext)
string GetProfileImageUrl(string slug, string fileName)
async Task DeleteProjectAsync(string slug, string projectId)
static IReadOnlyList< PortfolioAdminUser > BuildEffectiveAdminUsers(PortfolioConfig config)
static void EnsureAdminUser(PortfolioConfig config, PortfolioCurrentUser user, string role)
IReadOnlyList< PortfolioAdminUser > EffectiveAdminUsers
static bool IsOwnerUser(PortfolioConfig config, PortfolioCurrentUser user)
async Task DeleteProjectVideoAsync(string slug, string projectId, string fileName)
static List< PortfolioAdminUser > GetAdminUsers(PortfolioConfig config)
async Task DeleteMessageAsync(string slug, string msgId)
async Task MarkReadAsync(string slug, string msgId)
async Task UploadProfileImageAsync(string slug, IBrowserFile file)
async Task< bool > ChangePasswordAsync(string slug, string current, string next, string confirm)
async Task DeleteProjectCoverAsync(string slug, string projectId)
string GetImageUrl(string slug, string projectId, string fileName)
async Task DeleteProjectMediaAsync(string slug, string projectId, string fileName)
string GetMediaUrl(string slug, string projectId, string fileName)
static bool IsAdminUser(PortfolioConfig config, PortfolioCurrentUser user)