Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@
"InvalidSiteError": "Login failed: site is unreachable or invalid. Please check and try again.",
"AddAccount": "Add Site",
"SwitchSite": "Switch Site",
"RecentSites": "Recent sites",
"ClearRecentSites": "Clear",
"RemoveRecentSite": "Remove",
"LoginAuthenticationExpired": "Login authentication has expired. Please log in again",
"LoginAuthenticationExpiredDescription": "If there are multiple sites, you will be automatically switched to the next site",
"Logout": "Logout",
Expand Down
3 changes: 3 additions & 0 deletions i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@
"InvalidSiteError": "登录失败:站点不可用或地址不正确,请检查后重试",
"AddAccount": "新增账号",
"SwitchSite": "切换站点",
"RecentSites": "最近站点",
"ClearRecentSites": "清空",
"RemoveRecentSite": "移除",
"LoginAuthenticationExpired": "登录认证已失效,请重新登录",
"LoginAuthenticationExpiredDescription": "若存在多个站点,将会自动为您自动切换到下一个站点",
"Logout": "退出登录",
Expand Down
130 changes: 117 additions & 13 deletions ui/components/SideBar/profile.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { LangType, ThemeType, UserData } from "~/types/index";

import { useSettingManager } from "~/composables/useSettingManager";
import { useUserInfoStore } from "~/store/modules/userInfo";
import RecentSites from "./RecentSites.vue";

interface VersionAlertPayload {
type: string;
Expand All @@ -13,6 +14,8 @@ interface VersionAlertPayload {

const props = defineProps<{ collapse: boolean }>();

const recentSiteLimit = 5;

const toast = useToast();
const appConfig = useAppConfig();
const localePath = useLocalePath();
Expand All @@ -21,7 +24,16 @@ const userInfoStore = useUserInfoStore();
const { t, locales, locale } = useI18n();
const { loggedIn, currentSite, userMap, currentUser } = storeToRefs(userInfoStore);

const { setLang, theme, themeMode, primaryColorLight, primaryColorDark } = useSettingManager();
const {
setLang,
theme,
themeMode,
primaryColorLight,
primaryColorDark,
recentSites,
setRecentSites,
hydrationPromise
} = useSettingManager();
const { manualSetTheme, enableFollowSystem, followSystem, userTheme } = useThemeAdapter();
const { applyPrimaryColor } = useColor();

Expand All @@ -30,15 +42,45 @@ const errorMessage = ref("");
const loginBtn = ref(false);
const openModal = ref(false);
const hasValidationError = ref(false);
let loginBtnUnlockTimer: ReturnType<typeof setTimeout> | null = null;
const recentSitesDismissed = ref(false);
const unlistenErrorPageRef = ref<UnlistenFn | null>(null);
const unlistenLoginFailedRef = ref<UnlistenFn | null>(null);
const inputRef = ref<ComponentPublicInstance | null>(null);

let loginBtnUnlockTimer: ReturnType<typeof setTimeout> | null = null;

useEventBus().on("login", openLoginPage);

const normalizedInputSite = computed(() => normalizeSite(inputSite.value));

const normalizedRecentSites = computed(() => {
const raw = Array.isArray(recentSites.value) ? recentSites.value : [];
const normalized: string[] = [];
const seen = new Set<string>();

for (const site of raw) {
const value = normalizeSite(site);
if (!value || seen.has(value)) continue;
seen.add(value);
normalized.push(value);
if (normalized.length >= recentSiteLimit) break;
}

return normalized;
});

const filteredRecentSites = computed(() => {
const query = normalizeSite(inputSite.value).toLowerCase();
const list = normalizedRecentSites.value;
if (!query) return list;

return list.filter((site) => site.toLowerCase().includes(query));
});

const showRecentSites = computed(
() => openModal.value && !recentSitesDismissed.value && filteredRecentSites.value.length > 0
);

const clearLoginBtnUnlockTimer = () => {
if (loginBtnUnlockTimer) {
clearTimeout(loginBtnUnlockTimer);
Expand Down Expand Up @@ -208,6 +250,65 @@ function normalizeSite(value: string): string {
return s.replace(/\/+$/, "");
}

const ensureRecentSitesReady = async () => {
if (hydrationPromise.value) {
await hydrationPromise.value;
}
};

const saveRecentSite = async (site: string) => {
try {
const normalized = normalizeSite(site);
if (!normalized) return;

await ensureRecentSitesReady();
const next = [normalized, ...normalizedRecentSites.value.filter((item) => item !== normalized)].slice(
0,
recentSiteLimit
);
setRecentSites(next);
} catch (err) {
console.error("save recent sites failed", err);
}
};

const removeRecentSite = async (site: string) => {
try {
const normalized = normalizeSite(site);
if (!normalized) return;

await ensureRecentSitesReady();
const next = normalizedRecentSites.value.filter((item) => item !== normalized);
setRecentSites(next);
} catch (err) {
console.error("remove recent site failed", err);
}
};

const clearRecentSites = async () => {
try {
await ensureRecentSitesReady();
setRecentSites([]);
} catch (err) {
console.error("clear recent sites failed", err);
}
};

const selectRecentSite = (site: string) => {
inputSite.value = site;
clearValidationError();
recentSitesDismissed.value = true;
nextTick(() => {
inputRef.value?.$el?.querySelector("input")?.focus();
});
};

const handleClearInput = () => {
inputSite.value = "";
clearValidationError();
recentSitesDismissed.value = false;
};

function normalizeVersionMessage(raw: string) {
if (raw === "incompatible") {
return { status: "incompatible" as const, versions: [] as string[] };
Expand Down Expand Up @@ -294,6 +395,7 @@ const checkVersionBeforeOAuth = async (site: string) => {
*/
function openLoginPage() {
openModal.value = true;
recentSitesDismissed.value = false;
hasValidationError.value = false;
errorMessage.value = "";
nextTick(() => {
Expand Down Expand Up @@ -395,6 +497,7 @@ const handleInputSanitize = (event: Event) => {
}

inputSite.value = sanitized;
recentSitesDismissed.value = false;
clearValidationError();
};

Expand All @@ -404,6 +507,7 @@ const handleInputSanitize = (event: Event) => {
*/
const handleClipboard = (value: string) => {
inputSite.value = normalizeSite(value);
recentSitesDismissed.value = false;
};

/**
Expand Down Expand Up @@ -458,6 +562,7 @@ const handleConfirm = async () => {
await useTauriCoreInvoke("auth_login", {
site: normalizedSite
});
void saveRecentSite(normalizedSite);
} catch (e: any) {
const raw = (e?.message || e || "").toString();
const looksLikeSiteIssue = [
Expand Down Expand Up @@ -622,13 +727,7 @@ onBeforeUnmount(() => {
</div>
</UDropdownMenu>

<UButton
v-else
variant="subtle"
icon="line-md:log-in"
class="w-full mb-2"
@click="openLoginPage"
>
<UButton v-else variant="subtle" icon="line-md:log-in" class="w-full mb-2" @click="openLoginPage">
<span v-if="!props.collapse">
{{ t("Common.Login") }}
</span>
Expand Down Expand Up @@ -668,14 +767,19 @@ onBeforeUnmount(() => {
size="sm"
icon="i-lucide-circle-x"
aria-label="Clear input"
@click="
inputSite = '';
clearValidationError();
"
@click="handleClearInput"
/>
</template>
</UInput>

<recentSites
:visible="showRecentSites"
:sites="filteredRecentSites"
@select="selectRecentSite"
@remove="removeRecentSite"
@clear="clearRecentSites"
/>

<div v-if="hasValidationError" class="text-red-500 text-xs px-1">
{{ errorMessage }}
</div>
Expand Down
73 changes: 73 additions & 0 deletions ui/components/SideBar/recentSites.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<script setup lang="ts">
defineProps<{
sites: string[]
visible: boolean
}>();

const emit = defineEmits<{
(e: "select", site: string): void
(e: "remove", site: string): void
(e: "clear"): void
}>();

const { t } = useI18n();
</script>

<template>
<Transition name="recent-sites">
<div v-if="visible" class="rounded-md border border-black/10 dark:border-white/10 p-1 mt-2">
<div class="flex items-center justify-between px-1">
<span class="text-[11px] text-gray-500 dark:text-gray-400">
{{ t("Login.RecentSites") }}
</span>
<UButton
color="neutral"
variant="link"
size="xs"
:label="t('Login.ClearRecentSites')"
@click="emit('clear')"
/>
</div>
<div class="mt-1 max-h-28 space-y-1 overflow-y-auto">
<div v-for="site in sites" :key="site" class="flex items-center gap-1">
<UButton
color="neutral"
variant="ghost"
size="xs"
class="flex-1 justify-start truncate"
@click="emit('select', site)"
>
<span class="truncate">{{ site }}</span>
</UButton>
<UButton
color="neutral"
variant="ghost"
size="xs"
icon="i-lucide-x"
:aria-label="t('Login.RemoveRecentSite')"
@click.stop="emit('remove', site)"
/>
</div>
</div>
</div>
</Transition>
</template>

<style scoped>
.recent-sites-enter-active,
.recent-sites-leave-active {
transition: opacity 150ms ease, transform 150ms ease;
}

.recent-sites-enter-from,
.recent-sites-leave-to {
opacity: 0;
transform: translateY(-4px);
}

.recent-sites-enter-to,
.recent-sites-leave-from {
opacity: 1;
transform: translateY(0);
}
</style>
8 changes: 7 additions & 1 deletion ui/composables/useSettingManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,11 @@ export const useSettingManager = () => {
persist({ rdpSmartSize: state.rdpSmartSize });
};

const setRecentSites = (sites: string[]) => {
state.recentSites = Array.isArray(sites) ? [...sites] : [];
persist({ recentSites: state.recentSites });
};

return {
...toRefs(state),

Expand All @@ -248,6 +253,7 @@ export const useSettingManager = () => {
setKeyboardLayoutPreference,
setRdpClientOptionPreference,
setRdpColorQualityPreference,
setRdpSmartSizePreference
setRdpSmartSizePreference,
setRecentSites
};
};
4 changes: 3 additions & 1 deletion ui/composables/useSettingStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface UserSettingPersistedState {
rdpClientOption: string[]
rdpColorQuality: string
rdpSmartSize: string
recentSites: string[]
}

const STORE_PATH = "user-setting.json";
Expand All @@ -50,7 +51,8 @@ const DEFAULT_STATE: UserSettingPersistedState = {
keyboardLayout: "en-us-qwerty",
rdpClientOption: [],
rdpColorQuality: "32",
rdpSmartSize: "0"
rdpSmartSize: "0",
recentSites: []
};

let storeInstance: Store | null = null;
Expand Down