diff --git a/package.json b/package.json index 4f85d682..09fe2cff 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "electron-log": "^5.1.4", "electron-updater": "^6.1.8", "fetch-cookie": "^3.0.1", + "file-type": "^19.0.0", "flexsearch": "^0.7.43", "i18next": "^23.11.2", "i18next-browser-languagedetector": "^7.2.1", diff --git a/src/locales/ar/translation.json b/src/locales/ar/translation.json index 09547da8..ec73337c 100644 --- a/src/locales/ar/translation.json +++ b/src/locales/ar/translation.json @@ -1,7 +1,6 @@ { "home": { "featured": "مميّز", - "recently_added": "مضاف مؤخراً", "trending": "شائع", "surprise_me": "فاجئني", "no_results": "لم يتم العثور على نتائج" @@ -15,12 +14,7 @@ "paused": "{{title}} (متوقف)", "downloading": "{{title}} ({{percentage}} - جارٍ التنزيل...)", "filter": "بحث في المكتبة", - "follow_us": "تابعنا", - "home": "الرئيسية", - "discord": "انضم إلى الـDiscord الخاص بنا", - "telegram": "انضم إلى قناة Telegram الخاصة بنا", - "x": "تابعنا على X", - "github": "ساهم في مشروعنا على GitHub" + "home": "الرئيسية" }, "header": { "search": "ابحث عن الألعاب", diff --git a/src/locales/be/translation.json b/src/locales/be/translation.json index 617a444a..167764cf 100644 --- a/src/locales/be/translation.json +++ b/src/locales/be/translation.json @@ -1,7 +1,6 @@ { "home": { "featured": "Рэкамэндаванае", - "recently_added": "Нядаўна дададзенае", "trending": "Актуальнае", "surprise_me": "Здзіві мяне", "no_results": "Няма вынікаў" @@ -15,12 +14,7 @@ "paused": "{{title}} (Спынена)", "downloading": "{{title}} ({{percentage}} - Сцягванне…)", "filter": "Фільтар бібліятэкі", - "follow_us": "Падпісвайцеся на нас", - "home": "Галоўная", - "discord": "Далучайцеся да Discord", - "telegram": "Далучайцеся да Telegram", - "x": "Падпісвайцеся на X", - "github": "Зрабіць свой унёсак на GitHub" + "home": "Галоўная" }, "header": { "search": "Пошук", diff --git a/src/locales/da/translation.json b/src/locales/da/translation.json index 67cab4a7..a9163793 100644 --- a/src/locales/da/translation.json +++ b/src/locales/da/translation.json @@ -1,7 +1,6 @@ { "home": { "featured": "Anbefalet", - "recently_added": "Nyligt tilføjet", "trending": "Trender", "surprise_me": "Overrask mig", "no_results": "Ingen resultater fundet" @@ -15,12 +14,7 @@ "paused": "{{title}} (Paused)", "downloading": "{{title}} ({{percentage}} - Downloading…)", "filter": "Filtrer bibliotek", - "follow_us": "Følg os", - "home": "Hjem", - "discord": "Tilslut dig vores Discord", - "telegram": "Tilslut dig vores Telegram", - "x": "Følg på X", - "github": "Bidrag på GitHub" + "home": "Hjem" }, "header": { "search": "Søg spil", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index d46cafdb..59145c6f 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -228,11 +228,21 @@ "user_profile": { "amount_hours": "{{amount}} hours", "amount_minutes": "{{amount}} minutes", - "play_time": "Played for {{amount}}", "last_time_played": "Last played {{period}}", "sign_out": "Sign out", - "activity": "Recent Activity", + "activity": "Recent activity", "library": "Library", - "total_play_time": "Total playtime: {{amount}}" + "total_play_time": "Total playtime: {{amount}}", + "no_recent_activity_title": "Hmmm… nothing here", + "no_recent_activity_description": "You haven't played any games recently. It's time to change that!", + "display_name": "Display name", + "saving": "Saving", + "save": "Save", + "edit_profile": "Edit Profile", + "saved_successfully": "Saved successfully", + "try_again": "Please, try again", + "signout_modal_title": "Are you sure?", + "cancel": "Cancel", + "signout": "Sign Out" } } diff --git a/src/locales/fa/translation.json b/src/locales/fa/translation.json index e2ea2974..497f3938 100644 --- a/src/locales/fa/translation.json +++ b/src/locales/fa/translation.json @@ -1,7 +1,6 @@ { "home": { "featured": "پیشنهادی", - "recently_added": "تازه اضافه شده", "trending": "پرطرفدار", "surprise_me": "سوپرایزم کن", "no_results": "اتمام‌ای پیدا نشد" @@ -15,12 +14,7 @@ "paused": "{{title}} (متوقف شده)", "downloading": "{{title}} ({{percentage}} - در حال دانلود…)", "filter": "فیلتر کردن کتابخانه", - "follow_us": "دنبال کردن ما", - "home": "خانه", - "discord": "عضویت در دیسکورد ما", - "telegram": "عضویت در تلگرام ما", - "x": "دنبال کرد در ایکس", - "github": "مشارکت در گیتهاب" + "home": "خانه" }, "header": { "search": "جستجوی بازی‌ها", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index 352128f7..6d10adf7 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -1,7 +1,6 @@ { "home": { "featured": "En vedette", - "recently_added": "Récemment ajouté", "trending": "Tendance", "surprise_me": "Surprenez-moi", "no_results": "Aucun résultat trouvé" @@ -15,8 +14,7 @@ "paused": "{{title}} (En pause)", "downloading": "{{title}} ({{percentage}} - Téléchargement en cours…)", "filter": "Filtrer la bibliothèque", - "home": "Page d’accueil", - "follow_us": "Suivez-nous" + "home": "Page d’accueil" }, "header": { "search": "Recherche", diff --git a/src/locales/hu/translation.json b/src/locales/hu/translation.json index 0800f1f9..507a175a 100644 --- a/src/locales/hu/translation.json +++ b/src/locales/hu/translation.json @@ -1,7 +1,6 @@ { "home": { "featured": "Featured", - "recently_added": "Nemrég hozzáadott", "trending": "Népszerű", "surprise_me": "Lepj meg", "no_results": "Nem található" @@ -15,7 +14,6 @@ "paused": "{{title}} (Szünet)", "downloading": "{{title}} ({{percentage}} - Letöltés…)", "filter": "Könyvtár szűrése", - "follow_us": "Kövess minket", "home": "Főoldal" }, "header": { diff --git a/src/locales/id/translation.json b/src/locales/id/translation.json index f58c9757..671cc36a 100644 --- a/src/locales/id/translation.json +++ b/src/locales/id/translation.json @@ -1,7 +1,6 @@ { "home": { "featured": "Unggulan", - "recently_added": "Terbaru", "trending": "Trending", "surprise_me": "Kejutkan Saya", "no_results": "Tidak ada hasil" @@ -15,12 +14,7 @@ "paused": "{{title}} (Terhenti)", "downloading": "{{title}} ({{percentage}} - Mengunduh…)", "filter": "Filter koleksi", - "follow_us": "Ikuti kami", - "home": "Beranda", - "discord": "Gabung Discord kami", - "telegram": "Gabung Telegram kami", - "x": "Ikuti akun X kami", - "github": "Kontribusi di GitHub" + "home": "Beranda" }, "header": { "search": "Pencarian", diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index 5cb4e8a0..4d87d919 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -1,7 +1,6 @@ { "home": { "featured": "In primo piano", - "recently_added": "Aggiunti di recente", "trending": "Di tendenza", "surprise_me": "Sorprendimi", "no_results": "Nessun risultato trovato" @@ -15,12 +14,7 @@ "paused": "{{title}} (In pausa)", "downloading": "{{title}} ({{percentage}} - Download…)", "filter": "Filtra libreria", - "follow_us": "Seguici", - "home": "Home", - "discord": "Unisciti al nostro Discord", - "telegram": "Unisciti al nostro Telegram", - "x": "Segui su X", - "github": "Contribuisci su GitHub" + "home": "Home" }, "header": { "search": "Cerca", diff --git a/src/locales/ko/translation.json b/src/locales/ko/translation.json index 748bc616..e82ac9fc 100644 --- a/src/locales/ko/translation.json +++ b/src/locales/ko/translation.json @@ -1,7 +1,6 @@ { "home": { "featured": "추천", - "recently_added": "최근 추가됨", "trending": "인기", "surprise_me": "무작위 추천", "no_results": "결과 없음" @@ -15,12 +14,7 @@ "paused": "{{title}} (일시 정지됨)", "downloading": "{{title}} ({{percentage}} - 다운로드 중…)", "filter": "라이브러리 정렬", - "follow_us": "공식 SNS", - "home": "홈", - "discord": "공식 디스코드", - "telegram": "공식 텔레그램", - "x": "공식 X (구 트위터)", - "github": "GitHub에서 기여하기" + "home": "홈" }, "header": { "search": "게임 검색하기", diff --git a/src/locales/nl/translation.json b/src/locales/nl/translation.json index fe48fdf4..1ffb8ae3 100644 --- a/src/locales/nl/translation.json +++ b/src/locales/nl/translation.json @@ -1,7 +1,6 @@ { "home": { "featured": "Uitgelicht", - "recently_added": "Recent Toegevoegd", "trending": "Trending", "surprise_me": "Verrasing", "no_results": "Geen resultaten gevonden" @@ -15,12 +14,7 @@ "paused": "{{title}} (Gepauzeerd)", "downloading": "{{title}} ({{percentage}} - Downloading…)", "filter": "Filter Bibliotheek", - "follow_us": "volg ons", - "home": "Home", - "discord": "Volg onze Discord", - "telegram": "Volg onze Telegram", - "x": "Volg ons op X", - "github": "Contribute op GitHub" + "home": "Home" }, "header": { "search": "Zoek spellen", diff --git a/src/locales/pl/translation.json b/src/locales/pl/translation.json index b2ec4e4b..6179ec0d 100644 --- a/src/locales/pl/translation.json +++ b/src/locales/pl/translation.json @@ -1,7 +1,6 @@ { "home": { "featured": "Wyróżnione", - "recently_added": "Ostatnio dodane", "trending": "Trendujące", "surprise_me": "Zaskocz mnie", "no_results": "Nie znaleziono wyników" @@ -15,12 +14,7 @@ "paused": "{{title}} (Zatrzymano)", "downloading": "{{title}} ({{percentage}} - Pobieranie…)", "filter": "Filtruj biblioteke", - "follow_us": "Śledź nas", - "home": "Główna", - "discord": "Dołącz nasz Discord", - "telegram": "Dołącz nasz Telegram", - "x": "Śledź na X", - "github": "Przyczyń się na GitHub" + "home": "Główna" }, "header": { "search": "Szukaj", diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index 8dd937d2..18ff1356 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -15,7 +15,6 @@ "downloading": "{{title}} ({{percentage}} - Baixando…)", "filter": "Filtrar biblioteca", "home": "Início", - "follow_us": "Acompanhe-nos", "queued": "{{title}} (Na fila)", "game_has_no_executable": "Jogo não possui executável selecionado" }, @@ -229,11 +228,21 @@ "user_profile": { "amount_hours": "{{amount}} horas", "amount_minutes": "{{amount}} minutos", - "play_time": "Jogado por {{amount}}", "last_time_played": "Jogou {{period}}", "sign_out": "Sair da conta", - "activity": "Atividade Recente", + "activity": "Atividade recente", "library": "Biblioteca", - "total_play_time": "Tempo total de jogo: {{amount}}" + "total_play_time": "Tempo total de jogo: {{amount}}", + "no_recent_activity_title": "Hmmm… nada por aqui", + "no_recent_activity_description": "Parece que você não jogou nada recentemente. Que tal começar agora?", + "display_name": "Nome de exibição", + "saving": "Salvando…", + "save": "Salvar", + "edit_profile": "Editar Perfil", + "saved_successfully": "Salvo com sucesso", + "try_again": "Por favor, tente novamente", + "cancel": "Cancelar", + "signout": "Sair da conta", + "signout_modal_title": "Tem certeza?" } } diff --git a/src/locales/tr/translation.json b/src/locales/tr/translation.json index 1e6ef65f..e0750fc0 100644 --- a/src/locales/tr/translation.json +++ b/src/locales/tr/translation.json @@ -1,7 +1,6 @@ { "home": { "featured": "Öne çıkan", - "recently_added": "Son eklenen", "trending": "Popüler", "surprise_me": "Şaşırt beni", "no_results": "Sonuç bulunamadı" @@ -15,12 +14,7 @@ "paused": "{{title}} (Duraklatıldı)", "downloading": "{{title}} ({{percentage}} - İndiriliyor…)", "filter": "Kütüphaneyi filtrele", - "follow_us": "Bizi takip et", - "home": "Ana menü", - "discord": "Discord'umuza katıl", - "telegram": "Telegram'umuza katıl", - "x": "X'te bizi takip et", - "github": "GitHub'da bize katkı yap" + "home": "Ana menü" }, "header": { "search": "Ara", diff --git a/src/locales/uk/translation.json b/src/locales/uk/translation.json index ad11f777..bbb8cf97 100644 --- a/src/locales/uk/translation.json +++ b/src/locales/uk/translation.json @@ -1,7 +1,6 @@ { "home": { "featured": "Рекомендоване", - "recently_added": "Нове", "trending": "У тренді", "surprise_me": "Здивуй мене", "no_results": "Результатів не знайдено" @@ -15,12 +14,7 @@ "paused": "{{title}} (Призупинено)", "downloading": "{{title}} ({{percentage}} - Завантаження…)", "filter": "Фільтр бібліотеки", - "follow_us": "Підписуйтесь на нас", - "home": "Головна", - "discord": "Приєднуйтесь до Discord", - "telegram": "Приєднуйтесь до Telegram", - "x": "Підписуйтесь на X", - "github": "Зробіть свій внесок на GitHub" + "home": "Головна" }, "header": { "search": "Пошук", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index e0d71b58..67731a6d 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -1,7 +1,6 @@ { "home": { "featured": "特色推荐", - "recently_added": "最近添加", "trending": "最近热门", "surprise_me": "向我推荐", "no_results": "没有找到结果" @@ -15,12 +14,7 @@ "paused": "{{title}} (已暂停)", "downloading": "{{title}} ({{percentage}} - 正在下载…)", "filter": "筛选游戏库", - "follow_us": "关注我们", - "home": "主页", - "discord": "加入我们的Discord", - "telegram": "加入我们的Telegram", - "x": "在X上关注我们", - "github": "在GitHub上贡献" + "home": "主页" }, "header": { "search": "搜索", diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 096b14a1..548106e0 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -22,6 +22,7 @@ import "./library/open-game-installer-path"; import "./library/update-executable-path"; import "./library/remove-game"; import "./library/remove-game-from-library"; +import "./misc/is-user-logged-in"; import "./misc/open-external"; import "./misc/show-open-dialog"; import "./torrenting/cancel-game-download"; @@ -42,6 +43,7 @@ import "./download-sources/sync-download-sources"; import "./auth/signout"; import "./user/get-user"; import "./profile/get-me"; +import "./profile/update-profile"; ipcMain.handle("ping", () => "pong"); ipcMain.handle("getVersion", () => app.getVersion()); diff --git a/src/main/events/misc/is-user-logged-in.ts b/src/main/events/misc/is-user-logged-in.ts new file mode 100644 index 00000000..d1874b01 --- /dev/null +++ b/src/main/events/misc/is-user-logged-in.ts @@ -0,0 +1,8 @@ +import { registerEvent } from "../register-event"; +import { HydraApi } from "@main/services/hydra-api"; + +const isUserLoggedIn = async (_event: Electron.IpcMainInvokeEvent) => { + return HydraApi.isLoggedIn(); +}; + +registerEvent("isUserLoggedIn", isUserLoggedIn); diff --git a/src/main/events/profile/update-profile.ts b/src/main/events/profile/update-profile.ts new file mode 100644 index 00000000..5a485a99 --- /dev/null +++ b/src/main/events/profile/update-profile.ts @@ -0,0 +1,63 @@ +import { registerEvent } from "../register-event"; +import { HydraApi } from "@main/services/hydra-api"; +import axios from "axios"; +import fs from "node:fs"; +import path from "node:path"; +import { fileTypeFromFile } from "file-type"; +import { UserProfile } from "@types"; + +const patchUserProfile = async ( + displayName: string, + profileImageUrl?: string +) => { + if (profileImageUrl) { + return HydraApi.patch("/profile", { + displayName, + profileImageUrl, + }); + } else { + return HydraApi.patch("/profile", { + displayName, + }); + } +}; + +const updateProfile = async ( + _event: Electron.IpcMainInvokeEvent, + displayName: string, + newProfileImagePath: string | null +): Promise => { + console.log(newProfileImagePath); + + if (!newProfileImagePath) { + return (await patchUserProfile(displayName)).data; + } + + const stats = fs.statSync(newProfileImagePath); + const fileBuffer = fs.readFileSync(newProfileImagePath); + const fileSizeInBytes = stats.size; + + const profileImageUrl = await HydraApi.post(`/presigned-urls/profile-image`, { + imageExt: path.extname(newProfileImagePath).slice(1), + imageLength: fileSizeInBytes, + }) + .then(async (preSignedResponse) => { + const { presignedUrl, profileImageUrl } = preSignedResponse.data; + + const mimeType = await fileTypeFromFile(newProfileImagePath); + + await axios.put(presignedUrl, fileBuffer, { + headers: { + "Content-Type": mimeType?.mime, + }, + }); + return profileImageUrl; + }) + .catch(() => { + return undefined; + }); + + return (await patchUserProfile(displayName, profileImageUrl)).data; +}; + +registerEvent("updateProfile", updateProfile); diff --git a/src/main/index.ts b/src/main/index.ts index 53b68bba..10399beb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2,6 +2,7 @@ import { app, BrowserWindow, net, protocol } from "electron"; import updater from "electron-updater"; import i18n from "i18next"; import path from "node:path"; +import url from "node:url"; import { electronApp, optimizer } from "@electron-toolkit/utils"; import { DownloadManager, logger, WindowManager } from "@main/services"; import { dataSource } from "@main/data-source"; @@ -51,9 +52,10 @@ if (process.defaultApp) { app.whenReady().then(async () => { electronApp.setAppUserModelId("site.hydralauncher.hydra"); - protocol.handle("hydra", (request) => - net.fetch("file://" + request.url.slice("hydra://".length)) - ); + protocol.handle("local", (request) => { + const filePath = request.url.slice("local://".length); + return net.fetch(url.pathToFileURL(filePath).toString()); + }); await dataSource.initialize(); await dataSource.runMigrations(); diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index 87bb4077..8e50e646 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -11,6 +11,8 @@ export class HydraApi { private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; + private static secondsToMilliseconds = (seconds: number) => seconds * 1000; + private static userAuth = { authToken: "", refreshToken: "", @@ -32,7 +34,9 @@ export class HydraApi { const now = new Date(); const tokenExpirationTimestamp = - now.getTime() + expiresIn - this.EXPIRATION_OFFSET_IN_MS; + now.getTime() + + this.secondsToMilliseconds(expiresIn) - + this.EXPIRATION_OFFSET_IN_MS; this.userAuth = { authToken: accessToken, @@ -119,7 +123,9 @@ export class HydraApi { const { accessToken, expiresIn } = response.data; const tokenExpirationTimestamp = - now.getTime() + expiresIn - this.EXPIRATION_OFFSET_IN_MS; + now.getTime() + + this.secondsToMilliseconds(expiresIn) - + this.EXPIRATION_OFFSET_IN_MS; this.userAuth.authToken = accessToken; this.userAuth.expirationTimestamp = tokenExpirationTimestamp; diff --git a/src/preload/index.ts b/src/preload/index.ts index 883091f6..dd03f114 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -112,6 +112,7 @@ contextBridge.exposeInMainWorld("electron", { getVersion: () => ipcRenderer.invoke("getVersion"), getDefaultDownloadsPath: () => ipcRenderer.invoke("getDefaultDownloadsPath"), openExternal: (src: string) => ipcRenderer.invoke("openExternal", src), + isUserLoggedIn: () => ipcRenderer.invoke("isUserLoggedIn"), showOpenDialog: (options: Electron.OpenDialogOptions) => ipcRenderer.invoke("showOpenDialog", options), platform: process.platform, @@ -134,6 +135,8 @@ contextBridge.exposeInMainWorld("electron", { /* Profile */ getMe: () => ipcRenderer.invoke("getMe"), + updateProfile: (displayName: string, newProfileImagePath: string | null) => + ipcRenderer.invoke("updateProfile", displayName, newProfileImagePath), /* User */ getUser: (userId: string) => ipcRenderer.invoke("getUser", userId), diff --git a/src/renderer/index.html b/src/renderer/index.html index 85a75bdc..4cef28aa 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -6,7 +6,7 @@ Hydra diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 916105e7..332a846b 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -19,6 +19,8 @@ import { setUserPreferences, toggleDraggingDisabled, closeToast, + setUserDetails, + setProfileBackground, } from "@renderer/features"; export interface AppProps { @@ -31,7 +33,8 @@ export function App() { const { clearDownload, setLastPacket } = useDownload(); - const { updateUser, clearUser } = useUserDetails(); + const { fetchUserDetails, updateUserDetails, clearUserDetails } = + useUserDetails(); const dispatch = useAppDispatch(); @@ -73,26 +76,44 @@ export function App() { }, [clearDownload, setLastPacket, updateLibrary]); useEffect(() => { - updateUser(); - }, [updateUser]); + const cachedUserDetails = window.localStorage.getItem("userDetails"); + + if (cachedUserDetails) { + const { profileBackground, ...userDetails } = + JSON.parse(cachedUserDetails); + + dispatch(setUserDetails(userDetails)); + dispatch(setProfileBackground(profileBackground)); + } + + window.electron.isUserLoggedIn().then((isLoggedIn) => { + if (isLoggedIn) { + fetchUserDetails().then((response) => { + if (response) setUserDetails(response); + }); + } + }); + }, [dispatch, fetchUserDetails]); useEffect(() => { const listeners = [ window.electron.onSignIn(() => { - updateUser(); + fetchUserDetails().then((response) => { + if (response) updateUserDetails(response); + }); }), window.electron.onLibraryBatchComplete(() => { updateLibrary(); }), window.electron.onSignOut(() => { - clearUser(); + clearUserDetails(); }), ]; return () => { listeners.forEach((unsubscribe) => unsubscribe()); }; - }, [updateUser, updateLibrary, clearUser]); + }, [fetchUserDetails, updateUserDetails, clearUserDetails]); const handleSearch = useCallback( (query: string) => { diff --git a/src/renderer/src/components/header/header.tsx b/src/renderer/src/components/header/header.tsx index 8b3ac2fa..d3315098 100644 --- a/src/renderer/src/components/header/header.tsx +++ b/src/renderer/src/components/header/header.tsx @@ -39,7 +39,7 @@ export function Header({ onSearch, onClear, search }: HeaderProps) { const title = useMemo(() => { if (location.pathname.startsWith("/game")) return headerTitle; - if (location.pathname.startsWith("/profile")) return headerTitle; + if (location.pathname.startsWith("/user")) return headerTitle; if (location.pathname.startsWith("/search")) return t("search_results"); return t(pathTitle[location.pathname]); diff --git a/src/renderer/src/components/sidebar/sidebar-profile.css.ts b/src/renderer/src/components/sidebar/sidebar-profile.css.ts new file mode 100644 index 00000000..9681c866 --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar-profile.css.ts @@ -0,0 +1,58 @@ +import { style } from "@vanilla-extract/css"; + +import { SPACING_UNIT, vars } from "../../theme.css"; + +export const profileButton = style({ + display: "flex", + cursor: "pointer", + transition: "all ease 0.1s", + padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`, + color: vars.color.muted, + borderBottom: `solid 1px ${vars.color.border}`, + boxShadow: "0px 0px 15px 0px #000000", + ":hover": { + backgroundColor: "rgba(255, 255, 255, 0.15)", + }, +}); + +export const profileButtonContent = style({ + display: "flex", + alignItems: "center", + gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`, + height: "40px", +}); + +export const profileAvatar = style({ + width: "35px", + height: "35px", + borderRadius: "50%", + display: "flex", + justifyContent: "center", + alignItems: "center", + backgroundColor: vars.color.background, + border: `solid 1px ${vars.color.border}`, + position: "relative", + objectFit: "cover", +}); + +export const profileButtonInformation = style({ + display: "flex", + flexDirection: "column", + alignItems: "flex-start", +}); + +export const statusBadge = style({ + width: "9px", + height: "9px", + borderRadius: "50%", + backgroundColor: vars.color.danger, + position: "absolute", + bottom: "-2px", + right: "-3px", + zIndex: "1", +}); + +export const profileButtonTitle = style({ + fontWeight: "bold", + fontSize: vars.size.body, +}); diff --git a/src/renderer/src/components/sidebar/sidebar-profile.tsx b/src/renderer/src/components/sidebar/sidebar-profile.tsx index c81b24fb..4a89eff1 100644 --- a/src/renderer/src/components/sidebar/sidebar-profile.tsx +++ b/src/renderer/src/components/sidebar/sidebar-profile.tsx @@ -1,6 +1,7 @@ import { useNavigate } from "react-router-dom"; import { PersonIcon } from "@primer/octicons-react"; -import * as styles from "./sidebar.css"; +import * as styles from "./sidebar-profile.css"; + import { useUserDetails } from "@renderer/hooks"; import { useMemo } from "react"; @@ -9,12 +10,13 @@ export function SidebarProfile() { const { userDetails, profileBackground } = useUserDetails(); - const handleClickProfile = () => { - navigate(`/user/${userDetails!.id}`); - }; + const handleButtonClick = () => { + if (userDetails === null) { + window.electron.openExternal("https://auth.hydra.losbroxas.org"); + return; + } - const handleClickLogin = () => { - window.electron.openExternal("https://auth.hydra.losbroxas.org"); + navigate(`/user/${userDetails!.id}`); }; const profileButtonBackground = useMemo(() => { @@ -22,36 +24,16 @@ export function SidebarProfile() { return undefined; }, [profileBackground]); - if (userDetails == null) { - return ( - <> - - - ); - } - return ( - <> - - + + ); } diff --git a/src/renderer/src/components/sidebar/sidebar.css.ts b/src/renderer/src/components/sidebar/sidebar.css.ts index 5a96e87a..75ac2dd5 100644 --- a/src/renderer/src/components/sidebar/sidebar.css.ts +++ b/src/renderer/src/components/sidebar/sidebar.css.ts @@ -125,48 +125,3 @@ export const section = style({ flexDirection: "column", paddingBottom: `${SPACING_UNIT}px`, }); - -export const profileButton = style({ - display: "flex", - cursor: "pointer", - transition: "all ease 0.1s", - gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`, - alignItems: "center", - padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`, - color: vars.color.muted, - borderBottom: `solid 1px ${vars.color.border}`, - boxShadow: "0px 0px 15px 0px #000000", - ":hover": { - backgroundColor: "rgba(255, 255, 255, 0.15)", - }, -}); - -export const profileAvatar = style({ - width: "30px", - height: "30px", - borderRadius: "50%", - display: "flex", - justifyContent: "center", - alignItems: "center", - backgroundColor: vars.color.background, - border: `solid 1px ${vars.color.border}`, - position: "relative", - objectFit: "cover", -}); - -export const profileButtonInformation = style({ - display: "flex", - flexDirection: "column", - alignItems: "flex-start", -}); - -export const statusBadge = style({ - width: "9px", - height: "9px", - borderRadius: "50%", - backgroundColor: vars.color.danger, - position: "absolute", - bottom: "-2px", - right: "-3px", - zIndex: "1", -}); diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index dd73ffb5..6a0160c9 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -97,6 +97,7 @@ declare global { /* Misc */ openExternal: (src: string) => Promise; + isUserLoggedIn: () => Promise; getVersion: () => Promise; ping: () => string; getDefaultDownloadsPath: () => Promise; @@ -122,6 +123,10 @@ declare global { /* Profile */ getMe: () => Promise; + updateProfile: ( + displayName: string, + newProfileImagePath: string | null + ) => Promise; } interface Window { diff --git a/src/renderer/src/features/user-details-slice.ts b/src/renderer/src/features/user-details-slice.ts index af14ce56..0cc395b0 100644 --- a/src/renderer/src/features/user-details-slice.ts +++ b/src/renderer/src/features/user-details-slice.ts @@ -15,18 +15,14 @@ export const userDetailsSlice = createSlice({ name: "user-details", initialState, reducers: { - setUserDetails: (state, action: PayloadAction) => { + setUserDetails: (state, action: PayloadAction) => { state.userDetails = action.payload; }, - setProfileBackground: (state, action: PayloadAction) => { + setProfileBackground: (state, action: PayloadAction) => { state.profileBackground = action.payload; }, - clearUserDetails: (state) => { - state.userDetails = null; - state.profileBackground = null; - }, }, }); -export const { setUserDetails, setProfileBackground, clearUserDetails } = +export const { setUserDetails, setProfileBackground } = userDetailsSlice.actions; diff --git a/src/renderer/src/hooks/use-user-details.ts b/src/renderer/src/hooks/use-user-details.ts index fe4c0505..75de473f 100644 --- a/src/renderer/src/hooks/use-user-details.ts +++ b/src/renderer/src/hooks/use-user-details.ts @@ -2,12 +2,9 @@ import { useCallback } from "react"; import { average } from "color.js"; import { useAppDispatch, useAppSelector } from "./redux"; -import { - clearUserDetails, - setProfileBackground, - setUserDetails, -} from "@renderer/features"; +import { setProfileBackground, setUserDetails } from "@renderer/features"; import { darkenColor } from "@renderer/helpers"; +import { UserDetails } from "@types"; export function useUserDetails() { const dispatch = useAppDispatch(); @@ -16,42 +13,69 @@ export function useUserDetails() { (state) => state.userDetails ); - const clearUser = useCallback(async () => { - dispatch(clearUserDetails()); + const clearUserDetails = useCallback(async () => { + dispatch(setUserDetails(null)); + dispatch(setProfileBackground(null)); + + window.localStorage.removeItem("userDetails"); }, [dispatch]); const signOut = useCallback(async () => { - clearUser(); + clearUserDetails(); return window.electron.signOut(); - }, [clearUser]); + }, [clearUserDetails]); - const updateUser = useCallback(async () => { - return window.electron.getMe().then(async (userDetails) => { - if (userDetails) { - dispatch(setUserDetails(userDetails)); + const updateUserDetails = useCallback( + async (userDetails: UserDetails) => { + dispatch(setUserDetails(userDetails)); - if (userDetails.profileImageUrl) { - const output = await average(userDetails.profileImageUrl, { - amount: 1, - format: "hex", - }); + if (userDetails.profileImageUrl) { + const output = await average(userDetails.profileImageUrl, { + amount: 1, + format: "hex", + }); - dispatch( - setProfileBackground( - `linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.7)})` - ) - ); - } + const profileBackground = `linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.8)})`; + + dispatch(setProfileBackground(profileBackground)); + + window.localStorage.setItem( + "userDetails", + JSON.stringify({ ...userDetails, profileBackground }) + ); + } else { + dispatch(setProfileBackground(null)); + + window.localStorage.setItem("userDetails", JSON.stringify(userDetails)); } - }); - }, [dispatch]); + }, + [dispatch] + ); + + const fetchUserDetails = useCallback(async () => { + return window.electron.getMe(); + }, []); + + const patchUser = useCallback( + async (displayName: string, imageProfileUrl: string | null) => { + const response = await window.electron.updateProfile( + displayName, + imageProfileUrl + ); + + return updateUserDetails(response); + }, + [updateUserDetails] + ); return { userDetails, - updateUser, + fetchUserDetails, signOut, - clearUser, + clearUserDetails, + updateUserDetails, + patchUser, profileBackground, }; } diff --git a/src/renderer/src/pages/home/catalogue-home.css.ts b/src/renderer/src/pages/home/catalogue-home.css.ts index 8096bf97..a13d58f4 100644 --- a/src/renderer/src/pages/home/catalogue-home.css.ts +++ b/src/renderer/src/pages/home/catalogue-home.css.ts @@ -1,5 +1,4 @@ import { style } from "@vanilla-extract/css"; -import { recipe } from "@vanilla-extract/recipes"; import { SPACING_UNIT } from "../../theme.css"; @@ -17,11 +16,9 @@ export const content = style({ flex: "1", }); -export const cards = recipe({ - base: { - display: "grid", - gridTemplateColumns: "repeat(2, 1fr)", - gap: `${SPACING_UNIT * 2}px`, - transition: "all ease 0.2s", - }, +export const cards = style({ + display: "grid", + gridTemplateColumns: "repeat(2, 1fr)", + gap: `${SPACING_UNIT * 2}px`, + transition: "all ease 0.2s", }); diff --git a/src/renderer/src/pages/user/user-content.tsx b/src/renderer/src/pages/user/user-content.tsx index 600f9128..5b4798e7 100644 --- a/src/renderer/src/pages/user/user-content.tsx +++ b/src/renderer/src/pages/user/user-content.tsx @@ -3,26 +3,35 @@ import cn from "classnames"; import * as styles from "./user.css"; import { SPACING_UNIT, vars } from "@renderer/theme.css"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import { useDate, useUserDetails } from "@renderer/hooks"; import { useNavigate } from "react-router-dom"; import { buildGameDetailsPath } from "@renderer/helpers"; -import { PersonIcon } from "@primer/octicons-react"; +import { PersonIcon, TelescopeIcon } from "@primer/octicons-react"; import { Button } from "@renderer/components"; +import { UserEditProfileModal } from "./user-edit-modal"; +import { UserSignOutModal } from "./user-signout-modal"; const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120; export interface ProfileContentProps { userProfile: UserProfile; + updateUserProfile: () => Promise; } -export function UserContent({ userProfile }: ProfileContentProps) { +export function UserContent({ + userProfile, + updateUserProfile, +}: ProfileContentProps) { const { t, i18n } = useTranslation("user_profile"); const { userDetails, profileBackground, signOut } = useUserDetails(); + const [showEditProfileModal, setShowEditProfileModal] = useState(false); + const [showSignOutModal, setShowSignOutModal] = useState(false); + const navigate = useNavigate(); const numberFormatter = useMemo(() => { @@ -54,8 +63,12 @@ export function UserContent({ userProfile }: ProfileContentProps) { navigate(buildGameDetailsPath(game)); }; - const handleSignout = async () => { - await signOut(); + const handleEditProfile = () => { + setShowEditProfileModal(true); + }; + + const handleConfirmSignout = async () => { + signOut(); navigate("/"); }; @@ -69,10 +82,24 @@ export function UserContent({ userProfile }: ProfileContentProps) { return ( <> + setShowEditProfileModal(false)} + updateUserProfile={updateUserProfile} + userProfile={userProfile} + /> + + setShowSignOutModal(false)} + onConfirm={handleConfirmSignout} + /> +
@@ -93,27 +120,53 @@ export function UserContent({ userProfile }: ProfileContentProps) { {isMe && (
- +
+ <> + + + + +
)}
-
-

{t("activity")}

-
-
- {userProfile.recentGames.map((game) => { - return ( +

{t("activity")}

+ + {!userProfile.recentGames.length ? ( +
+
+ +
+

{t("no_recent_activity_title")}

+

+ {t("no_recent_activity_description")} +

+
+ ) : ( +
+ {userProfile.recentGames.map((game) => (
- ); - })} -
+ ))} +
+ )}
@@ -170,33 +223,28 @@ export function UserContent({ userProfile }: ProfileContentProps) {
- {userProfile.libraryGames.map((game) => { - return ( - - ); - })} + {userProfile.libraryGames.map((game) => ( + + ))}
diff --git a/src/renderer/src/pages/user/user-edit-modal.tsx b/src/renderer/src/pages/user/user-edit-modal.tsx new file mode 100644 index 00000000..d61a2da5 --- /dev/null +++ b/src/renderer/src/pages/user/user-edit-modal.tsx @@ -0,0 +1,147 @@ +import { Button, Modal, TextField } from "@renderer/components"; +import { UserProfile } from "@types"; +import * as styles from "./user.css"; +import { DeviceCameraIcon, PersonIcon } from "@primer/octicons-react"; +import { SPACING_UNIT } from "@renderer/theme.css"; +import { useEffect, useMemo, useState } from "react"; +import { useToast, useUserDetails } from "@renderer/hooks"; +import { useTranslation } from "react-i18next"; + +export interface UserEditProfileModalProps { + userProfile: UserProfile; + visible: boolean; + onClose: () => void; + updateUserProfile: () => Promise; +} + +export const UserEditProfileModal = ({ + userProfile, + visible, + onClose, + updateUserProfile, +}: UserEditProfileModalProps) => { + const { t } = useTranslation("user_profile"); + + const [displayName, setDisplayName] = useState(""); + const [newImagePath, setNewImagePath] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + const { patchUser } = useUserDetails(); + + const { showSuccessToast, showErrorToast } = useToast(); + + useEffect(() => { + setDisplayName(userProfile.displayName); + }, [userProfile.displayName]); + + const handleChangeProfileAvatar = async () => { + const { filePaths } = await window.electron.showOpenDialog({ + properties: ["openFile"], + filters: [ + { + name: "Image", + extensions: ["jpg", "jpeg", "png", "gif", "webp", "bmp"], + }, + ], + }); + + if (filePaths && filePaths.length > 0) { + const path = filePaths[0]; + + setNewImagePath(path); + } + }; + + const handleSaveProfile: React.FormEventHandler = async ( + event + ) => { + event.preventDefault(); + setIsSaving(true); + + patchUser(displayName, newImagePath) + .then(async () => { + await updateUserProfile(); + showSuccessToast(t("saved_successfully")); + cleanFormAndClose(); + }) + .catch(() => { + showErrorToast(t("try_again")); + }) + .finally(() => { + setIsSaving(false); + }); + }; + + const resetModal = () => { + setDisplayName(userProfile.displayName); + setNewImagePath(null); + }; + + const cleanFormAndClose = () => { + resetModal(); + onClose(); + }; + + const avatarUrl = useMemo(() => { + if (newImagePath) return `local:${newImagePath}`; + if (userProfile.profileImageUrl) return userProfile.profileImageUrl; + return null; + }, [newImagePath, userProfile.profileImageUrl]); + + return ( + <> + +
+ + + setDisplayName(e.target.value)} + /> + + +
+ + ); +}; diff --git a/src/renderer/src/pages/user/user-signout-modal.tsx b/src/renderer/src/pages/user/user-signout-modal.tsx new file mode 100644 index 00000000..a6c40630 --- /dev/null +++ b/src/renderer/src/pages/user/user-signout-modal.tsx @@ -0,0 +1,37 @@ +import { Button, Modal } from "@renderer/components"; +import * as styles from "./user.css"; +import { useTranslation } from "react-i18next"; + +export interface UserEditProfileModalProps { + visible: boolean; + onConfirm: () => void; + onClose: () => void; +} + +export const UserSignOutModal = ({ + visible, + onConfirm, + onClose, +}: UserEditProfileModalProps) => { + const { t } = useTranslation("user_profile"); + + return ( + <> + +
+ + + +
+
+ + ); +}; diff --git a/src/renderer/src/pages/user/user-skeleton.tsx b/src/renderer/src/pages/user/user-skeleton.tsx index 442322cc..dc23fb0e 100644 --- a/src/renderer/src/pages/user/user-skeleton.tsx +++ b/src/renderer/src/pages/user/user-skeleton.tsx @@ -1,13 +1,40 @@ import Skeleton from "react-loading-skeleton"; +import cn from "classnames"; import * as styles from "./user.css"; +import { SPACING_UNIT } from "@renderer/theme.css"; +import { useTranslation } from "react-i18next"; export const UserSkeleton = () => { + const { t } = useTranslation("user_profile"); return ( <>
- - +
+

{t("activity")}

+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+ +
+

{t("library")}

+
+ {Array.from({ length: 8 }).map((_, index) => ( + + ))} +
+
); diff --git a/src/renderer/src/pages/user/user.css.ts b/src/renderer/src/pages/user/user.css.ts index 6ec6b820..3a9b4c86 100644 --- a/src/renderer/src/pages/user/user.css.ts +++ b/src/renderer/src/pages/user/user.css.ts @@ -4,6 +4,7 @@ import { style } from "@vanilla-extract/css"; export const wrapper = style({ padding: "24px", width: "100%", + height: "100%", display: "flex", flexDirection: "column", gap: `${SPACING_UNIT * 3}px`, @@ -12,12 +13,10 @@ export const wrapper = style({ export const profileContentBox = style({ display: "flex", gap: `${SPACING_UNIT * 3}px`, - padding: `${SPACING_UNIT * 4}px ${SPACING_UNIT * 2}px`, alignItems: "center", borderRadius: "4px", border: `solid 1px ${vars.color.border}`, width: "100%", - overflow: "hidden", boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.7)", transition: "all ease 0.3s", }); @@ -36,10 +35,38 @@ export const profileAvatarContainer = style({ boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)", }); +export const profileAvatarEditContainer = style({ + width: "128px", + height: "128px", + display: "flex", + borderRadius: "50%", + color: vars.color.body, + justifyContent: "center", + alignItems: "center", + backgroundColor: vars.color.background, + position: "relative", + border: `solid 1px ${vars.color.border}`, + boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)", + cursor: "pointer", +}); + export const profileAvatar = style({ - width: "96px", - height: "96px", + height: "100%", + width: "100%", + borderRadius: "50%", + overflow: "hidden", objectFit: "cover", + animationPlayState: "paused", +}); + +export const profileAvatarEditOverlay = style({ + position: "absolute", + width: "100%", + height: "100%", + backgroundColor: "#00000055", + color: vars.color.muted, + zIndex: 1, + cursor: "pointer", }); export const profileInformation = style({ @@ -51,12 +78,14 @@ export const profileInformation = style({ export const profileContent = style({ display: "flex", + height: "100%", flexDirection: "row", gap: `${SPACING_UNIT * 4}px`, }); export const profileGameSection = style({ width: "100%", + height: "100%", display: "flex", flexDirection: "column", gap: `${SPACING_UNIT * 2}px`, @@ -73,28 +102,17 @@ export const contentSidebar = style({ maxWidth: "250px", width: "100%", }, - "(min-width: 1280px)": { - width: "100%", - maxWidth: "350px", - }, }, }); export const feedGameIcon = style({ height: "100%", - display: "flex", - justifyContent: "center", - alignItems: "center", - position: "relative", }); export const libraryGameIcon = style({ + width: "100%", height: "100%", borderRadius: "4px", - display: "flex", - justifyContent: "center", - alignItems: "center", - position: "relative", }); export const feedItem = style({ @@ -114,13 +132,11 @@ export const feedItem = style({ export const gameListItem = style({ color: vars.color.body, - display: "flex", - flexDirection: "row", - gap: `${SPACING_UNIT}px`, - aspectRatio: "1", transition: "all ease 0.2s", cursor: "pointer", zIndex: "1", + overflow: "hidden", + padding: `${SPACING_UNIT + SPACING_UNIT / 2}px`, ":hover": { backgroundColor: "rgba(255, 255, 255, 0.15)", }, @@ -134,5 +150,49 @@ export const gameInformation = style({ }); export const profileHeaderSkeleton = style({ - height: "200px", + height: "144px", +}); + +export const editProfileImageBadge = style({ + width: "28px", + height: "28px", + borderRadius: "50%", + display: "flex", + alignItems: "center", + justifyContent: "center", + color: vars.color.background, + backgroundColor: vars.color.muted, + position: "absolute", + bottom: "0px", + right: "0px", + zIndex: "1", +}); + +export const telescopeIcon = style({ + width: "60px", + height: "60px", + borderRadius: "50%", + backgroundColor: "rgba(255, 255, 255, 0.06)", + display: "flex", + alignItems: "center", + justifyContent: "center", + marginBottom: `${SPACING_UNIT * 2}px`, +}); + +export const noDownloads = style({ + display: "flex", + width: "100%", + height: "100%", + justifyContent: "center", + alignItems: "center", + flexDirection: "column", + gap: `${SPACING_UNIT}px`, +}); + +export const signOutModalButtonsContainer = style({ + display: "flex", + width: "100%", + justifyContent: "end", + alignItems: "center", + gap: `${SPACING_UNIT}px`, }); diff --git a/src/renderer/src/pages/user/user.tsx b/src/renderer/src/pages/user/user.tsx index e50ea1e2..bce22211 100644 --- a/src/renderer/src/pages/user/user.tsx +++ b/src/renderer/src/pages/user/user.tsx @@ -1,5 +1,5 @@ import { UserProfile } from "@types"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useParams } from "react-router-dom"; import { setHeaderTitle } from "@renderer/features"; import { useAppDispatch } from "@renderer/hooks"; @@ -15,8 +15,8 @@ export const User = () => { const dispatch = useAppDispatch(); - useEffect(() => { - window.electron.getUser(userId!).then((userProfile) => { + const getUserProfile = useCallback(() => { + return window.electron.getUser(userId!).then((userProfile) => { if (userProfile) { dispatch(setHeaderTitle(userProfile.displayName)); setUserProfile(userProfile); @@ -24,11 +24,20 @@ export const User = () => { }); }, [dispatch, userId]); + useEffect(() => { + getUserProfile(); + }, [getUserProfile]); + + console.log(userProfile); + return (
{userProfile ? ( - + ) : ( )} diff --git a/yarn.lock b/yarn.lock index f438ecf4..b822ab1d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3305,6 +3305,15 @@ file-type@^18.7.0: strtok3 "^7.0.0" token-types "^5.0.1" +file-type@^19.0.0: + version "19.0.0" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-19.0.0.tgz#62a6cadc43f73ba38c53e1a174943a75fdafafa9" + integrity sha512-s7cxa7/leUWLiXO78DVVfBVse+milos9FitauDLG1pI7lNaJ2+5lzPnr2N24ym+84HVwJL6hVuGfgVE+ALvU8Q== + dependencies: + readable-web-to-node-stream "^3.0.2" + strtok3 "^7.0.0" + token-types "^5.0.1" + file-uri-to-path@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"