feat: enhance tutorial functionality by disabling inputs and buttons during active steps
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m12s
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m12s
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
name: Developer
|
name: Developer
|
||||||
description: Implements core application features across Python backends and Vue frontends.
|
description: Implements core application features across Python backends and Vue frontends.
|
||||||
mode: subagent
|
mode: subagent
|
||||||
model: deepseek/deepseek-v4-pro
|
model: moonshotai/kimi-k2.7-code
|
||||||
temperature: 0.2
|
temperature: 0.2
|
||||||
maxSteps: 50
|
maxSteps: 50
|
||||||
permission:
|
permission:
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: bugfix
|
||||||
|
description: "Executes a bugfix pipeline on one or more gitea issues: Developer -> Tester -> Reviewer"
|
||||||
|
---
|
||||||
|
|
||||||
|
## What I do
|
||||||
|
|
||||||
|
I orchestrate a sequential bugfix and verification pipeline - I will retrieve issues(s) from Gitea. I will then forward information from the issues to the respective subagents.
|
||||||
|
|
||||||
|
Use gitea-mcp-server to interact with Gitea. Verify that the server is running and accessible.
|
||||||
|
|
||||||
|
If an issue is not provided, ask the user for the issue number(s).
|
||||||
|
|
||||||
|
1. **Developer**: Provides a code fix for each issue.
|
||||||
|
2. **Reviewer**: Audits the code and architectural soundness.
|
||||||
|
3. **Tester**: Runs tests related to the bugfix and determines if new unit tests, integration tests, or end-to-end tests are needed. If so, implement. Verify by running the test suite.
|
||||||
|
|
||||||
|
## Execution Rules
|
||||||
|
|
||||||
|
- Stop and ask the user for clarification if a step fails or is ambiguous.
|
||||||
|
- Use the `@` mention to trigger the respective subagents sequentially.
|
||||||
|
- Pass the context from the previous stage to the next stage to ensure consistency.
|
||||||
|
- Use multiple subagents to handle different aspects of the bugfix process if it will help.
|
||||||
|
|
||||||
|
## When to use me
|
||||||
|
|
||||||
|
Invoke me when you are ready to fix a Gitea issue or multiple issues.
|
||||||
@@ -2,20 +2,20 @@
|
|||||||
"cookies": [
|
"cookies": [
|
||||||
{
|
{
|
||||||
"name": "refresh_token",
|
"name": "refresh_token",
|
||||||
"value": "QRDDJrwVfrRB_5Z--i4k48Q4-0kktQnZJelqd9JABBQ",
|
"value": "qYJH5LMH5O4UK92tdAp7R49rzGACFvTMVj29S-Pzygc",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/api/auth",
|
"path": "/api/auth",
|
||||||
"expires": 1791758861.201632,
|
"expires": 1792435594.088715,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Strict"
|
"sameSite": "Strict"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "access_token",
|
"name": "access_token",
|
||||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiIyNjU1MGUxZS00NTBiLTRmMzMtOGI3MC02NjA1MGU2MzZkNzgiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODM5OTM2NjF9.pZegL2iwonftDyVlaADPhHeTPrx7VKkoi8TtDY3Byuk",
|
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiI2NmNlMzgzMy0zMGQ3LTRjMTItYjRhMi1iNmIxMjcxYTI1YzEiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODQ2NzAzOTR9.jkyiFTxBQ0oatY6jCAWFG88E8e6mCrDDlN_8I56RMBA",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"expires": 1783993661.201585,
|
"expires": 1784670394.088677,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Lax"
|
"sameSite": "Lax"
|
||||||
@@ -27,11 +27,11 @@
|
|||||||
"localStorage": [
|
"localStorage": [
|
||||||
{
|
{
|
||||||
"name": "authSyncEvent",
|
"name": "authSyncEvent",
|
||||||
"value": "{\"type\":\"logout\",\"at\":1783982861064}"
|
"value": "{\"type\":\"logout\",\"at\":1784659593888}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "parentAuth",
|
"name": "parentAuth",
|
||||||
"value": "{\"expiresAt\":1784155661355}"
|
"value": "{\"expiresAt\":1784832394248}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,20 @@
|
|||||||
"cookies": [
|
"cookies": [
|
||||||
{
|
{
|
||||||
"name": "refresh_token",
|
"name": "refresh_token",
|
||||||
"value": "QCQAmUFxc_xNvuLjIGYO4HL49nrDnWW2K54jTZT3ZBw",
|
"value": "PylNoNcxDjRuoocNg4ZmApg_7OIxp9Jfs7ttsX3C5uA",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/api/auth",
|
"path": "/api/auth",
|
||||||
"expires": 1791758861.100584,
|
"expires": 1792435594.088028,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Strict"
|
"sameSite": "Strict"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "access_token",
|
"name": "access_token",
|
||||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiZjkyN2FjMzItZDE3ZC00MjhhLWI5YTgtYTQ5NjM5YjE4YTgzIiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzgzOTkzNjYxfQ.GE6KZ4PXLwLsF8HYZckYwvBruIBMNqVxY-Yg94_QBcU",
|
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiN2UzNjdjMGItMDU3ZS00MWEzLThlNzYtZWZmNDUyMmFmOTU2IiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzg0NjcwMzk0fQ.cVkoiVTtH1O7TFqW5yQ9BEBlTq7hR8YEya7FW2b_Yh8",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"expires": 1783993661.100535,
|
"expires": 1784670394.08796,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Lax"
|
"sameSite": "Lax"
|
||||||
@@ -27,11 +27,11 @@
|
|||||||
"localStorage": [
|
"localStorage": [
|
||||||
{
|
{
|
||||||
"name": "authSyncEvent",
|
"name": "authSyncEvent",
|
||||||
"value": "{\"type\":\"logout\",\"at\":1783982860894}"
|
"value": "{\"type\":\"logout\",\"at\":1784659593889}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "parentAuth",
|
"name": "parentAuth",
|
||||||
"value": "{\"expiresAt\":1784155661256}"
|
"value": "{\"expiresAt\":1784832394248}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,20 @@
|
|||||||
"cookies": [
|
"cookies": [
|
||||||
{
|
{
|
||||||
"name": "refresh_token",
|
"name": "refresh_token",
|
||||||
"value": "xQQnxda8ujhjlZDBQ2Rl4Xxlh4X2_Kt1e80YSWniAXA",
|
"value": "LZA3Bk15HR3io_q8vfyqD3wlRoyV-GJwY-MjWvtUQzg",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/api/auth",
|
"path": "/api/auth",
|
||||||
"expires": 1791760814.863417,
|
"expires": 1792435594.060453,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Strict"
|
"sameSite": "Strict"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "access_token",
|
"name": "access_token",
|
||||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS10dXRvcmlhbEB0ZXN0LmNvbSIsInVzZXJfaWQiOiIzNWI3ZGVjMS01ODNmLTQ5M2YtYTQ5ZS1mNWM0NzM3YTQ5NzgiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODM5OTU2MTR9.KWJqO7mVswb5IoHsJOOdZSm8iCmYoNVXwixDsQ8lbmI",
|
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS10dXRvcmlhbEB0ZXN0LmNvbSIsInVzZXJfaWQiOiJjYjQyMzZkNC1kNDRlLTQ0YjMtOTYzYi0xYTUwMWEzZjM2NTMiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODQ2NzAzOTR9.pPd0JYgaAyn4_0YPOlxKoX1B9Ka4Vq2M3CpNcl7EDAU",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"expires": 1783995614.863369,
|
"expires": 1784670394.06041,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Lax"
|
"sameSite": "Lax"
|
||||||
@@ -27,11 +27,11 @@
|
|||||||
"localStorage": [
|
"localStorage": [
|
||||||
{
|
{
|
||||||
"name": "authSyncEvent",
|
"name": "authSyncEvent",
|
||||||
"value": "{\"type\":\"logout\",\"at\":1783984814749}"
|
"value": "{\"type\":\"logout\",\"at\":1784659593886}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "parentAuth",
|
"name": "parentAuth",
|
||||||
"value": "{\"expiresAt\":1784157614996}"
|
"value": "{\"expiresAt\":1784832394232}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,20 @@
|
|||||||
"cookies": [
|
"cookies": [
|
||||||
{
|
{
|
||||||
"name": "refresh_token",
|
"name": "refresh_token",
|
||||||
"value": "zFYZnbIZmC4uH9DcQ3PO8WPPAy_IOts57gvbCMERHvw",
|
"value": "MYMeAyxeWQy7LVHrJsvF-10FozU5Ko_QoEvmV3bHwPc",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/api/auth",
|
"path": "/api/auth",
|
||||||
"expires": 1791760813.448801,
|
"expires": 1792435592.056481,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Strict"
|
"sameSite": "Strict"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "access_token",
|
"name": "access_token",
|
||||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiIzNDkyZjAyMi0yMDdiLTRjMjMtYWFlYy1lMmNjNzc0N2IxYjAiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODM5OTU2MTN9.PM5XaijvAuzEPSY3Mz1DB1jvYTJDeaMhGZnrYukUJ0Q",
|
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiJiODJhZDU2Mi03MjMwLTRkN2EtODU3NS04N2FhNzE1ZWNhMjQiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODQ2NzAzOTJ9.iEbCtV3csb9AUWoXL4MJ-GLL1yiJDumvYmKr-vyuwBg",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"expires": 1783995613.448755,
|
"expires": 1784670392.056434,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Lax"
|
"sameSite": "Lax"
|
||||||
@@ -27,11 +27,11 @@
|
|||||||
"localStorage": [
|
"localStorage": [
|
||||||
{
|
{
|
||||||
"name": "authSyncEvent",
|
"name": "authSyncEvent",
|
||||||
"value": "{\"type\":\"logout\",\"at\":1783984813329}"
|
"value": "{\"type\":\"logout\",\"at\":1784659591903}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "parentAuth",
|
"name": "parentAuth",
|
||||||
"value": "{\"expiresAt\":1784157613579}"
|
"value": "{\"expiresAt\":1784832392197}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -377,4 +377,68 @@ test.describe('Tutorial system', () => {
|
|||||||
await page.reload()
|
await page.reload()
|
||||||
await expect(getTutorialCard(page)).not.toBeVisible({ timeout: 3000 })
|
await expect(getTutorialCard(page)).not.toBeVisible({ timeout: 3000 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('add-child FAB is disabled while the create-child tutorial is showing', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
await deleteAllChildren(request)
|
||||||
|
await page.goto('/parent')
|
||||||
|
await expectTutorialCard(page, 'Add your child')
|
||||||
|
|
||||||
|
const fab = page.locator('.fab')
|
||||||
|
await expect(fab).toBeVisible()
|
||||||
|
await expect(fab).toBeDisabled()
|
||||||
|
|
||||||
|
// The page should still be on the children list and the tutorial visible.
|
||||||
|
await expect(page).toHaveURL('/parent')
|
||||||
|
await expect(getTutorialCard(page)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('child form inputs are disabled while the edit-child-name tutorial is showing', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/parent/children/create')
|
||||||
|
await expect(page).toHaveURL('/parent/children/create')
|
||||||
|
await expectTutorialCard(page, "Child's Name")
|
||||||
|
|
||||||
|
await expect(page.locator('input#name')).toBeDisabled()
|
||||||
|
await expect(page.locator('input#age')).toBeDisabled()
|
||||||
|
|
||||||
|
// The tutorial card should remain visible after checking the inputs.
|
||||||
|
await expect(getTutorialCard(page)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicking the highlighted Points area does not navigate while child-points tutorial is showing', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const childName = 'TutorialPointsKid'
|
||||||
|
await ensureChild(request, childName, 7)
|
||||||
|
await page.goto('/parent')
|
||||||
|
await expect(page).toHaveURL('/parent')
|
||||||
|
|
||||||
|
// Wait for the child card to render so the children list has finished
|
||||||
|
// loading before we assert on the Points tutorial.
|
||||||
|
await expect(page.getByText(childName, { exact: true }).first()).toBeVisible({ timeout: 10000 })
|
||||||
|
|
||||||
|
await expectTutorialCard(page, 'Points')
|
||||||
|
|
||||||
|
// Small resilience delay so the spotlight blocker is positioned before the
|
||||||
|
// synthetic click reaches it.
|
||||||
|
await page.waitForTimeout(50)
|
||||||
|
|
||||||
|
const points = page.locator('.card .points').first()
|
||||||
|
await expect(points).toBeVisible()
|
||||||
|
const box = await points.boundingBox()
|
||||||
|
if (!box) throw new Error('Could not resolve points element bounding box')
|
||||||
|
|
||||||
|
// Click the center of the highlighted points area. The tutorial spotlight
|
||||||
|
// blocker should intercept the click, preventing the card click handler
|
||||||
|
// from navigating to the child detail page.
|
||||||
|
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2)
|
||||||
|
|
||||||
|
await expect(page).toHaveURL('/parent')
|
||||||
|
await expect(getTutorialCard(page)).toBeVisible()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
maybeShow as tutorialMaybeShow,
|
maybeShow as tutorialMaybeShow,
|
||||||
markStepSeen as tutorialMark,
|
markStepSeen as tutorialMark,
|
||||||
tutorialReady,
|
tutorialReady,
|
||||||
|
isTutorialActive,
|
||||||
} from '@/tutorial/controller'
|
} from '@/tutorial/controller'
|
||||||
import type {
|
import type {
|
||||||
Child,
|
Child,
|
||||||
@@ -141,10 +142,12 @@ const fetchChildren = async (): Promise<Child[]> => {
|
|||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
children.value = childList
|
||||||
return childList
|
return childList
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to fetch children'
|
error.value = err instanceof Error ? err.message : 'Failed to fetch children'
|
||||||
console.error('Error fetching children:', err)
|
console.error('Error fetching children:', err)
|
||||||
|
children.value = []
|
||||||
return []
|
return []
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -182,8 +185,7 @@ onMounted(async () => {
|
|||||||
eventBus.on('child_reward_triggered', handleChildRewardTriggered)
|
eventBus.on('child_reward_triggered', handleChildRewardTriggered)
|
||||||
|
|
||||||
const listPromise = fetchChildren()
|
const listPromise = fetchChildren()
|
||||||
listPromise.then((list) => {
|
listPromise.then(() => {
|
||||||
children.value = list
|
|
||||||
maybeTriggerCreateChildTutorial()
|
maybeTriggerCreateChildTutorial()
|
||||||
})
|
})
|
||||||
// listen for outside clicks to auto-close any open kebab menu
|
// listen for outside clicks to auto-close any open kebab menu
|
||||||
@@ -387,6 +389,7 @@ onBeforeUnmount(() => {
|
|||||||
<FloatingActionButton
|
<FloatingActionButton
|
||||||
v-if="isParentAuthenticated"
|
v-if="isParentAuthenticated"
|
||||||
aria-label="Add Child"
|
aria-label="Add Child"
|
||||||
|
:disabled="isTutorialActive"
|
||||||
@click="createChild"
|
@click="createChild"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
:label="field.label"
|
:label="field.label"
|
||||||
:modelValue="formData[field.name]"
|
:modelValue="formData[field.name]"
|
||||||
@update:modelValue="(val: boolean) => (formData[field.name] = val)"
|
@update:modelValue="(val: boolean) => (formData[field.name] = val)"
|
||||||
:disabled="field.disabled"
|
:disabled="field.disabled || isTutorialActive"
|
||||||
:description="field.description"
|
:description="field.description"
|
||||||
:error="props.fieldErrors?.[field.name]"
|
:error="props.fieldErrors?.[field.name]"
|
||||||
/>
|
/>
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:maxlength="field.maxlength"
|
:maxlength="field.maxlength"
|
||||||
|
:disabled="isTutorialActive"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'number'"
|
v-else-if="field.type === 'number'"
|
||||||
@@ -40,11 +41,13 @@
|
|||||||
:max="field.max"
|
:max="field.max"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
pattern="\\d{1,3}"
|
pattern="\\d{1,3}"
|
||||||
|
:disabled="isTutorialActive"
|
||||||
@input="
|
@input="
|
||||||
(e) => {
|
(e: Event) => {
|
||||||
if (field.maxlength && e.target.value.length > field.maxlength) {
|
const target = e.target as HTMLInputElement | null
|
||||||
e.target.value = e.target.value.slice(0, field.maxlength)
|
if (field.maxlength && target && target.value.length > field.maxlength) {
|
||||||
formData[field.name] = e.target.value
|
target.value = target.value.slice(0, field.maxlength)
|
||||||
|
formData[field.name] = target.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
@@ -54,6 +57,7 @@
|
|||||||
:id="field.name"
|
:id="field.name"
|
||||||
v-model="formData[field.name]"
|
v-model="formData[field.name]"
|
||||||
:image-type="field.imageType || 1"
|
:image-type="field.imageType || 1"
|
||||||
|
:disabled="isTutorialActive"
|
||||||
@add-image="onAddImage"
|
@add-image="onAddImage"
|
||||||
/>
|
/>
|
||||||
</slot>
|
</slot>
|
||||||
@@ -81,7 +85,7 @@
|
|||||||
import { ref, onMounted, nextTick, watch, computed } from 'vue'
|
import { ref, onMounted, nextTick, watch, computed } from 'vue'
|
||||||
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||||||
import ToggleField from './ToggleField.vue'
|
import ToggleField from './ToggleField.vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { isTutorialActive } from '@/tutorial/controller'
|
||||||
import '@/assets/styles.css'
|
import '@/assets/styles.css'
|
||||||
|
|
||||||
type Field = {
|
type Field = {
|
||||||
@@ -118,7 +122,6 @@ const props = withDefaults(
|
|||||||
|
|
||||||
const emit = defineEmits(['submit', 'cancel', 'add-image'])
|
const emit = defineEmits(['submit', 'cancel', 'add-image'])
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const formData = ref<Record<string, any>>({ ...props.initialData })
|
const formData = ref<Record<string, any>>({ ...props.initialData })
|
||||||
const baselineData = ref<Record<string, any>>({ ...props.initialData })
|
const baselineData = ref<Record<string, any>>({ ...props.initialData })
|
||||||
const formRef = ref<HTMLFormElement | null>(null)
|
const formRef = ref<HTMLFormElement | null>(null)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<button class="fab" @click="$emit('click')" :aria-label="ariaLabel">
|
<button class="fab" :disabled="disabled" @click="onClick" :aria-label="ariaLabel">
|
||||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
|
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
|
||||||
<circle cx="14" cy="14" r="14" fill="#667eea" />
|
<circle cx="14" cy="14" r="14" fill="#667eea" />
|
||||||
<path d="M14 8v12M8 14h12" stroke="#fff" stroke-width="2" stroke-linecap="round" />
|
<path d="M14 8v12M8 14h12" stroke="#fff" stroke-width="2" stroke-linecap="round" />
|
||||||
@@ -8,7 +8,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{ ariaLabel?: string }>()
|
const props = defineProps<{ ariaLabel?: string; disabled?: boolean }>()
|
||||||
|
const emit = defineEmits<{ (e: 'click'): void }>()
|
||||||
|
|
||||||
|
function onClick() {
|
||||||
|
if (!props.disabled) {
|
||||||
|
emit('click')
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -38,6 +45,10 @@ defineProps<{ ariaLabel?: string }>()
|
|||||||
.fab:active {
|
.fab:active {
|
||||||
background: var(--fab-active-bg);
|
background: var(--fab-active-bg);
|
||||||
}
|
}
|
||||||
|
.fab:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
svg {
|
svg {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import EntityEditForm from '../EntityEditForm.vue'
|
import EntityEditForm from '../EntityEditForm.vue'
|
||||||
|
import { activeStep } from '@/tutorial/controller'
|
||||||
|
|
||||||
vi.mock('vue-router', () => ({
|
vi.mock('vue-router', () => ({
|
||||||
useRouter: vi.fn(() => ({
|
useRouter: vi.fn(() => ({
|
||||||
@@ -9,6 +10,69 @@ vi.mock('vue-router', () => ({
|
|||||||
})),
|
})),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
describe('EntityEditForm tutorial interaction', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
activeStep.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
activeStep.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables text and number inputs while a tutorial step is active', async () => {
|
||||||
|
activeStep.value = {
|
||||||
|
def: {
|
||||||
|
id: 'edit-child-name',
|
||||||
|
title: "Child's Name",
|
||||||
|
body: 'Tutorial body',
|
||||||
|
},
|
||||||
|
anchor: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapper = mount(EntityEditForm, {
|
||||||
|
props: {
|
||||||
|
entityLabel: 'Child',
|
||||||
|
fields: [
|
||||||
|
{ name: 'name', label: 'Name', type: 'text', required: true },
|
||||||
|
{ name: 'age', label: 'Age', type: 'number', required: true, min: 0, max: 120 },
|
||||||
|
],
|
||||||
|
initialData: { name: '', age: null },
|
||||||
|
isEdit: false,
|
||||||
|
loading: false,
|
||||||
|
requireDirty: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect((wrapper.find('#name').element as HTMLInputElement).disabled).toBe(true)
|
||||||
|
expect((wrapper.find('#age').element as HTMLInputElement).disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps inputs enabled when no tutorial step is active', async () => {
|
||||||
|
activeStep.value = null
|
||||||
|
|
||||||
|
const wrapper = mount(EntityEditForm, {
|
||||||
|
props: {
|
||||||
|
entityLabel: 'Child',
|
||||||
|
fields: [
|
||||||
|
{ name: 'name', label: 'Name', type: 'text', required: true },
|
||||||
|
{ name: 'age', label: 'Age', type: 'number', required: true, min: 0, max: 120 },
|
||||||
|
],
|
||||||
|
initialData: { name: '', age: null },
|
||||||
|
isEdit: false,
|
||||||
|
loading: false,
|
||||||
|
requireDirty: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect((wrapper.find('#name').element as HTMLInputElement).disabled).toBe(false)
|
||||||
|
expect((wrapper.find('#age').element as HTMLInputElement).disabled).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('EntityEditForm', () => {
|
describe('EntityEditForm', () => {
|
||||||
it('keeps Create disabled when required number field is empty', async () => {
|
it('keeps Create disabled when required number field is empty', async () => {
|
||||||
const wrapper = mount(EntityEditForm, {
|
const wrapper = mount(EntityEditForm, {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import FloatingActionButton from '../FloatingActionButton.vue'
|
||||||
|
|
||||||
|
describe('FloatingActionButton', () => {
|
||||||
|
it('emits click when enabled and clicked', async () => {
|
||||||
|
const wrapper = mount(FloatingActionButton, {
|
||||||
|
props: { ariaLabel: 'Add Child', disabled: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.find('button').trigger('click')
|
||||||
|
expect(wrapper.emitted('click')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is disabled and does not emit click when disabled prop is true', async () => {
|
||||||
|
const wrapper = mount(FloatingActionButton, {
|
||||||
|
props: { ariaLabel: 'Add Child', disabled: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const button = wrapper.find('button')
|
||||||
|
expect((button.element as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
|
||||||
|
await button.trigger('click')
|
||||||
|
expect(wrapper.emitted('click')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,10 +4,16 @@ import { getCachedImageUrl } from '@/common/imageCache'
|
|||||||
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
|
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
|
||||||
import '@/assets/styles.css'
|
import '@/assets/styles.css'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
modelValue?: string | null // selected image id or local-upload
|
modelValue?: string | null // selected image id or local-upload
|
||||||
imageType?: number // 1 or 2, default 1
|
imageType?: number // 1 or 2, default 1
|
||||||
}>()
|
disabled?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
const emit = defineEmits(['update:modelValue', 'add-image'])
|
const emit = defineEmits(['update:modelValue', 'add-image'])
|
||||||
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
@@ -28,6 +34,7 @@ const loadingImages = ref(false)
|
|||||||
const typeParam = computed(() => props.imageType ?? 1)
|
const typeParam = computed(() => props.imageType ?? 1)
|
||||||
|
|
||||||
const selectImage = (id: string | undefined) => {
|
const selectImage = (id: string | undefined) => {
|
||||||
|
if (props.disabled) return
|
||||||
if (!id) {
|
if (!id) {
|
||||||
console.warn('selectImage called with null id')
|
console.warn('selectImage called with null id')
|
||||||
return
|
return
|
||||||
@@ -36,25 +43,28 @@ const selectImage = (id: string | undefined) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addFromLocal = () => {
|
const addFromLocal = () => {
|
||||||
|
if (props.disabled) return
|
||||||
fileInput.value?.click()
|
fileInput.value?.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onFileChange = async (event: Event) => {
|
const onFileChange = async (event: Event) => {
|
||||||
|
if (props.disabled) return
|
||||||
const files = (event.target as HTMLInputElement).files
|
const files = (event.target as HTMLInputElement).files
|
||||||
if (files && files.length > 0) {
|
if (!files || files.length === 0) return
|
||||||
const file = files[0]
|
const file = files[0]
|
||||||
|
if (!file) return
|
||||||
if (localImageUrl.value) URL.revokeObjectURL(localImageUrl.value)
|
if (localImageUrl.value) URL.revokeObjectURL(localImageUrl.value)
|
||||||
const { blob, url } = await resizeImageFile(file, 512)
|
const { blob, url } = await resizeImageFile(file, 512)
|
||||||
localImageUrl.value = url
|
localImageUrl.value = url
|
||||||
updateLocalImage(url, new File([blob], file.name, { type: 'image/png' }))
|
updateLocalImage(url, new File([blob], file.name, { type: 'image/png' }))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (localImageUrl.value) URL.revokeObjectURL(localImageUrl.value)
|
if (localImageUrl.value) URL.revokeObjectURL(localImageUrl.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
const addFromCamera = async () => {
|
const addFromCamera = async () => {
|
||||||
|
if (props.disabled) return
|
||||||
cameraError.value = null
|
cameraError.value = null
|
||||||
capturedImageUrl.value = null
|
capturedImageUrl.value = null
|
||||||
showCamera.value = true
|
showCamera.value = true
|
||||||
@@ -152,9 +162,11 @@ onMounted(async () => {
|
|||||||
const idx = images.findIndex((img) => img.id === props.modelValue)
|
const idx = images.findIndex((img) => img.id === props.modelValue)
|
||||||
if (idx > 0) {
|
if (idx > 0) {
|
||||||
const [selected] = images.splice(idx, 1)
|
const [selected] = images.splice(idx, 1)
|
||||||
|
if (selected) {
|
||||||
images.unshift(selected)
|
images.unshift(selected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
availableImages.value = images
|
availableImages.value = images
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -199,8 +211,8 @@ function updateLocalImage(url: string, file: File) {
|
|||||||
const idx = availableImages.value.findIndex((img) => img.id === 'local-upload')
|
const idx = availableImages.value.findIndex((img) => img.id === 'local-upload')
|
||||||
if (idx === -1) {
|
if (idx === -1) {
|
||||||
availableImages.value.unshift({ id: 'local-upload', url })
|
availableImages.value.unshift({ id: 'local-upload', url })
|
||||||
} else {
|
} else if (availableImages.value[idx]) {
|
||||||
availableImages.value[idx].url = url
|
availableImages.value[idx]!.url = url
|
||||||
}
|
}
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
@@ -224,7 +236,7 @@ function updateLocalImage(url: string, file: File) {
|
|||||||
:key="img.id"
|
:key="img.id"
|
||||||
:src="img.url"
|
:src="img.url"
|
||||||
class="selectable-image"
|
class="selectable-image"
|
||||||
:class="{ selected: modelValue === img.id }"
|
:class="{ selected: modelValue === img.id, disabled: props.disabled }"
|
||||||
:alt="`Image ${img.id}`"
|
:alt="`Image ${img.id}`"
|
||||||
@click="selectImage(img.id)"
|
@click="selectImage(img.id)"
|
||||||
/>
|
/>
|
||||||
@@ -239,10 +251,10 @@ function updateLocalImage(url: string, file: File) {
|
|||||||
@change="onFileChange"
|
@change="onFileChange"
|
||||||
/>
|
/>
|
||||||
<div class="image-actions">
|
<div class="image-actions">
|
||||||
<button ref="addPhotoBtn" type="button" class="icon-btn" @click="addFromLocal" aria-label="Add from device">
|
<button ref="addPhotoBtn" type="button" class="icon-btn" :disabled="props.disabled" @click="addFromLocal" aria-label="Add from device">
|
||||||
<span class="icon">+</span>
|
<span class="icon">+</span>
|
||||||
</button>
|
</button>
|
||||||
<button ref="cameraBtn" type="button" class="icon-btn" @click="addFromCamera" aria-label="Add from camera">
|
<button ref="cameraBtn" type="button" class="icon-btn" :disabled="props.disabled" @click="addFromCamera" aria-label="Add from camera">
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
<rect x="3" y="6" width="14" height="10" rx="2" stroke="#667eea" stroke-width="1.5" />
|
<rect x="3" y="6" width="14" height="10" rx="2" stroke="#667eea" stroke-width="1.5" />
|
||||||
@@ -315,6 +327,10 @@ function updateLocalImage(url: string, file: File) {
|
|||||||
border-color: var(--selectable-image-selected);
|
border-color: var(--selectable-image-selected);
|
||||||
box-shadow: 0 0 0 2px #667eea55;
|
box-shadow: 0 0 0 2px #667eea55;
|
||||||
}
|
}
|
||||||
|
.selectable-image.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
.loading-images {
|
.loading-images {
|
||||||
color: var(--loading-text);
|
color: var(--loading-text);
|
||||||
font-size: 0.98rem;
|
font-size: 0.98rem;
|
||||||
@@ -344,6 +360,10 @@ function updateLocalImage(url: string, file: File) {
|
|||||||
color: var(--icon-btn-color);
|
color: var(--icon-btn-color);
|
||||||
box-shadow: var(--icon-btn-shadow);
|
box-shadow: var(--icon-btn-shadow);
|
||||||
}
|
}
|
||||||
|
.icon-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
.icon-btn svg {
|
.icon-btn svg {
|
||||||
width: 32px; /* Bigger camera icon */
|
width: 32px; /* Bigger camera icon */
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import ImagePicker from '../ImagePicker.vue'
|
||||||
|
|
||||||
|
vi.mock('@/common/imageCache', () => ({
|
||||||
|
getCachedImageUrl: vi.fn(async (imageId: string) => `blob:mock-url-${imageId}`),
|
||||||
|
revokeImageUrl: vi.fn(),
|
||||||
|
revokeAllImageUrls: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
global.fetch = vi.fn()
|
||||||
|
|
||||||
|
async function flushPromises() {
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ImagePicker disabled behavior', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
const mockFetch = vi.fn()
|
||||||
|
global.fetch = mockFetch
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ ids: ['img1'] }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders selectable images and action buttons', async () => {
|
||||||
|
const wrapper = mount(ImagePicker, {
|
||||||
|
props: { modelValue: null, imageType: 1, disabled: false },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.findAll('.selectable-image').length).toBeGreaterThan(0)
|
||||||
|
expect(wrapper.find('button[aria-label="Add from device"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('button[aria-label="Add from camera"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables image selection and action buttons when disabled is true', async () => {
|
||||||
|
const wrapper = mount(ImagePicker, {
|
||||||
|
props: { modelValue: null, imageType: 1, disabled: true },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const image = wrapper.find('.selectable-image')
|
||||||
|
expect(image.classes()).toContain('disabled')
|
||||||
|
|
||||||
|
const addFromDevice = wrapper.find('button[aria-label="Add from device"]')
|
||||||
|
const addFromCamera = wrapper.find('button[aria-label="Add from camera"]')
|
||||||
|
expect((addFromDevice.element as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
expect((addFromCamera.element as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not emit update:modelValue when a disabled image is clicked', async () => {
|
||||||
|
const wrapper = mount(ImagePicker, {
|
||||||
|
props: { modelValue: null, imageType: 1, disabled: true },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const image = wrapper.find('.selectable-image')
|
||||||
|
await image.trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not open file input when Add from device is clicked while disabled', async () => {
|
||||||
|
const wrapper = mount(ImagePicker, {
|
||||||
|
props: { modelValue: null, imageType: 1, disabled: true },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const fileInput = wrapper.find('input[type="file"]').element as HTMLInputElement
|
||||||
|
const clickSpy = vi.spyOn(fileInput, 'click')
|
||||||
|
|
||||||
|
await wrapper.find('button[aria-label="Add from device"]').trigger('click')
|
||||||
|
|
||||||
|
expect(clickSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the file input when Add from device is clicked while enabled', async () => {
|
||||||
|
const wrapper = mount(ImagePicker, {
|
||||||
|
props: { modelValue: null, imageType: 1, disabled: false },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const fileInput = wrapper.find('input[type="file"]').element as HTMLInputElement
|
||||||
|
const clickSpy = vi.spyOn(fileInput, 'click')
|
||||||
|
|
||||||
|
await wrapper.find('button[aria-label="Add from device"]').trigger('click')
|
||||||
|
|
||||||
|
expect(clickSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -15,6 +15,9 @@
|
|||||||
<div class="dim" :style="dimLeftStyle" />
|
<div class="dim" :style="dimLeftStyle" />
|
||||||
<div class="dim" :style="dimRightStyle" />
|
<div class="dim" :style="dimRightStyle" />
|
||||||
<div class="ring" :style="ringStyle" aria-hidden="true" />
|
<div class="ring" :style="ringStyle" aria-hidden="true" />
|
||||||
|
<!-- Transparent blocker over the highlighted element so it cannot be
|
||||||
|
clicked/interacted with while this step is active. -->
|
||||||
|
<div class="anchor-blocker" :style="blockerStyle" aria-hidden="true" />
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="dim dim-full" />
|
<div v-else class="dim dim-full" />
|
||||||
|
|
||||||
@@ -163,6 +166,17 @@ const ringStyle = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const blockerStyle = computed(() => {
|
||||||
|
const r = anchorRect.value
|
||||||
|
if (!r) return {}
|
||||||
|
return {
|
||||||
|
top: `${r.top - 6}px`,
|
||||||
|
left: `${r.left - 6}px`,
|
||||||
|
width: `${r.width + 12}px`,
|
||||||
|
height: `${r.height + 12}px`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const cardStyle = computed(() => {
|
const cardStyle = computed(() => {
|
||||||
if (isMobile.value) return {}
|
if (isMobile.value) return {}
|
||||||
const r = anchorRect.value
|
const r = anchorRect.value
|
||||||
@@ -322,6 +336,14 @@ onBeforeUnmount(stopTracking)
|
|||||||
animation: tutorial-pulse 1.6s ease-in-out infinite;
|
animation: tutorial-pulse 1.6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.anchor-blocker {
|
||||||
|
position: fixed;
|
||||||
|
background: transparent;
|
||||||
|
pointer-events: auto;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.ring {
|
.ring {
|
||||||
animation: none;
|
animation: none;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { eventBus } from '@/common/eventBus'
|
import { eventBus } from '@/common/eventBus'
|
||||||
import { stepRegistry, type StepDef } from './steps'
|
import { stepRegistry, type StepDef } from './steps'
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ export const tutorialEnabled = ref(true)
|
|||||||
export const tutorialProgress = ref<Record<string, boolean>>({})
|
export const tutorialProgress = ref<Record<string, boolean>>({})
|
||||||
export const tutorialReady = ref(false)
|
export const tutorialReady = ref(false)
|
||||||
export const activeStep = ref<ActiveStep | null>(null)
|
export const activeStep = ref<ActiveStep | null>(null)
|
||||||
|
export const isTutorialActive = computed(() => activeStep.value !== null)
|
||||||
export const sessionSkipped = ref(false)
|
export const sessionSkipped = ref(false)
|
||||||
/** When a modal is open, this holds the step ID that should fire via the `?` button. */
|
/** When a modal is open, this holds the step ID that should fire via the `?` button. */
|
||||||
export const modalTutorialStepId = ref<string | null>(null)
|
export const modalTutorialStepId = ref<string | null>(null)
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
{
|
{
|
||||||
"status": "failed",
|
"status": "passed",
|
||||||
"failedTests": [
|
"failedTests": []
|
||||||
"dee24a66635148f43df6-c43480256bf17c8988e1"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"mcp": {
|
||||||
|
"gitea-mcp": {
|
||||||
|
"enabled": true,
|
||||||
|
"type": "local",
|
||||||
|
"command": [
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"-i",
|
||||||
|
"--rm",
|
||||||
|
"-e",
|
||||||
|
"GITEA_ACCESS_TOKEN",
|
||||||
|
"-e",
|
||||||
|
"GITEA_HOST",
|
||||||
|
"docker.gitea.com/gitea-mcp-server"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"GITEA_ACCESS_TOKEN": "<secret>",
|
||||||
|
"GITEA_HOST": "https://git.ryankegel.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user