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
1 change: 1 addition & 0 deletions apps/frontend/.env.example
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
TURNSTILE_PUBLIC=1x00000000000000000000AA
DEV_API_TARGET=http://localhost:8000
11 changes: 7 additions & 4 deletions apps/frontend/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,14 +99,17 @@ export default defineNuxtConfig({
name: 'Blueprint',
},
nitro: {
// Allow switching dev API target via DEV_API_TARGET (e.g., https://blueprint.zip)
// Falls back to local backend when unset.
devProxy: {
'/api': {
// Change to https://blueprint.zip/api to use the production API
// Local API is http://localhost:8000/api
target: 'http://localhost:8000/api',
target: process.env.DEV_API_TARGET?.replace(/\/$/, '') || 'http://localhost:8000',
changeOrigin: true,
Comment on lines +106 to +107
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The proxy target URL transformation is incomplete. The original target was 'http://localhost:8000/api' but now it's 'http://localhost:8000'. When proxying '/api' requests, the path '/api' will be appended to the target, resulting in 'http://localhost:8000/api' which matches the original behavior. However, if DEV_API_TARGET is set to 'https://blueprint.zip', requests to '/api/endpoint' will be proxied to 'https://blueprint.zip/api/endpoint', which may be incorrect if the API is actually at 'https://blueprint.zip/api'. Verify that this path handling is intentional and matches your API structure.

Copilot uses AI. Check for mistakes.
},
'/browse/sitemap.xml': {
target: process.env.DEV_API_TARGET?.replace(/\/$/, '') || 'http://localhost:8000',
changeOrigin: true,
},
'/browse/sitemap.xml': 'http://localhost:8000/browse/sitemap.xml',
'/yay': 'https://blueprint.zip/yay',
},
routeRules: {
Expand Down
81 changes: 81 additions & 0 deletions apps/frontend/src/components/ui/browse/Filters.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,35 @@
</button>
</div>
</div>

<div
class="divide-y divide-neutral-700 rounded-2xl border border-neutral-700 transition-colors focus-within:divide-neutral-500 focus-within:border-neutral-500"
>
<div class="flex items-center gap-1.5 p-2 font-bold transition-colors">
<Icon
name="memory:cash"
:size="22"
mode="svg"
class="block"
/>
<span>Price</span>
</div>
<div class="space-y-3 p-4 transition-colors">
<input
type="range"
min="0"
max="1000"
step="10"
v-model.number="props.form.maxPrice"
class="slider block w-full rounded-lg appearance-none cursor-pointer"
/>
<div class="flex justify-between text-xs text-default-font/40">
<span>Free</span>
<span>Paid</span>
</div>
</div>
</div>

<div class="group">
<button
class="focus:text-brand-50 hover:text-brand-50 w-full cursor-pointer rounded-t-2xl border border-neutral-700 p-2 outline-0 transition-colors group-focus-within:border-neutral-500"
Expand Down Expand Up @@ -81,6 +110,7 @@ const props = defineProps<{
sortBy: string
showExtensions: boolean
showThemes: boolean
maxPrice: number
}
}>()

Expand All @@ -90,3 +120,54 @@ const sortOptions = [
{ value: 'created', label: 'Newest First' },
]
</script>

<style scoped>
.slider {
background: white;
height: 6px;
border-radius: 999px;
}

.slider::-webkit-slider-runnable-track {
background: white;
height: 6px;
border-radius: 999px;
}

.slider::-moz-range-track {
background: white;
height: 6px;
border-radius: 999px;
}

.slider::-webkit-slider-thumb {
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #6db7ff;
cursor: pointer;
border: 2px solid #6db7ff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
margin-top: -6px;
}

.slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: #6db7ff;
cursor: pointer;
border: 2px solid #6db7ff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
margin-top: -6px;
}

.slider::-webkit-slider-thumb:hover {
transform: scale(1.1);
}

.slider::-moz-range-thumb:hover {
transform: scale(1.1);
}
</style>
23 changes: 23 additions & 0 deletions apps/frontend/src/pages/browse/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,22 @@ const form = ref({
sortBy: 'popularity',
showExtensions: true,
showThemes: true,
// Note: Despite the name, this is used as a pricing filter flag:
// 0 = show free only, non-zero = show paid only.
maxPrice: 0,
})

const getMinPrice = (extension: Extension): number => {
const prices = Object.values(extension.platforms)
.map(p => p.price)
.filter(p => p >= 0)
return prices.length > 0 ? Math.min(...prices) : 0
}

const isFree = (extension: Extension): boolean => {
return getMinPrice(extension) === 0
}
Comment on lines +166 to +168
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isFree function is defined but never used in the codebase. Consider removing it or using it in place of the inline check at line 196 where 'minPrice === 0' is evaluated, which would improve code reusability and maintainability.

Copilot uses AI. Check for mistakes.

const filteredAndSortedExtensions = computed(() => {
if (!extensions.value) return []

Expand All @@ -178,6 +192,15 @@ const filteredAndSortedExtensions = computed(() => {
return true
})

filtered = filtered.filter((extension) => {
const minPrice = getMinPrice(extension)
if (form.value.maxPrice === 0) {
return minPrice === 0
}
// any non-zero maxPrice shows all price tiers (free and paid)
return true
})

switch (form.value.sortBy) {
case 'popularity':
filtered.sort((a, b) => b.stats.panels - a.stats.panels)
Expand Down