Skip to content
Open
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
23 changes: 23 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Share Project Guidelines

## Git Workflow
- Always create feature branches before major work: `feature/<name>`
- Keep main branch clean; merge via pull requests
- Commit messages: concise, imperative ("Add feature", "Fix bug")

## Architecture
- **State management**: Zustand with slice pattern (`src/store/*Slice.js`)
- **Components**: React functional components, MUI for UI
- **Auth**: Auth0 via `src/Auth0/Auth0Proxy.js`, `useAuth0()` hook
- **Privacy/cookies**: `js-cookie` + `src/privacy/Expires.js` (365-day expiry)
- **Navigation**: `navigateToModel()` does full page reload; `navWith()` for SPA nav
- **Dialogs**: Extend `src/Components/Dialog.jsx` base component

## Testing
- `yarn test` for unit tests (Jest + React Testing Library)
- `yarn build` for production build verification

## Key Patterns
- Store slices: export default function `create*Slice(set, get)` returning state + setters
- New UI dialogs: visibility controlled via Zustand store state
- Rate limiting: client-side via `src/privacy/usageTracking.js` (localStorage + cookie backup)
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ test-results
# symlink for test-models repo
public/test-models

# local environment
.env
.env.local

# misc
.DS_Store
.vscode
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "bldrs",
"version": "1.0.1894",
"version": "1.0.1900",
"main": "src/index.jsx",
"license": "AGPL-3.0",
"homepage": "https://github.com/bldrs-ai/Share",
Expand Down
2 changes: 2 additions & 0 deletions src/BaseRoutes.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {useAuth0} from './Auth0/Auth0Proxy'
import PopupAuth from './Components/Auth/PopupAuth'
import PopupCallback from './Components/Auth/PopupCallback'
import {checkOPFSAvailability, setUpGlobalDebugFunctions} from './OPFS/utils'
import {initUsageFromOPFS} from './privacy/usageTracking'
import ShareRoutes from './ShareRoutes'
import Styles from './Styles'
import About from './pages/About'
Expand Down Expand Up @@ -72,6 +73,7 @@ export default function BaseRoutes({testElt = null}) {

if (available) {
setUpGlobalDebugFunctions()
initUsageFromOPFS()
}

setIsOpfsAvailable(available)
Expand Down
12 changes: 12 additions & 0 deletions src/Components/Onboarding/OnboardingOverlay.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {useNavigate} from 'react-router-dom'
import {Box, Fade, Paper, Stack, Typography} from '@mui/material'
import {useTheme} from '@mui/material/styles'
import {FileUpload as FileUploadIcon} from '@mui/icons-material'
import {useAuth0} from '../../Auth0/Auth0Proxy'
import {getUserTier} from '../../privacy/usageTracking'
import useStore from '../../store/useStore'
import {handleFileDrop, handleDragOverOrEnter, handleDragLeave} from '../../utils/dragAndDrop'

Expand All @@ -21,9 +23,13 @@ export default function OnboardingOverlay({isVisible, onClose}) {

// Store state and navigation
const appPrefix = useStore((state) => state.appPrefix)
const appMetadata = useStore((state) => state.appMetadata)
const isOpfsAvailable = useStore((state) => state.isOpfsAvailable)
const setAlert = useStore((state) => state.setAlert)
const setIsUsageLimitDialogVisible = useStore((state) => state.setIsUsageLimitDialogVisible)
const {isAuthenticated} = useAuth0()
const navigate = useNavigate()
const userTier = getUserTier(isAuthenticated, appMetadata)

// Find actual button positions when overlay becomes visible
useEffect(() => {
Expand Down Expand Up @@ -73,6 +79,12 @@ export default function OnboardingOverlay({isVisible, onClose}) {
isOpfsAvailable,
setAlert,
() => onClose(true), // onSuccess callback - close overlay and skip help dialog
undefined,
userTier,
(info) => {
onClose(false)
setIsUsageLimitDialogVisible(true, info)
},
)
}

Expand Down
13 changes: 13 additions & 0 deletions src/Components/Open/GitHubFileBrowser.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React, {ReactElement, useState} from 'react'
import {Button, Stack, Typography} from '@mui/material'
import {navigateBaseOnModelPath} from '../../utils/location'
import {navigateToModel} from '../../utils/navigate'
import {canLoadModel, recordModelLoad} from '../../privacy/usageTracking'
import {useAuth0} from '../../Auth0/Auth0Proxy'
import {pathSuffixSupported} from '../../Filetype'
import {getFilesAndFolders} from '../../net/github/Files'
Expand All @@ -23,6 +24,8 @@ export default function GitHubFileBrowser({
navigate,
orgNamesArr,
setIsDialogDisplayed,
userTier,
onLimitReached,
}) {
const [currentPath, setCurrentPath] = useState('')
const [foldersArr, setFoldersArr] = useState([''])
Expand Down Expand Up @@ -122,6 +125,16 @@ export default function GitHubFileBrowser({

const navigateToFile = () => {
if (pathSuffixSupported(fileName)) {
if (userTier && userTier !== 'pro') {
const check = canLoadModel(userTier)
if (!check.allowed) {
if (onLimitReached) {
onLimitReached(check)
}
return
}
recordModelLoad()
}
const branch = branchName || 'main'
navigateToModel({pathname: navigateBaseOnModelPath(orgName, repoName, branch, `${currentPath}/${fileName}`)}, navigate)
setIsDialogDisplayed(false)
Expand Down
32 changes: 32 additions & 0 deletions src/Components/Open/OpenModelDialog.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {Button, Stack, Typography, TextField} from '@mui/material'
import {useAuth0} from '../../Auth0/Auth0Proxy'
import {checkOPFSAvailability} from '../../OPFS/utils'
import {looksLikeLink, githubUrlOrPathToSharePath} from '../../net/github/utils'
import {getUserTier, canLoadModel, recordModelLoad} from '../../privacy/usageTracking'
import useStore from '../../store/useStore'
import {loadLocalFile, loadLocalFileFallback} from '../../utils/loader'
import {disablePageReloadApprovalCheck} from '../../utils/event'
Expand Down Expand Up @@ -33,14 +34,35 @@ export default function OpenModelDialog({
const tabLabels = [LABEL_LOCAL, LABEL_GITHUB, LABEL_SAMPLES]
const {isAuthenticated, user} = useAuth0()
const appPrefix = useStore((state) => state.appPrefix)
const appMetadata = useStore((state) => state.appMetadata)
const setCurrentTab = useStore((state) => state.setCurrentTab)
const currentTab = useStore((state) => state.currentTab)
const setIsUsageLimitDialogVisible = useStore((state) => state.setIsUsageLimitDialogVisible)
const isOpfsAvailable = checkOPFSAvailability()
const isMobile = useIsMobile()
const userTier = getUserTier(isAuthenticated, appMetadata)

/**
* Show usage limit dialog and close this dialog.
*
* @param {object} check Rate limit check result
*/
const showLimitDialog = (check) => {
setIsDialogDisplayed(false)
setIsUsageLimitDialogVisible(true, {reason: check.reason, stats: check.stats})
}

const openFile = () => {
// Rate-limit check for local file open
if (userTier !== 'pro') {
const check = canLoadModel(userTier)
if (!check.allowed) {
showLimitDialog(check)
return
}
}
const onLoad = (filename) => {
recordModelLoad()
// Use full reload when opening a new local file
disablePageReloadApprovalCheck()
navigateToModel(`${appPrefix}/v/new/${filename}`, navigate)
Expand Down Expand Up @@ -106,6 +128,14 @@ export default function OpenModelDialog({
onChange={(event) => {
const ghPath = event.target.value
if (looksLikeLink(ghPath)) {
if (userTier !== 'pro') {
const check = canLoadModel(userTier)
if (!check.allowed) {
showLimitDialog(check)
return
}
recordModelLoad()
}
setIsDialogDisplayed(false)
navigateToModel(githubUrlOrPathToSharePath(ghPath), navigate)
}
Expand All @@ -117,6 +147,8 @@ export default function OpenModelDialog({
orgNamesArr={orgNamesArr}
user={user}
setIsDialogDisplayed={setIsDialogDisplayed}
userTier={userTier}
onLimitReached={(check) => showLimitDialog(check)}
/>}
{!isAuthenticated && <PleaseLogin/>}
</Stack>
Expand Down
38 changes: 38 additions & 0 deletions src/Components/Search/SearchBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@ import React, {ReactElement, useRef, useEffect, useState} from 'react'
import {useLocation, useNavigate, useSearchParams} from 'react-router-dom'
import {Autocomplete, TextField} from '@mui/material'
import {Close as CloseIcon} from '@mui/icons-material'
import {useAuth0} from '../../Auth0/Auth0Proxy'
import {looksLikeLink, githubUrlOrPathToSharePath} from '../../net/github/utils'
import {getUserTier, canLoadModel, recordModelLoad} from '../../privacy/usageTracking'
import {isSampleOrExemptPath} from '../../privacy/sampleModelPaths'
import {processExternalUrl} from '../../routes/routes'
import useStore from '../../store/useStore'
import {disablePageReloadApprovalCheck} from '../../utils/event'
import {navWithSearchParamRemoved, navigateToModel} from '../../utils/navigate'
import {assertDefined} from '../../utils/assert'
Expand All @@ -28,11 +32,39 @@ export default function SearchBar({
assertDefined(placeholder, isGitHubSearch)
const location = useLocation()
const navigate = useNavigate()
const {isAuthenticated} = useAuth0()
const appMetadata = useStore((state) => state.appMetadata)
const setIsUsageLimitDialogVisible = useStore((state) => state.setIsUsageLimitDialogVisible)
const userTier = getUserTier(isAuthenticated, appMetadata)
const [searchParams, setSearchParams] = useSearchParams()
const [inputText, setInputText] = useState('')
const [error, setError] = useState('')
const searchInputRef = useRef(null)

/**
* Check rate limit and show dialog if exceeded.
*
* @param {string|object} modelPath The model path to check
* @return {boolean} true if rate-limited (blocked)
*/
const checkRateLimit = (modelPath) => {
if (userTier === 'pro') {
return false
}
// Sample/exempt paths don't count
const pathStr = typeof modelPath === 'string' ? modelPath : modelPath?.pathname || ''
if (isSampleOrExemptPath(pathStr)) {
return false
}
const check = canLoadModel(userTier)
if (!check.allowed) {
setIsUsageLimitDialogVisible(true, {reason: check.reason, stats: check.stats})
return true
}
recordModelLoad()
return false
}


useEffect(() => {
if (location.search) {
Expand Down Expand Up @@ -68,6 +100,9 @@ export default function SearchBar({
if (looksLikeLink(inputText)) {
try {
const modelPath = githubUrlOrPathToSharePath(inputText)
if (checkRateLimit(modelPath)) {
return
}
disablePageReloadApprovalCheck()
navigateToModel(modelPath, navigate)
if (onSuccess) {
Expand All @@ -82,6 +117,9 @@ export default function SearchBar({
const result = processExternalUrl(window.location.href, inputText)
if (result) {
try {
if (checkRateLimit(`/share/v/u/${inputText}`)) {
return
}
disablePageReloadApprovalCheck()
navigate(`/share/v/u/${inputText}`)
if (onSuccess) {
Expand Down
122 changes: 122 additions & 0 deletions src/Components/UsageLimitDialog.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import React, {ReactElement} from 'react'
import {useNavigate} from 'react-router-dom'
import {Box, Button, LinearProgress, Stack, Typography} from '@mui/material'
import {Lock as LockIcon} from '@mui/icons-material'
import {useAuth0} from '../Auth0/Auth0Proxy'
import useStore from '../store/useStore'
import Dialog from './Dialog'


/**
* Dialog shown when a user hits their model load rate limit.
* Anonymous users are prompted to sign in; free users are prompted to upgrade.
*
* @return {ReactElement}
*/
export default function UsageLimitDialog() {
const navigate = useNavigate()
const {isAuthenticated} = useAuth0()
const isVisible = useStore((state) => state.isUsageLimitDialogVisible)
const info = useStore((state) => state.usageLimitInfo)
const setIsUsageLimitDialogVisible = useStore((state) => state.setIsUsageLimitDialogVisible)
const setIsLoginVisible = useStore((state) => state.setIsLoginVisible)

if (!isVisible || !info) {
return null
}

const {stats} = info
const headerText = isAuthenticated ? 'Model limit reached' : 'Sign in to load more models'

const onClose = () => setIsUsageLimitDialogVisible(false)

const onPrimaryCTA = () => {
onClose()
if (isAuthenticated) {
window.location.href = '/subscribe/'
} else {
setIsLoginVisible(true)
}
}

const onLearnMore = () => {
onClose()
navigate('/pricing')
}

return (
<Dialog
headerIcon={<LockIcon className='icon-share'/>}
headerText={headerText}
isDialogDisplayed={isVisible}
setIsDialogDisplayed={onClose}
>
<Stack spacing={2}>
<UsageBar
label='Today'
used={stats.dailyUsed}
limit={stats.dailyLimit}
/>
<UsageBar
label='This month'
used={stats.monthlyUsed}
limit={stats.monthlyLimit}
/>

<Button
variant='contained'
fullWidth
onClick={onPrimaryCTA}
data-testid='usage-limit-primary-cta'
>
{isAuthenticated ? 'Upgrade to Pro' : 'Sign in for free'}
</Button>

<Button
variant='text'
size='small'
onClick={onLearnMore}
data-testid='usage-limit-learn-more'
>
Learn more
</Button>

<Typography variant='caption' sx={{textAlign: 'center', color: 'text.secondary'}}>
Sample models are always free to browse
</Typography>
</Stack>
</Dialog>
)
}


const FULL_PERCENT = 100

/**
* A simple labeled progress bar for usage display.
*
* @property {string} label Display label for the bar
* @property {number} used Current usage count
* @property {number} limit Maximum allowed count
* @return {ReactElement}
*/
function UsageBar({label, used, limit}) {
const percent = limit === Infinity ? 0 : Math.min((used / limit) * FULL_PERCENT, FULL_PERCENT)
const limitDisplay = limit === Infinity ? '\u221E' : limit
return (
<Box>
<Box sx={{display: 'flex', justifyContent: 'space-between', mb: 0.5}}>
<Typography variant='body2'>{label}</Typography>
<Typography variant='body2'>{used}/{limitDisplay}</Typography>
</Box>
<LinearProgress
variant='determinate'
value={percent}
sx={{
height: 8,
borderRadius: 4,
}}
/>
</Box>
)
}
Loading