Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
a00438d
feat(lenses): allow community-visibility lenses in bootstrap and vers…
ofcskn Jun 17, 2026
b240cdc
feat(lenses): surface version-unavailable state in LensBodyViewer
ofcskn Jun 17, 2026
846c66a
fix(gateway): fix binary path resolution and add startup error hints
ofcskn Jun 17, 2026
cb66b0c
chore: merge feature/community-visibility-gateway-fixes into development
ofcskn Jun 17, 2026
ca71a29
feat(ui): add community/followers visibility options and saved parame…
ofcskn Jun 18, 2026
427fbfb
fix(data): use SECURITY DEFINER RPCs and add SavedParameterPreset types
ofcskn Jun 18, 2026
ad961e0
feat(db): add followers visibility and saved parameter presets
ofcskn Jun 17, 2026
e798d66
feat(db): add SECURITY DEFINER RPCs for saved parameter preset CRUD
ofcskn Jun 18, 2026
63e7499
feat(mobile): add community and followers visibility options to creat…
ofcskn Jun 18, 2026
b468db9
fix(types): add followers to lenser visibility and content_visibility…
ofcskn Jun 18, 2026
9933b97
test: add specs for saved parameter presets and visibility options
ofcskn Jun 18, 2026
27b1a37
fix(ui): correct SavedPresetsPanel method names, import paths, and ty…
ofcskn Jun 18, 2026
6643808
fix(types): extend LenserProfileHeader visibility prop to include fol…
ofcskn Jun 18, 2026
f96990e
feat: followers visibility + saved parameter presets (#followers-preset)
ofcskn Jun 18, 2026
e2437f7
chore(deps): add security overrides for ws, form-data, hono and bump …
ofcskn Jun 18, 2026
4315c08
fix: remove duplicate GenerativeMediaProvider import in localByokAdapter
ofcskn Jun 18, 2026
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
8 changes: 5 additions & 3 deletions apps/cli/src/commands/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,8 @@ const KEYCHAIN_ACCOUNT = 'device:active'
function findDaemonBinary(): string | null {
const candidates = [
path.resolve(process.cwd(), 'dist/apps/gateway/main.js'),
path.resolve(dirname(fileURLToPath(import.meta.url)), '../../gateway/main.js'),
// When CLI runs from dist/apps/cli/main.js, gateway is one dir up: dist/apps/gateway/
path.resolve(dirname(fileURLToPath(import.meta.url)), '../gateway/main.js'),
]
for (const c of candidates) {
if (existsSync(c)) return c
Expand Down Expand Up @@ -356,8 +357,9 @@ const serve = defineCommand({
const binary = findDaemonBinary()
if (!binary) {
consola.warn(
'lf-gatewayd binary not found. Build apps/gateway first:\n' +
' pnpm nx run gateway:build && node dist/apps/gateway/main.js'
'lf-gatewayd binary not found. Build it first, then re-run this command:\n' +
' pnpm nx run gateway:build\n' +
' lf gateway serve'
)
process.exit(4)
}
Expand Down
13 changes: 12 additions & 1 deletion apps/gateway/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,17 @@ async function printPairingBlock(gatewayUrl: string): Promise<void> {
}

main().catch((err) => {
process.stderr.write(`[lf-gatewayd] fatal: ${(err as Error).message}\n`)
const e = err as NodeJS.ErrnoException
let hint = ''
if (e.code === 'EPERM' || e.code === 'EACCES') {
hint =
'\n Hint: a VPN, network extension, or OS firewall may be blocking the bind.' +
'\n Try a different port: LF_GATEWAY_PORT=38081 node dist/apps/gateway/main.js'
} else if (e.code === 'EADDRINUSE') {
hint =
'\n Hint: another process owns this port.' +
'\n Try: LF_GATEWAY_PORT=38081 node dist/apps/gateway/main.js'
}
process.stderr.write(`[lf-gatewayd] fatal: ${e.message}${hint}\n`)
process.exit(1)
})
2 changes: 1 addition & 1 deletion apps/mobile/src/context/ThreadSheetContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export interface EditThreadData {
title: string
content: string
tags: string[]
visibility: 'public' | 'private'
visibility: 'public' | 'community' | 'followers' | 'private'
}

const ThreadSheetContext = createContext<ThreadSheetContextValue | null>(null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ export const CreateLensSheet: React.FC<CreateLensSheetProps> = ({
{/* Visibility */}
<Field label={t('lenses.visibility')}>
<View style={styles.visibilityRow}>
{(['public', 'private'] as VisibilityEnum[]).map((v) => (
{(['public', 'community', 'followers', 'private'] as VisibilityEnum[]).map((v) => (
<View key={v} style={styles.visibilityOption}>
<MobileButton
label={t(`visibility.${v}`)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ interface CreateThreadSheetProps {
editData?: EditThreadData | null
}

type Visibility = 'public' | 'private'
type Visibility = 'public' | 'community' | 'followers' | 'private'

export const CreateThreadSheet: React.FC<CreateThreadSheetProps> = ({
visible,
Expand Down Expand Up @@ -113,7 +113,7 @@ export const CreateThreadSheet: React.FC<CreateThreadSheetProps> = ({
{/* Visibility */}
<Field label={t('threads.visibility')}>
<View style={styles.visibilityRow}>
{(['public', 'private'] as Visibility[]).map((v) => (
{(['public', 'community', 'followers', 'private'] as Visibility[]).map((v) => (
<View key={v} style={styles.visibilityOption}>
<MobileButton
label={t(`visibility.${v}`)}
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@
},
"visibility": {
"public": "Public",
"community": "Members only",
"followers": "Followers only",
"private": "Private"
},
"tags": {
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/locales/tr.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@
},
"visibility": {
"public": "Herkese açık",
"community": "Yalnızca üyeler",
"followers": "Yalnızca takipçiler",
"private": "Gizli"
},
"tags": {
Expand Down
7 changes: 6 additions & 1 deletion libs/data/cache/src/lib/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,4 +295,9 @@ export const queryKeys = {
status: (type: string, id: string) =>
[...queryKeys.artifactLifecycle.all, 'status', type, id] as const,
},
}
savedPresets: {
all: ['savedPresets'] as const,
byVersion: (lensVersionId: string) =>
[...queryKeys.savedPresets.all, 'byVersion', lensVersionId] as const,
},
}
2 changes: 2 additions & 0 deletions libs/data/repositories/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,5 @@ export * from './lib/repositories/tournamentRepository'
export * from './lib/repositories/adminRepository'
export * from './lib/repositories/generatedChallengesRepository'
export * from './lib/services/challengeGenerationService'

export * from './lib/repositories/savedPresetsRepository'
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'

const { mockRpc } = vi.hoisted(() => ({
mockRpc: vi.fn(),
}))

vi.mock('@lenserfight/data/supabase', () => ({
supabase: { rpc: mockRpc },
}))

import { SavedPresetsRepository } from './savedPresetsRepository'

const VERSION_ID = 'version-uuid-1'
const PRESET_ID = 'preset-uuid-1'

const rawPreset = {
id: PRESET_ID,
lenser_id: 'lenser-1',
lens_id: 'lens-1',
lens_version_id: VERSION_ID,
name: 'My Preset',
note: null,
values: { tone: 'formal' },
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}

describe('SavedPresetsRepository', () => {
let repo: SavedPresetsRepository

beforeEach(() => {
repo = new SavedPresetsRepository()
vi.clearAllMocks()
})

describe('listSavedPresets', () => {
it('calls supabase.rpc("fn_list_saved_presets") with correct params and returns data', async () => {
mockRpc.mockResolvedValue({ data: [rawPreset], error: null })
const result = await repo.listSavedPresets(VERSION_ID)
expect(mockRpc).toHaveBeenCalledWith('fn_list_saved_presets', {
p_lens_version_id: VERSION_ID,
})
expect(result).toEqual([rawPreset])
})

it('returns empty array when data is null', async () => {
mockRpc.mockResolvedValue({ data: null, error: null })
expect(await repo.listSavedPresets(VERSION_ID)).toEqual([])
})

it('throws on error', async () => {
mockRpc.mockResolvedValue({ data: null, error: new Error('db error') })
await expect(repo.listSavedPresets(VERSION_ID)).rejects.toThrow('db error')
})
})

describe('createSavedPreset', () => {
it('calls supabase.rpc("fn_create_saved_preset") with correct params and returns preset', async () => {
mockRpc.mockResolvedValue({ data: rawPreset, error: null })
const input = {
lenser_id: 'lenser-1',
lens_id: 'lens-1',
lens_version_id: VERSION_ID,
name: 'My Preset',
values: { tone: 'formal' },
}
const result = await repo.createSavedPreset(input)
expect(mockRpc).toHaveBeenCalledWith('fn_create_saved_preset', expect.objectContaining({
p_lens_id: 'lens-1',
p_lens_version_id: VERSION_ID,
p_name: 'My Preset',
}))
expect(result).toEqual(rawPreset)
})

it('throws on error', async () => {
mockRpc.mockResolvedValue({ data: null, error: new Error('insert error') })
await expect(
repo.createSavedPreset({
lenser_id: 'l', lens_id: 'ls', lens_version_id: VERSION_ID,
name: 'x', values: {},
}),
).rejects.toThrow('insert error')
})
})

describe('deleteSavedPreset', () => {
it('calls supabase.rpc("fn_delete_saved_preset") with correct preset id', async () => {
mockRpc.mockResolvedValue({ error: null })
await repo.deleteSavedPreset(PRESET_ID)
expect(mockRpc).toHaveBeenCalledWith('fn_delete_saved_preset', {
p_preset_id: PRESET_ID,
})
})

it('throws on error', async () => {
mockRpc.mockResolvedValue({ error: new Error('delete error') })
await expect(repo.deleteSavedPreset(PRESET_ID)).rejects.toThrow('delete error')
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { supabase } from '@lenserfight/data/supabase'
import {
SavedParameterPreset,
CreateSavedPresetInput,
UpdateSavedPresetInput,
} from '@lenserfight/types'

export class SavedPresetsRepository {
private handleError(error: unknown) {
const normalizedError = error as { code?: string; message?: string }
if (!error) return
if (
normalizedError.code === '42501' ||
normalizedError.message?.includes('permission denied')
) {
throw new Error('This resource is private or hidden and cannot be accessed.')
}
if (normalizedError.code === 'PGRST116') {
throw new Error('Requested resource was not found.')
}
throw error
}

async listSavedPresets(lensVersionId: string): Promise<SavedParameterPreset[]> {
const { data, error } = await supabase.rpc('fn_list_saved_presets', {
p_lens_version_id: lensVersionId,
})
if (error) this.handleError(error)
return (data ?? []) as SavedParameterPreset[]
}

async createSavedPreset(input: CreateSavedPresetInput): Promise<SavedParameterPreset> {
const { data, error } = await supabase.rpc('fn_create_saved_preset', {
p_lens_id: input.lens_id,
p_lens_version_id: input.lens_version_id,
p_name: input.name,
p_note: input.note ?? null,
p_values: input.values ?? {},
})
if (error) this.handleError(error)
return data as SavedParameterPreset
}

async updateSavedPreset(
id: string,
input: UpdateSavedPresetInput,
): Promise<SavedParameterPreset> {
const { data, error } = await supabase.rpc('fn_update_saved_preset', {
p_preset_id: id,
p_name: input.name ?? null,
p_note: input.note ?? null,
p_values: input.values ?? null,
})
if (error) this.handleError(error)
return data as SavedParameterPreset
}

async deleteSavedPreset(id: string): Promise<void> {
const { error } = await supabase.rpc('fn_delete_saved_preset', {
p_preset_id: id,
})
if (error) this.handleError(error)
}
}

export const savedPresetsRepository = new SavedPresetsRepository()
1 change: 0 additions & 1 deletion libs/features/lenses/src/lib/adapters/localByokAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
* adapter's caller's concern.
*/
import { callGenerativeMedia, getGenerativeAdapter } from '@lenserfight/providers'
import type { GenerativeMediaProvider } from '@lenserfight/providers'
import { generateUUID } from '@lenserfight/utils/text'


Expand Down
105 changes: 105 additions & 0 deletions libs/features/lenses/src/lib/components/CreateLensModal.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { render, screen } from '@testing-library/react'
import React from 'react'
import { describe, expect, it, vi } from 'vitest'

vi.mock('@lenserfight/features/lens-kinds', () => ({
LensKindPicker: () => null,
LENS_KIND_REGISTRY: {},
}))

vi.mock('@lenserfight/ui/overlays', () => ({
Dialog: ({ open, children, footer }: any) =>
open ? React.createElement('div', null, children, footer) : null,
ModalFooter: ({ primaryButton, leftButton }: any) =>
React.createElement(
'div',
null,
leftButton && React.createElement('button', { onClick: leftButton.onClick }, leftButton.label),
primaryButton && React.createElement('button', { onClick: primaryButton.onClick }, primaryButton.label),
),
}))

vi.mock('@lenserfight/ui/components', () => ({
FormError: () => null,
Button: ({ children, onClick, disabled }: any) =>
React.createElement('button', { onClick, disabled }, children),
}))

vi.mock('@lenserfight/ui/forms', () => ({
SelectField: ({ value, onChange, options }: any) =>
React.createElement(
'select',
{ value, onChange: (e: any) => onChange?.(e.target.value) },
options?.map((o: any) => React.createElement('option', { key: o.value, value: o.value }, o.label)),
),
InputField: ({ label, value, onChange, placeholder }: any) =>
React.createElement('input', { 'aria-label': label, value: value ?? '', onChange, placeholder }),
LensContentEditor: React.forwardRef(({ value, onChange }: any, _ref: any) =>
React.createElement('textarea', { value: value ?? '', onChange: (e: any) => onChange?.(e.target.value) }),
),
}))

vi.mock('@lenserfight/utils/text', () => ({
copyTextToClipboard: vi.fn(),
}))

vi.mock('@lenserfight/utils/validation', () => ({
useFormValidation: () => ({ errors: {}, validate: vi.fn(() => true), clearError: vi.fn() }),
isRequired: () => () => null,
minLength: () => () => null,
}))

vi.mock('../hooks/useTools', () => ({
useTools: () => ({ tools: [], isLoading: false, textToolId: undefined }),
}))

vi.mock('./GenerateWithAIButton', () => ({
GenerateWithAIButton: () => null,
}))

vi.mock('./LensParameterPanel', () => ({
ParameterPanel: () => null,
}))

vi.mock('./LensTagInput', () => ({
LensTagInput: () => null,
}))

vi.mock('./LensVersionHistoryButton', () => ({
LensVersionHistoryButton: () => null,
}))

import { CreateLensModal } from './CreateLensModal'

const defaultForm = {
title: '',
setTitle: vi.fn(),
content: '',
setContent: vi.fn(),
tags: [],
setTags: vi.fn(),
visibility: 'public' as const,
setVisibility: vi.fn(),
versionParams: [],
setVersionParams: vi.fn(),
syncParamsFromContent: vi.fn(),
}

describe('CreateLensModal visibility options', () => {
it('renders all 4 visibility options (public, members only, followers, private)', () => {
render(
<CreateLensModal
isOpen
onClose={vi.fn()}
onSubmit={vi.fn()}
form={defaultForm}
isSubmitting={false}
error={null}
/>,
)
expect(screen.getByRole('option', { name: 'Public' })).toBeTruthy()
expect(screen.getByRole('option', { name: 'Members only' })).toBeTruthy()
expect(screen.getByRole('option', { name: 'Followers only' })).toBeTruthy()
expect(screen.getByRole('option', { name: 'Private' })).toBeTruthy()
})
})
Loading
Loading