mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-05 21:43:07 +00:00
Compare commits
6 Commits
coderabbit
...
adding-3rd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e17014bce4 | ||
|
|
f34fd955ac | ||
|
|
46cb6fa425 | ||
|
|
818d8af998 | ||
|
|
8d5e7b068c | ||
|
|
d417666fe1 |
1
.claude/.gitignore
vendored
Normal file
1
.claude/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
hans/
|
||||||
1
.dockerignore
Normal file
1
.dockerignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
@@ -13,7 +13,8 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAppStore, defaultBackgroundSettings } from '@/store/app-store';
|
import { useAppStore, defaultBackgroundSettings } from '@/store/app-store';
|
||||||
import { getHttpApiClient, getServerUrlSync } from '@/lib/http-api-client';
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
|
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||||
import { useBoardBackgroundSettings } from '@/hooks/use-board-background-settings';
|
import { useBoardBackgroundSettings } from '@/hooks/use-board-background-settings';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
@@ -62,12 +63,13 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
|||||||
// Update preview image when background settings change
|
// Update preview image when background settings change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentProject && backgroundSettings.imagePath) {
|
if (currentProject && backgroundSettings.imagePath) {
|
||||||
const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync();
|
|
||||||
// Add cache-busting query parameter to force browser to reload image
|
// Add cache-busting query parameter to force browser to reload image
|
||||||
const cacheBuster = imageVersion ? `&v=${imageVersion}` : `&v=${Date.now()}`;
|
const cacheBuster = imageVersion ?? Date.now().toString();
|
||||||
const imagePath = `${serverUrl}/api/fs/image?path=${encodeURIComponent(
|
const imagePath = getAuthenticatedImageUrl(
|
||||||
backgroundSettings.imagePath
|
backgroundSettings.imagePath,
|
||||||
)}&projectPath=${encodeURIComponent(currentProject.path)}${cacheBuster}`;
|
currentProject.path,
|
||||||
|
cacheBuster
|
||||||
|
);
|
||||||
setPreviewImage(imagePath);
|
setPreviewImage(imagePath);
|
||||||
} else {
|
} else {
|
||||||
setPreviewImage(null);
|
setPreviewImage(null);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { ImageIcon, X, Loader2, FileText } from 'lucide-react';
|
import { ImageIcon, X, Loader2, FileText } from 'lucide-react';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { getServerUrlSync } from '@/lib/http-api-client';
|
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||||
import { useAppStore, type FeatureImagePath, type FeatureTextFilePath } from '@/store/app-store';
|
import { useAppStore, type FeatureImagePath, type FeatureTextFilePath } from '@/store/app-store';
|
||||||
import {
|
import {
|
||||||
sanitizeFilename,
|
sanitizeFilename,
|
||||||
@@ -94,9 +94,8 @@ export function DescriptionImageDropZone({
|
|||||||
// Construct server URL for loading saved images
|
// Construct server URL for loading saved images
|
||||||
const getImageServerUrl = useCallback(
|
const getImageServerUrl = useCallback(
|
||||||
(imagePath: string): string => {
|
(imagePath: string): string => {
|
||||||
const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync();
|
|
||||||
const projectPath = currentProject?.path || '';
|
const projectPath = currentProject?.path || '';
|
||||||
return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`;
|
return getAuthenticatedImageUrl(imagePath, projectPath);
|
||||||
},
|
},
|
||||||
[currentProject?.path]
|
[currentProject?.path]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,29 +30,6 @@ interface CardActionsProps {
|
|||||||
onApprovePlan?: () => void;
|
onApprovePlan?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Render contextual action buttons for a feature row based on the feature's status and whether it is the current automated task.
|
|
||||||
*
|
|
||||||
* Renders an appropriate set of buttons (Approve Plan, Logs, Force Stop, Verify, Resume, Complete, Edit, View Plan, Make, Refine, etc.) depending on
|
|
||||||
* feature properties (status, planSpec, skipTests, prUrl, id), the isCurrentAutoTask flag, and which callback props are provided.
|
|
||||||
*
|
|
||||||
* @param feature - The feature object whose status and metadata determine which actions are shown.
|
|
||||||
* @param isCurrentAutoTask - When true, renders actions relevant to the currently running automated task.
|
|
||||||
* @param hasContext - If true, indicates the feature has surrounding context (affects layout/availability in some states).
|
|
||||||
* @param shortcutKey - Optional keyboard shortcut label shown next to the Logs button when present.
|
|
||||||
* @param onEdit - Invoked when the Edit button is clicked.
|
|
||||||
* @param onViewOutput - Invoked when a Logs/View Output button is clicked.
|
|
||||||
* @param onVerify - Invoked when the Verify button (verification pathway) is clicked.
|
|
||||||
* @param onResume - Invoked when the Resume button is clicked.
|
|
||||||
* @param onForceStop - Invoked when the Force Stop button is clicked.
|
|
||||||
* @param onManualVerify - Invoked when a manual verification button is clicked.
|
|
||||||
* @param onFollowUp - Invoked when the Refine/Follow-up button is clicked.
|
|
||||||
* @param onImplement - Invoked when the Make/Implement button is clicked.
|
|
||||||
* @param onComplete - Invoked when the Complete button is clicked.
|
|
||||||
* @param onViewPlan - Invoked when the View Plan button is clicked.
|
|
||||||
* @param onApprovePlan - Invoked when the Approve Plan button is clicked.
|
|
||||||
* @returns The JSX element containing the action buttons for the feature row.
|
|
||||||
*/
|
|
||||||
export function CardActions({
|
export function CardActions({
|
||||||
feature,
|
feature,
|
||||||
isCurrentAutoTask,
|
isCurrentAutoTask,
|
||||||
@@ -132,90 +109,73 @@ export function CardActions({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!isCurrentAutoTask &&
|
{!isCurrentAutoTask && feature.status === 'in_progress' && (
|
||||||
(feature.status === 'in_progress' ||
|
<>
|
||||||
(typeof feature.status === 'string' && feature.status.startsWith('pipeline_'))) && (
|
{/* Approve Plan button - shows when plan is generated and waiting for approval */}
|
||||||
<>
|
{feature.planSpec?.status === 'generated' && onApprovePlan && (
|
||||||
{/* Approve Plan button - shows when plan is generated and waiting for approval */}
|
<Button
|
||||||
{feature.planSpec?.status === 'generated' && onApprovePlan && (
|
variant="default"
|
||||||
<Button
|
size="sm"
|
||||||
variant="default"
|
className="flex-1 h-7 text-[11px] bg-purple-600 hover:bg-purple-700 text-white animate-pulse"
|
||||||
size="sm"
|
onClick={(e) => {
|
||||||
className="flex-1 h-7 text-[11px] bg-purple-600 hover:bg-purple-700 text-white animate-pulse"
|
e.stopPropagation();
|
||||||
onClick={(e) => {
|
onApprovePlan();
|
||||||
e.stopPropagation();
|
}}
|
||||||
onApprovePlan();
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
}}
|
data-testid={`approve-plan-${feature.id}`}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
>
|
||||||
data-testid={`approve-plan-${feature.id}`}
|
<FileText className="w-3 h-3 mr-1" />
|
||||||
>
|
Approve Plan
|
||||||
<FileText className="w-3 h-3 mr-1" />
|
</Button>
|
||||||
Approve Plan
|
)}
|
||||||
</Button>
|
{feature.skipTests && onManualVerify ? (
|
||||||
)}
|
<Button
|
||||||
{feature.skipTests && onManualVerify ? (
|
variant="default"
|
||||||
<Button
|
size="sm"
|
||||||
variant="default"
|
className="flex-1 h-7 text-[11px]"
|
||||||
size="sm"
|
onClick={(e) => {
|
||||||
className="flex-1 h-7 text-[11px]"
|
e.stopPropagation();
|
||||||
onClick={(e) => {
|
onManualVerify();
|
||||||
e.stopPropagation();
|
}}
|
||||||
onManualVerify();
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
}}
|
data-testid={`manual-verify-${feature.id}`}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
>
|
||||||
data-testid={`manual-verify-${feature.id}`}
|
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||||
>
|
Verify
|
||||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
</Button>
|
||||||
Verify
|
) : onResume ? (
|
||||||
</Button>
|
<Button
|
||||||
) : onResume ? (
|
variant="default"
|
||||||
<Button
|
size="sm"
|
||||||
variant="default"
|
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
|
||||||
size="sm"
|
onClick={(e) => {
|
||||||
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
|
e.stopPropagation();
|
||||||
onClick={(e) => {
|
onResume();
|
||||||
e.stopPropagation();
|
}}
|
||||||
onResume();
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
}}
|
data-testid={`resume-feature-${feature.id}`}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
>
|
||||||
data-testid={`resume-feature-${feature.id}`}
|
<RotateCcw className="w-3 h-3 mr-1" />
|
||||||
>
|
Resume
|
||||||
<RotateCcw className="w-3 h-3 mr-1" />
|
</Button>
|
||||||
Resume
|
) : null}
|
||||||
</Button>
|
{onViewOutput && !feature.skipTests && (
|
||||||
) : onVerify ? (
|
<Button
|
||||||
<Button
|
variant="secondary"
|
||||||
variant="default"
|
size="sm"
|
||||||
size="sm"
|
className="h-7 text-[11px] px-2"
|
||||||
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
|
onClick={(e) => {
|
||||||
onClick={(e) => {
|
e.stopPropagation();
|
||||||
e.stopPropagation();
|
onViewOutput();
|
||||||
onVerify();
|
}}
|
||||||
}}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
data-testid={`view-output-inprogress-${feature.id}`}
|
||||||
data-testid={`verify-feature-${feature.id}`}
|
>
|
||||||
>
|
<FileText className="w-3 h-3" />
|
||||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
</Button>
|
||||||
Verify
|
)}
|
||||||
</Button>
|
</>
|
||||||
) : null}
|
)}
|
||||||
{onViewOutput && !feature.skipTests && (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-[11px] px-2"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onViewOutput();
|
|
||||||
}}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
data-testid={`view-output-inprogress-${feature.id}`}
|
|
||||||
>
|
|
||||||
<FileText className="w-3 h-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!isCurrentAutoTask && feature.status === 'verified' && (
|
{!isCurrentAutoTask && feature.status === 'verified' && (
|
||||||
<>
|
<>
|
||||||
{/* Logs button */}
|
{/* Logs button */}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useAppStore, defaultBackgroundSettings } from '@/store/app-store';
|
import { useAppStore, defaultBackgroundSettings } from '@/store/app-store';
|
||||||
import { getServerUrlSync } from '@/lib/http-api-client';
|
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||||
|
|
||||||
interface UseBoardBackgroundProps {
|
interface UseBoardBackgroundProps {
|
||||||
currentProject: { path: string; id: string } | null;
|
currentProject: { path: string; id: string } | null;
|
||||||
@@ -22,14 +22,14 @@ export function useBoardBackground({ currentProject }: UseBoardBackgroundProps)
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const imageUrl = getAuthenticatedImageUrl(
|
||||||
|
backgroundSettings.imagePath,
|
||||||
|
currentProject.path,
|
||||||
|
backgroundSettings.imageVersion
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
backgroundImage: `url(${
|
backgroundImage: `url(${imageUrl})`,
|
||||||
import.meta.env.VITE_SERVER_URL || getServerUrlSync()
|
|
||||||
}/api/fs/image?path=${encodeURIComponent(
|
|
||||||
backgroundSettings.imagePath
|
|
||||||
)}&projectPath=${encodeURIComponent(currentProject.path)}${
|
|
||||||
backgroundSettings.imageVersion ? `&v=${backgroundSettings.imageVersion}` : ''
|
|
||||||
})`,
|
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: 'center',
|
||||||
backgroundRepeat: 'no-repeat',
|
backgroundRepeat: 'no-repeat',
|
||||||
|
|||||||
@@ -16,23 +16,6 @@ interface UseBoardEffectsProps {
|
|||||||
setFeaturesWithContext: (set: Set<string>) => void;
|
setFeaturesWithContext: (set: Set<string>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers and manages side effects for the board view (IPC/event listeners, global exposure, and context checks).
|
|
||||||
*
|
|
||||||
* Sets up event subscriptions to suggestions, spec regeneration, and auto-mode events; exposes the current project globally for modals; syncs running tasks from the backend; and maintains the set of feature IDs that have associated context files.
|
|
||||||
*
|
|
||||||
* @param currentProject - The active project object or `null`. Exposed globally for modal use and used when syncing backend state.
|
|
||||||
* @param specCreatingForProject - Project path currently undergoing spec regeneration, or `null`.
|
|
||||||
* @param setSpecCreatingForProject - Setter to clear or set the spec-regenerating project path.
|
|
||||||
* @param setSuggestionsCount - Setter for the persisted number of suggestion items.
|
|
||||||
* @param setFeatureSuggestions - Setter for the latest suggestion payload.
|
|
||||||
* @param setIsGeneratingSuggestions - Setter to mark whether suggestions are being generated.
|
|
||||||
* @param checkContextExists - Async function that returns whether a given feature ID has context files.
|
|
||||||
* @param features - Array of feature records to evaluate for potential context files.
|
|
||||||
* @param isLoading - Flag indicating whether features are still loading; context checks run only when loading is complete.
|
|
||||||
* @param featuresWithContext - Set of feature IDs currently known to have context files.
|
|
||||||
* @param setFeaturesWithContext - Setter that replaces the set of feature IDs that have context files.
|
|
||||||
*/
|
|
||||||
export function useBoardEffects({
|
export function useBoardEffects({
|
||||||
currentProject,
|
currentProject,
|
||||||
specCreatingForProject,
|
specCreatingForProject,
|
||||||
@@ -147,10 +130,7 @@ export function useBoardEffects({
|
|||||||
const checkAllContexts = async () => {
|
const checkAllContexts = async () => {
|
||||||
const featuresWithPotentialContext = features.filter(
|
const featuresWithPotentialContext = features.filter(
|
||||||
(f) =>
|
(f) =>
|
||||||
f.status === 'in_progress' ||
|
f.status === 'in_progress' || f.status === 'waiting_approval' || f.status === 'verified'
|
||||||
f.status === 'waiting_approval' ||
|
|
||||||
f.status === 'verified' ||
|
|
||||||
(typeof f.status === 'string' && f.status.startsWith('pipeline_'))
|
|
||||||
);
|
);
|
||||||
const contextChecks = await Promise.all(
|
const contextChecks = await Promise.all(
|
||||||
featuresWithPotentialContext.map(async (f) => ({
|
featuresWithPotentialContext.map(async (f) => ({
|
||||||
|
|||||||
@@ -153,3 +153,37 @@ export async function apiDeleteRaw(
|
|||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
return apiFetch(endpoint, 'DELETE', options);
|
return apiFetch(endpoint, 'DELETE', options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an authenticated image URL for use in <img> tags or CSS background-image
|
||||||
|
* Adds authentication via query parameter since headers can't be set for image loads
|
||||||
|
*
|
||||||
|
* @param path - Image path
|
||||||
|
* @param projectPath - Project path
|
||||||
|
* @param version - Optional cache-busting version
|
||||||
|
* @returns Full URL with auth credentials
|
||||||
|
*/
|
||||||
|
export function getAuthenticatedImageUrl(
|
||||||
|
path: string,
|
||||||
|
projectPath: string,
|
||||||
|
version?: string | number
|
||||||
|
): string {
|
||||||
|
const serverUrl = getServerUrl();
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
path,
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (version !== undefined) {
|
||||||
|
params.set('v', String(version));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add auth credential as query param (needed for image loads that can't set headers)
|
||||||
|
const apiKey = getApiKey();
|
||||||
|
if (apiKey) {
|
||||||
|
params.set('apiKey', apiKey);
|
||||||
|
}
|
||||||
|
// Note: Session token auth relies on cookies which are sent automatically by the browser
|
||||||
|
|
||||||
|
return `${serverUrl}/api/fs/image?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { Buffer } from 'buffer';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import {
|
import {
|
||||||
@@ -118,21 +119,10 @@ test.describe('Add Context Image', () => {
|
|||||||
|
|
||||||
test('should import an image file to context', async ({ page }) => {
|
test('should import an image file to context', async ({ page }) => {
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
await setupProjectWithFixture(page, getFixturePath());
|
||||||
|
await authenticateForTests(page);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await waitForNetworkIdle(page);
|
await waitForNetworkIdle(page);
|
||||||
|
|
||||||
// Check if we're on the login screen and authenticate if needed
|
|
||||||
const loginInput = page.locator('input[type="password"][placeholder*="API key"]');
|
|
||||||
const isLoginScreen = await loginInput.isVisible({ timeout: 2000 }).catch(() => false);
|
|
||||||
if (isLoginScreen) {
|
|
||||||
const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests';
|
|
||||||
await loginInput.fill(apiKey);
|
|
||||||
await page.locator('button:has-text("Login")').click();
|
|
||||||
await page.waitForURL('**/', { timeout: 5000 });
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
await navigateToContext(page);
|
await navigateToContext(page);
|
||||||
|
|
||||||
// Wait for the file input to be attached to the DOM before setting files
|
// Wait for the file input to be attached to the DOM before setting files
|
||||||
|
|||||||
19
init.mjs
19
init.mjs
@@ -516,6 +516,7 @@ async function main() {
|
|||||||
console.log('═══════════════════════════════════════════════════════');
|
console.log('═══════════════════════════════════════════════════════');
|
||||||
console.log(' 1) Web Application (Browser)');
|
console.log(' 1) Web Application (Browser)');
|
||||||
console.log(' 2) Desktop Application (Electron)');
|
console.log(' 2) Desktop Application (Electron)');
|
||||||
|
console.log(' 3) Docker Container');
|
||||||
console.log('═══════════════════════════════════════════════════════');
|
console.log('═══════════════════════════════════════════════════════');
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
@@ -533,7 +534,7 @@ async function main() {
|
|||||||
|
|
||||||
// Prompt for choice
|
// Prompt for choice
|
||||||
while (true) {
|
while (true) {
|
||||||
const choice = await prompt('Enter your choice (1 or 2): ');
|
const choice = await prompt('Enter your choice (1, 2, or 3): ');
|
||||||
|
|
||||||
if (choice === '1') {
|
if (choice === '1') {
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -634,9 +635,23 @@ async function main() {
|
|||||||
electronProcess.on('close', resolve);
|
electronProcess.on('close', resolve);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
break;
|
||||||
|
} else if (choice === '3') {
|
||||||
|
console.log('');
|
||||||
|
log('Launching Docker Container...', 'blue');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Run docker compose up --build via npm run dev:docker
|
||||||
|
const dockerProcess = runNpm(['run', 'dev:docker'], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
dockerProcess.on('close', resolve);
|
||||||
|
});
|
||||||
|
|
||||||
break;
|
break;
|
||||||
} else {
|
} else {
|
||||||
log('Invalid choice. Please enter 1 or 2.', 'red');
|
log('Invalid choice. Please enter 1, 2, or 3.', 'red');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user