Spaces:
Running
Running
fix: replace programmatic like with redirect to HF Space page
Browse filesHuggingFace intentionally blocks programmatic likes via API tokens to
prevent spam. Replace the failing POST /api/spaces/{id}/like call with
a redirect to the Space page on huggingface.co where users can like
natively. Likes are refreshed automatically when the tab regains focus.
Also remove the now-unnecessary write-discussions OAuth scope.
Co-authored-by: Cursor <cursoragent@cursor.com>
- README.md +0 -2
- src/components/InstallModal.jsx +9 -4
- src/context/AuthContext.jsx +27 -63
- src/pages/Apps.jsx +27 -11
README.md
CHANGED
|
@@ -6,8 +6,6 @@ colorTo: purple
|
|
| 6 |
sdk: docker
|
| 7 |
pinned: true
|
| 8 |
hf_oauth: true
|
| 9 |
-
hf_oauth_scopes:
|
| 10 |
-
- write-discussions
|
| 11 |
thumbnail: >-
|
| 12 |
https://cdn-uploads.huggingface.co/production/uploads/671faa3a541a76b548647676/XWNDlOu0R4fHXR0kCW3Wd.png
|
| 13 |
short_description: All about Reachy Mini, from building to getting started
|
|
|
|
| 6 |
sdk: docker
|
| 7 |
pinned: true
|
| 8 |
hf_oauth: true
|
|
|
|
|
|
|
| 9 |
thumbnail: >-
|
| 10 |
https://cdn-uploads.huggingface.co/production/uploads/671faa3a541a76b548647676/XWNDlOu0R4fHXR0kCW3Wd.png
|
| 11 |
short_description: All about Reachy Mini, from building to getting started
|
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,14 +171,19 @@ function InstallModal({ open, onClose, app }) {
|
|
| 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={() =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
sx={{
|
| 183 |
display: 'flex',
|
| 184 |
alignItems: 'center',
|
|
|
|
| 21 |
import { useAuth } from '../context/AuthContext';
|
| 22 |
|
| 23 |
function InstallModal({ open, onClose, app }) {
|
| 24 |
+
const { isLoggedIn, isSpaceLiked, openSpaceForLike, login } = 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 ? 'Like on Hugging Face' : 'Sign in to like this app'}
|
| 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',
|
src/context/AuthContext.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { createContext, useContext, useState, useEffect, useCallback
|
| 2 |
import { oauthLoginUrl, oauthHandleRedirectIfPresent } from '@huggingface/hub';
|
| 3 |
|
| 4 |
const AuthContext = createContext(null);
|
|
@@ -43,11 +43,10 @@ async function fetchUserLikedSpaces(username) {
|
|
| 43 |
|
| 44 |
// Provider component
|
| 45 |
export function AuthProvider({ children }) {
|
| 46 |
-
const [user, setUser] = useState(null); // { name, avatarUrl
|
| 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(() => {
|
|
@@ -65,12 +64,11 @@ export function AuthProvider({ children }) {
|
|
| 65 |
// Check if user just completed OAuth redirect
|
| 66 |
const oauthResult = await oauthHandleRedirectIfPresent();
|
| 67 |
if (oauthResult) {
|
| 68 |
-
const {
|
| 69 |
const userData = {
|
| 70 |
name: userInfo.name,
|
| 71 |
preferredUsername: userInfo.preferred_username || userInfo.name,
|
| 72 |
avatarUrl: userInfo.picture,
|
| 73 |
-
accessToken: accessToken,
|
| 74 |
};
|
| 75 |
setUser(userData);
|
| 76 |
|
|
@@ -124,66 +122,31 @@ export function AuthProvider({ children }) {
|
|
| 124 |
[likedSpaceIds]
|
| 125 |
);
|
| 126 |
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
// Prevent duplicate requests for the same space
|
| 140 |
-
if (pendingLikes.current.has(spaceIdLower)) return;
|
| 141 |
-
pendingLikes.current.add(spaceIdLower);
|
| 142 |
-
|
| 143 |
-
const wasLiked = likedSpaceIds.has(spaceIdLower);
|
| 144 |
-
const method = wasLiked ? 'DELETE' : 'POST';
|
| 145 |
-
|
| 146 |
-
// Optimistic update
|
| 147 |
-
setLikedSpaceIds((prev) => {
|
| 148 |
-
const next = new Set(prev);
|
| 149 |
-
if (wasLiked) {
|
| 150 |
-
next.delete(spaceIdLower);
|
| 151 |
-
} else {
|
| 152 |
-
next.add(spaceIdLower);
|
| 153 |
-
}
|
| 154 |
-
return next;
|
| 155 |
-
});
|
| 156 |
-
|
| 157 |
-
try {
|
| 158 |
-
const response = await fetch(`${HF_API}/api/spaces/${spaceId}/like`, {
|
| 159 |
-
method,
|
| 160 |
-
headers: {
|
| 161 |
-
Authorization: `Bearer ${user.accessToken}`,
|
| 162 |
-
},
|
| 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 |
-
[
|
| 185 |
);
|
| 186 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
return (
|
| 188 |
<AuthContext.Provider
|
| 189 |
value={{
|
|
@@ -195,7 +158,8 @@ export function AuthProvider({ children }) {
|
|
| 195 |
login,
|
| 196 |
logout,
|
| 197 |
isSpaceLiked,
|
| 198 |
-
|
|
|
|
| 199 |
}}
|
| 200 |
>
|
| 201 |
{children}
|
|
|
|
| 1 |
+
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
| 2 |
import { oauthLoginUrl, oauthHandleRedirectIfPresent } from '@huggingface/hub';
|
| 3 |
|
| 4 |
const AuthContext = createContext(null);
|
|
|
|
| 43 |
|
| 44 |
// Provider component
|
| 45 |
export function AuthProvider({ children }) {
|
| 46 |
+
const [user, setUser] = useState(null); // { name, avatarUrl }
|
| 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(() => {
|
|
|
|
| 64 |
// Check if user just completed OAuth redirect
|
| 65 |
const oauthResult = await oauthHandleRedirectIfPresent();
|
| 66 |
if (oauthResult) {
|
| 67 |
+
const { userInfo } = oauthResult;
|
| 68 |
const userData = {
|
| 69 |
name: userInfo.name,
|
| 70 |
preferredUsername: userInfo.preferred_username || userInfo.name,
|
| 71 |
avatarUrl: userInfo.picture,
|
|
|
|
| 72 |
};
|
| 73 |
setUser(userData);
|
| 74 |
|
|
|
|
| 122 |
[likedSpaceIds]
|
| 123 |
);
|
| 124 |
|
| 125 |
+
/**
|
| 126 |
+
* Open the HF Space page so the user can like it natively.
|
| 127 |
+
* HuggingFace intentionally blocks programmatic likes via API tokens
|
| 128 |
+
* to prevent spam ("To prevent spam usage, it is not possible to like a
|
| 129 |
+
* repository from a script." — huggingface_hub SDK).
|
| 130 |
+
* Reading likes works fine; writing requires the native HF session.
|
| 131 |
+
*/
|
| 132 |
+
const openSpaceForLike = useCallback(
|
| 133 |
+
(spaceId) => {
|
| 134 |
+
if (!spaceId) return;
|
| 135 |
+
window.open(`${HF_API}/spaces/${spaceId}`, '_blank', 'noopener,noreferrer');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
login,
|
| 159 |
logout,
|
| 160 |
isSpaceLiked,
|
| 161 |
+
openSpaceForLike,
|
| 162 |
+
refreshLikes,
|
| 163 |
}}
|
| 164 |
>
|
| 165 |
{children}
|
src/pages/Apps.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { useState, useMemo, useCallback } from 'react';
|
| 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,19 +175,18 @@ function AppCard({ app, onInstallClick, isLiked, onToggleLike, isLoggedIn }) {
|
|
| 175 |
)}
|
| 176 |
</Box>
|
| 177 |
|
| 178 |
-
{/* Likes -
|
| 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 |
-
|
| 191 |
}}
|
| 192 |
sx={{
|
| 193 |
display: 'flex',
|
|
@@ -334,7 +333,7 @@ function AppCard({ app, onInstallClick, isLiked, onToggleLike, isLoggedIn }) {
|
|
| 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,
|
| 338 |
const [searchQuery, setSearchQuery] = useState('');
|
| 339 |
const [officialOnly, setOfficialOnly] = useState(false);
|
| 340 |
|
|
@@ -352,13 +351,30 @@ export default function Apps() {
|
|
| 352 |
setSelectedApp(null);
|
| 353 |
};
|
| 354 |
|
| 355 |
-
|
|
|
|
| 356 |
(spaceId) => {
|
| 357 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
},
|
| 359 |
-
[
|
| 360 |
);
|
| 361 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
// Filter apps based on search and official toggle
|
| 363 |
const filteredApps = useMemo(() => {
|
| 364 |
let result = apps;
|
|
@@ -698,7 +714,7 @@ export default function Apps() {
|
|
| 698 |
app={app}
|
| 699 |
onInstallClick={handleInstallClick}
|
| 700 |
isLiked={isSpaceLiked(app.id)}
|
| 701 |
-
|
| 702 |
isLoggedIn={isLoggedIn}
|
| 703 |
/>
|
| 704 |
))}
|
|
|
|
| 1 |
+
import { useState, useMemo, useCallback, useEffect } 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, onLikeClick, 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 - Click opens HF Space page to like natively */}
|
| 179 |
<Tooltip
|
| 180 |
+
title={isLoggedIn ? 'Like on Hugging Face' : 'Sign in to like this app'}
|
| 181 |
arrow
|
| 182 |
placement="top"
|
|
|
|
| 183 |
>
|
| 184 |
<Box
|
| 185 |
component="button"
|
| 186 |
onClick={(e) => {
|
| 187 |
e.preventDefault();
|
| 188 |
e.stopPropagation();
|
| 189 |
+
onLikeClick?.(app.id);
|
| 190 |
}}
|
| 191 |
sx={{
|
| 192 |
display: 'flex',
|
|
|
|
| 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, openSpaceForLike, refreshLikes } = useAuth();
|
| 337 |
const [searchQuery, setSearchQuery] = useState('');
|
| 338 |
const [officialOnly, setOfficialOnly] = useState(false);
|
| 339 |
|
|
|
|
| 351 |
setSelectedApp(null);
|
| 352 |
};
|
| 353 |
|
| 354 |
+
// Handle like click: if logged in, open HF page; if not, prompt login
|
| 355 |
+
const handleLikeClick = useCallback(
|
| 356 |
(spaceId) => {
|
| 357 |
+
if (!isLoggedIn) {
|
| 358 |
+
login();
|
| 359 |
+
return;
|
| 360 |
+
}
|
| 361 |
+
openSpaceForLike(spaceId);
|
| 362 |
},
|
| 363 |
+
[isLoggedIn, login, openSpaceForLike]
|
| 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 |
app={app}
|
| 715 |
onInstallClick={handleInstallClick}
|
| 716 |
isLiked={isSpaceLiked(app.id)}
|
| 717 |
+
onLikeClick={handleLikeClick}
|
| 718 |
isLoggedIn={isLoggedIn}
|
| 719 |
/>
|
| 720 |
))}
|