Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0eb4b3b526 | |||
| 32945a01b4 | |||
| 3817aec5b1 | |||
| 82054eb1c7 | |||
| dc7f3f5aa4 | |||
| 2c58b32bbc | |||
| 3a5e172262 | |||
| 7f38183cbb | |||
| c42cea5ca9 | |||
| da8505f61d | |||
| 58586483dc |
@@ -49,9 +49,7 @@ jobs:
|
||||
|
||||
- name: Publish
|
||||
if: ${{ github.event_name == 'release' }}
|
||||
env:
|
||||
NPM_TAG: ${{ github.event.release.prerelease && 'rc' || 'latest' }}
|
||||
run: mise run ci-publish -- --tag "$NPM_TAG"
|
||||
run: mise run ci-publish
|
||||
|
||||
docker:
|
||||
name: Docker
|
||||
@@ -104,7 +102,7 @@ jobs:
|
||||
name=ghcr.io/${{ github.repository_owner }}/immich-cli
|
||||
tags: |
|
||||
type=raw,value=${{ steps.package-version.outputs.version }},enable=${{ github.event_name == 'release' }}
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }}
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
|
||||
@@ -147,7 +147,7 @@ jobs:
|
||||
platforms: ${{ matrix.platforms }}
|
||||
runner-mapping: ${{ matrix.runner-mapping }}
|
||||
suffixes: ${{ matrix.suffixes }}
|
||||
dockerhub-push: ${{ github.event_name == 'release' && !github.event.release.prerelease }}
|
||||
dockerhub-push: ${{ github.event_name == 'release' }}
|
||||
build-args: |
|
||||
DEVICE=${{ matrix.device }}
|
||||
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
image: immich-server
|
||||
context: .
|
||||
dockerfile: server/Dockerfile
|
||||
dockerhub-push: ${{ github.event_name == 'release' && !github.event.release.prerelease }}
|
||||
dockerhub-push: ${{ github.event_name == 'release' }}
|
||||
build-args: |
|
||||
DEVICE=cpu
|
||||
|
||||
|
||||
@@ -98,16 +98,9 @@ jobs:
|
||||
shouldDeploy: true
|
||||
};
|
||||
} else if (eventType == "release") {
|
||||
const tag = context.payload.workflow_run.head_branch;
|
||||
const { data: release } = await github.rest.repos.getReleaseByTag({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag,
|
||||
});
|
||||
parameters = {
|
||||
event: "release",
|
||||
name: tag,
|
||||
prerelease: release.prerelease,
|
||||
name: context.payload.workflow_run.head_branch,
|
||||
shouldDeploy: !isFork
|
||||
};
|
||||
}
|
||||
@@ -153,7 +146,6 @@ jobs:
|
||||
const parameters = JSON.parse(process.env.PARAM_JSON);
|
||||
core.setOutput("event", parameters.event);
|
||||
core.setOutput("name", parameters.name);
|
||||
core.setOutput("prerelease", parameters.prerelease);
|
||||
core.setOutput("shouldDeploy", parameters.shouldDeploy);
|
||||
|
||||
- name: Download artifact
|
||||
@@ -211,7 +203,7 @@ jobs:
|
||||
run: mise run //docs:deploy
|
||||
|
||||
- name: Deploy Docs Release Domain
|
||||
if: ${{ steps.parameters.outputs.event == 'release' && steps.parameters.outputs.prerelease != 'true' }}
|
||||
if: ${{ steps.parameters.outputs.event == 'release' }}
|
||||
env:
|
||||
TF_VAR_prefix_name: ${{ steps.parameters.outputs.name}}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
@@ -39,6 +39,4 @@ jobs:
|
||||
run: pnpm --filter @immich/sdk build
|
||||
|
||||
- name: Publish
|
||||
env:
|
||||
NPM_TAG: ${{ github.event.release.prerelease && 'rc' || 'latest' }}
|
||||
run: pnpm --filter @immich/sdk publish --provenance --no-git-checks --tag "$NPM_TAG"
|
||||
run: pnpm --filter @immich/sdk publish --provenance --no-git-checks
|
||||
|
||||
@@ -72,10 +72,6 @@ jobs:
|
||||
run: flutter pub get
|
||||
working-directory: ./mobile/packages/ui
|
||||
|
||||
- name: Install dependencies for UI Showcase
|
||||
run: flutter pub get
|
||||
working-directory: ./mobile/packages/ui/showcase
|
||||
|
||||
- name: Generate translation files
|
||||
run: mise //mobile:codegen:translation
|
||||
|
||||
|
||||
@@ -109,6 +109,24 @@ mise //mobile:translation
|
||||
|
||||
The mobile app asks you what backend to connect to. You can utilize the demo backend (https://demo.immich.app/) if you don't need to change server code or upload photos. Alternatively, you can run the server yourself per the instructions above.
|
||||
|
||||
#### UI components and widget previews
|
||||
|
||||
Shared design-system widgets (buttons, inputs, forms) live in the
|
||||
[`immich_ui` package](https://github.com/immich-app/immich/tree/main/mobile/packages/ui/)
|
||||
under `mobile/packages/ui/`. Components are defined in `lib/src/components/`
|
||||
and have matching previews in `lib/src/previews/`.
|
||||
|
||||
To inspect a component in isolation with a light/dark toggle and hot reload,
|
||||
launch [Flutter's Widget Previewer](https://docs.flutter.dev/tools/widget-previewer):
|
||||
|
||||
```bash
|
||||
cd mobile/packages/ui
|
||||
flutter widget-preview start
|
||||
```
|
||||
|
||||
In VS Code or Android Studio with the Flutter plugin, the previewer
|
||||
auto-starts when you open the **Flutter Widget Preview** tab in the sidebar.
|
||||
|
||||
## IDE setup
|
||||
|
||||
### Lint / format extensions
|
||||
|
||||
@@ -24,11 +24,13 @@ class WorkflowTrigger {
|
||||
String toJson() => value;
|
||||
|
||||
static const assetCreate = WorkflowTrigger._(r'AssetCreate');
|
||||
static const assetMetadataExtraction = WorkflowTrigger._(r'AssetMetadataExtraction');
|
||||
static const personRecognized = WorkflowTrigger._(r'PersonRecognized');
|
||||
|
||||
/// List of all possible values in this [enum][WorkflowTrigger].
|
||||
static const values = <WorkflowTrigger>[
|
||||
assetCreate,
|
||||
assetMetadataExtraction,
|
||||
personRecognized,
|
||||
];
|
||||
|
||||
@@ -69,6 +71,7 @@ class WorkflowTriggerTypeTransformer {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'AssetCreate': return WorkflowTrigger.assetCreate;
|
||||
case r'AssetMetadataExtraction': return WorkflowTrigger.assetMetadataExtraction;
|
||||
case r'PersonRecognized': return WorkflowTrigger.personRecognized;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widget_previews.dart';
|
||||
import 'package:immich_ui/src/theme.dart';
|
||||
|
||||
const ColorScheme _lightColorScheme = ColorScheme.light(
|
||||
primary: Color(0xFF4250AF),
|
||||
onPrimary: Colors.white,
|
||||
primaryContainer: Color(0xFFD4D6F0),
|
||||
onPrimaryContainer: Color(0xFF181E44),
|
||||
secondary: Color(0xFF737373),
|
||||
onSecondary: Colors.white,
|
||||
error: Color(0xFFE53E3E),
|
||||
onError: Colors.white,
|
||||
surface: Color(0xFFFAFAFA),
|
||||
onSurface: Color(0xFF1A1C1E),
|
||||
surfaceContainerHighest: Color(0xFFE3E4E8),
|
||||
outline: Color(0xFFD1D3D9),
|
||||
outlineVariant: Color(0xFFD4D4D4),
|
||||
);
|
||||
|
||||
const ColorScheme _darkColorScheme = ColorScheme.dark(
|
||||
primary: Color(0xFFACCBFA),
|
||||
onPrimary: Color(0xFF0F1433),
|
||||
primaryContainer: Color(0xFF616D94),
|
||||
onPrimaryContainer: Color(0xFFD4D6F0),
|
||||
secondary: Color(0xFFC4C6D0),
|
||||
onSecondary: Color(0xFF2E3042),
|
||||
error: Color(0xFFE88080),
|
||||
onError: Color(0xFF0F1433),
|
||||
surface: Color(0xFF0A0A0A),
|
||||
onSurface: Color(0xFFE3E3E6),
|
||||
surfaceContainerHighest: Color(0xFF262626),
|
||||
outline: Color(0xFF8E9099),
|
||||
outlineVariant: Color(0xFF43464F),
|
||||
);
|
||||
|
||||
PreviewThemeData immichPreviewTheme() => PreviewThemeData(
|
||||
materialLight: ThemeData(colorScheme: _lightColorScheme, useMaterial3: true),
|
||||
materialDark: ThemeData(colorScheme: _darkColorScheme, useMaterial3: true),
|
||||
);
|
||||
|
||||
Widget immichPreviewWrapper(Widget child) {
|
||||
return Builder(
|
||||
builder: (context) => ImmichThemeProvider(
|
||||
colorScheme: Theme.of(context).colorScheme,
|
||||
child: Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Align(alignment: Alignment.topLeft, child: child),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final class ImmichPreview extends Preview {
|
||||
const ImmichPreview({super.name, super.group, super.size, super.textScaleFactor})
|
||||
: super(theme: immichPreviewTheme, wrapper: immichPreviewWrapper);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/components/close_button.dart';
|
||||
import 'package:immich_ui/src/previews.dart';
|
||||
import 'package:immich_ui/src/types.dart';
|
||||
|
||||
void _previewNoop() {}
|
||||
|
||||
@ImmichPreview(group: 'CloseButton', name: 'Variants')
|
||||
Widget previewCloseButtonVariants() => const Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichCloseButton(onPressed: _previewNoop),
|
||||
ImmichCloseButton(onPressed: _previewNoop, variant: ImmichVariant.filled),
|
||||
],
|
||||
);
|
||||
|
||||
@ImmichPreview(group: 'CloseButton', name: 'Colors')
|
||||
Widget previewCloseButtonColors() => const Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichCloseButton(onPressed: _previewNoop),
|
||||
ImmichCloseButton(onPressed: _previewNoop, color: ImmichColor.secondary),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/components/form.dart';
|
||||
import 'package:immich_ui/src/components/password_input.dart';
|
||||
import 'package:immich_ui/src/components/text_input.dart';
|
||||
import 'package:immich_ui/src/constants.dart';
|
||||
import 'package:immich_ui/src/previews.dart';
|
||||
|
||||
@ImmichPreview(group: 'Form', name: 'Login Form')
|
||||
Widget previewFormLogin() => const _PreviewLoginForm();
|
||||
|
||||
class _PreviewLoginForm extends StatefulWidget {
|
||||
const _PreviewLoginForm();
|
||||
|
||||
@override
|
||||
State<_PreviewLoginForm> createState() => _PreviewLoginFormState();
|
||||
}
|
||||
|
||||
class _PreviewLoginFormState extends State<_PreviewLoginForm> {
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
String _result = '';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ImmichForm(
|
||||
submitText: 'Login',
|
||||
submitIcon: Icons.login,
|
||||
onSubmit: () async {
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_result = 'Form submitted!';
|
||||
});
|
||||
},
|
||||
builder: (context, form) => Column(
|
||||
spacing: ImmichSpacing.sm,
|
||||
children: [
|
||||
ImmichTextInput(
|
||||
label: 'Email',
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (value) => value?.isEmpty ?? true ? 'Required' : null,
|
||||
),
|
||||
ImmichPasswordInput(
|
||||
label: 'Password',
|
||||
controller: _passwordController,
|
||||
validator: (value) => value?.isEmpty ?? true ? 'Required' : null,
|
||||
onSubmit: (_) => form.submit(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_result.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(_result, style: const TextStyle(color: Colors.green)),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/components/formatted_text.dart';
|
||||
import 'package:immich_ui/src/previews.dart';
|
||||
|
||||
@ImmichPreview(group: 'FormattedText', name: 'Bold')
|
||||
Widget previewFormattedTextBold() => const ImmichFormattedText('This is <b>bold text</b>.');
|
||||
|
||||
@ImmichPreview(group: 'FormattedText', name: 'Links')
|
||||
Widget previewFormattedTextLinks() => const _PreviewFormattedTextLinks();
|
||||
|
||||
@ImmichPreview(group: 'FormattedText', name: 'Mixed Content')
|
||||
Widget previewFormattedTextMixed() => const _PreviewFormattedTextMixed();
|
||||
|
||||
class _PreviewFormattedTextLinks extends StatelessWidget {
|
||||
const _PreviewFormattedTextLinks();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichFormattedText(
|
||||
'Read the <docs-link>documentation</docs-link> or visit <github-link>GitHub</github-link>.',
|
||||
spanBuilder: (tag) => FormattedSpan(
|
||||
onTap: switch (tag) {
|
||||
'docs-link' =>
|
||||
() => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Docs link clicked!'))),
|
||||
'github-link' =>
|
||||
() => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('GitHub link clicked!'))),
|
||||
_ => null,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PreviewFormattedTextMixed extends StatelessWidget {
|
||||
const _PreviewFormattedTextMixed();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichFormattedText(
|
||||
'You can use <b>bold text</b> and <link>links</link> together.',
|
||||
spanBuilder: (tag) => switch (tag) {
|
||||
'b' => const FormattedSpan(style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
_ => FormattedSpan(
|
||||
onTap: () =>
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Link clicked!'))),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/components/icon_button.dart';
|
||||
import 'package:immich_ui/src/previews.dart';
|
||||
import 'package:immich_ui/src/types.dart';
|
||||
|
||||
void _previewNoop() {}
|
||||
|
||||
@ImmichPreview(group: 'IconButton', name: 'Variants')
|
||||
Widget previewIconButtonVariants() => const Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichIconButton(icon: Icons.add, onPressed: _previewNoop),
|
||||
ImmichIconButton(icon: Icons.edit, onPressed: _previewNoop, variant: ImmichVariant.ghost),
|
||||
],
|
||||
);
|
||||
|
||||
@ImmichPreview(group: 'IconButton', name: 'Colors')
|
||||
Widget previewIconButtonColors() => const Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichIconButton(icon: Icons.favorite, onPressed: _previewNoop),
|
||||
ImmichIconButton(icon: Icons.delete, onPressed: _previewNoop, color: ImmichColor.secondary),
|
||||
],
|
||||
);
|
||||
|
||||
@ImmichPreview(group: 'IconButton', name: 'Disabled')
|
||||
Widget previewIconButtonDisabled() => const Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichIconButton(icon: Icons.settings, onPressed: _previewNoop, disabled: true),
|
||||
ImmichIconButton(
|
||||
icon: Icons.settings,
|
||||
onPressed: _previewNoop,
|
||||
disabled: true,
|
||||
variant: ImmichVariant.ghost,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/components/password_input.dart';
|
||||
import 'package:immich_ui/src/previews.dart';
|
||||
|
||||
@ImmichPreview(group: 'PasswordInput', name: 'With Validator')
|
||||
Widget previewPasswordInput() => ImmichPasswordInput(
|
||||
label: 'Password',
|
||||
hintText: 'Enter your password',
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Password is required';
|
||||
}
|
||||
if (value.length < 8) {
|
||||
return 'Password must be at least 8 characters';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/components/text_button.dart';
|
||||
import 'package:immich_ui/src/previews.dart';
|
||||
import 'package:immich_ui/src/types.dart';
|
||||
|
||||
void _previewNoop() {}
|
||||
|
||||
@ImmichPreview(group: 'TextButton', name: 'Variants')
|
||||
Widget previewTextButtonVariants() => const Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichTextButton(onPressed: _previewNoop, labelText: 'Filled', expanded: false),
|
||||
ImmichTextButton(onPressed: _previewNoop, labelText: 'Ghost', variant: ImmichVariant.ghost, expanded: false),
|
||||
],
|
||||
);
|
||||
|
||||
@ImmichPreview(group: 'TextButton', name: 'Colors')
|
||||
Widget previewTextButtonColors() => const Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichTextButton(onPressed: _previewNoop, labelText: 'Primary', expanded: false),
|
||||
ImmichTextButton(onPressed: _previewNoop, labelText: 'Secondary', color: ImmichColor.secondary, expanded: false),
|
||||
],
|
||||
);
|
||||
|
||||
@ImmichPreview(group: 'TextButton', name: 'With Icons')
|
||||
Widget previewTextButtonWithIcons() => const Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichTextButton(onPressed: _previewNoop, labelText: 'With Icon', icon: Icons.add, expanded: false),
|
||||
ImmichTextButton(
|
||||
onPressed: _previewNoop,
|
||||
labelText: 'Download',
|
||||
icon: Icons.download,
|
||||
variant: ImmichVariant.ghost,
|
||||
expanded: false,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ImmichPreview(group: 'TextButton', name: 'Loading')
|
||||
Widget previewTextButtonLoading() => const _PreviewLoadingDemo();
|
||||
|
||||
@ImmichPreview(group: 'TextButton', name: 'Disabled')
|
||||
Widget previewTextButtonDisabled() => const Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichTextButton(onPressed: _previewNoop, labelText: 'Disabled', disabled: true, expanded: false),
|
||||
ImmichTextButton(
|
||||
onPressed: _previewNoop,
|
||||
labelText: 'Disabled Ghost',
|
||||
variant: ImmichVariant.ghost,
|
||||
disabled: true,
|
||||
expanded: false,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
class _PreviewLoadingDemo extends StatefulWidget {
|
||||
const _PreviewLoadingDemo();
|
||||
|
||||
@override
|
||||
State<_PreviewLoadingDemo> createState() => _PreviewLoadingDemoState();
|
||||
}
|
||||
|
||||
class _PreviewLoadingDemoState extends State<_PreviewLoadingDemo> {
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichTextButton(
|
||||
onPressed: () async {
|
||||
setState(() => _isLoading = true);
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
},
|
||||
labelText: _isLoading ? 'Loading...' : 'Click Me',
|
||||
loading: _isLoading,
|
||||
expanded: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/components/text_input.dart';
|
||||
import 'package:immich_ui/src/previews.dart';
|
||||
|
||||
@ImmichPreview(group: 'TextInput', name: 'Basic')
|
||||
Widget previewTextInputBasic() => const _PreviewTextInputBasic();
|
||||
|
||||
@ImmichPreview(group: 'TextInput', name: 'With Validator')
|
||||
Widget previewTextInputValidator() => const _PreviewTextInputValidator();
|
||||
|
||||
class _PreviewTextInputBasic extends StatefulWidget {
|
||||
const _PreviewTextInputBasic();
|
||||
|
||||
@override
|
||||
State<_PreviewTextInputBasic> createState() => _PreviewTextInputBasicState();
|
||||
}
|
||||
|
||||
class _PreviewTextInputBasicState extends State<_PreviewTextInputBasic> {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichTextInput(
|
||||
label: 'Email',
|
||||
hintText: 'Enter your email',
|
||||
controller: _controller,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PreviewTextInputValidator extends StatefulWidget {
|
||||
const _PreviewTextInputValidator();
|
||||
|
||||
@override
|
||||
State<_PreviewTextInputValidator> createState() => _PreviewTextInputValidatorState();
|
||||
}
|
||||
|
||||
class _PreviewTextInputValidatorState extends State<_PreviewTextInputValidator> {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichTextInput(
|
||||
label: 'Username',
|
||||
controller: _controller,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Username is required';
|
||||
}
|
||||
if (value.length < 3) {
|
||||
return 'Username must be at least 3 characters';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/components/url_input.dart';
|
||||
import 'package:immich_ui/src/previews.dart';
|
||||
|
||||
@ImmichPreview(group: 'URLInput', name: 'Basic')
|
||||
Widget previewUrlInput() => const _PreviewUrlInput();
|
||||
|
||||
class _PreviewUrlInput extends StatefulWidget {
|
||||
const _PreviewUrlInput();
|
||||
|
||||
@override
|
||||
State<_PreviewUrlInput> createState() => _PreviewUrlInputState();
|
||||
}
|
||||
|
||||
class _PreviewUrlInputState extends State<_PreviewUrlInput> {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichURLInput(label: 'Server URL', hintText: 'https://demo.immich.com', controller: _controller);
|
||||
}
|
||||
}
|
||||
@@ -185,5 +185,5 @@ packages:
|
||||
source: hosted
|
||||
version: "15.2.0"
|
||||
sdks:
|
||||
dart: ">=3.11.0 <4.0.0"
|
||||
dart: ">=3.12.0 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
|
||||
@@ -2,7 +2,7 @@ name: immich_ui
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: '>=3.11.0 <4.0.0'
|
||||
sdk: '>=3.12.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# Build artifacts
|
||||
build/
|
||||
|
||||
# Test cache and generated files
|
||||
.dart_tool/
|
||||
.packages
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
|
||||
# IDE-specific files
|
||||
.vscode/
|
||||
@@ -1,30 +0,0 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
- platform: web
|
||||
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
@@ -1 +0,0 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
@@ -1,339 +0,0 @@
|
||||
{
|
||||
"name": "GitHub Dark",
|
||||
"settings": [
|
||||
{
|
||||
"settings": {
|
||||
"foreground": "#e1e4e8",
|
||||
"background": "#24292e"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"comment",
|
||||
"punctuation.definition.comment",
|
||||
"string.comment"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "#6a737d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"constant",
|
||||
"entity.name.constant",
|
||||
"variable.other.constant",
|
||||
"variable.other.enummember",
|
||||
"variable.language"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "#79b8ff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"entity",
|
||||
"entity.name"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "#b392f0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "variable.parameter.function",
|
||||
"settings": {
|
||||
"foreground": "#e1e4e8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "entity.name.tag",
|
||||
"settings": {
|
||||
"foreground": "#85e89d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "keyword",
|
||||
"settings": {
|
||||
"foreground": "#f97583"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"storage",
|
||||
"storage.type"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "#f97583"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"storage.modifier.package",
|
||||
"storage.modifier.import",
|
||||
"storage.type.java"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "#e1e4e8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"string",
|
||||
"punctuation.definition.string",
|
||||
"string punctuation.section.embedded source"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "#9ecbff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "support",
|
||||
"settings": {
|
||||
"foreground": "#79b8ff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "meta.property-name",
|
||||
"settings": {
|
||||
"foreground": "#79b8ff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "variable",
|
||||
"settings": {
|
||||
"foreground": "#ffab70"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "variable.other",
|
||||
"settings": {
|
||||
"foreground": "#e1e4e8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "invalid.broken",
|
||||
"settings": {
|
||||
"fontStyle": "italic",
|
||||
"foreground": "#fdaeb7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "invalid.deprecated",
|
||||
"settings": {
|
||||
"fontStyle": "italic",
|
||||
"foreground": "#fdaeb7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "invalid.illegal",
|
||||
"settings": {
|
||||
"fontStyle": "italic",
|
||||
"foreground": "#fdaeb7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "invalid.unimplemented",
|
||||
"settings": {
|
||||
"fontStyle": "italic",
|
||||
"foreground": "#fdaeb7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "message.error",
|
||||
"settings": {
|
||||
"foreground": "#fdaeb7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "string variable",
|
||||
"settings": {
|
||||
"foreground": "#79b8ff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"source.regexp",
|
||||
"string.regexp"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "#dbedff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"string.regexp.character-class",
|
||||
"string.regexp constant.character.escape",
|
||||
"string.regexp source.ruby.embedded",
|
||||
"string.regexp string.regexp.arbitrary-repitition"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "#dbedff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "string.regexp constant.character.escape",
|
||||
"settings": {
|
||||
"fontStyle": "bold",
|
||||
"foreground": "#85e89d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "support.constant",
|
||||
"settings": {
|
||||
"foreground": "#79b8ff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "support.variable",
|
||||
"settings": {
|
||||
"foreground": "#79b8ff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "meta.module-reference",
|
||||
"settings": {
|
||||
"foreground": "#79b8ff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "punctuation.definition.list.begin.markdown",
|
||||
"settings": {
|
||||
"foreground": "#ffab70"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"markup.heading",
|
||||
"markup.heading entity.name"
|
||||
],
|
||||
"settings": {
|
||||
"fontStyle": "bold",
|
||||
"foreground": "#79b8ff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "markup.quote",
|
||||
"settings": {
|
||||
"foreground": "#85e89d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "markup.italic",
|
||||
"settings": {
|
||||
"fontStyle": "italic",
|
||||
"foreground": "#e1e4e8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "markup.bold",
|
||||
"settings": {
|
||||
"fontStyle": "bold",
|
||||
"foreground": "#e1e4e8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "markup.underline",
|
||||
"settings": {
|
||||
"fontStyle": "underline"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "markup.inline.raw",
|
||||
"settings": {
|
||||
"foreground": "#79b8ff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"markup.deleted",
|
||||
"meta.diff.header.from-file",
|
||||
"punctuation.definition.deleted"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "#fdaeb7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"markup.inserted",
|
||||
"meta.diff.header.to-file",
|
||||
"punctuation.definition.inserted"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "#85e89d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"markup.changed",
|
||||
"punctuation.definition.changed"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "#ffab70"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"markup.ignored",
|
||||
"markup.untracked"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "#2f363d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "meta.diff.range",
|
||||
"settings": {
|
||||
"fontStyle": "bold",
|
||||
"foreground": "#b392f0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "meta.diff.header",
|
||||
"settings": {
|
||||
"foreground": "#79b8ff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "meta.separator",
|
||||
"settings": {
|
||||
"fontStyle": "bold",
|
||||
"foreground": "#79b8ff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "meta.output",
|
||||
"settings": {
|
||||
"foreground": "#79b8ff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"brackethighlighter.tag",
|
||||
"brackethighlighter.curly",
|
||||
"brackethighlighter.round",
|
||||
"brackethighlighter.square",
|
||||
"brackethighlighter.angle",
|
||||
"brackethighlighter.quote"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "#d1d5da"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "brackethighlighter.unmatched",
|
||||
"settings": {
|
||||
"foreground": "#fdaeb7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"constant.other.reference.link",
|
||||
"string.other.link"
|
||||
],
|
||||
"settings": {
|
||||
"fontStyle": "underline",
|
||||
"foreground": "#dbedff"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppTheme {
|
||||
// Light theme colors
|
||||
static const _primary500 = Color(0xFF4250AF);
|
||||
static const _primary100 = Color(0xFFD4D6F0);
|
||||
static const _primary900 = Color(0xFF181E44);
|
||||
static const _danger500 = Color(0xFFE53E3E);
|
||||
static const _light50 = Color(0xFFFAFAFA);
|
||||
static const _light300 = Color(0xFFD4D4D4);
|
||||
static const _light500 = Color(0xFF737373);
|
||||
|
||||
// Dark theme colors
|
||||
static const _darkPrimary500 = Color(0xFFACCBFA);
|
||||
static const _darkPrimary300 = Color(0xFF616D94);
|
||||
static const _darkDanger500 = Color(0xFFE88080);
|
||||
static const _darkLight50 = Color(0xFF0A0A0A);
|
||||
static const _darkLight100 = Color(0xFF171717);
|
||||
static const _darkLight200 = Color(0xFF262626);
|
||||
|
||||
static ThemeData get lightTheme {
|
||||
return ThemeData(
|
||||
colorScheme: const ColorScheme.light(
|
||||
primary: _primary500,
|
||||
onPrimary: Colors.white,
|
||||
primaryContainer: _primary100,
|
||||
onPrimaryContainer: _primary900,
|
||||
secondary: _light500,
|
||||
onSecondary: Colors.white,
|
||||
error: _danger500,
|
||||
onError: Colors.white,
|
||||
surface: _light50,
|
||||
onSurface: Color(0xFF1A1C1E),
|
||||
surfaceContainerHighest: Color(0xFFE3E4E8),
|
||||
outline: Color(0xFFD1D3D9),
|
||||
outlineVariant: _light300,
|
||||
),
|
||||
useMaterial3: true,
|
||||
fontFamily: 'GoogleSans',
|
||||
scaffoldBackgroundColor: _light50,
|
||||
cardTheme: const CardThemeData(
|
||||
elevation: 0,
|
||||
color: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
side: BorderSide(color: _light300, width: 1),
|
||||
),
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
centerTitle: false,
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.white,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
foregroundColor: Color(0xFF1A1C1E),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static ThemeData get darkTheme {
|
||||
return ThemeData(
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: _darkPrimary500,
|
||||
onPrimary: Color(0xFF0F1433),
|
||||
primaryContainer: _darkPrimary300,
|
||||
onPrimaryContainer: _primary100,
|
||||
secondary: Color(0xFFC4C6D0),
|
||||
onSecondary: Color(0xFF2E3042),
|
||||
error: _darkDanger500,
|
||||
onError: Color(0xFF0F1433),
|
||||
surface: _darkLight50,
|
||||
onSurface: Color(0xFFE3E3E6),
|
||||
surfaceContainerHighest: _darkLight200,
|
||||
outline: Color(0xFF8E9099),
|
||||
outlineVariant: Color(0xFF43464F),
|
||||
),
|
||||
useMaterial3: true,
|
||||
fontFamily: 'GoogleSans',
|
||||
scaffoldBackgroundColor: _darkLight50,
|
||||
cardTheme: const CardThemeData(
|
||||
elevation: 0,
|
||||
color: _darkLight100,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
side: BorderSide(color: _darkLight200, width: 1),
|
||||
),
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
centerTitle: false,
|
||||
elevation: 0,
|
||||
backgroundColor: _darkLight50,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
foregroundColor: Color(0xFFE3E3E6),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
const String appTitle = '@immich/ui';
|
||||
|
||||
class LayoutConstants {
|
||||
static const double sidebarWidth = 220.0;
|
||||
|
||||
static const double gridSpacing = 16.0;
|
||||
static const double gridAspectRatio = 2.5;
|
||||
|
||||
static const double borderRadiusSmall = 6.0;
|
||||
static const double borderRadiusMedium = 8.0;
|
||||
static const double borderRadiusLarge = 12.0;
|
||||
|
||||
static const double iconSizeSmall = 16.0;
|
||||
static const double iconSizeMedium = 18.0;
|
||||
static const double iconSizeLarge = 20.0;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:showcase/app_theme.dart';
|
||||
import 'package:showcase/constants.dart';
|
||||
import 'package:showcase/router.dart';
|
||||
import 'package:showcase/widgets/example_card.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await initializeCodeHighlighter();
|
||||
runApp(const ShowcaseApp());
|
||||
}
|
||||
|
||||
class ShowcaseApp extends StatefulWidget {
|
||||
const ShowcaseApp({super.key});
|
||||
|
||||
@override
|
||||
State<ShowcaseApp> createState() => _ShowcaseAppState();
|
||||
}
|
||||
|
||||
class _ShowcaseAppState extends State<ShowcaseApp> {
|
||||
ThemeMode _themeMode = ThemeMode.light;
|
||||
late final GoRouter _router;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_router = AppRouter.createRouter(_toggleTheme);
|
||||
}
|
||||
|
||||
void _toggleTheme() {
|
||||
setState(() {
|
||||
_themeMode = _themeMode == ThemeMode.light
|
||||
? ThemeMode.dark
|
||||
: ThemeMode.light;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp.router(
|
||||
title: appTitle,
|
||||
themeMode: _themeMode,
|
||||
routerConfig: _router,
|
||||
theme: AppTheme.lightTheme,
|
||||
darkTheme: AppTheme.darkTheme,
|
||||
debugShowCheckedModeBanner: false,
|
||||
builder: (context, child) => ImmichThemeProvider(
|
||||
colorScheme: Theme.of(context).colorScheme,
|
||||
child: child!,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:showcase/routes.dart';
|
||||
import 'package:showcase/widgets/component_examples.dart';
|
||||
import 'package:showcase/widgets/example_card.dart';
|
||||
import 'package:showcase/widgets/page_title.dart';
|
||||
|
||||
class CloseButtonPage extends StatelessWidget {
|
||||
const CloseButtonPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageTitle(
|
||||
title: AppRoute.closeButton.name,
|
||||
child: ComponentExamples(
|
||||
title: 'ImmichCloseButton',
|
||||
subtitle: 'Pre-configured close button for dialogs and sheets.',
|
||||
examples: [
|
||||
ExampleCard(
|
||||
title: 'Default & Custom',
|
||||
preview: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichCloseButton(onPressed: () {}),
|
||||
ImmichCloseButton(
|
||||
variant: ImmichVariant.filled,
|
||||
onPressed: () {},
|
||||
),
|
||||
ImmichCloseButton(
|
||||
color: ImmichColor.secondary,
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class FormattedTextBoldText extends StatelessWidget {
|
||||
const FormattedTextBoldText({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichFormattedText('This is <b>bold text</b>.');
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class FormattedTextLinks extends StatelessWidget {
|
||||
const FormattedTextLinks({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichFormattedText(
|
||||
'Read the <docs-link>documentation</docs-link> or visit <github-link>GitHub</github-link>.',
|
||||
spanBuilder: (tag) => FormattedSpan(
|
||||
onTap: switch (tag) {
|
||||
'docs-link' => () => ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Docs link clicked!'))),
|
||||
'github-link' => () => ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('GitHub link clicked!'))),
|
||||
_ => null,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class FormattedTextMixedContent extends StatelessWidget {
|
||||
const FormattedTextMixedContent({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichFormattedText(
|
||||
'You can use <b>bold text</b> and <link>links</link> together.',
|
||||
spanBuilder: (tag) => switch (tag) {
|
||||
'b' => const FormattedSpan(
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
_ => FormattedSpan(
|
||||
onTap: () => ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Link clicked!'))),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:showcase/routes.dart';
|
||||
import 'package:showcase/widgets/component_examples.dart';
|
||||
import 'package:showcase/widgets/example_card.dart';
|
||||
import 'package:showcase/widgets/page_title.dart';
|
||||
|
||||
class FormPage extends StatefulWidget {
|
||||
const FormPage({super.key});
|
||||
|
||||
@override
|
||||
State<FormPage> createState() => _FormPageState();
|
||||
}
|
||||
|
||||
class _FormPageState extends State<FormPage> {
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
String _result = '';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageTitle(
|
||||
title: AppRoute.form.name,
|
||||
child: ComponentExamples(
|
||||
title: 'ImmichForm',
|
||||
subtitle:
|
||||
'Form container with built-in validation and submit handling.',
|
||||
examples: [
|
||||
ExampleCard(
|
||||
title: 'Login Form',
|
||||
preview: Column(
|
||||
children: [
|
||||
ImmichForm(
|
||||
submitText: 'Login',
|
||||
submitIcon: Icons.login,
|
||||
onSubmit: () async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
setState(() {
|
||||
_result = 'Form submitted!';
|
||||
});
|
||||
},
|
||||
builder: (context, form) => Column(
|
||||
spacing: 10,
|
||||
children: [
|
||||
ImmichTextInput(
|
||||
label: 'Email',
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (value) =>
|
||||
value?.isEmpty ?? true ? 'Required' : null,
|
||||
),
|
||||
ImmichPasswordInput(
|
||||
label: 'Password',
|
||||
controller: _passwordController,
|
||||
validator: (value) =>
|
||||
value?.isEmpty ?? true ? 'Required' : null,
|
||||
onSubmit: (_) => form.submit(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_result.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(_result, style: const TextStyle(color: Colors.green)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:showcase/pages/components/examples/formatted_text_bold_text.dart';
|
||||
import 'package:showcase/pages/components/examples/formatted_text_links.dart';
|
||||
import 'package:showcase/pages/components/examples/formatted_text_mixed_tags.dart';
|
||||
import 'package:showcase/routes.dart';
|
||||
import 'package:showcase/widgets/component_examples.dart';
|
||||
import 'package:showcase/widgets/example_card.dart';
|
||||
import 'package:showcase/widgets/page_title.dart';
|
||||
|
||||
class FormattedTextPage extends StatelessWidget {
|
||||
const FormattedTextPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageTitle(
|
||||
title: AppRoute.formattedText.name,
|
||||
child: ComponentExamples(
|
||||
title: 'ImmichFormattedText',
|
||||
subtitle: 'Render text with HTML formatting (bold, links).',
|
||||
examples: [
|
||||
ExampleCard(
|
||||
title: 'Bold Text',
|
||||
preview: const FormattedTextBoldText(),
|
||||
code: 'formatted_text_bold_text.dart',
|
||||
),
|
||||
ExampleCard(
|
||||
title: 'Links',
|
||||
preview: const FormattedTextLinks(),
|
||||
code: 'formatted_text_links.dart',
|
||||
),
|
||||
ExampleCard(
|
||||
title: 'Mixed Content',
|
||||
preview: const FormattedTextMixedContent(),
|
||||
code: 'formatted_text_mixed_tags.dart',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:showcase/routes.dart';
|
||||
import 'package:showcase/widgets/component_examples.dart';
|
||||
import 'package:showcase/widgets/example_card.dart';
|
||||
import 'package:showcase/widgets/page_title.dart';
|
||||
|
||||
class IconButtonPage extends StatelessWidget {
|
||||
const IconButtonPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageTitle(
|
||||
title: AppRoute.iconButton.name,
|
||||
child: ComponentExamples(
|
||||
title: 'ImmichIconButton',
|
||||
subtitle: 'Icon-only button with customizable styling.',
|
||||
examples: [
|
||||
ExampleCard(
|
||||
title: 'Variants & Colors',
|
||||
preview: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichIconButton(
|
||||
icon: Icons.add,
|
||||
onPressed: () {},
|
||||
variant: ImmichVariant.filled,
|
||||
),
|
||||
ImmichIconButton(
|
||||
icon: Icons.edit,
|
||||
onPressed: () {},
|
||||
variant: ImmichVariant.ghost,
|
||||
),
|
||||
ImmichIconButton(
|
||||
icon: Icons.delete,
|
||||
onPressed: () {},
|
||||
color: ImmichColor.secondary,
|
||||
),
|
||||
ImmichIconButton(
|
||||
icon: Icons.settings,
|
||||
onPressed: () {},
|
||||
disabled: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:showcase/routes.dart';
|
||||
import 'package:showcase/widgets/component_examples.dart';
|
||||
import 'package:showcase/widgets/example_card.dart';
|
||||
import 'package:showcase/widgets/page_title.dart';
|
||||
|
||||
class PasswordInputPage extends StatelessWidget {
|
||||
const PasswordInputPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageTitle(
|
||||
title: AppRoute.passwordInput.name,
|
||||
child: ComponentExamples(
|
||||
title: 'ImmichPasswordInput',
|
||||
subtitle: 'Password field with visibility toggle.',
|
||||
examples: [
|
||||
ExampleCard(
|
||||
title: 'Password Input',
|
||||
preview: ImmichPasswordInput(
|
||||
label: 'Password',
|
||||
hintText: 'Enter your password',
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Password is required';
|
||||
}
|
||||
if (value.length < 8) {
|
||||
return 'Password must be at least 8 characters';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:showcase/routes.dart';
|
||||
import 'package:showcase/widgets/component_examples.dart';
|
||||
import 'package:showcase/widgets/example_card.dart';
|
||||
import 'package:showcase/widgets/page_title.dart';
|
||||
|
||||
class TextButtonPage extends StatefulWidget {
|
||||
const TextButtonPage({super.key});
|
||||
|
||||
@override
|
||||
State<TextButtonPage> createState() => _TextButtonPageState();
|
||||
}
|
||||
|
||||
class _TextButtonPageState extends State<TextButtonPage> {
|
||||
bool _isLoading = false;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageTitle(
|
||||
title: AppRoute.textButton.name,
|
||||
child: ComponentExamples(
|
||||
title: 'ImmichTextButton',
|
||||
subtitle:
|
||||
'A versatile button component with multiple variants and color options.',
|
||||
examples: [
|
||||
ExampleCard(
|
||||
title: 'Variants',
|
||||
description:
|
||||
'Filled and ghost variants for different visual hierarchy',
|
||||
preview: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichTextButton(
|
||||
onPressed: () {},
|
||||
labelText: 'Filled',
|
||||
variant: ImmichVariant.filled,
|
||||
expanded: false,
|
||||
),
|
||||
ImmichTextButton(
|
||||
onPressed: () {},
|
||||
labelText: 'Ghost',
|
||||
variant: ImmichVariant.ghost,
|
||||
expanded: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ExampleCard(
|
||||
title: 'Colors',
|
||||
description: 'Primary and secondary color options',
|
||||
preview: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichTextButton(
|
||||
onPressed: () {},
|
||||
labelText: 'Primary',
|
||||
color: ImmichColor.primary,
|
||||
expanded: false,
|
||||
),
|
||||
ImmichTextButton(
|
||||
onPressed: () {},
|
||||
labelText: 'Secondary',
|
||||
color: ImmichColor.secondary,
|
||||
expanded: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ExampleCard(
|
||||
title: 'With Icons',
|
||||
description: 'Add leading icons',
|
||||
preview: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichTextButton(
|
||||
onPressed: () {},
|
||||
labelText: 'With Icon',
|
||||
icon: Icons.add,
|
||||
expanded: false,
|
||||
),
|
||||
ImmichTextButton(
|
||||
onPressed: () {},
|
||||
labelText: 'Download',
|
||||
icon: Icons.download,
|
||||
variant: ImmichVariant.ghost,
|
||||
expanded: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ExampleCard(
|
||||
title: 'Loading State',
|
||||
description: 'Shows loading indicator during async operations',
|
||||
preview: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ImmichTextButton(
|
||||
onPressed: () async {
|
||||
setState(() => _isLoading = true);
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
},
|
||||
labelText: _isLoading ? 'Loading...' : 'Click Me',
|
||||
loading: _isLoading,
|
||||
expanded: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ExampleCard(
|
||||
title: 'Disabled State',
|
||||
description: 'Buttons can be disabled',
|
||||
preview: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichTextButton(
|
||||
onPressed: () {},
|
||||
labelText: 'Disabled',
|
||||
disabled: true,
|
||||
expanded: false,
|
||||
),
|
||||
ImmichTextButton(
|
||||
onPressed: () {},
|
||||
labelText: 'Disabled Ghost',
|
||||
variant: ImmichVariant.ghost,
|
||||
disabled: true,
|
||||
expanded: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:showcase/routes.dart';
|
||||
import 'package:showcase/widgets/component_examples.dart';
|
||||
import 'package:showcase/widgets/example_card.dart';
|
||||
import 'package:showcase/widgets/page_title.dart';
|
||||
|
||||
class TextInputPage extends StatefulWidget {
|
||||
const TextInputPage({super.key});
|
||||
|
||||
@override
|
||||
State<TextInputPage> createState() => _TextInputPageState();
|
||||
}
|
||||
|
||||
class _TextInputPageState extends State<TextInputPage> {
|
||||
final _controller1 = TextEditingController();
|
||||
final _controller2 = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageTitle(
|
||||
title: AppRoute.textInput.name,
|
||||
child: ComponentExamples(
|
||||
title: 'ImmichTextInput',
|
||||
subtitle: 'Text field with validation support.',
|
||||
examples: [
|
||||
ExampleCard(
|
||||
title: 'Basic Usage',
|
||||
preview: Column(
|
||||
children: [
|
||||
ImmichTextInput(
|
||||
label: 'Email',
|
||||
hintText: 'Enter your email',
|
||||
controller: _controller1,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ImmichTextInput(
|
||||
label: 'Username',
|
||||
controller: _controller2,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Username is required';
|
||||
}
|
||||
if (value.length < 3) {
|
||||
return 'Username must be at least 3 characters';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller1.dispose();
|
||||
_controller2.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,396 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:showcase/routes.dart';
|
||||
import 'package:showcase/widgets/component_examples.dart';
|
||||
import 'package:showcase/widgets/example_card.dart';
|
||||
import 'package:showcase/widgets/page_title.dart';
|
||||
|
||||
class ConstantsPage extends StatefulWidget {
|
||||
const ConstantsPage({super.key});
|
||||
|
||||
@override
|
||||
State<ConstantsPage> createState() => _ConstantsPageState();
|
||||
}
|
||||
|
||||
class _ConstantsPageState extends State<ConstantsPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageTitle(
|
||||
title: AppRoute.constants.name,
|
||||
child: ComponentExamples(
|
||||
title: 'Constants',
|
||||
subtitle: 'Consistent spacing, sizing, and styling constants.',
|
||||
expand: true,
|
||||
examples: [
|
||||
const ExampleCard(
|
||||
title: 'Spacing',
|
||||
description: 'ImmichSpacing (4.0 → 48.0)',
|
||||
preview: Column(
|
||||
children: [
|
||||
_SpacingBox(label: 'xs', size: ImmichSpacing.xs),
|
||||
_SpacingBox(label: 'sm', size: ImmichSpacing.sm),
|
||||
_SpacingBox(label: 'md', size: ImmichSpacing.md),
|
||||
_SpacingBox(label: 'lg', size: ImmichSpacing.lg),
|
||||
_SpacingBox(label: 'xl', size: ImmichSpacing.xl),
|
||||
_SpacingBox(label: 'xxl', size: ImmichSpacing.xxl),
|
||||
_SpacingBox(label: 'xxxl', size: ImmichSpacing.xxxl),
|
||||
],
|
||||
),
|
||||
),
|
||||
const ExampleCard(
|
||||
title: 'Border Radius',
|
||||
description: 'ImmichRadius (0.0 → 24.0)',
|
||||
preview: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
_RadiusBox(label: 'none', radius: ImmichRadius.none),
|
||||
_RadiusBox(label: 'xs', radius: ImmichRadius.xs),
|
||||
_RadiusBox(label: 'sm', radius: ImmichRadius.sm),
|
||||
_RadiusBox(label: 'md', radius: ImmichRadius.md),
|
||||
_RadiusBox(label: 'lg', radius: ImmichRadius.lg),
|
||||
_RadiusBox(label: 'xl', radius: ImmichRadius.xl),
|
||||
_RadiusBox(label: 'xxl', radius: ImmichRadius.xxl),
|
||||
],
|
||||
),
|
||||
),
|
||||
const ExampleCard(
|
||||
title: 'Icon Sizes',
|
||||
description: 'ImmichIconSize (16.0 → 48.0)',
|
||||
preview: Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
alignment: WrapAlignment.start,
|
||||
children: [
|
||||
_IconSizeBox(label: 'xs', size: ImmichIconSize.xs),
|
||||
_IconSizeBox(label: 'sm', size: ImmichIconSize.sm),
|
||||
_IconSizeBox(label: 'md', size: ImmichIconSize.md),
|
||||
_IconSizeBox(label: 'lg', size: ImmichIconSize.lg),
|
||||
_IconSizeBox(label: 'xl', size: ImmichIconSize.xl),
|
||||
_IconSizeBox(label: 'xxl', size: ImmichIconSize.xxl),
|
||||
],
|
||||
),
|
||||
),
|
||||
const ExampleCard(
|
||||
title: 'Text Sizes',
|
||||
description: 'ImmichTextSize (10.0 → 60.0)',
|
||||
preview: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Caption',
|
||||
style: TextStyle(fontSize: ImmichTextSize.caption),
|
||||
),
|
||||
Text('Label', style: TextStyle(fontSize: ImmichTextSize.label)),
|
||||
Text('Body', style: TextStyle(fontSize: ImmichTextSize.body)),
|
||||
Text('H6', style: TextStyle(fontSize: ImmichTextSize.h6)),
|
||||
Text('H5', style: TextStyle(fontSize: ImmichTextSize.h5)),
|
||||
Text('H4', style: TextStyle(fontSize: ImmichTextSize.h4)),
|
||||
Text('H3', style: TextStyle(fontSize: ImmichTextSize.h3)),
|
||||
Text('H2', style: TextStyle(fontSize: ImmichTextSize.h2)),
|
||||
Text('H1', style: TextStyle(fontSize: ImmichTextSize.h1)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const ExampleCard(
|
||||
title: 'Elevation',
|
||||
description: 'ImmichElevation (0.0 → 16.0)',
|
||||
preview: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
_ElevationBox(label: 'none', elevation: ImmichElevation.none),
|
||||
_ElevationBox(label: 'xs', elevation: ImmichElevation.xs),
|
||||
_ElevationBox(label: 'sm', elevation: ImmichElevation.sm),
|
||||
_ElevationBox(label: 'md', elevation: ImmichElevation.md),
|
||||
_ElevationBox(label: 'lg', elevation: ImmichElevation.lg),
|
||||
_ElevationBox(label: 'xl', elevation: ImmichElevation.xl),
|
||||
_ElevationBox(label: 'xxl', elevation: ImmichElevation.xxl),
|
||||
],
|
||||
),
|
||||
),
|
||||
const ExampleCard(
|
||||
title: 'Border Width',
|
||||
description: 'ImmichBorderWidth (0.5 → 4.0)',
|
||||
preview: Column(
|
||||
children: [
|
||||
_BorderBox(
|
||||
label: 'hairline',
|
||||
borderWidth: ImmichBorderWidth.hairline,
|
||||
),
|
||||
_BorderBox(label: 'base', borderWidth: ImmichBorderWidth.base),
|
||||
_BorderBox(label: 'md', borderWidth: ImmichBorderWidth.md),
|
||||
_BorderBox(label: 'lg', borderWidth: ImmichBorderWidth.lg),
|
||||
_BorderBox(label: 'xl', borderWidth: ImmichBorderWidth.xl),
|
||||
],
|
||||
),
|
||||
),
|
||||
const ExampleCard(
|
||||
title: 'Animation Durations',
|
||||
description: 'ImmichDuration (100ms → 700ms)',
|
||||
preview: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
_AnimatedDurationBox(
|
||||
label: 'Extra Fast',
|
||||
duration: ImmichDuration.extraFast,
|
||||
),
|
||||
_AnimatedDurationBox(
|
||||
label: 'Fast',
|
||||
duration: ImmichDuration.fast,
|
||||
),
|
||||
_AnimatedDurationBox(
|
||||
label: 'Normal',
|
||||
duration: ImmichDuration.normal,
|
||||
),
|
||||
_AnimatedDurationBox(
|
||||
label: 'Slow',
|
||||
duration: ImmichDuration.slow,
|
||||
),
|
||||
_AnimatedDurationBox(
|
||||
label: 'Extra Slow',
|
||||
duration: ImmichDuration.extraSlow,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SpacingBox extends StatelessWidget {
|
||||
final String label;
|
||||
final double size;
|
||||
|
||||
const _SpacingBox({required this.label, required this.size});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 60,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontFamily: 'GoogleSansCode'),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: size,
|
||||
height: 24,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('${size.toStringAsFixed(1)}px'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RadiusBox extends StatelessWidget {
|
||||
final String label;
|
||||
final double radius;
|
||||
|
||||
const _RadiusBox({required this.label, required this.radius});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(label, style: const TextStyle(fontSize: 12)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IconSizeBox extends StatelessWidget {
|
||||
final String label;
|
||||
final double size;
|
||||
|
||||
const _IconSizeBox({required this.label, required this.size});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(Icons.palette_rounded, size: size),
|
||||
const SizedBox(height: 4),
|
||||
Text(label, style: const TextStyle(fontSize: 12)),
|
||||
Text(
|
||||
'${size.toStringAsFixed(0)}px',
|
||||
style: const TextStyle(fontSize: 10, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ElevationBox extends StatelessWidget {
|
||||
final String label;
|
||||
final double elevation;
|
||||
|
||||
const _ElevationBox({required this.label, required this.elevation});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Material(
|
||||
elevation: elevation,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
alignment: Alignment.center,
|
||||
child: Text(label, style: const TextStyle(fontSize: 12)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
elevation.toStringAsFixed(1),
|
||||
style: const TextStyle(fontSize: 10),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BorderBox extends StatelessWidget {
|
||||
final String label;
|
||||
final double borderWidth;
|
||||
|
||||
const _BorderBox({required this.label, required this.borderWidth});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontFamily: 'GoogleSansCode'),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: borderWidth,
|
||||
),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('${borderWidth.toStringAsFixed(1)}px'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AnimatedDurationBox extends StatefulWidget {
|
||||
final String label;
|
||||
final Duration duration;
|
||||
|
||||
const _AnimatedDurationBox({required this.label, required this.duration});
|
||||
|
||||
@override
|
||||
State<_AnimatedDurationBox> createState() => _AnimatedDurationBoxState();
|
||||
}
|
||||
|
||||
class _AnimatedDurationBoxState extends State<_AnimatedDurationBox> {
|
||||
bool _atEnd = false;
|
||||
bool _isAnimating = false;
|
||||
|
||||
void _playAnimation() async {
|
||||
if (_isAnimating) return;
|
||||
setState(() => _isAnimating = true);
|
||||
setState(() => _atEnd = true);
|
||||
await Future.delayed(widget.duration);
|
||||
if (!mounted) return;
|
||||
setState(() => _atEnd = false);
|
||||
await Future.delayed(widget.duration);
|
||||
if (!mounted) return;
|
||||
setState(() => _isAnimating = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 90,
|
||||
child: Text(
|
||||
widget.label,
|
||||
style: const TextStyle(fontFamily: 'GoogleSansCode', fontSize: 12),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: AnimatedAlign(
|
||||
duration: widget.duration,
|
||||
curve: Curves.easeInOut,
|
||||
alignment: _atEnd ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
width: 60,
|
||||
height: 28,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'${widget.duration.inMilliseconds}ms',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: colorScheme.onPrimary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
onPressed: _isAnimating ? null : _playAnimation,
|
||||
icon: Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
color: _isAnimating ? colorScheme.outline : colorScheme.primary,
|
||||
),
|
||||
iconSize: 24,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:showcase/constants.dart';
|
||||
import 'package:showcase/routes.dart';
|
||||
|
||||
class HomePage extends StatelessWidget {
|
||||
final VoidCallback onThemeToggle;
|
||||
|
||||
const HomePage({super.key, required this.onThemeToggle});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Title(
|
||||
title: appTitle,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||
children: [
|
||||
Text(
|
||||
appTitle,
|
||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'A collection of Flutter components that are shared across all Immich projects',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
...routesByCategory.entries.map((entry) {
|
||||
if (entry.key == AppRouteCategory.root) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
entry.key.displayName,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
crossAxisSpacing: LayoutConstants.gridSpacing,
|
||||
mainAxisSpacing: LayoutConstants.gridSpacing,
|
||||
childAspectRatio: LayoutConstants.gridAspectRatio,
|
||||
),
|
||||
itemCount: entry.value.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _ComponentCard(route: entry.value[index]);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ComponentCard extends StatelessWidget {
|
||||
final AppRoute route;
|
||||
|
||||
const _ComponentCard({required this.route});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () => context.go(route.path),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(LayoutConstants.borderRadiusLarge)),
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(route.icon, size: 32, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
route.name,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
route.description,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant, height: 1.4),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:showcase/pages/components/close_button_page.dart';
|
||||
import 'package:showcase/pages/components/form_page.dart';
|
||||
import 'package:showcase/pages/components/formatted_text_page.dart';
|
||||
import 'package:showcase/pages/components/icon_button_page.dart';
|
||||
import 'package:showcase/pages/components/password_input_page.dart';
|
||||
import 'package:showcase/pages/components/text_button_page.dart';
|
||||
import 'package:showcase/pages/components/text_input_page.dart';
|
||||
import 'package:showcase/pages/design_system/constants_page.dart';
|
||||
import 'package:showcase/pages/home_page.dart';
|
||||
import 'package:showcase/routes.dart';
|
||||
import 'package:showcase/widgets/shell_layout.dart';
|
||||
|
||||
class AppRouter {
|
||||
static GoRouter createRouter(VoidCallback onThemeToggle) {
|
||||
return GoRouter(
|
||||
initialLocation: AppRoute.home.path,
|
||||
routes: [
|
||||
ShellRoute(
|
||||
builder: (context, state, child) =>
|
||||
ShellLayout(onThemeToggle: onThemeToggle, child: child),
|
||||
routes: AppRoute.values
|
||||
.map(
|
||||
(route) => GoRoute(
|
||||
path: route.path,
|
||||
pageBuilder: (context, state) => NoTransitionPage(
|
||||
key: state.pageKey,
|
||||
child: switch (route) {
|
||||
AppRoute.home => HomePage(onThemeToggle: onThemeToggle),
|
||||
AppRoute.textButton => const TextButtonPage(),
|
||||
AppRoute.iconButton => const IconButtonPage(),
|
||||
AppRoute.closeButton => const CloseButtonPage(),
|
||||
AppRoute.textInput => const TextInputPage(),
|
||||
AppRoute.passwordInput => const PasswordInputPage(),
|
||||
AppRoute.form => const FormPage(),
|
||||
AppRoute.formattedText => const FormattedTextPage(),
|
||||
AppRoute.constants => const ConstantsPage(),
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum AppRouteCategory {
|
||||
root(''),
|
||||
forms('Forms'),
|
||||
buttons('Buttons'),
|
||||
designSystem('Design System');
|
||||
|
||||
final String displayName;
|
||||
const AppRouteCategory(this.displayName);
|
||||
}
|
||||
|
||||
enum AppRoute {
|
||||
home(
|
||||
name: 'Home',
|
||||
description: 'Home page',
|
||||
path: '/',
|
||||
category: AppRouteCategory.root,
|
||||
icon: Icons.home_outlined,
|
||||
),
|
||||
textButton(
|
||||
name: 'Text Button',
|
||||
description: 'Versatile button with filled and ghost variants',
|
||||
path: '/text-button',
|
||||
category: AppRouteCategory.buttons,
|
||||
icon: Icons.smart_button_rounded,
|
||||
),
|
||||
iconButton(
|
||||
name: 'Icon Button',
|
||||
description: 'Icon-only button with customizable styling',
|
||||
path: '/icon-button',
|
||||
category: AppRouteCategory.buttons,
|
||||
icon: Icons.radio_button_unchecked_rounded,
|
||||
),
|
||||
closeButton(
|
||||
name: 'Close Button',
|
||||
description: 'Pre-configured close button for dialogs',
|
||||
path: '/close-button',
|
||||
category: AppRouteCategory.buttons,
|
||||
icon: Icons.close_rounded,
|
||||
),
|
||||
textInput(
|
||||
name: 'Text Input',
|
||||
description: 'Text field with validation support',
|
||||
path: '/text-input',
|
||||
category: AppRouteCategory.forms,
|
||||
icon: Icons.text_fields_outlined,
|
||||
),
|
||||
passwordInput(
|
||||
name: 'Password Input',
|
||||
description: 'Password field with visibility toggle',
|
||||
path: '/password-input',
|
||||
category: AppRouteCategory.forms,
|
||||
icon: Icons.password_outlined,
|
||||
),
|
||||
form(
|
||||
name: 'Form',
|
||||
description: 'Form container with built-in validation',
|
||||
path: '/form',
|
||||
category: AppRouteCategory.forms,
|
||||
icon: Icons.description_outlined,
|
||||
),
|
||||
formattedText(
|
||||
name: 'Formatted Text',
|
||||
description: 'Render text with HTML formatting',
|
||||
path: '/formatted-text',
|
||||
category: AppRouteCategory.forms,
|
||||
icon: Icons.code_rounded,
|
||||
),
|
||||
constants(
|
||||
name: 'Constants',
|
||||
description: 'Spacing, colors, typography, and more',
|
||||
path: '/constants',
|
||||
category: AppRouteCategory.designSystem,
|
||||
icon: Icons.palette_outlined,
|
||||
);
|
||||
|
||||
final String name;
|
||||
final String description;
|
||||
final String path;
|
||||
final AppRouteCategory category;
|
||||
final IconData icon;
|
||||
|
||||
const AppRoute({
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.path,
|
||||
required this.category,
|
||||
required this.icon,
|
||||
});
|
||||
}
|
||||
|
||||
final routesByCategory = AppRoute.values
|
||||
.fold<Map<AppRouteCategory, List<AppRoute>>>({}, (map, route) {
|
||||
map.putIfAbsent(route.category, () => []).add(route);
|
||||
return map;
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ComponentExamples extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final List<Widget> examples;
|
||||
final bool expand;
|
||||
|
||||
const ComponentExamples({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.examples,
|
||||
this.expand = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 24, 24, 24),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: _PageHeader(title: title, subtitle: subtitle),
|
||||
),
|
||||
const SliverPadding(padding: EdgeInsets.only(top: 24)),
|
||||
if (expand)
|
||||
SliverList.builder(
|
||||
itemCount: examples.length,
|
||||
itemBuilder: (context, index) => examples[index],
|
||||
)
|
||||
else
|
||||
SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SliverList.builder(
|
||||
itemCount: examples.length,
|
||||
itemBuilder: (context, index) => Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minWidth: constraints.crossAxisExtent * 0.6,
|
||||
maxWidth: constraints.crossAxisExtent,
|
||||
),
|
||||
child: IntrinsicWidth(child: examples[index]),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PageHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
|
||||
const _PageHeader({required this.title, this.subtitle});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:showcase/constants.dart';
|
||||
import 'package:syntax_highlight/syntax_highlight.dart';
|
||||
|
||||
late final Highlighter _codeHighlighter;
|
||||
|
||||
Future<void> initializeCodeHighlighter() async {
|
||||
await Highlighter.initialize(['dart']);
|
||||
final darkTheme = await HighlighterTheme.loadFromAssets([
|
||||
'assets/themes/github_dark.json',
|
||||
], const TextStyle(color: Color(0xFFe1e4e8)));
|
||||
|
||||
_codeHighlighter = Highlighter(language: 'dart', theme: darkTheme);
|
||||
}
|
||||
|
||||
class ExampleCard extends StatefulWidget {
|
||||
final String title;
|
||||
final String? description;
|
||||
final Widget preview;
|
||||
final String? code;
|
||||
|
||||
const ExampleCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.description,
|
||||
required this.preview,
|
||||
this.code,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ExampleCard> createState() => _ExampleCardState();
|
||||
}
|
||||
|
||||
class _ExampleCardState extends State<ExampleCard> {
|
||||
bool _showPreview = true;
|
||||
String? code;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.code != null) {
|
||||
rootBundle
|
||||
.loadString('lib/pages/components/examples/${widget.code!}')
|
||||
.then((value) {
|
||||
setState(() {
|
||||
code = value;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 1,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.title,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (widget.description != null)
|
||||
Text(
|
||||
widget.description!,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (code != null) ...[
|
||||
const SizedBox(width: 16),
|
||||
Row(
|
||||
children: [
|
||||
_ToggleButton(
|
||||
icon: Icons.visibility_rounded,
|
||||
label: 'Preview',
|
||||
isSelected: _showPreview,
|
||||
onTap: () => setState(() => _showPreview = true),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ToggleButton(
|
||||
icon: Icons.code_rounded,
|
||||
label: 'Code',
|
||||
isSelected: !_showPreview,
|
||||
onTap: () => setState(() => _showPreview = false),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
if (_showPreview)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: SizedBox(width: double.infinity, child: widget.preview),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF24292e),
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(
|
||||
LayoutConstants.borderRadiusMedium,
|
||||
),
|
||||
bottomRight: Radius.circular(
|
||||
LayoutConstants.borderRadiusMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: _CodeCard(code: code!),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ToggleButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ToggleButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(24)),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.7)
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(24)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CodeCard extends StatelessWidget {
|
||||
final String code;
|
||||
|
||||
const _CodeCard({required this.code});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lines = code.split('\n');
|
||||
final lineNumberColor = Colors.white.withValues(alpha: 0.4);
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 12, top: 8, bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: List.generate(
|
||||
lines.length,
|
||||
(index) => SizedBox(
|
||||
height: 20,
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: TextStyle(
|
||||
fontFamily: 'GoogleSansCode',
|
||||
fontSize: 13,
|
||||
color: lineNumberColor,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
SelectableText.rich(
|
||||
_codeHighlighter.highlight(code),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'GoogleSansCode',
|
||||
fontSize: 13,
|
||||
height: 1.54,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class PageTitle extends StatelessWidget {
|
||||
final String title;
|
||||
final Widget child;
|
||||
|
||||
const PageTitle({super.key, required this.title, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Title(
|
||||
title: '$title | @immich/ui',
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:showcase/constants.dart';
|
||||
import 'package:showcase/widgets/sidebar_navigation.dart';
|
||||
|
||||
class ShellLayout extends StatelessWidget {
|
||||
final Widget child;
|
||||
final VoidCallback onThemeToggle;
|
||||
|
||||
const ShellLayout({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.onThemeToggle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset('assets/immich_logo.png', height: 32, width: 32),
|
||||
const SizedBox(width: 8),
|
||||
Image.asset(
|
||||
isDark
|
||||
? 'assets/immich-text-dark.png'
|
||||
: 'assets/immich-text-light.png',
|
||||
height: 24,
|
||||
filterQuality: FilterQuality.none,
|
||||
isAntiAlias: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
isDark ? Icons.light_mode_outlined : Icons.dark_mode_outlined,
|
||||
size: LayoutConstants.iconSizeLarge,
|
||||
),
|
||||
onPressed: onThemeToggle,
|
||||
tooltip: 'Toggle theme',
|
||||
),
|
||||
],
|
||||
shape: Border(
|
||||
bottom: BorderSide(color: Theme.of(context).dividerColor, width: 1),
|
||||
),
|
||||
),
|
||||
body: Row(
|
||||
children: [
|
||||
const SidebarNavigation(),
|
||||
const VerticalDivider(),
|
||||
Expanded(child: child),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:showcase/constants.dart';
|
||||
import 'package:showcase/routes.dart';
|
||||
|
||||
class SidebarNavigation extends StatelessWidget {
|
||||
const SidebarNavigation({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: LayoutConstants.sidebarWidth,
|
||||
decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
|
||||
children: [
|
||||
...routesByCategory.entries.expand((entry) {
|
||||
final category = entry.key;
|
||||
final routes = entry.value;
|
||||
return [
|
||||
if (category != AppRouteCategory.root) _CategoryHeader(category),
|
||||
...routes.map((route) => _NavItem(route)),
|
||||
const SizedBox(height: 24),
|
||||
];
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryHeader extends StatelessWidget {
|
||||
final AppRouteCategory category;
|
||||
|
||||
const _CategoryHeader(this.category);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 12, top: 8, bottom: 8),
|
||||
child: Text(
|
||||
category.displayName,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavItem extends StatelessWidget {
|
||||
final AppRoute route;
|
||||
|
||||
const _NavItem(this.route);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentRoute = GoRouterState.of(context).uri.toString();
|
||||
final isSelected = currentRoute == route.path;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
context.go(route.path);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(
|
||||
LayoutConstants.borderRadiusMedium,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? (isDark
|
||||
? Colors.white.withValues(alpha: 0.1)
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer.withValues(alpha: 0.5))
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(
|
||||
LayoutConstants.borderRadiusMedium,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
route.icon,
|
||||
size: 20,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
route.name,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
device_info_plus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus
|
||||
sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.5.0"
|
||||
device_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_platform_interface
|
||||
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.3"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
go_router:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: go_router
|
||||
sha256: "92d8cee7c57dff0a6c409c05597b460002434eccf7424a712283225b3962d03f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "17.2.3"
|
||||
immich_ui:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: ".."
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.0.0"
|
||||
irondash_engine_context:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: irondash_engine_context
|
||||
sha256: "2bb0bc13dfda9f5aaef8dde06ecc5feb1379f5bb387d59716d799554f3f305d7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.5"
|
||||
irondash_message_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: irondash_message_channel
|
||||
sha256: b4101669776509c76133b8917ab8cfc704d3ad92a8c450b92934dd8884a2f060
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
pixel_snap:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pixel_snap
|
||||
sha256: "677410ea37b07cd37ecb6d5e6c0d8d7615a7cf3bd92ba406fd1ac57e937d1fb0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.5"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
super_clipboard:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: super_clipboard
|
||||
sha256: e73f3bb7e66cc9260efa1dc507f979138e7e106c3521e2dda2d0311f6d728a16
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.1"
|
||||
super_native_extensions:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: super_native_extensions
|
||||
sha256: b9611dcb68f1047d6f3ef11af25e4e68a21b1a705bbcc3eb8cb4e9f5c3148569
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.1"
|
||||
syntax_highlight:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: syntax_highlight
|
||||
sha256: "4d3ba40658cadba6ba55d697f29f00b43538ebb6eb4a0ca0e895c568eaced138"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.3"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.2.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
win32_registry:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32_registry
|
||||
sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
sdks:
|
||||
dart: ">=3.11.0 <4.0.0"
|
||||
flutter: ">=3.35.0"
|
||||
@@ -1,47 +0,0 @@
|
||||
name: showcase
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.0
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
immich_ui:
|
||||
path: ../
|
||||
go_router: ^17.2.1
|
||||
syntax_highlight: ^0.5.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
- assets/
|
||||
- assets/themes/
|
||||
- lib/pages/components/examples/
|
||||
|
||||
fonts:
|
||||
- family: GoogleSans
|
||||
fonts:
|
||||
- asset: ../../../fonts/GoogleSans/GoogleSans-Regular.ttf
|
||||
- asset: ../../../fonts/GoogleSans/GoogleSans-Italic.ttf
|
||||
style: italic
|
||||
- asset: ../../../fonts/GoogleSans/GoogleSans-Medium.ttf
|
||||
weight: 500
|
||||
- asset: ../../../fonts/GoogleSans/GoogleSans-SemiBold.ttf
|
||||
weight: 600
|
||||
- asset: ../../../fonts/GoogleSans/GoogleSans-Bold.ttf
|
||||
weight: 700
|
||||
- family: GoogleSansCode
|
||||
fonts:
|
||||
- asset: ../../../fonts/GoogleSansCode/GoogleSansCode-Regular.ttf
|
||||
- asset: ../../../fonts/GoogleSansCode/GoogleSansCode-Medium.ttf
|
||||
weight: 500
|
||||
- asset: ../../../fonts/GoogleSansCode/GoogleSansCode-SemiBold.ttf
|
||||
weight: 600
|
||||
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
@@ -1,38 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!--
|
||||
If you are serving your web app in a path other than the root, change the
|
||||
href value below to reflect the base path you are serving from.
|
||||
|
||||
The path provided below has to start and end with a slash "/" in order for
|
||||
it to work correctly.
|
||||
|
||||
For more details:
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
||||
|
||||
This is a placeholder for base href that will be replaced by the value of
|
||||
the `--base-href` argument provided to `flutter build`.
|
||||
-->
|
||||
<base href="$FLUTTER_BASE_HREF">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="Immich UI component library showcase and documentation">
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="@immich/ui">
|
||||
<link rel="apple-touch-icon" href="icons/apple-icon-180.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico"/>
|
||||
|
||||
<title>@immich/ui</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"name": "@immich/ui Showcase",
|
||||
"short_name": "@immich/ui",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#FCFCFD",
|
||||
"theme_color": "#4250AF",
|
||||
"description": "Immich UI component library showcase and documentation",
|
||||
"orientation": "landscape",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/Icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -26355,6 +26355,7 @@
|
||||
"description": "Plugin trigger type",
|
||||
"enum": [
|
||||
"AssetCreate",
|
||||
"AssetMetadataExtraction",
|
||||
"PersonRecognized"
|
||||
],
|
||||
"type": "string"
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"wasmPath": "dist/plugin.wasm",
|
||||
"templates": [
|
||||
{
|
||||
"name": "auto-archive-screenshots",
|
||||
"title": "Auto-archive screenshots",
|
||||
"name": "screenshots-smart-album",
|
||||
"title": "Archive screenshots",
|
||||
"description": "Archive uploads with \"screenshot\" in the filename and optionally add them to an album",
|
||||
"trigger": "AssetCreate",
|
||||
"steps": [
|
||||
@@ -29,6 +29,27 @@
|
||||
{
|
||||
"method": "immich-plugin-core#assetAddToAlbums",
|
||||
"config": {
|
||||
"albumName": "Screenshots",
|
||||
"albumIds": []
|
||||
}
|
||||
}
|
||||
],
|
||||
"uiHints": ["SmartAlbum"]
|
||||
},
|
||||
{
|
||||
"name": "missing-timezone-smart-album",
|
||||
"title": "Missing timezone",
|
||||
"description": "Automatically create an album for assets without a time zone",
|
||||
"trigger": "AssetMetadataExtraction",
|
||||
"steps": [
|
||||
{
|
||||
"method": "immich-plugin-core#assetMissingTimeZoneFilter",
|
||||
"config": {}
|
||||
},
|
||||
{
|
||||
"method": "immich-plugin-core#assetAddToAlbums",
|
||||
"config": {
|
||||
"albumName": "Missing time zone",
|
||||
"albumIds": []
|
||||
}
|
||||
}
|
||||
@@ -68,6 +89,24 @@
|
||||
},
|
||||
"uiHints": ["Filter"]
|
||||
},
|
||||
{
|
||||
"name": "assetMissingTimeZoneFilter",
|
||||
"title": "Filter by missing time zone",
|
||||
"description": "Filter assets that have no time zone information",
|
||||
"types": ["AssetV1"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"inverse": {
|
||||
"type": "boolean",
|
||||
"title": "Inverse",
|
||||
"description": "Missing by default, set to true to filter assets with a time zone",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"uiHints": ["Filter"]
|
||||
},
|
||||
{
|
||||
"name": "filterFileType",
|
||||
"title": "Filter by file type",
|
||||
@@ -189,6 +228,12 @@
|
||||
"array": true,
|
||||
"description": "Target album IDs",
|
||||
"uiHint": "AlbumId"
|
||||
},
|
||||
"albumName": {
|
||||
"type": "string",
|
||||
"title": "Album name",
|
||||
"array": true,
|
||||
"description": "Use an album with this name if one exists, otherwise create a new one"
|
||||
}
|
||||
},
|
||||
"required": ["albumIds"]
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"license": "AGPL-3.0",
|
||||
"devDependencies": {
|
||||
"@extism/js-pdk": "^1.0.1",
|
||||
"@immich/sdk": "workspace:*",
|
||||
"@immich/plugin-sdk": "workspace:*",
|
||||
"esbuild": "^0.28.0",
|
||||
"typescript": "^6.0.0"
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
// copy from
|
||||
// import '@immich/plugin-sdk/host-functions';
|
||||
// keep in sync with plugin-sdk/host-functions.ts';
|
||||
declare module 'extism:host' {
|
||||
interface user {
|
||||
albumAddAssets(ptr: PTR): I64;
|
||||
searchAlbums(ptr: PTR): I64;
|
||||
createAlbum(ptr: PTR): I64;
|
||||
addAssetsToAlbum(ptr: PTR): I64;
|
||||
addAssetsToAlbums(ptr: PTR): I64;
|
||||
}
|
||||
}
|
||||
|
||||
// keep in sync with manifest.json
|
||||
declare module 'main' {
|
||||
// filters
|
||||
export function assetFileFilter(): I32;
|
||||
export function assetMissingTimeZoneFilter(): I32;
|
||||
|
||||
// updates
|
||||
export function assetFavorite(): I32;
|
||||
export function assetVisibility(): I32;
|
||||
export function assetArchive(): I32;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AssetStatus, AssetVisibility, WorkflowType, wrapper } from '@immich/plugin-sdk';
|
||||
import { wrapper } from '@immich/plugin-sdk';
|
||||
import { AssetVisibility, WorkflowType } from '@immich/sdk';
|
||||
|
||||
type AssetFileFilterConfig = {
|
||||
pattern: string;
|
||||
@@ -41,6 +42,14 @@ export const assetFileFilter = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export const assetMissingTimeZoneFilter = () => {
|
||||
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
|
||||
const hasTimeZone = !!data.asset?.exifInfo?.timeZone;
|
||||
const needsTimeZone = config.inverse ? true : false;
|
||||
return { workflow: { continue: hasTimeZone === needsTimeZone } };
|
||||
});
|
||||
};
|
||||
|
||||
export const assetFavorite = () => {
|
||||
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
|
||||
const target = config.inverse ? false : true;
|
||||
@@ -89,28 +98,35 @@ export const assetLock = () => {
|
||||
};
|
||||
|
||||
export const assetTrash = () => {
|
||||
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => ({
|
||||
changes: {
|
||||
asset: config.inverse
|
||||
? { deletedAt: null, status: AssetStatus.Active }
|
||||
: { deletedAt: new Date().toISOString(), status: AssetStatus.Trashed },
|
||||
},
|
||||
}));
|
||||
// TODO use trash/untrash host functions
|
||||
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
|
||||
};
|
||||
|
||||
export const assetAddToAlbums = () => {
|
||||
return wrapper<WorkflowType.AssetV1, { albumIds: string[] }>(({ config, data, functions }) => {
|
||||
return wrapper<WorkflowType.AssetV1, { albumIds: string[]; albumName?: string }>(({ config, data, functions }) => {
|
||||
const assetId = data.asset.id;
|
||||
|
||||
if (config.albumIds.length === 0) {
|
||||
// noop
|
||||
return {};
|
||||
if (!config.albumName) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const [existing] = functions.searchAlbums({ name: config.albumName });
|
||||
if (!existing) {
|
||||
const created = functions.createAlbum({ albumName: config.albumName, assetIds: [assetId] });
|
||||
config.albumIds.push(created.id);
|
||||
return {};
|
||||
}
|
||||
|
||||
config.albumIds.push(existing.id);
|
||||
}
|
||||
|
||||
if (config.albumIds.length === 1) {
|
||||
functions.albumAddAssets(config.albumIds[0], [data.asset.id]);
|
||||
functions.addAssetsToAlbum(config.albumIds[0], [assetId]);
|
||||
return {};
|
||||
}
|
||||
|
||||
functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [data.asset.id] });
|
||||
functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [assetId] });
|
||||
return {};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"name": "@immich/plugin-sdk",
|
||||
"version": "0.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./host-functions": {
|
||||
@@ -11,7 +10,8 @@
|
||||
},
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
export enum WorkflowTrigger {
|
||||
AssetCreate = 'AssetCreate',
|
||||
PersonRecognized = 'PersonRecognized',
|
||||
}
|
||||
|
||||
export enum WorkflowType {
|
||||
AssetV1 = 'AssetV1',
|
||||
AssetPersonV1 = 'AssetPersonV1',
|
||||
}
|
||||
|
||||
export enum AssetType {
|
||||
Image = 'IMAGE',
|
||||
Video = 'VIDEO',
|
||||
Audio = 'AUDIO',
|
||||
Other = 'OTHER',
|
||||
}
|
||||
|
||||
export enum AssetStatus {
|
||||
Active = 'active',
|
||||
Trashed = 'trashed',
|
||||
Deleted = 'deleted',
|
||||
}
|
||||
|
||||
export enum AssetVisibility {
|
||||
Archive = 'archive',
|
||||
Timeline = 'timeline',
|
||||
|
||||
/**
|
||||
* Video part of the LivePhotos and MotionPhotos
|
||||
*/
|
||||
Hidden = 'hidden',
|
||||
Locked = 'locked',
|
||||
}
|
||||
@@ -1,15 +1,26 @@
|
||||
import { type BulkIdResponseDto, type BulkIdsDto } from '@immich/sdk';
|
||||
import {
|
||||
getAllAlbums,
|
||||
type AlbumResponseDto,
|
||||
type BulkIdResponseDto,
|
||||
type BulkIdsDto,
|
||||
type CreateAlbumDto,
|
||||
} from '@immich/sdk';
|
||||
|
||||
// keep in sync with plugin-core/src/index.d.ts';
|
||||
declare module 'extism:host' {
|
||||
interface user {
|
||||
albumAddAssets(ptr: PTR): I64;
|
||||
searchAlbums(ptr: PTR): I64;
|
||||
createAlbum(ptr: PTR): I64;
|
||||
addAssetsToAlbum(ptr: PTR): I64;
|
||||
addAssetsToAlbums(ptr: PTR): I64;
|
||||
}
|
||||
}
|
||||
|
||||
const host = Host.getFunctions();
|
||||
type HostFunctionName = keyof typeof host;
|
||||
type AlbumsToAssets = {
|
||||
assetIds: string[];
|
||||
albumIds: string[];
|
||||
};
|
||||
|
||||
type HostFunctionSuccessResult<T> = { success: true; response: T };
|
||||
type HostFunctionErrorResult = {
|
||||
success: false;
|
||||
@@ -20,39 +31,49 @@ type HostFunctionResult<T> =
|
||||
| HostFunctionSuccessResult<T>
|
||||
| HostFunctionErrorResult;
|
||||
|
||||
const call = <T, R>(name: HostFunctionName, authToken: string, args: T) => {
|
||||
const pointer1 = Memory.fromString(JSON.stringify({ authToken, args }));
|
||||
const fn = host[name];
|
||||
const handler = Memory.find(fn(pointer1.offset));
|
||||
type QueryParams<T extends (...args: any) => any> = Parameters<T>[0];
|
||||
type AlbumSearchDto = QueryParams<typeof getAllAlbums>;
|
||||
|
||||
try {
|
||||
const result = JSON.parse(handler.readString()) as HostFunctionResult<R>;
|
||||
export const hostFunctions = (authToken: string) => {
|
||||
const host = Host.getFunctions();
|
||||
type HostFunctionName = keyof typeof host;
|
||||
|
||||
if (result.success) {
|
||||
return result.response;
|
||||
const call = <T, R>(name: HostFunctionName, authToken: string, args: T) => {
|
||||
const pointer1 = Memory.fromString(JSON.stringify({ authToken, args }));
|
||||
const fn = host[name];
|
||||
const handler = Memory.find(fn(pointer1.offset));
|
||||
|
||||
try {
|
||||
const result = JSON.parse(handler.readString()) as HostFunctionResult<R>;
|
||||
|
||||
if (result.success) {
|
||||
return result.response;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to call host function "${String(name)}", received ${result.status} - ${JSON.stringify(result.message)}`,
|
||||
);
|
||||
} finally {
|
||||
handler.free();
|
||||
pointer1.free();
|
||||
}
|
||||
};
|
||||
|
||||
throw new Error(
|
||||
`Failed to call host function "${String(name)}", received ${result.status} - ${JSON.stringify(result.message)}`,
|
||||
);
|
||||
} finally {
|
||||
handler.free();
|
||||
pointer1.free();
|
||||
}
|
||||
return {
|
||||
// album
|
||||
searchAlbums: (dto: AlbumSearchDto) =>
|
||||
call<[AlbumSearchDto], AlbumResponseDto[]>('searchAlbums', authToken, [
|
||||
dto,
|
||||
]),
|
||||
createAlbum: (dto: CreateAlbumDto) =>
|
||||
call<[CreateAlbumDto], AlbumResponseDto>('createAlbum', authToken, [dto]),
|
||||
addAssetsToAlbum: (albumId: string, assetIds: string[]) =>
|
||||
call<[string, BulkIdsDto], BulkIdResponseDto[]>(
|
||||
'addAssetsToAlbum',
|
||||
authToken,
|
||||
[albumId, { ids: assetIds }],
|
||||
),
|
||||
addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) =>
|
||||
call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]),
|
||||
};
|
||||
};
|
||||
|
||||
type AlbumsToAssets = {
|
||||
assetIds: string[];
|
||||
albumIds: string[];
|
||||
};
|
||||
|
||||
export const hostFunctions = (authToken: string) => ({
|
||||
albumAddAssets: (albumId: string, assetIds: string[]) =>
|
||||
call<[string, BulkIdsDto], BulkIdResponseDto[]>(
|
||||
'albumAddAssets',
|
||||
authToken,
|
||||
[albumId, { ids: assetIds }],
|
||||
),
|
||||
addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) =>
|
||||
call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from 'src/enum.js';
|
||||
export * from 'src/host-functions.js';
|
||||
export * from 'src/sdk.js';
|
||||
export * from 'src/types.js';
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { WorkflowType } from 'src/enum.js';
|
||||
import type { WorkflowType } from '@immich/sdk';
|
||||
import { hostFunctions } from 'src/host-functions.js';
|
||||
import type {
|
||||
ConfigValue,
|
||||
WorkflowEventPayload,
|
||||
WorkflowResponse,
|
||||
WorkflowStepConfig,
|
||||
} from 'src/types.js';
|
||||
|
||||
export const wrapper = <
|
||||
@@ -19,19 +20,28 @@ export const wrapper = <
|
||||
const input = Host.inputString();
|
||||
|
||||
try {
|
||||
const event = JSON.parse(input) as WorkflowEventPayload<T, TConfig>;
|
||||
// const debug = event.workflow.debug ?? false;
|
||||
const payload = JSON.parse(input) as WorkflowEventPayload<T, TConfig>;
|
||||
const event = {
|
||||
...payload,
|
||||
functions: hostFunctions(payload.workflow.authToken),
|
||||
};
|
||||
|
||||
const eventConfigBefore = JSON.stringify(event.config);
|
||||
|
||||
console.debug(
|
||||
`Inputs: trigger=${event.trigger}, event=${event.type}, config=${JSON.stringify(event.config)}`,
|
||||
`Inputs: trigger=${event.trigger}, event=${event.type}, config=${eventConfigBefore}`,
|
||||
);
|
||||
|
||||
const response =
|
||||
fn({ ...event, functions: hostFunctions(event.workflow.authToken) }) ??
|
||||
{};
|
||||
const response = fn(event) ?? {};
|
||||
|
||||
// if config changed, notify host
|
||||
const eventConfigAfter = JSON.stringify(event.config);
|
||||
if (!response.config && eventConfigBefore !== eventConfigAfter) {
|
||||
response.config = event.config as WorkflowStepConfig;
|
||||
}
|
||||
|
||||
console.debug(
|
||||
`Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}`,
|
||||
`Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}, config=${JSON.stringify(response.config)}`,
|
||||
);
|
||||
|
||||
const output = JSON.stringify(response);
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import type {
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
AssetVisibility,
|
||||
WorkflowTrigger,
|
||||
WorkflowType,
|
||||
} from 'src/enum.js';
|
||||
import type { AssetTypeEnum, AssetVisibility, WorkflowType } from '@immich/sdk';
|
||||
|
||||
type DeepPartial<T> = T extends Date
|
||||
? T
|
||||
@@ -21,6 +15,12 @@ export type WorkflowEventMap = {
|
||||
|
||||
export type WorkflowEventData<T extends WorkflowType> = WorkflowEventMap[T];
|
||||
|
||||
export enum WorkflowTrigger {
|
||||
AssetCreate = 'AssetCreate',
|
||||
AssetMetadataExtraction = 'AssetMetadataExtraction',
|
||||
PersonRecognized = 'PersonRecognized',
|
||||
}
|
||||
|
||||
export type WorkflowEventPayload<
|
||||
T extends WorkflowType = WorkflowType,
|
||||
TConfig = WorkflowStepConfig,
|
||||
@@ -48,6 +48,8 @@ export type WorkflowResponse<T extends WorkflowType = WorkflowType> = {
|
||||
changes?: WorkflowChanges<T>;
|
||||
/** data to be passed to the next workflow step */
|
||||
data?: Record<string, unknown>;
|
||||
/** update step config */
|
||||
config?: WorkflowStepConfig;
|
||||
};
|
||||
|
||||
export type WorkflowStepConfig = {
|
||||
@@ -66,7 +68,7 @@ export type AssetV1 = {
|
||||
asset: {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
type: AssetType;
|
||||
type: AssetTypeEnum;
|
||||
originalPath: string;
|
||||
fileCreatedAt: string;
|
||||
fileModifiedAt: string;
|
||||
@@ -83,7 +85,6 @@ export type AssetV1 = {
|
||||
localDateTime: string;
|
||||
stackId: string | null;
|
||||
duplicateId: string | null;
|
||||
status: AssetStatus;
|
||||
visibility: AssetVisibility;
|
||||
isEdited: boolean;
|
||||
exifInfo: {
|
||||
|
||||
@@ -7081,6 +7081,7 @@ export enum WorkflowType {
|
||||
}
|
||||
export enum WorkflowTrigger {
|
||||
AssetCreate = "AssetCreate",
|
||||
AssetMetadataExtraction = "AssetMetadataExtraction",
|
||||
PersonRecognized = "PersonRecognized"
|
||||
}
|
||||
export enum QueueJobStatus {
|
||||
|
||||
@@ -320,6 +320,9 @@ importers:
|
||||
'@immich/plugin-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../plugin-sdk
|
||||
'@immich/sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../sdk
|
||||
esbuild:
|
||||
specifier: ^0.28.0
|
||||
version: 0.28.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { WorkflowTrigger } from '@immich/plugin-sdk';
|
||||
import { WorkflowController } from 'src/controllers/workflow.controller';
|
||||
import { WorkflowTrigger } from 'src/enum';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { WorkflowService } from 'src/services/workflow.service';
|
||||
import request from 'supertest';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { WorkflowTrigger } from '@immich/plugin-sdk';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { JsonSchemaDto } from 'src/dtos/json-schema.dto';
|
||||
import { WorkflowTrigger, WorkflowTriggerSchema, WorkflowType, WorkflowTypeSchema } from 'src/enum';
|
||||
import { WorkflowTriggerSchema, WorkflowType, WorkflowTypeSchema } from 'src/enum';
|
||||
import { asPluginKey } from 'src/utils/workflow';
|
||||
import z from 'zod';
|
||||
|
||||
|
||||
@@ -3,7 +3,14 @@ import { Place } from 'src/database';
|
||||
import { HistoryBuilder } from 'src/decorators';
|
||||
import { AlbumResponseSchema } from 'src/dtos/album.dto';
|
||||
import { AssetResponseSchema } from 'src/dtos/asset-response.dto';
|
||||
import { AssetOrder, AssetOrderSchema, AssetTypeSchema, AssetVisibilitySchema } from 'src/enum';
|
||||
import {
|
||||
AssetOrder,
|
||||
AssetOrderSchema,
|
||||
AssetTypeSchema,
|
||||
AssetVisibilitySchema,
|
||||
SearchOrderField,
|
||||
SearchOrderFieldSchema,
|
||||
} from 'src/enum';
|
||||
import { emptyStringToNull, isoDatetimeToDate, stringToBool } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
@@ -141,6 +148,199 @@ const SearchSuggestionRequestSchema = z
|
||||
})
|
||||
.meta({ id: 'SearchSuggestionRequestDto' });
|
||||
|
||||
// v3 SearchFilter DTOs — new shape introduced alongside the legacy flat DTOs above.
|
||||
|
||||
const atLeastOneKey = <T extends z.ZodObject>(schema: T) => {
|
||||
const keys = Object.keys(schema.shape);
|
||||
return schema.refine((value) => Object.values(value).some((v) => v !== undefined), {
|
||||
message: `At least one of the following keys is required: ${keys.join(', ')}`,
|
||||
});
|
||||
};
|
||||
|
||||
const IdFilterSchema = atLeastOneKey(
|
||||
z.strictObject({
|
||||
eq: z.uuidv4().optional(),
|
||||
ne: z.uuidv4().optional(),
|
||||
}),
|
||||
).meta({ id: 'IdFilter' });
|
||||
|
||||
const IdFilterNullableSchema = atLeastOneKey(
|
||||
z.strictObject({
|
||||
eq: z.uuidv4().nullable().optional(),
|
||||
ne: z.uuidv4().nullable().optional(),
|
||||
}),
|
||||
).meta({ id: 'IdFilterNullable' });
|
||||
|
||||
const IdsFilterSchema = atLeastOneKey(
|
||||
z.strictObject({
|
||||
any: z.array(z.uuidv4()).min(1).optional(),
|
||||
all: z.array(z.uuidv4()).min(1).optional(),
|
||||
none: z.array(z.uuidv4()).min(1).optional(),
|
||||
}),
|
||||
).meta({ id: 'IdsFilter' });
|
||||
|
||||
const StringFilterSchema = atLeastOneKey(
|
||||
z.strictObject({
|
||||
eq: z.string().optional(),
|
||||
ne: z.string().optional(),
|
||||
in: z.array(z.string()).min(1).optional(),
|
||||
notIn: z.array(z.string()).min(1).optional(),
|
||||
}),
|
||||
).meta({ id: 'StringFilter' });
|
||||
|
||||
const StringFilterNullableSchema = atLeastOneKey(
|
||||
z.strictObject({
|
||||
eq: z.string().nullable().optional(),
|
||||
ne: z.string().nullable().optional(),
|
||||
in: z.array(z.string()).min(1).optional(),
|
||||
notIn: z.array(z.string()).min(1).optional(),
|
||||
}),
|
||||
).meta({ id: 'StringFilterNullable' });
|
||||
|
||||
const StringPatternFilterSchema = atLeastOneKey(
|
||||
z.strictObject({
|
||||
eq: z.string().nullable().optional(),
|
||||
ne: z.string().nullable().optional(),
|
||||
in: z.array(z.string()).min(1).optional(),
|
||||
notIn: z.array(z.string()).min(1).optional(),
|
||||
like: z.string().min(1).optional(),
|
||||
notLike: z.string().min(1).optional(),
|
||||
startsWith: z.string().min(1).optional(),
|
||||
endsWith: z.string().min(1).optional(),
|
||||
}),
|
||||
).meta({ id: 'StringPatternFilter' });
|
||||
|
||||
const NumberFilterSchema = atLeastOneKey(
|
||||
z.strictObject({
|
||||
eq: z.number().optional(),
|
||||
ne: z.number().optional(),
|
||||
lt: z.number().optional(),
|
||||
lte: z.number().optional(),
|
||||
gt: z.number().optional(),
|
||||
gte: z.number().optional(),
|
||||
in: z.array(z.number()).min(1).optional(),
|
||||
notIn: z.array(z.number()).min(1).optional(),
|
||||
}),
|
||||
).meta({ id: 'NumberFilter' });
|
||||
|
||||
const NumberFilterNullableSchema = atLeastOneKey(
|
||||
z.strictObject({
|
||||
eq: z.number().nullable().optional(),
|
||||
ne: z.number().nullable().optional(),
|
||||
lt: z.number().optional(),
|
||||
lte: z.number().optional(),
|
||||
gt: z.number().optional(),
|
||||
gte: z.number().optional(),
|
||||
in: z.array(z.number()).min(1).optional(),
|
||||
notIn: z.array(z.number()).min(1).optional(),
|
||||
}),
|
||||
).meta({ id: 'NumberFilterNullable' });
|
||||
|
||||
const DateFilterSchema = atLeastOneKey(
|
||||
z.strictObject({
|
||||
eq: isoDatetimeToDate.optional(),
|
||||
ne: isoDatetimeToDate.optional(),
|
||||
gt: isoDatetimeToDate.optional(),
|
||||
gte: isoDatetimeToDate.optional(),
|
||||
lt: isoDatetimeToDate.optional(),
|
||||
lte: isoDatetimeToDate.optional(),
|
||||
}),
|
||||
).meta({ id: 'DateFilter' });
|
||||
|
||||
const DateFilterNullableSchema = atLeastOneKey(
|
||||
z.strictObject({
|
||||
eq: isoDatetimeToDate.nullable().optional(),
|
||||
ne: isoDatetimeToDate.nullable().optional(),
|
||||
gt: isoDatetimeToDate.optional(),
|
||||
gte: isoDatetimeToDate.optional(),
|
||||
lt: isoDatetimeToDate.optional(),
|
||||
lte: isoDatetimeToDate.optional(),
|
||||
}),
|
||||
).meta({ id: 'DateFilterNullable' });
|
||||
|
||||
const BoolFilterSchema = z.strictObject({ eq: z.boolean() }).meta({ id: 'BoolFilter' });
|
||||
|
||||
const enumFilterSchema = <T extends z.core.util.EnumLike>(values: z.ZodEnum<T>, id: string) =>
|
||||
atLeastOneKey(
|
||||
z.strictObject({
|
||||
eq: values.optional(),
|
||||
ne: values.optional(),
|
||||
in: z.array(values).min(1).optional(),
|
||||
notIn: z.array(values).min(1).optional(),
|
||||
}),
|
||||
).meta({ id });
|
||||
|
||||
const EnumFilterAssetTypeSchema = enumFilterSchema(AssetTypeSchema, 'EnumFilterAssetType');
|
||||
const EnumFilterAssetVisibilitySchema = enumFilterSchema(AssetVisibilitySchema, 'EnumFilterAssetVisibility');
|
||||
|
||||
const StringSimilarityFilterSchema = z
|
||||
.strictObject({
|
||||
matches: z.string().min(1),
|
||||
})
|
||||
.meta({ id: 'StringSimilarityFilter' });
|
||||
|
||||
export const SearchOrderSchema = z
|
||||
.strictObject({
|
||||
field: SearchOrderFieldSchema.default(SearchOrderField.FileCreatedAt),
|
||||
direction: AssetOrderSchema.default(AssetOrder.Desc),
|
||||
})
|
||||
.meta({ id: 'SearchOrder' });
|
||||
|
||||
const SearchFilterBranchSchema = z
|
||||
.strictObject({
|
||||
id: IdFilterSchema.optional(),
|
||||
libraryId: IdFilterNullableSchema.optional(),
|
||||
type: EnumFilterAssetTypeSchema.optional(),
|
||||
visibility: EnumFilterAssetVisibilitySchema.optional(),
|
||||
isFavorite: BoolFilterSchema.optional(),
|
||||
isMotion: BoolFilterSchema.optional(),
|
||||
isOffline: BoolFilterSchema.optional(),
|
||||
isEncoded: BoolFilterSchema.optional(),
|
||||
hasAlbums: BoolFilterSchema.optional(),
|
||||
hasPeople: BoolFilterSchema.optional(),
|
||||
hasTags: BoolFilterSchema.optional(),
|
||||
city: StringFilterNullableSchema.optional(),
|
||||
state: StringFilterNullableSchema.optional(),
|
||||
country: StringFilterNullableSchema.optional(),
|
||||
make: StringFilterNullableSchema.optional(),
|
||||
model: StringFilterNullableSchema.optional(),
|
||||
lensModel: StringFilterNullableSchema.optional(),
|
||||
description: StringPatternFilterSchema.optional(),
|
||||
originalFileName: StringPatternFilterSchema.optional(),
|
||||
originalPath: StringPatternFilterSchema.optional(),
|
||||
ocr: StringSimilarityFilterSchema.optional(),
|
||||
rating: NumberFilterNullableSchema.optional(),
|
||||
fileSizeInBytes: NumberFilterSchema.optional(),
|
||||
takenAt: DateFilterSchema.optional(),
|
||||
createdAt: DateFilterSchema.optional(),
|
||||
updatedAt: DateFilterSchema.optional(),
|
||||
trashedAt: DateFilterNullableSchema.optional(),
|
||||
personIds: IdsFilterSchema.optional(),
|
||||
tagIds: IdsFilterSchema.optional(),
|
||||
albumIds: IdsFilterSchema.optional(),
|
||||
checksum: StringFilterSchema.optional(),
|
||||
encodedVideoPath: StringFilterSchema.optional(),
|
||||
})
|
||||
.meta({ id: 'SearchFilterBranch' });
|
||||
|
||||
export const SearchFilterSchema = SearchFilterBranchSchema.extend({
|
||||
or: z.array(SearchFilterBranchSchema).min(1).optional(),
|
||||
}).meta({ id: 'SearchFilter' });
|
||||
|
||||
export type IdFilter = z.infer<typeof IdFilterSchema>;
|
||||
export type IdFilterNullable = z.infer<typeof IdFilterNullableSchema>;
|
||||
export type IdsFilter = z.infer<typeof IdsFilterSchema>;
|
||||
export type StringFilter = z.infer<typeof StringFilterSchema>;
|
||||
export type StringFilterNullable = z.infer<typeof StringFilterNullableSchema>;
|
||||
export type StringPatternFilter = z.infer<typeof StringPatternFilterSchema>;
|
||||
export type NumberFilter = z.infer<typeof NumberFilterSchema>;
|
||||
export type NumberFilterNullable = z.infer<typeof NumberFilterNullableSchema>;
|
||||
export type DateFilter = z.infer<typeof DateFilterSchema>;
|
||||
export type DateFilterNullable = z.infer<typeof DateFilterNullableSchema>;
|
||||
export type SearchOrder = z.infer<typeof SearchOrderSchema>;
|
||||
export type SearchFilter = z.infer<typeof SearchFilterSchema>;
|
||||
export type SearchFilterBranch = z.infer<typeof SearchFilterBranchSchema>;
|
||||
|
||||
export class RandomSearchDto extends createZodDto(RandomSearchSchema) {}
|
||||
export class LargeAssetSearchDto extends createZodDto(LargeAssetSearchSchema) {}
|
||||
export class MetadataSearchDto extends createZodDto(MetadataSearchSchema) {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { WorkflowStepConfig } from '@immich/plugin-sdk';
|
||||
import type { WorkflowStepConfig, WorkflowTrigger } from '@immich/plugin-sdk';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { WorkflowTrigger, WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum';
|
||||
import { WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum';
|
||||
import z from 'zod';
|
||||
|
||||
const WorkflowTriggerResponseSchema = z
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { WorkflowTrigger } from '@immich/plugin-sdk';
|
||||
import z from 'zod';
|
||||
|
||||
export enum AuthType {
|
||||
@@ -1164,11 +1165,6 @@ export enum PluginContext {
|
||||
|
||||
export const PluginContextSchema = z.enum(PluginContext).describe('Plugin context').meta({ id: 'PluginContextType' });
|
||||
|
||||
export enum WorkflowTrigger {
|
||||
AssetCreate = 'AssetCreate',
|
||||
PersonRecognized = 'PersonRecognized',
|
||||
}
|
||||
|
||||
export const WorkflowTriggerSchema = z
|
||||
.enum(WorkflowTrigger)
|
||||
.describe('Plugin trigger type')
|
||||
@@ -1180,3 +1176,12 @@ export enum WorkflowType {
|
||||
}
|
||||
|
||||
export const WorkflowTypeSchema = z.enum(WorkflowType).describe('Workflow type').meta({ id: 'WorkflowType' });
|
||||
|
||||
export enum SearchOrderField {
|
||||
FileCreatedAt = 'fileCreatedAt',
|
||||
LocalDateTime = 'localDateTime',
|
||||
FileSizeInBytes = 'fileSizeInBytes',
|
||||
Rating = 'rating',
|
||||
}
|
||||
|
||||
export const SearchOrderFieldSchema = z.enum(SearchOrderField).meta({ id: 'SearchOrderField' });
|
||||
|
||||
@@ -281,3 +281,444 @@ where
|
||||
and "deletedAt" is null
|
||||
and "lensModel" is not null
|
||||
and "lensModel" != $3
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (baseline)
|
||||
select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and $2
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc
|
||||
limit
|
||||
$3
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (empty)
|
||||
select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
$1
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc
|
||||
limit
|
||||
$2
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (or-exif-only)
|
||||
select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and "asset_exif"."city" = $2
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc
|
||||
limit
|
||||
$3
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (string-eq-null)
|
||||
select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and "asset_exif"."city" is null
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc
|
||||
limit
|
||||
$2
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (string-pattern-like)
|
||||
select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and f_unaccent ("asset_exif"."description") ilike ('%' || f_unaccent ($2) || '%')
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc
|
||||
limit
|
||||
$3
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (string-pattern-notLike)
|
||||
select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and f_unaccent ("asset_exif"."description") not ilike ('%' || f_unaccent ($2) || '%')
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc
|
||||
limit
|
||||
$3
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (string-pattern-startsWith)
|
||||
select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and f_unaccent ("asset"."originalFileName") ilike (f_unaccent ($2) || '%')
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc
|
||||
limit
|
||||
$3
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (string-similarity-ocr)
|
||||
select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and exists (
|
||||
select
|
||||
from
|
||||
"ocr_search"
|
||||
where
|
||||
"ocr_search"."assetId" = "asset"."id"
|
||||
and f_unaccent (ocr_search.text) %>> f_unaccent ($2)
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc
|
||||
limit
|
||||
$3
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (ids-any)
|
||||
select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and exists (
|
||||
select
|
||||
from
|
||||
"album_asset"
|
||||
where
|
||||
"album_asset"."assetId" = "asset"."id"
|
||||
and "album_asset"."albumId" = any ($2::uuid[])
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc
|
||||
limit
|
||||
$3
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (ids-all)
|
||||
select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and exists (
|
||||
select
|
||||
"asset_face"."assetId"
|
||||
from
|
||||
"asset_face"
|
||||
where
|
||||
"asset_face"."assetId" = "asset"."id"
|
||||
and "asset_face"."personId" = any ($2::uuid[])
|
||||
and "asset_face"."deletedAt" is null
|
||||
and "asset_face"."isVisible" = $3
|
||||
group by
|
||||
"asset_face"."assetId"
|
||||
having
|
||||
count(distinct "asset_face"."personId") = $4
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc
|
||||
limit
|
||||
$5
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (ids-all-single)
|
||||
select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and exists (
|
||||
select
|
||||
from
|
||||
"album_asset"
|
||||
where
|
||||
"album_asset"."assetId" = "asset"."id"
|
||||
and "album_asset"."albumId" = any ($2::uuid[])
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc
|
||||
limit
|
||||
$3
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (ids-none)
|
||||
select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and not exists (
|
||||
select
|
||||
from
|
||||
"tag_asset"
|
||||
inner join "tag_closure" on "tag_asset"."tagId" = "tag_closure"."id_descendant"
|
||||
where
|
||||
"tag_asset"."assetId" = "asset"."id"
|
||||
and "tag_closure"."id_ancestor" = any ($2::uuid[])
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc
|
||||
limit
|
||||
$3
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (ids-tags-all)
|
||||
select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and exists (
|
||||
select
|
||||
"tag_asset"."assetId"
|
||||
from
|
||||
"tag_asset"
|
||||
inner join "tag_closure" on "tag_asset"."tagId" = "tag_closure"."id_descendant"
|
||||
where
|
||||
"tag_asset"."assetId" = "asset"."id"
|
||||
and "tag_closure"."id_ancestor" = any ($2::uuid[])
|
||||
group by
|
||||
"tag_asset"."assetId"
|
||||
having
|
||||
count(distinct "tag_closure"."id_ancestor") >= $3
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc
|
||||
limit
|
||||
$4
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (has-albums-false)
|
||||
select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and not exists (
|
||||
select
|
||||
from
|
||||
"album_asset"
|
||||
where
|
||||
"album_asset"."assetId" = "asset"."id"
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc
|
||||
limit
|
||||
$2
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (is-encoded)
|
||||
select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and exists (
|
||||
select
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"asset_file"."assetId" = "asset"."id"
|
||||
and "asset_file"."type" = $2
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc
|
||||
limit
|
||||
$3
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (number-range)
|
||||
select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset_exif"."fileSizeInByte" <= $2
|
||||
and "asset_exif"."fileSizeInByte" >= $3
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc
|
||||
limit
|
||||
$4
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (date-eq)
|
||||
select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and "asset"."fileCreatedAt" = $2
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc
|
||||
limit
|
||||
$3
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (date-range)
|
||||
select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset"."fileCreatedAt" >= $2
|
||||
and "asset"."fileCreatedAt" < $3
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc
|
||||
limit
|
||||
$4
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (order-fileSize-noExif)
|
||||
select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and $2
|
||||
order by
|
||||
"asset_exif"."fileSizeInByte" desc
|
||||
limit
|
||||
$3
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (order-rating-withExif)
|
||||
select
|
||||
to_json("asset_exif") as "exifInfo",
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and $2
|
||||
order by
|
||||
"asset_exif"."rating" asc
|
||||
limit
|
||||
$3
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (or-branches)
|
||||
select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset"."isFavorite" = $2
|
||||
or exists (
|
||||
select
|
||||
from
|
||||
"asset_face"
|
||||
where
|
||||
"asset_face"."assetId" = "asset"."id"
|
||||
and "asset_face"."personId" = any ($3::uuid[])
|
||||
and "asset_face"."deletedAt" is null
|
||||
and "asset_face"."isVisible" = $4
|
||||
)
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc
|
||||
limit
|
||||
$5
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (or-with-top-level)
|
||||
select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset"."fileCreatedAt" >= $2
|
||||
and "asset"."fileCreatedAt" < $3
|
||||
and (
|
||||
"asset"."isFavorite" = $4
|
||||
or exists (
|
||||
select
|
||||
from
|
||||
"album_asset"
|
||||
where
|
||||
"album_asset"."assetId" = "asset"."id"
|
||||
and "album_asset"."albumId" = any ($5::uuid[])
|
||||
)
|
||||
)
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc
|
||||
limit
|
||||
$6
|
||||
|
||||
-- SearchRepository.searchStatisticsV3 (baseline)
|
||||
select
|
||||
count(*) as "total"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and $2
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc
|
||||
|
||||
-- SearchRepository.searchStatisticsV3 (with-filter)
|
||||
select
|
||||
count(*) as "total"
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset_exif"."fileSizeInByte" >= $2
|
||||
and "asset"."fileCreatedAt" >= $3
|
||||
and "asset"."fileCreatedAt" < $4
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc
|
||||
|
||||
-- SearchRepository.searchStatisticsV3 (with-or)
|
||||
select
|
||||
count(*) as "total"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset"."isFavorite" = $2
|
||||
or not exists (
|
||||
select
|
||||
from
|
||||
"album_asset"
|
||||
where
|
||||
"album_asset"."assetId" = "asset"."id"
|
||||
)
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc
|
||||
|
||||
@@ -2,11 +2,12 @@ import { Injectable } from '@nestjs/common';
|
||||
import { Kysely, OrderByDirection, Selectable, ShallowDehydrateObject, sql } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { AssetStatus, AssetType, AssetVisibility, VectorIndex } from 'src/enum';
|
||||
import { SearchFilter, SearchOrder } from 'src/dtos/search.dto';
|
||||
import { AssetOrder, AssetStatus, AssetType, AssetVisibility, SearchOrderField, VectorIndex } from 'src/enum';
|
||||
import { probes } from 'src/repositories/database.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { anyUuid, searchAssetBuilder, withExifInner } from 'src/utils/database';
|
||||
import { anyUuid, searchAssetBuilder, searchAssetBuilderLegacy, withExifInner } from 'src/utils/database';
|
||||
import { paginationHelper } from 'src/utils/pagination';
|
||||
import { isValidInteger } from 'src/validation';
|
||||
|
||||
@@ -121,6 +122,22 @@ export type AssetSearchOptions = BaseAssetSearchOptions & SearchRelationOptions;
|
||||
|
||||
export type AssetSearchBuilderOptions = Omit<AssetSearchOptions, 'orderDirection'>;
|
||||
|
||||
export interface AssetSearchBuilderV3Options {
|
||||
filter?: SearchFilter;
|
||||
/** Server-derived ownership scope. Never client-controlled. */
|
||||
userIds?: string[];
|
||||
withExif?: boolean;
|
||||
withFaces?: boolean;
|
||||
withPeople?: boolean;
|
||||
withStacked?: boolean;
|
||||
order?: SearchOrder;
|
||||
}
|
||||
|
||||
export interface AssetSearchPaginationV3Options {
|
||||
cursor?: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type SmartSearchOptions = SearchDateOptions &
|
||||
SearchEmbeddingOptions &
|
||||
SearchExifOptions &
|
||||
@@ -195,7 +212,7 @@ export class SearchRepository {
|
||||
})
|
||||
async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions) {
|
||||
const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection;
|
||||
const items = await searchAssetBuilder(this.db, options)
|
||||
const items = await searchAssetBuilderLegacy(this.db, options)
|
||||
.selectAll('asset')
|
||||
.orderBy('asset.fileCreatedAt', orderDirection)
|
||||
.limit(pagination.size + 1)
|
||||
@@ -216,7 +233,7 @@ export class SearchRepository {
|
||||
],
|
||||
})
|
||||
searchStatistics(options: AssetSearchOptions) {
|
||||
return searchAssetBuilder(this.db, options)
|
||||
return searchAssetBuilderLegacy(this.db, options)
|
||||
.select((qb) => qb.fn.countAll<number>().as('total'))
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
@@ -234,7 +251,7 @@ export class SearchRepository {
|
||||
],
|
||||
})
|
||||
async searchRandom(size: number, options: AssetSearchOptions) {
|
||||
return searchAssetBuilder(this.db, options)
|
||||
return searchAssetBuilderLegacy(this.db, options)
|
||||
.selectAll('asset')
|
||||
.orderBy(sql`random()`)
|
||||
.limit(size)
|
||||
@@ -255,7 +272,7 @@ export class SearchRepository {
|
||||
})
|
||||
searchLargeAssets(size: number, options: LargeAssetSearchOptions) {
|
||||
const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection;
|
||||
return searchAssetBuilder(this.db, options)
|
||||
return searchAssetBuilderLegacy(this.db, options)
|
||||
.selectAll('asset')
|
||||
.$call(withExifInner)
|
||||
.where('asset_exif.fileSizeInByte', '>', options.minFileSize || 0)
|
||||
@@ -284,7 +301,7 @@ export class SearchRepository {
|
||||
|
||||
return this.db.transaction().execute(async (trx) => {
|
||||
await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.Clip])}`.execute(trx);
|
||||
const items = await searchAssetBuilder(trx, options)
|
||||
const items = await searchAssetBuilderLegacy(trx, options)
|
||||
.selectAll('asset')
|
||||
.innerJoin('smart_search', 'asset.id', 'smart_search.assetId')
|
||||
.orderBy(sql`smart_search.embedding <=> ${options.embedding}`)
|
||||
@@ -489,6 +506,175 @@ export class SearchRepository {
|
||||
return res.map((row) => row.lensModel!);
|
||||
}
|
||||
|
||||
@GenerateSql(
|
||||
{ name: 'baseline', params: [{ size: 100 }, { userIds: [DummyValue.UUID] }] },
|
||||
{ name: 'empty', params: [{ size: 100 }, {}] },
|
||||
{
|
||||
name: 'or-exif-only',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { or: [{ city: { eq: DummyValue.STRING } }] } }],
|
||||
},
|
||||
{
|
||||
name: 'string-eq-null',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { city: { eq: null } } }],
|
||||
},
|
||||
{
|
||||
name: 'string-pattern-like',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { description: { like: DummyValue.STRING } } }],
|
||||
},
|
||||
{
|
||||
name: 'string-pattern-notLike',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { description: { notLike: DummyValue.STRING } } }],
|
||||
},
|
||||
{
|
||||
name: 'string-pattern-startsWith',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{ userIds: [DummyValue.UUID], filter: { originalFileName: { startsWith: DummyValue.STRING } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'string-similarity-ocr',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { ocr: { matches: DummyValue.STRING } } }],
|
||||
},
|
||||
{
|
||||
name: 'ids-any',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { albumIds: { any: [DummyValue.UUID] } } }],
|
||||
},
|
||||
{
|
||||
name: 'ids-all',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{ userIds: [DummyValue.UUID], filter: { personIds: { all: [DummyValue.UUID, DummyValue.UUID] } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ids-all-single',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { albumIds: { all: [DummyValue.UUID] } } }],
|
||||
},
|
||||
{
|
||||
name: 'ids-none',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { tagIds: { none: [DummyValue.UUID] } } }],
|
||||
},
|
||||
{
|
||||
name: 'ids-tags-all',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{ userIds: [DummyValue.UUID], filter: { tagIds: { all: [DummyValue.UUID, DummyValue.UUID] } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'has-albums-false',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { hasAlbums: { eq: false } } }],
|
||||
},
|
||||
{
|
||||
name: 'is-encoded',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { isEncoded: { eq: true } } }],
|
||||
},
|
||||
{
|
||||
name: 'number-range',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { fileSizeInBytes: { gte: 100, lte: 1000 } } }],
|
||||
},
|
||||
{
|
||||
name: 'date-eq',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { takenAt: { eq: DummyValue.DATE } } }],
|
||||
},
|
||||
{
|
||||
name: 'date-range',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
filter: { takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'order-fileSize-noExif',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
order: { field: SearchOrderField.FileSizeInBytes, direction: AssetOrder.Desc },
|
||||
withExif: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'order-rating-withExif',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
order: { field: SearchOrderField.Rating, direction: AssetOrder.Asc },
|
||||
withExif: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'or-branches',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
filter: {
|
||||
or: [{ isFavorite: { eq: true } }, { personIds: { any: [DummyValue.UUID] } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'or-with-top-level',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
filter: {
|
||||
takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE },
|
||||
or: [{ isFavorite: { eq: true } }, { albumIds: { any: [DummyValue.UUID] } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
async searchMetadataV3(pagination: AssetSearchPaginationV3Options, options: AssetSearchBuilderV3Options) {
|
||||
return await searchAssetBuilder(this.db, options)
|
||||
.selectAll('asset')
|
||||
.limit(pagination.size + 1)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql(
|
||||
{ name: 'baseline', params: [{ userIds: [DummyValue.UUID] }] },
|
||||
{
|
||||
name: 'with-filter',
|
||||
params: [
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
filter: {
|
||||
takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE },
|
||||
fileSizeInBytes: { gte: 100 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'with-or',
|
||||
params: [
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
filter: {
|
||||
or: [{ isFavorite: { eq: true } }, { hasAlbums: { eq: false } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
searchStatisticsV3(options: AssetSearchBuilderV3Options) {
|
||||
return searchAssetBuilder(this.db, options)
|
||||
.select((qb) => qb.fn.countAll<number>().as('total'))
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
private getExifField(field: 'city' | 'state' | 'country' | 'make' | 'model' | 'lensModel', userIds: string[]) {
|
||||
return this.db
|
||||
.selectFrom('asset_exif')
|
||||
|
||||
@@ -103,6 +103,10 @@ export class WorkflowRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async updateStep(id: string, dto: Updateable<WorkflowStepTable>) {
|
||||
await this.db.updateTable('workflow_step').where('workflow_step.id', '=', id).set(dto).execute();
|
||||
}
|
||||
|
||||
private async replaceAndReturn(tx: Kysely<DB>, workflowId: string, steps?: WorkflowStepUpsert[]) {
|
||||
if (steps) {
|
||||
await tx.deleteFrom('workflow_step').where('workflowId', '=', workflowId).execute();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { WorkflowTrigger } from '@immich/plugin-sdk';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
@@ -9,7 +10,6 @@ import {
|
||||
UpdateDateColumn,
|
||||
} from '@immich/sql-tools';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { WorkflowTrigger } from 'src/enum';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
|
||||
@Table('workflow')
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { CurrentPlugin } from '@extism/extism';
|
||||
import { WorkflowChanges, WorkflowEventData, WorkflowEventPayload, WorkflowResponse } from '@immich/plugin-sdk';
|
||||
import {
|
||||
WorkflowChanges,
|
||||
WorkflowEventData,
|
||||
WorkflowEventPayload,
|
||||
WorkflowResponse,
|
||||
WorkflowTrigger,
|
||||
} from '@immich/plugin-sdk';
|
||||
import { HttpException, UnauthorizedException } from '@nestjs/common';
|
||||
import { join } from 'node:path';
|
||||
import { DummyValue, OnEvent, OnJob } from 'src/decorators';
|
||||
import { AlbumsAddAssetsDto } from 'src/dtos/album.dto';
|
||||
import { AlbumsAddAssetsDto, CreateAlbumDto, GetAlbumsDto } from 'src/dtos/album.dto';
|
||||
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
|
||||
@@ -15,7 +21,6 @@ import {
|
||||
JobName,
|
||||
JobStatus,
|
||||
QueueName,
|
||||
WorkflowTrigger,
|
||||
WorkflowType,
|
||||
} from 'src/enum';
|
||||
import { ArgOf } from 'src/repositories/event.repository';
|
||||
@@ -61,7 +66,9 @@ export class WorkflowExecutionService extends BaseService {
|
||||
|
||||
const albumService = BaseService.create(AlbumService, this);
|
||||
|
||||
const albumAddAssets = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, args) =>
|
||||
const searchAlbums = this.wrap<[dto: GetAlbumsDto]>((authDto, args) => albumService.getAll(authDto, ...args));
|
||||
const createAlbum = this.wrap<[dto: CreateAlbumDto]>((authDto, args) => albumService.create(authDto, ...args));
|
||||
const addAssetsToAlbum = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, args) =>
|
||||
albumService.addAssets(authDto, ...args),
|
||||
);
|
||||
const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, args) =>
|
||||
@@ -69,12 +76,16 @@ export class WorkflowExecutionService extends BaseService {
|
||||
);
|
||||
|
||||
const functions = {
|
||||
albumAddAssets,
|
||||
searchAlbums,
|
||||
createAlbum,
|
||||
addAssetsToAlbum,
|
||||
addAssetsToAlbums,
|
||||
};
|
||||
|
||||
const stubs = {
|
||||
albumAddAssets: dummy,
|
||||
const stubs: typeof functions = {
|
||||
searchAlbums: dummy,
|
||||
createAlbum: dummy,
|
||||
addAssetsToAlbum: dummy,
|
||||
addAssetsToAlbums: dummy,
|
||||
};
|
||||
|
||||
@@ -252,6 +263,17 @@ export class WorkflowExecutionService extends BaseService {
|
||||
return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetCreate });
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'AssetMetadataExtracted' })
|
||||
onAssetMetadataExtracted({ userId, assetId, source }: ArgOf<'AssetMetadataExtracted'>) {
|
||||
// prevent loops
|
||||
// TODO loop detection in job service directly
|
||||
if (source === 'sidecar-write') {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetMetadataExtraction });
|
||||
}
|
||||
|
||||
private async onAssetTrigger({ userId, assetId, trigger }: AssetTrigger) {
|
||||
const items = await this.workflowRepository.search({ userId, trigger });
|
||||
await this.jobRepository.queueAll(
|
||||
@@ -286,6 +308,25 @@ export class WorkflowExecutionService extends BaseService {
|
||||
await assetService.update(auth, assetId, {
|
||||
isFavorite: asset.isFavorite,
|
||||
visibility: asset.visibility,
|
||||
dateTimeOriginal: asset.exifInfo?.dateTimeOriginal ?? undefined,
|
||||
// TODO allow setting to null
|
||||
longitude: asset.exifInfo?.longitude ?? undefined,
|
||||
// TODO allow setting to null
|
||||
latitude: asset.exifInfo?.latitude ?? undefined,
|
||||
// TODO allow setting to null
|
||||
description: asset.exifInfo?.description ?? undefined,
|
||||
rating: asset.exifInfo?.rating,
|
||||
|
||||
// TODO add to update dto
|
||||
// make: asset.exifInfo?.make,
|
||||
// model: asset.exifInfo?.model,
|
||||
// city: asset.exifInfo?.city,
|
||||
// state: asset.exifInfo?.state,
|
||||
// country: asset.exifInfo?.country,
|
||||
// lensModel: asset.exifInfo?.lensModel,
|
||||
// fNumber: asset.exifInfo?.fNumber,
|
||||
// fps: asset.exifInfo?.fps,
|
||||
// iso: asset.exifInfo?.iso,
|
||||
});
|
||||
},
|
||||
} satisfies ExecuteOptions<typeof type>;
|
||||
@@ -367,6 +408,10 @@ export class WorkflowExecutionService extends BaseService {
|
||||
({ data } = await read(type));
|
||||
}
|
||||
|
||||
if (result?.config) {
|
||||
await this.workflowRepository.updateStep(step.id, { config: result.config });
|
||||
}
|
||||
|
||||
const shouldContinue = result?.workflow?.continue ?? true;
|
||||
if (!shouldContinue) {
|
||||
break;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WorkflowStepConfig } from '@immich/plugin-sdk';
|
||||
import { WorkflowStepConfig, WorkflowTrigger } from '@immich/plugin-sdk';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
WorkflowTriggerResponseDto,
|
||||
WorkflowUpdateDto,
|
||||
} from 'src/dtos/workflow.dto';
|
||||
import { Permission, WorkflowTrigger } from 'src/enum';
|
||||
import { Permission } from 'src/enum';
|
||||
import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { getWorkflowTriggers, isMethodCompatible, resolveMethod } from 'src/utils/workflow';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { WorkflowTrigger } from '@immich/plugin-sdk';
|
||||
import { ShallowDehydrateObject } from 'kysely';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { VECTOR_EXTENSIONS } from 'src/constants';
|
||||
@@ -29,7 +30,6 @@ import {
|
||||
TranscodeTarget,
|
||||
UserMetadataKey,
|
||||
VideoCodec,
|
||||
WorkflowTrigger,
|
||||
WorkflowType,
|
||||
} from 'src/enum';
|
||||
|
||||
|
||||
@@ -11,17 +11,41 @@ import {
|
||||
SelectQueryBuilder,
|
||||
ShallowDehydrateObject,
|
||||
sql,
|
||||
SqlBool,
|
||||
} from 'kysely';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { Notice, PostgresError } from 'postgres';
|
||||
import { columns, lockableProperties, LockableProperty, Person } from 'src/database';
|
||||
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||
import { AssetFileType, AssetOrderBy, AssetVisibility, DatabaseExtension, ExifOrientation } from 'src/enum';
|
||||
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
|
||||
import {
|
||||
DateFilter,
|
||||
DateFilterNullable,
|
||||
IdFilter,
|
||||
IdFilterNullable,
|
||||
IdsFilter,
|
||||
NumberFilter,
|
||||
NumberFilterNullable,
|
||||
SearchFilter,
|
||||
SearchFilterBranch,
|
||||
StringFilter,
|
||||
StringFilterNullable,
|
||||
StringPatternFilter,
|
||||
} from 'src/dtos/search.dto';
|
||||
import {
|
||||
AssetFileType,
|
||||
AssetOrder,
|
||||
AssetOrderBy,
|
||||
AssetVisibility,
|
||||
DatabaseExtension,
|
||||
ExifOrientation,
|
||||
SearchOrderField,
|
||||
} from 'src/enum';
|
||||
import { AssetSearchBuilderOptions, AssetSearchBuilderV3Options } from 'src/repositories/search.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { AudioStreamInfo, VectorExtension, VideoFormat, VideoPacketInfo, VideoStreamInfo } from 'src/types';
|
||||
import { fromChecksum } from 'src/utils/request';
|
||||
|
||||
export const getKyselyConfig = (connection: DatabaseConnectionParams): KyselyConfig => {
|
||||
return {
|
||||
@@ -368,7 +392,7 @@ export function withEdits(eb: ExpressionBuilder<DB, 'asset'>): AliasedEditAction
|
||||
const joinDeduplicationPlugin = new DeduplicateJoinsPlugin();
|
||||
/** TODO: This should only be used for search-related queries, not as a general purpose query builder */
|
||||
|
||||
export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuilderOptions) {
|
||||
export function searchAssetBuilderLegacy(kysely: Kysely<DB>, options: AssetSearchBuilderOptions) {
|
||||
options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline);
|
||||
const visibility = options.visibility == null ? AssetVisibility.Timeline : options.visibility;
|
||||
|
||||
@@ -485,6 +509,587 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
|
||||
.$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null));
|
||||
}
|
||||
|
||||
/**
|
||||
* Join strategy each `SearchFilterBranch` field needs against the database.
|
||||
* - `asset`: column on the `asset` table; simple WHERE; no join.
|
||||
* - `asset_exif`: column on `asset_exif`; inner join
|
||||
* - `asset_file`: column on `asset_file`; SQL is field-dependent
|
||||
* - `ocr_search`: specialised trigram-indexed search column
|
||||
* - `membership`: not a column; junction-table membership; SQL is operator-dependent
|
||||
*/
|
||||
type Backing = 'asset' | 'asset_exif' | 'asset_file' | 'ocr_search' | 'membership';
|
||||
|
||||
/**
|
||||
* Exhaustive `SearchFilterBranch` backing map
|
||||
*/
|
||||
const FIELD_BACKING: Record<keyof Omit<SearchFilterBranch, 'or'>, Backing> = {
|
||||
id: 'asset',
|
||||
libraryId: 'asset',
|
||||
type: 'asset',
|
||||
visibility: 'asset',
|
||||
isFavorite: 'asset',
|
||||
isMotion: 'asset',
|
||||
isOffline: 'asset',
|
||||
isEncoded: 'asset_file',
|
||||
hasAlbums: 'membership',
|
||||
hasPeople: 'membership',
|
||||
hasTags: 'membership',
|
||||
city: 'asset_exif',
|
||||
state: 'asset_exif',
|
||||
country: 'asset_exif',
|
||||
make: 'asset_exif',
|
||||
model: 'asset_exif',
|
||||
lensModel: 'asset_exif',
|
||||
description: 'asset_exif',
|
||||
originalFileName: 'asset',
|
||||
originalPath: 'asset',
|
||||
ocr: 'ocr_search',
|
||||
rating: 'asset_exif',
|
||||
fileSizeInBytes: 'asset_exif',
|
||||
takenAt: 'asset',
|
||||
createdAt: 'asset',
|
||||
updatedAt: 'asset',
|
||||
trashedAt: 'asset',
|
||||
personIds: 'membership',
|
||||
tagIds: 'membership',
|
||||
albumIds: 'membership',
|
||||
checksum: 'asset',
|
||||
encodedVideoPath: 'asset_file',
|
||||
};
|
||||
|
||||
function branchNeedsExifJoin(branch: SearchFilterBranch): boolean {
|
||||
for (const key of Object.keys(branch) as (keyof typeof FIELD_BACKING)[]) {
|
||||
if (FIELD_BACKING[key] === 'asset_exif') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exhaustive `SearchOrderField` backing map
|
||||
*/
|
||||
const ORDER_BACKING = {
|
||||
[SearchOrderField.FileCreatedAt]: 'asset',
|
||||
[SearchOrderField.LocalDateTime]: 'asset',
|
||||
[SearchOrderField.FileSizeInBytes]: 'asset_exif',
|
||||
[SearchOrderField.Rating]: 'asset_exif',
|
||||
} satisfies Record<SearchOrderField, Backing>;
|
||||
|
||||
/**
|
||||
* `asset_exif` join is needed when either any filter or order field needs `asset_exif`
|
||||
*/
|
||||
function exifJoinRequired(filter: SearchFilter, orderField: SearchOrderField): boolean {
|
||||
if (ORDER_BACKING[orderField] === 'asset_exif') {
|
||||
return true;
|
||||
}
|
||||
if (branchNeedsExifJoin(filter)) {
|
||||
return true;
|
||||
}
|
||||
return filter.or?.some((branch) => branchNeedsExifJoin(branch)) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* EB type used by `buildBranchPredicates`. The runtime invariant is that
|
||||
* whenever a predicate references an `asset_exif` column, the `asset_exif`
|
||||
* join has already been planted at the top of the builder chain (guaranteed
|
||||
* by `exifJoinRequired`). `searchAssetBuilder` casts its `eb` into this type
|
||||
* because TS can't see through the conditional `.$if(needsExifJoin, …)`.
|
||||
*/
|
||||
type AssetEB = ExpressionBuilder<DB, 'asset' | 'asset_exif'>;
|
||||
|
||||
function existsAlbumLink(eb: AssetEB, want: boolean): Expression<SqlBool> {
|
||||
const e = eb.exists((eb2) => eb2.selectFrom('album_asset').whereRef('album_asset.assetId', '=', 'asset.id'));
|
||||
return want ? e : eb.not(e);
|
||||
}
|
||||
|
||||
function existsPersonLink(eb: AssetEB, want: boolean): Expression<SqlBool> {
|
||||
const e = eb.exists((eb2) =>
|
||||
eb2
|
||||
.selectFrom('asset_face')
|
||||
.whereRef('asset_face.assetId', '=', 'asset.id')
|
||||
.where('asset_face.deletedAt', 'is', null)
|
||||
.where('asset_face.isVisible', '=', true),
|
||||
);
|
||||
return want ? e : eb.not(e);
|
||||
}
|
||||
|
||||
function existsTagLink(eb: AssetEB, want: boolean): Expression<SqlBool> {
|
||||
const e = eb.exists((eb2) => eb2.selectFrom('tag_asset').whereRef('tag_asset.assetId', '=', 'asset.id'));
|
||||
return want ? e : eb.not(e);
|
||||
}
|
||||
|
||||
function existsEncodedVideo(eb: AssetEB, want: boolean): Expression<SqlBool> {
|
||||
const e = eb.exists((eb2) =>
|
||||
eb2
|
||||
.selectFrom('asset_file')
|
||||
.whereRef('asset_file.assetId', '=', 'asset.id')
|
||||
.where('asset_file.type', '=', AssetFileType.EncodedVideo),
|
||||
);
|
||||
return want ? e : eb.not(e);
|
||||
}
|
||||
|
||||
function existsOcrMatch(eb: AssetEB, matches: string): Expression<SqlBool> {
|
||||
const tokens = tokenizeForSearch(matches).join(' ');
|
||||
return eb.exists((eb2) =>
|
||||
eb2
|
||||
.selectFrom('ocr_search')
|
||||
.whereRef('ocr_search.assetId', '=', 'asset.id')
|
||||
.where(sql<SqlBool>`f_unaccent(ocr_search.text) %>> f_unaccent(${tokens})`),
|
||||
);
|
||||
}
|
||||
|
||||
const encodedVideoFileBase = (eb2: ExpressionBuilder<DB, 'asset' | 'asset_exif'>) =>
|
||||
eb2
|
||||
.selectFrom('asset_file')
|
||||
.whereRef('asset_file.assetId', '=', 'asset.id')
|
||||
.where('asset_file.type', '=', AssetFileType.EncodedVideo)
|
||||
.where('asset_file.isEdited', '=', false);
|
||||
|
||||
function existsEncodedVideoPath(eb: AssetEB, f: StringFilter): Expression<SqlBool>[] {
|
||||
const out: Expression<SqlBool>[] = [];
|
||||
if (f.eq !== undefined) {
|
||||
out.push(eb.exists((eb2) => encodedVideoFileBase(eb2).where('asset_file.path', '=', f.eq!)));
|
||||
}
|
||||
if (f.ne !== undefined) {
|
||||
out.push(eb.exists((eb2) => encodedVideoFileBase(eb2).where('asset_file.path', '<>', f.ne!)));
|
||||
}
|
||||
if (f.in !== undefined) {
|
||||
out.push(eb.exists((eb2) => encodedVideoFileBase(eb2).where('asset_file.path', 'in', f.in!)));
|
||||
}
|
||||
if (f.notIn !== undefined) {
|
||||
out.push(eb.exists((eb2) => encodedVideoFileBase(eb2).where('asset_file.path', 'not in', f.notIn!)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type IdsKind = 'album' | 'person' | 'tag';
|
||||
|
||||
function idsAnyExists(eb: AssetEB, kind: IdsKind, ids: string[]): Expression<SqlBool> {
|
||||
switch (kind) {
|
||||
case 'album': {
|
||||
return eb.exists((eb2) =>
|
||||
eb2
|
||||
.selectFrom('album_asset')
|
||||
.whereRef('album_asset.assetId', '=', 'asset.id')
|
||||
.where('album_asset.albumId', '=', anyUuid(ids)),
|
||||
);
|
||||
}
|
||||
case 'person': {
|
||||
return eb.exists((eb2) =>
|
||||
eb2
|
||||
.selectFrom('asset_face')
|
||||
.whereRef('asset_face.assetId', '=', 'asset.id')
|
||||
.where('asset_face.personId', '=', anyUuid(ids))
|
||||
.where('asset_face.deletedAt', 'is', null)
|
||||
.where('asset_face.isVisible', '=', true),
|
||||
);
|
||||
}
|
||||
case 'tag': {
|
||||
return eb.exists((eb2) =>
|
||||
eb2
|
||||
.selectFrom('tag_asset')
|
||||
.innerJoin('tag_closure', 'tag_asset.tagId', 'tag_closure.id_descendant')
|
||||
.whereRef('tag_asset.assetId', '=', 'asset.id')
|
||||
.where('tag_closure.id_ancestor', '=', anyUuid(ids)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function idsAllExists(eb: AssetEB, kind: IdsKind, ids: string[]): Expression<SqlBool> {
|
||||
switch (kind) {
|
||||
case 'album': {
|
||||
return eb.exists((eb2) =>
|
||||
eb2
|
||||
.selectFrom('album_asset')
|
||||
.select('album_asset.assetId')
|
||||
.whereRef('album_asset.assetId', '=', 'asset.id')
|
||||
.where('album_asset.albumId', '=', anyUuid(ids))
|
||||
.groupBy('album_asset.assetId')
|
||||
.having((e3) => e3.fn.count('album_asset.albumId').distinct(), '=', ids.length),
|
||||
);
|
||||
}
|
||||
case 'person': {
|
||||
return eb.exists((eb2) =>
|
||||
eb2
|
||||
.selectFrom('asset_face')
|
||||
.select('asset_face.assetId')
|
||||
.whereRef('asset_face.assetId', '=', 'asset.id')
|
||||
.where('asset_face.personId', '=', anyUuid(ids))
|
||||
.where('asset_face.deletedAt', 'is', null)
|
||||
.where('asset_face.isVisible', '=', true)
|
||||
.groupBy('asset_face.assetId')
|
||||
.having((e3) => e3.fn.count('asset_face.personId').distinct(), '=', ids.length),
|
||||
);
|
||||
}
|
||||
case 'tag': {
|
||||
return eb.exists((eb2) =>
|
||||
eb2
|
||||
.selectFrom('tag_asset')
|
||||
.innerJoin('tag_closure', 'tag_asset.tagId', 'tag_closure.id_descendant')
|
||||
.select('tag_asset.assetId')
|
||||
.whereRef('tag_asset.assetId', '=', 'asset.id')
|
||||
.where('tag_closure.id_ancestor', '=', anyUuid(ids))
|
||||
.groupBy('tag_asset.assetId')
|
||||
.having((e3) => e3.fn.count('tag_closure.id_ancestor').distinct(), '>=', ids.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pushIdsFilter(preds: Expression<SqlBool>[], eb: AssetEB, kind: IdsKind, f: IdsFilter) {
|
||||
if (f.any) {
|
||||
preds.push(idsAnyExists(eb, kind, f.any));
|
||||
}
|
||||
if (f.all) {
|
||||
preds.push(f.all.length === 1 ? idsAnyExists(eb, kind, f.all) : idsAllExists(eb, kind, f.all));
|
||||
}
|
||||
if (f.none) {
|
||||
preds.push(eb.not(idsAnyExists(eb, kind, f.none)));
|
||||
}
|
||||
}
|
||||
|
||||
function pushIdEqNe(
|
||||
preds: Expression<SqlBool>[],
|
||||
eb: AssetEB,
|
||||
column: 'asset.id' | 'asset.libraryId',
|
||||
f: IdFilter | IdFilterNullable | undefined,
|
||||
) {
|
||||
if (!f) {
|
||||
return;
|
||||
}
|
||||
if (f.eq === null) {
|
||||
preds.push(eb(column, 'is', null));
|
||||
} else if (f.eq !== undefined) {
|
||||
preds.push(eb(column, '=', asUuid(f.eq)));
|
||||
}
|
||||
if (f.ne === null) {
|
||||
preds.push(eb(column, 'is not', null));
|
||||
} else if (f.ne !== undefined) {
|
||||
preds.push(eb(column, '<>', asUuid(f.ne)));
|
||||
}
|
||||
}
|
||||
|
||||
function pushEnum<T extends string>(
|
||||
preds: Expression<SqlBool>[],
|
||||
eb: AssetEB,
|
||||
column: 'asset.type' | 'asset.visibility',
|
||||
f: { eq?: T; ne?: T; in?: T[]; notIn?: T[] } | undefined,
|
||||
) {
|
||||
if (!f) {
|
||||
return;
|
||||
}
|
||||
// Values cast: column type unions across asset.type / asset.visibility resolve
|
||||
// to `AssetType | AssetVisibility` and TS can't narrow to the caller's T.
|
||||
// The caller side (the SearchFilter enum schemas) is what guarantees validity.
|
||||
if (f.eq !== undefined) {
|
||||
preds.push(eb(column, '=', f.eq as never));
|
||||
}
|
||||
if (f.ne !== undefined) {
|
||||
preds.push(eb(column, '<>', f.ne as never));
|
||||
}
|
||||
if (f.in !== undefined) {
|
||||
preds.push(eb(column, 'in', f.in as never));
|
||||
}
|
||||
if (f.notIn !== undefined) {
|
||||
preds.push(eb(column, 'not in', f.notIn as never));
|
||||
}
|
||||
}
|
||||
|
||||
type StringColumn =
|
||||
| 'asset_exif.city'
|
||||
| 'asset_exif.state'
|
||||
| 'asset_exif.country'
|
||||
| 'asset_exif.make'
|
||||
| 'asset_exif.model'
|
||||
| 'asset_exif.lensModel'
|
||||
| 'asset_exif.description'
|
||||
| 'asset.originalFileName'
|
||||
| 'asset.originalPath';
|
||||
|
||||
function pushStringEqNeInNotIn(
|
||||
preds: Expression<SqlBool>[],
|
||||
eb: AssetEB,
|
||||
column: StringColumn,
|
||||
f: StringFilterNullable | StringPatternFilter | undefined,
|
||||
) {
|
||||
if (!f) {
|
||||
return;
|
||||
}
|
||||
if (f.eq === null) {
|
||||
preds.push(eb(column, 'is', null));
|
||||
} else if (f.eq !== undefined) {
|
||||
preds.push(eb(column, '=', f.eq));
|
||||
}
|
||||
if (f.ne === null) {
|
||||
preds.push(eb(column, 'is not', null));
|
||||
} else if (f.ne !== undefined) {
|
||||
preds.push(eb(column, '<>', f.ne));
|
||||
}
|
||||
if (f.in !== undefined) {
|
||||
preds.push(eb(column, 'in', f.in));
|
||||
}
|
||||
if (f.notIn !== undefined) {
|
||||
preds.push(eb(column, 'not in', f.notIn));
|
||||
}
|
||||
}
|
||||
|
||||
function pushStringPattern(
|
||||
preds: Expression<SqlBool>[],
|
||||
eb: AssetEB,
|
||||
column: StringColumn,
|
||||
f: StringPatternFilter | undefined,
|
||||
) {
|
||||
if (!f) {
|
||||
return;
|
||||
}
|
||||
pushStringEqNeInNotIn(preds, eb, column, f);
|
||||
const ref = sql.ref(column);
|
||||
if (f.like !== undefined) {
|
||||
preds.push(sql<SqlBool>`f_unaccent(${ref}) ilike ('%' || f_unaccent(${f.like}) || '%')`);
|
||||
}
|
||||
if (f.notLike !== undefined) {
|
||||
preds.push(sql<SqlBool>`f_unaccent(${ref}) not ilike ('%' || f_unaccent(${f.notLike}) || '%')`);
|
||||
}
|
||||
if (f.startsWith !== undefined) {
|
||||
preds.push(sql<SqlBool>`f_unaccent(${ref}) ilike (f_unaccent(${f.startsWith}) || '%')`);
|
||||
}
|
||||
if (f.endsWith !== undefined) {
|
||||
preds.push(sql<SqlBool>`f_unaccent(${ref}) ilike ('%' || f_unaccent(${f.endsWith}))`);
|
||||
}
|
||||
}
|
||||
|
||||
type NumberColumn = 'asset_exif.rating' | 'asset_exif.fileSizeInByte';
|
||||
|
||||
function pushNumber(
|
||||
preds: Expression<SqlBool>[],
|
||||
eb: AssetEB,
|
||||
column: NumberColumn,
|
||||
f: NumberFilter | NumberFilterNullable | undefined,
|
||||
) {
|
||||
if (!f) {
|
||||
return;
|
||||
}
|
||||
if (f.eq === null) {
|
||||
preds.push(eb(column, 'is', null));
|
||||
} else if (f.eq !== undefined) {
|
||||
preds.push(eb(column, '=', f.eq));
|
||||
}
|
||||
if (f.ne === null) {
|
||||
preds.push(eb(column, 'is not', null));
|
||||
} else if (f.ne !== undefined) {
|
||||
preds.push(eb(column, '<>', f.ne));
|
||||
}
|
||||
if (f.lt !== undefined) {
|
||||
preds.push(eb(column, '<', f.lt));
|
||||
}
|
||||
if (f.lte !== undefined) {
|
||||
preds.push(eb(column, '<=', f.lte));
|
||||
}
|
||||
if (f.gt !== undefined) {
|
||||
preds.push(eb(column, '>', f.gt));
|
||||
}
|
||||
if (f.gte !== undefined) {
|
||||
preds.push(eb(column, '>=', f.gte));
|
||||
}
|
||||
if (f.in !== undefined) {
|
||||
preds.push(eb(column, 'in', f.in));
|
||||
}
|
||||
if (f.notIn !== undefined) {
|
||||
preds.push(eb(column, 'not in', f.notIn));
|
||||
}
|
||||
}
|
||||
|
||||
type DateColumn = 'asset.fileCreatedAt' | 'asset.createdAt' | 'asset.updatedAt' | 'asset.deletedAt';
|
||||
|
||||
function pushDate(
|
||||
preds: Expression<SqlBool>[],
|
||||
eb: AssetEB,
|
||||
column: DateColumn,
|
||||
f: DateFilter | DateFilterNullable | undefined,
|
||||
) {
|
||||
if (!f) {
|
||||
return;
|
||||
}
|
||||
if (f.eq === null) {
|
||||
preds.push(eb(column, 'is', null));
|
||||
} else if (f.eq !== undefined) {
|
||||
preds.push(eb(column, '=', f.eq));
|
||||
}
|
||||
if (f.ne === null) {
|
||||
preds.push(eb(column, 'is not', null));
|
||||
} else if (f.ne !== undefined) {
|
||||
preds.push(eb(column, '<>', f.ne));
|
||||
}
|
||||
if (f.gt !== undefined) {
|
||||
preds.push(eb(column, '>', f.gt));
|
||||
}
|
||||
if (f.gte !== undefined) {
|
||||
preds.push(eb(column, '>=', f.gte));
|
||||
}
|
||||
if (f.lt !== undefined) {
|
||||
preds.push(eb(column, '<', f.lt));
|
||||
}
|
||||
if (f.lte !== undefined) {
|
||||
preds.push(eb(column, '<=', f.lte));
|
||||
}
|
||||
}
|
||||
|
||||
function pushChecksum(preds: Expression<SqlBool>[], eb: AssetEB, f: StringFilter | undefined) {
|
||||
if (!f) {
|
||||
return;
|
||||
}
|
||||
if (f.eq !== undefined) {
|
||||
preds.push(eb('asset.checksum', '=', fromChecksum(f.eq)));
|
||||
}
|
||||
if (f.ne !== undefined) {
|
||||
preds.push(eb('asset.checksum', '<>', fromChecksum(f.ne)));
|
||||
}
|
||||
if (f.in !== undefined) {
|
||||
preds.push(
|
||||
eb(
|
||||
'asset.checksum',
|
||||
'in',
|
||||
f.in.map((c: string) => fromChecksum(c)),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (f.notIn !== undefined) {
|
||||
preds.push(
|
||||
eb(
|
||||
'asset.checksum',
|
||||
'not in',
|
||||
f.notIn.map((c: string) => fromChecksum(c)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildBranchPredicates(eb: AssetEB, b: SearchFilterBranch): Expression<SqlBool>[] {
|
||||
const p: Expression<SqlBool>[] = [];
|
||||
|
||||
pushIdEqNe(p, eb, 'asset.id', b.id);
|
||||
pushIdEqNe(p, eb, 'asset.libraryId', b.libraryId);
|
||||
|
||||
pushEnum(p, eb, 'asset.type', b.type);
|
||||
pushEnum(p, eb, 'asset.visibility', b.visibility);
|
||||
|
||||
if (b.isFavorite) {
|
||||
p.push(eb('asset.isFavorite', '=', b.isFavorite.eq));
|
||||
}
|
||||
if (b.isOffline) {
|
||||
p.push(eb('asset.isOffline', '=', b.isOffline.eq));
|
||||
}
|
||||
if (b.isMotion) {
|
||||
p.push(eb('asset.livePhotoVideoId', b.isMotion.eq ? 'is not' : 'is', null));
|
||||
}
|
||||
if (b.isEncoded) {
|
||||
p.push(existsEncodedVideo(eb, b.isEncoded.eq));
|
||||
}
|
||||
|
||||
if (b.hasAlbums) {
|
||||
p.push(existsAlbumLink(eb, b.hasAlbums.eq));
|
||||
}
|
||||
if (b.hasPeople) {
|
||||
p.push(existsPersonLink(eb, b.hasPeople.eq));
|
||||
}
|
||||
if (b.hasTags) {
|
||||
p.push(existsTagLink(eb, b.hasTags.eq));
|
||||
}
|
||||
|
||||
pushStringEqNeInNotIn(p, eb, 'asset_exif.city', b.city);
|
||||
pushStringEqNeInNotIn(p, eb, 'asset_exif.state', b.state);
|
||||
pushStringEqNeInNotIn(p, eb, 'asset_exif.country', b.country);
|
||||
pushStringEqNeInNotIn(p, eb, 'asset_exif.make', b.make);
|
||||
pushStringEqNeInNotIn(p, eb, 'asset_exif.model', b.model);
|
||||
pushStringEqNeInNotIn(p, eb, 'asset_exif.lensModel', b.lensModel);
|
||||
|
||||
pushStringPattern(p, eb, 'asset_exif.description', b.description);
|
||||
pushStringPattern(p, eb, 'asset.originalFileName', b.originalFileName);
|
||||
pushStringPattern(p, eb, 'asset.originalPath', b.originalPath);
|
||||
|
||||
if (b.ocr) {
|
||||
p.push(existsOcrMatch(eb, b.ocr.matches));
|
||||
}
|
||||
|
||||
pushNumber(p, eb, 'asset_exif.rating', b.rating);
|
||||
pushNumber(p, eb, 'asset_exif.fileSizeInByte', b.fileSizeInBytes);
|
||||
|
||||
pushDate(p, eb, 'asset.fileCreatedAt', b.takenAt);
|
||||
pushDate(p, eb, 'asset.createdAt', b.createdAt);
|
||||
pushDate(p, eb, 'asset.updatedAt', b.updatedAt);
|
||||
pushDate(p, eb, 'asset.deletedAt', b.trashedAt);
|
||||
|
||||
if (b.albumIds) {
|
||||
pushIdsFilter(p, eb, 'album', b.albumIds);
|
||||
}
|
||||
if (b.personIds) {
|
||||
pushIdsFilter(p, eb, 'person', b.personIds);
|
||||
}
|
||||
if (b.tagIds) {
|
||||
pushIdsFilter(p, eb, 'tag', b.tagIds);
|
||||
}
|
||||
|
||||
pushChecksum(p, eb, b.checksum);
|
||||
|
||||
if (b.encodedVideoPath) {
|
||||
p.push(...existsEncodedVideoPath(eb, b.encodedVideoPath));
|
||||
}
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
function applySearchOrder<O>(
|
||||
qb: SelectQueryBuilder<DB, 'asset' | 'asset_exif', O>,
|
||||
field: SearchOrderField,
|
||||
direction: AssetOrder,
|
||||
) {
|
||||
switch (field) {
|
||||
case SearchOrderField.FileCreatedAt: {
|
||||
return qb.orderBy('asset.fileCreatedAt', direction);
|
||||
}
|
||||
case SearchOrderField.LocalDateTime: {
|
||||
return qb.orderBy('asset.localDateTime', direction);
|
||||
}
|
||||
case SearchOrderField.FileSizeInBytes: {
|
||||
return qb.orderBy('asset_exif.fileSizeInByte', direction);
|
||||
}
|
||||
case SearchOrderField.Rating: {
|
||||
return qb.orderBy('asset_exif.rating', direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuilderV3Options) {
|
||||
const filter = options.filter ?? {};
|
||||
const orderField = options.order?.field ?? SearchOrderField.FileCreatedAt;
|
||||
const orderDirection = options.order?.direction ?? AssetOrder.Desc;
|
||||
const needsExifJoin = exifJoinRequired(filter, orderField);
|
||||
|
||||
return kysely
|
||||
.withPlugin(joinDeduplicationPlugin)
|
||||
.selectFrom('asset')
|
||||
.$if(needsExifJoin && !options.withExif, (qb) => qb.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId'))
|
||||
.$if(!!options.withExif && needsExifJoin, withExifInner)
|
||||
.$if(!!options.withExif && !needsExifJoin, withExif)
|
||||
.$if(!!options.userIds && options.userIds.length > 0, (qb) =>
|
||||
qb.where('asset.ownerId', '=', anyUuid(options.userIds!)),
|
||||
)
|
||||
.$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople))
|
||||
.$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null))
|
||||
.where((eb) => {
|
||||
const top = buildBranchPredicates(eb, filter);
|
||||
if (filter.or && filter.or.length > 0) {
|
||||
top.push(eb.or(filter.or.map((branch) => eb.and(buildBranchPredicates(eb, branch)))));
|
||||
}
|
||||
return top.length > 0 ? eb.and(top) : eb.val(true);
|
||||
})
|
||||
.$call((qb) =>
|
||||
applySearchOrder(qb as SelectQueryBuilder<DB, 'asset' | 'asset_exif', unknown>, orderField, orderDirection),
|
||||
);
|
||||
}
|
||||
|
||||
export type ReindexVectorIndexOptions = { indexName: string; lists?: number };
|
||||
|
||||
type VectorIndexQueryOptions = { table: string; vectorExtension: VectorExtension } & ReindexVectorIndexOptions;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WorkflowTrigger, WorkflowType } from 'src/enum';
|
||||
import { WorkflowTrigger } from '@immich/plugin-sdk';
|
||||
import { WorkflowType } from 'src/enum';
|
||||
import { isMethodCompatible } from 'src/utils/workflow';
|
||||
|
||||
const tests: Array<{ trigger: WorkflowTrigger; types: WorkflowType[]; expected: boolean }> = [
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { WorkflowTrigger, WorkflowType } from 'src/enum';
|
||||
import { WorkflowTrigger } from '@immich/plugin-sdk';
|
||||
import { WorkflowType } from 'src/enum';
|
||||
import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository';
|
||||
|
||||
export const triggerMap: Record<WorkflowTrigger, WorkflowType[]> = {
|
||||
[WorkflowTrigger.AssetCreate]: [WorkflowType.AssetV1],
|
||||
[WorkflowTrigger.PersonRecognized]: [WorkflowType.AssetPersonV1],
|
||||
[WorkflowTrigger.AssetMetadataExtraction]: [WorkflowType.AssetV1],
|
||||
};
|
||||
|
||||
export const getWorkflowTriggers = () =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { WorkflowTrigger } from '@immich/plugin-sdk';
|
||||
import { Kysely } from 'kysely';
|
||||
import { WorkflowTrigger, WorkflowType } from 'src/enum';
|
||||
import { WorkflowType } from 'src/enum';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { PluginRepository } from 'src/repositories/plugin.repository';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { WorkflowStepConfig } from '@immich/plugin-sdk';
|
||||
import { WorkflowStepConfig, WorkflowTrigger } from '@immich/plugin-sdk';
|
||||
import { Kysely } from 'kysely';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
|
||||
import { AssetVisibility, LogLevel, WorkflowTrigger } from 'src/enum';
|
||||
import { AssetVisibility, LogLevel } from 'src/enum';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { AlbumRepository } from 'src/repositories/album.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
@@ -12,6 +12,7 @@ import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { PluginRepository } from 'src/repositories/plugin.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { UserRepository } from 'src/repositories/user.repository';
|
||||
import { WorkflowRepository } from 'src/repositories/workflow.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { WorkflowExecutionService } from 'src/services/workflow-execution.service';
|
||||
@@ -33,8 +34,9 @@ class WorkflowTestContext extends MediumTestContext<WorkflowExecutionService> {
|
||||
CryptoRepository,
|
||||
DatabaseRepository,
|
||||
LoggingRepository,
|
||||
StorageRepository,
|
||||
PluginRepository,
|
||||
StorageRepository,
|
||||
UserRepository,
|
||||
WorkflowRepository,
|
||||
],
|
||||
mock: [ConfigRepository],
|
||||
@@ -231,6 +233,52 @@ describe('core plugin', () => {
|
||||
});
|
||||
|
||||
describe('assetAddToAlbums', () => {
|
||||
it('should create an album by name', async () => {
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true });
|
||||
|
||||
const workflow = await createWorkflow({
|
||||
ownerId: user.id,
|
||||
trigger: WorkflowTrigger.AssetCreate,
|
||||
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [], albumName: 'Screenshots' } }],
|
||||
});
|
||||
|
||||
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
|
||||
const albums = await ctx.get(AlbumRepository).getAll(user.id);
|
||||
expect(albums).toHaveLength(1);
|
||||
|
||||
const album = albums[0]!;
|
||||
expect(album.albumName).toEqual('Screenshots');
|
||||
|
||||
const updated = await ctx.get(WorkflowRepository).get(workflow.id);
|
||||
expect(updated?.steps[0].config).toEqual({ albumIds: [album.id], albumName: 'Screenshots' });
|
||||
|
||||
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.toContain(asset.id);
|
||||
});
|
||||
|
||||
it('should not use the name when there is an albumId', async () => {
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true });
|
||||
const { album } = await ctx.newAlbum({ ownerId: user.id });
|
||||
|
||||
const workflow = await createWorkflow({
|
||||
ownerId: user.id,
|
||||
trigger: WorkflowTrigger.AssetCreate,
|
||||
steps: [
|
||||
{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id], albumName: 'Screenshots' } },
|
||||
],
|
||||
});
|
||||
|
||||
const albums = await ctx.get(AlbumRepository).getAll(user.id);
|
||||
expect(albums).toHaveLength(1);
|
||||
expect(albums[0].albumName).toEqual(album.albumName);
|
||||
|
||||
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
|
||||
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.toContain(asset.id);
|
||||
});
|
||||
|
||||
it('should add an asset to an album', async () => {
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true });
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import AlbumCover from '$lib/components/album-page/AlbumCover.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { getAlbumInfo } from '@immich/sdk';
|
||||
import { IconButton, LoadingSpinner } from '@immich/ui';
|
||||
import { IconButton, Text, LoadingSpinner } from '@immich/ui';
|
||||
import { mdiTrashCanOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -46,5 +46,22 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:catch}
|
||||
<div class="flex justify-between gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<Text>{$t('unknown')}</Text>
|
||||
<Text color="muted" size="small" variant="italic">{albumId}</Text>
|
||||
</div>
|
||||
<div class="">
|
||||
<IconButton
|
||||
icon={mdiTrashCanOutline}
|
||||
shape="round"
|
||||
color="danger"
|
||||
variant="ghost"
|
||||
onclick={onDelete}
|
||||
aria-label={$t('remove')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
<script module lang="ts">
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { getAlbumInfo } from '@immich/sdk';
|
||||
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const albumNameCache = new Map<string, Promise<string>>();
|
||||
|
||||
const getAlbumName = (id: string): Promise<string> => {
|
||||
let albumName = albumNameCache.get(id);
|
||||
if (!albumName) {
|
||||
albumName = getAlbumInfo({ ...authManager.params, id })
|
||||
.then((album) => album.albumName)
|
||||
.catch(() => id);
|
||||
albumNameCache.set(id, albumName);
|
||||
}
|
||||
return albumName;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
||||
import type { JSONSchemaProperty } from '$lib/types';
|
||||
import type { WorkflowStepDto } from '@immich/sdk';
|
||||
import { Badge, Card, CardBody, CardDescription, CardHeader, CardTitle, Icon, IconButton } from '@immich/ui';
|
||||
import {
|
||||
@@ -27,9 +47,13 @@
|
||||
|
||||
const method = $derived(pluginManager.getMethod(step.method));
|
||||
const isFilter = $derived(method?.uiHints?.includes('Filter') ?? false);
|
||||
const schema = $derived(method?.schema as JSONSchemaProperty | undefined);
|
||||
const configEntries = $derived(
|
||||
Object.entries(step.config ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== ''),
|
||||
);
|
||||
|
||||
const getUiHint = (key: string) => schema?.properties?.[key]?.uiHint;
|
||||
const toIds = (value: unknown): string[] => (Array.isArray(value) ? value.map(String) : [String(value)]);
|
||||
let dragImage = $state<Element>();
|
||||
let isDropTarget = $state(false);
|
||||
let hoverDrag = $state(false);
|
||||
@@ -204,15 +228,28 @@
|
||||
{#if configEntries.length > 0}
|
||||
<CardBody class="py-3">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
{#each configEntries as [key, value] (key)}
|
||||
{#snippet badge(key: string, content: string)}
|
||||
<Badge
|
||||
color={isFilter ? 'info' : 'warning'}
|
||||
shape="round"
|
||||
size="small"
|
||||
class="border font-mono {isFilter ? 'border-primary-200' : 'border-warning-200'}"
|
||||
>
|
||||
<span class="opacity-60">{key}</span>{formatConfigValue(value)}
|
||||
<span class="opacity-60">{key}</span>{content}
|
||||
</Badge>
|
||||
{/snippet}
|
||||
{#each configEntries as [key, value] (key)}
|
||||
{#if getUiHint(key) === 'AlbumId'}
|
||||
{#each toIds(value) as albumId (albumId)}
|
||||
{#await getAlbumName(albumId)}
|
||||
{@render badge($t('album'), '…')}
|
||||
{:then albumName}
|
||||
{@render badge($t('album'), `"${truncate(albumName)}"`)}
|
||||
{/await}
|
||||
{/each}
|
||||
{:else}
|
||||
{@render badge(key, formatConfigValue(value))}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</CardBody>
|
||||
|
||||