Spaces:
Running
Running
feat: restore interactive like with session cookies
Browse filesSince the site is served from huggingface.co/reachy-mini (same origin
as the API), we can use credentials:'include' to send HF session
cookies with like requests. This bypasses the OAuth token limitation
on the POST /api/spaces/{id}/like endpoint.
Co-authored-by: Cursor <cursoragent@cursor.com>
- src/components/InstallModal.jsx +4 -9
- src/context/AuthContext.jsx +59 -23
- src/pages/Apps.jsx +11 -27
src/components/InstallModal.jsx
CHANGED
|
@@ -21,7 +21,7 @@ import ComputerIcon from '@mui/icons-material/Computer';
|
|
| 21 |
import { useAuth } from '../context/AuthContext';
|
| 22 |
|
| 23 |
function InstallModal({ open, onClose, app }) {
|
| 24 |
-
const { isLoggedIn, isSpaceLiked,
|
| 25 |
// Detect Linux users
|
| 26 |
const isLinux = useMemo(() => {
|
| 27 |
if (typeof navigator === 'undefined') return false;
|
|
@@ -171,19 +171,14 @@ function InstallModal({ open, onClose, app }) {
|
|
| 171 |
{/* Stats row */}
|
| 172 |
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mt: 1 }}>
|
| 173 |
<Tooltip
|
| 174 |
-
title={isLoggedIn ? '
|
| 175 |
arrow
|
| 176 |
placement="top"
|
|
|
|
| 177 |
>
|
| 178 |
<Box
|
| 179 |
component="button"
|
| 180 |
-
onClick={() =>
|
| 181 |
-
if (!isLoggedIn) {
|
| 182 |
-
login();
|
| 183 |
-
return;
|
| 184 |
-
}
|
| 185 |
-
openSpaceForLike(app.id);
|
| 186 |
-
}}
|
| 187 |
sx={{
|
| 188 |
display: 'flex',
|
| 189 |
alignItems: 'center',
|
|
|
|
| 21 |
import { useAuth } from '../context/AuthContext';
|
| 22 |
|
| 23 |
function InstallModal({ open, onClose, app }) {
|
| 24 |
+
const { isLoggedIn, isSpaceLiked, toggleLike } = useAuth();
|
| 25 |
// Detect Linux users
|
| 26 |
const isLinux = useMemo(() => {
|
| 27 |
if (typeof navigator === 'undefined') return false;
|
|
|
|
| 171 |
{/* Stats row */}
|
| 172 |
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mt: 1 }}>
|
| 173 |
<Tooltip
|
| 174 |
+
title={isLoggedIn ? '' : 'Sign in to like this app'}
|
| 175 |
arrow
|
| 176 |
placement="top"
|
| 177 |
+
disableHoverListener={isLoggedIn}
|
| 178 |
>
|
| 179 |
<Box
|
| 180 |
component="button"
|
| 181 |
+
onClick={() => toggleLike(app.id)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
sx={{
|
| 183 |
display: 'flex',
|
| 184 |
alignItems: 'center',
|
src/context/AuthContext.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
| 2 |
import { oauthLoginUrl, oauthHandleRedirectIfPresent } from '@huggingface/hub';
|
| 3 |
|
| 4 |
const AuthContext = createContext(null);
|
|
@@ -47,6 +47,7 @@ export function AuthProvider({ children }) {
|
|
| 47 |
const [likedSpaceIds, setLikedSpaceIds] = useState(new Set());
|
| 48 |
const [isLoading, setIsLoading] = useState(true);
|
| 49 |
const [oauthConfig, setOauthConfig] = useState(null); // { clientId, scopes }
|
|
|
|
| 50 |
|
| 51 |
// On mount: fetch OAuth config + check if user just completed OAuth redirect
|
| 52 |
useEffect(() => {
|
|
@@ -123,30 +124,66 @@ export function AuthProvider({ children }) {
|
|
| 123 |
);
|
| 124 |
|
| 125 |
/**
|
| 126 |
-
*
|
| 127 |
-
*
|
| 128 |
-
*
|
| 129 |
-
* repository from a script." — huggingface_hub SDK).
|
| 130 |
-
* Reading likes works fine; writing requires the native HF session.
|
| 131 |
*/
|
| 132 |
-
const
|
| 133 |
-
(spaceId) => {
|
| 134 |
-
if (!
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
},
|
| 137 |
-
[]
|
| 138 |
);
|
| 139 |
|
| 140 |
-
/**
|
| 141 |
-
* Re-fetch user's liked spaces (call after user returns from HF page).
|
| 142 |
-
*/
|
| 143 |
-
const refreshLikes = useCallback(async () => {
|
| 144 |
-
if (!user?.preferredUsername) return;
|
| 145 |
-
const likes = await fetchUserLikedSpaces(user.preferredUsername);
|
| 146 |
-
setLikedSpaceIds(likes);
|
| 147 |
-
console.log(`[Auth] Refreshed likes: ${likes.size} liked spaces`);
|
| 148 |
-
}, [user]);
|
| 149 |
-
|
| 150 |
return (
|
| 151 |
<AuthContext.Provider
|
| 152 |
value={{
|
|
@@ -158,8 +195,7 @@ export function AuthProvider({ children }) {
|
|
| 158 |
login,
|
| 159 |
logout,
|
| 160 |
isSpaceLiked,
|
| 161 |
-
|
| 162 |
-
refreshLikes,
|
| 163 |
}}
|
| 164 |
>
|
| 165 |
{children}
|
|
|
|
| 1 |
+
import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
|
| 2 |
import { oauthLoginUrl, oauthHandleRedirectIfPresent } from '@huggingface/hub';
|
| 3 |
|
| 4 |
const AuthContext = createContext(null);
|
|
|
|
| 47 |
const [likedSpaceIds, setLikedSpaceIds] = useState(new Set());
|
| 48 |
const [isLoading, setIsLoading] = useState(true);
|
| 49 |
const [oauthConfig, setOauthConfig] = useState(null); // { clientId, scopes }
|
| 50 |
+
const pendingLikes = useRef(new Set()); // Track in-flight like/unlike requests
|
| 51 |
|
| 52 |
// On mount: fetch OAuth config + check if user just completed OAuth redirect
|
| 53 |
useEffect(() => {
|
|
|
|
| 124 |
);
|
| 125 |
|
| 126 |
/**
|
| 127 |
+
* Toggle like/unlike with optimistic update.
|
| 128 |
+
* Uses credentials: 'include' to send HF session cookies — this works when
|
| 129 |
+
* the page is served from huggingface.co (same-origin with the API).
|
|
|
|
|
|
|
| 130 |
*/
|
| 131 |
+
const toggleLike = useCallback(
|
| 132 |
+
async (spaceId) => {
|
| 133 |
+
if (!user) {
|
| 134 |
+
login();
|
| 135 |
+
return;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
const spaceIdLower = spaceId?.toLowerCase();
|
| 139 |
+
if (!spaceIdLower) return;
|
| 140 |
+
|
| 141 |
+
// Prevent duplicate requests for the same space
|
| 142 |
+
if (pendingLikes.current.has(spaceIdLower)) return;
|
| 143 |
+
pendingLikes.current.add(spaceIdLower);
|
| 144 |
+
|
| 145 |
+
const wasLiked = likedSpaceIds.has(spaceIdLower);
|
| 146 |
+
const method = wasLiked ? 'DELETE' : 'POST';
|
| 147 |
+
|
| 148 |
+
// Optimistic update
|
| 149 |
+
setLikedSpaceIds((prev) => {
|
| 150 |
+
const next = new Set(prev);
|
| 151 |
+
if (wasLiked) {
|
| 152 |
+
next.delete(spaceIdLower);
|
| 153 |
+
} else {
|
| 154 |
+
next.add(spaceIdLower);
|
| 155 |
+
}
|
| 156 |
+
return next;
|
| 157 |
+
});
|
| 158 |
+
|
| 159 |
+
try {
|
| 160 |
+
const response = await fetch(`${HF_API}/api/spaces/${spaceId}/like`, {
|
| 161 |
+
method,
|
| 162 |
+
credentials: 'include',
|
| 163 |
+
});
|
| 164 |
+
|
| 165 |
+
if (!response.ok) {
|
| 166 |
+
throw new Error(`Like API returned ${response.status}`);
|
| 167 |
+
}
|
| 168 |
+
} catch (err) {
|
| 169 |
+
console.error(`[Auth] Failed to ${wasLiked ? 'unlike' : 'like'} ${spaceId}:`, err);
|
| 170 |
+
// Revert optimistic update on error
|
| 171 |
+
setLikedSpaceIds((prev) => {
|
| 172 |
+
const reverted = new Set(prev);
|
| 173 |
+
if (wasLiked) {
|
| 174 |
+
reverted.add(spaceIdLower);
|
| 175 |
+
} else {
|
| 176 |
+
reverted.delete(spaceIdLower);
|
| 177 |
+
}
|
| 178 |
+
return reverted;
|
| 179 |
+
});
|
| 180 |
+
} finally {
|
| 181 |
+
pendingLikes.current.delete(spaceIdLower);
|
| 182 |
+
}
|
| 183 |
},
|
| 184 |
+
[user, likedSpaceIds, login]
|
| 185 |
);
|
| 186 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
return (
|
| 188 |
<AuthContext.Provider
|
| 189 |
value={{
|
|
|
|
| 195 |
login,
|
| 196 |
logout,
|
| 197 |
isSpaceLiked,
|
| 198 |
+
toggleLike,
|
|
|
|
| 199 |
}}
|
| 200 |
>
|
| 201 |
{children}
|
src/pages/Apps.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { useState, useMemo, useCallback
|
| 2 |
import {
|
| 3 |
Box,
|
| 4 |
Container,
|
|
@@ -30,7 +30,7 @@ import { useAuth } from '../context/AuthContext';
|
|
| 30 |
import InstallModal from '../components/InstallModal';
|
| 31 |
|
| 32 |
// App Card Component
|
| 33 |
-
function AppCard({ app, onInstallClick, isLiked,
|
| 34 |
const isOfficial = app.isOfficial;
|
| 35 |
const isPythonApp = app.extra?.isPythonApp !== false; // Default to true for backwards compatibility
|
| 36 |
const cardData = app.extra?.cardData || {};
|
|
@@ -175,18 +175,19 @@ function AppCard({ app, onInstallClick, isLiked, onLikeClick, isLoggedIn }) {
|
|
| 175 |
)}
|
| 176 |
</Box>
|
| 177 |
|
| 178 |
-
{/* Likes -
|
| 179 |
<Tooltip
|
| 180 |
-
title={isLoggedIn ? '
|
| 181 |
arrow
|
| 182 |
placement="top"
|
|
|
|
| 183 |
>
|
| 184 |
<Box
|
| 185 |
component="button"
|
| 186 |
onClick={(e) => {
|
| 187 |
e.preventDefault();
|
| 188 |
e.stopPropagation();
|
| 189 |
-
|
| 190 |
}}
|
| 191 |
sx={{
|
| 192 |
display: 'flex',
|
|
@@ -333,7 +334,7 @@ function AppCard({ app, onInstallClick, isLiked, onLikeClick, isLoggedIn }) {
|
|
| 333 |
export default function Apps() {
|
| 334 |
// Get apps from context (cached globally)
|
| 335 |
const { apps, loading, error } = useApps();
|
| 336 |
-
const { user, isLoggedIn, isOAuthAvailable, login, logout, isSpaceLiked,
|
| 337 |
const [searchQuery, setSearchQuery] = useState('');
|
| 338 |
const [officialOnly, setOfficialOnly] = useState(false);
|
| 339 |
|
|
@@ -351,30 +352,13 @@ export default function Apps() {
|
|
| 351 |
setSelectedApp(null);
|
| 352 |
};
|
| 353 |
|
| 354 |
-
|
| 355 |
-
const handleLikeClick = useCallback(
|
| 356 |
(spaceId) => {
|
| 357 |
-
|
| 358 |
-
login();
|
| 359 |
-
return;
|
| 360 |
-
}
|
| 361 |
-
openSpaceForLike(spaceId);
|
| 362 |
},
|
| 363 |
-
[
|
| 364 |
);
|
| 365 |
|
| 366 |
-
// Refresh likes when the tab regains focus (user may have liked on HF)
|
| 367 |
-
useEffect(() => {
|
| 368 |
-
if (!isLoggedIn) return;
|
| 369 |
-
const handleVisibilityChange = () => {
|
| 370 |
-
if (document.visibilityState === 'visible') {
|
| 371 |
-
refreshLikes();
|
| 372 |
-
}
|
| 373 |
-
};
|
| 374 |
-
document.addEventListener('visibilitychange', handleVisibilityChange);
|
| 375 |
-
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
|
| 376 |
-
}, [isLoggedIn, refreshLikes]);
|
| 377 |
-
|
| 378 |
// Filter apps based on search and official toggle
|
| 379 |
const filteredApps = useMemo(() => {
|
| 380 |
let result = apps;
|
|
@@ -714,7 +698,7 @@ export default function Apps() {
|
|
| 714 |
app={app}
|
| 715 |
onInstallClick={handleInstallClick}
|
| 716 |
isLiked={isSpaceLiked(app.id)}
|
| 717 |
-
|
| 718 |
isLoggedIn={isLoggedIn}
|
| 719 |
/>
|
| 720 |
))}
|
|
|
|
| 1 |
+
import { useState, useMemo, useCallback } from 'react';
|
| 2 |
import {
|
| 3 |
Box,
|
| 4 |
Container,
|
|
|
|
| 30 |
import InstallModal from '../components/InstallModal';
|
| 31 |
|
| 32 |
// App Card Component
|
| 33 |
+
function AppCard({ app, onInstallClick, isLiked, onToggleLike, isLoggedIn }) {
|
| 34 |
const isOfficial = app.isOfficial;
|
| 35 |
const isPythonApp = app.extra?.isPythonApp !== false; // Default to true for backwards compatibility
|
| 36 |
const cardData = app.extra?.cardData || {};
|
|
|
|
| 175 |
)}
|
| 176 |
</Box>
|
| 177 |
|
| 178 |
+
{/* Likes - Interactive */}
|
| 179 |
<Tooltip
|
| 180 |
+
title={isLoggedIn ? '' : 'Sign in to like this app'}
|
| 181 |
arrow
|
| 182 |
placement="top"
|
| 183 |
+
disableHoverListener={isLoggedIn}
|
| 184 |
>
|
| 185 |
<Box
|
| 186 |
component="button"
|
| 187 |
onClick={(e) => {
|
| 188 |
e.preventDefault();
|
| 189 |
e.stopPropagation();
|
| 190 |
+
onToggleLike?.(app.id);
|
| 191 |
}}
|
| 192 |
sx={{
|
| 193 |
display: 'flex',
|
|
|
|
| 334 |
export default function Apps() {
|
| 335 |
// Get apps from context (cached globally)
|
| 336 |
const { apps, loading, error } = useApps();
|
| 337 |
+
const { user, isLoggedIn, isOAuthAvailable, login, logout, isSpaceLiked, toggleLike } = useAuth();
|
| 338 |
const [searchQuery, setSearchQuery] = useState('');
|
| 339 |
const [officialOnly, setOfficialOnly] = useState(false);
|
| 340 |
|
|
|
|
| 352 |
setSelectedApp(null);
|
| 353 |
};
|
| 354 |
|
| 355 |
+
const handleToggleLike = useCallback(
|
|
|
|
| 356 |
(spaceId) => {
|
| 357 |
+
toggleLike(spaceId);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
},
|
| 359 |
+
[toggleLike]
|
| 360 |
);
|
| 361 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
// Filter apps based on search and official toggle
|
| 363 |
const filteredApps = useMemo(() => {
|
| 364 |
let result = apps;
|
|
|
|
| 698 |
app={app}
|
| 699 |
onInstallClick={handleInstallClick}
|
| 700 |
isLiked={isSpaceLiked(app.id)}
|
| 701 |
+
onToggleLike={handleToggleLike}
|
| 702 |
isLoggedIn={isLoggedIn}
|
| 703 |
/>
|
| 704 |
))}
|