Skip to content

Commit e6e26e7

Browse files
Merge branch 'main' into frontend-app-circularity-properties
2 parents 3899965 + d6b2ca9 commit e6e26e7

File tree

10 files changed

+303
-52
lines changed

10 files changed

+303
-52
lines changed

compose.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ services:
3232
- user_uploads:/opt/relab/backend/data/uploads
3333

3434
database:
35-
image: postgres:18@sha256:41fc5342eefba6cc2ccda736aaf034bbbb7c3df0fdb81516eba1ba33f360162c
35+
image: postgres:18@sha256:41bfa2e5b168fff0847a5286694a86cff102bdc4d59719869f6b117bb30b3a24
3636
env_file: ./backend/.env
3737
healthcheck:
3838
test: # Check if the database is ready to accept connections
@@ -45,7 +45,7 @@ services:
4545
- database_data:/var/lib/postgresql
4646

4747
docs:
48-
image: squidfunk/mkdocs-material:9@sha256:58dee36ad85b0ae4836522ee6d3f0150d828bca9a1f7d3bfbf430bca771c1441
48+
image: squidfunk/mkdocs-material:9@sha256:980e11fed03b8e7851e579be9f34b1210f516c9f0b4da1a1457f21a460bd6628
4949
restart: unless-stopped
5050
volumes:
5151
- ./docs:/docs

frontend-app/package-lock.json

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend-app/src/app/products/[id]/index.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import ProductPhysicalProperties from '@/components/product/ProductPhysicalPrope
1616
import ProductTags from '@/components/product/ProductTags';
1717
import ProductType from '@/components/product/ProductType';
1818
import ProductCircularityProperties from '@/components/product/ProductCircularityProperties';
19+
import ProductVideo from "@/components/product/ProductVideo";
1920

2021
import { useDialog } from '@/components/common/DialogProvider';
2122

@@ -153,6 +154,10 @@ export default function ProductPage(): JSX.Element {
153154
setProduct({ ...product, amountInParent: newAmount });
154155
};
155156

157+
const onVideoChange = (newVideos: { id?: number; url: string; description: string; title: string }[]) => {
158+
setProduct({ ...product, videos: newVideos });
159+
};
160+
156161
const onProductDelete = () => {
157162
deleteProduct(product).then(() => {
158163
setEditMode(false);
@@ -246,6 +251,7 @@ export default function ProductPage(): JSX.Element {
246251
editMode={editMode}
247252
onChangeCircularityProperties={onChangeCircularityProperties}
248253
/>
254+
<ProductVideo product={product} editMode={editMode} onVideoChange={onVideoChange} />
249255
<ProductComponents product={product} editMode={editMode} />
250256
<ProductMetaData product={product} />
251257
<ProductDelete product={product} editMode={editMode} onDelete={onProductDelete} />

frontend-app/src/components/base/TextInput.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,15 @@ import LightTheme from '@/assets/themes/light';
55

66
interface Props extends TextInputProps {
77
errorOnEmpty?: boolean;
8+
customValidation?: (value: string) => boolean;
89
ref?: React.Ref<NativeTextInput>;
910
}
1011

11-
export function TextInput({ style, children, errorOnEmpty = false, ref, ...props }: Props) {
12+
export function TextInput({ style, children, errorOnEmpty = false, customValidation, ref, ...props }: Props) {
1213
const darkMode = useColorScheme() === 'dark';
13-
const error = errorOnEmpty && (!props.value || props.value === '');
14+
const emptyError = errorOnEmpty && (!props.value || props.value === '');
15+
const validationError = customValidation && props.value && !customValidation(props.value);
16+
const error = emptyError || validationError;
1417

1518
return (
1619
<NativeTextInput
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { useState } from 'react';
2+
import { View, Text, TouchableOpacity, Linking } from 'react-native';
3+
import { MaterialCommunityIcons } from '@expo/vector-icons';
4+
import { InfoTooltip, TextInput } from '@/components/base';
5+
import { useDialog } from '@/components/common/DialogProvider';
6+
import { Product } from '@/types/Product';
7+
import { isValidUrl } from '@/services/api/validation/product';
8+
9+
interface Video {
10+
id?: number;
11+
url: string;
12+
title: string;
13+
description: string;
14+
}
15+
16+
interface Props {
17+
product: Product;
18+
editMode: boolean;
19+
onVideoChange?: (videos: Video[]) => void;
20+
}
21+
22+
export default function ProductVideo({ product, editMode, onVideoChange }: Props) {
23+
const [videos, setVideos] = useState<Video[]>(product.videos || []);
24+
const dialog = useDialog();
25+
26+
const handleVideoChange = (idx: number, field: 'url' | 'title' | 'description', value: string) => {
27+
const updated = videos.map((v, i) => i === idx ? { ...v, [field]: value } : v);
28+
setVideos(updated);
29+
onVideoChange?.(updated);
30+
};
31+
32+
const handleRemove = (idx: number) => {
33+
const updated = videos.filter((_, i) => i !== idx);
34+
setVideos(updated);
35+
onVideoChange?.(updated);
36+
};
37+
38+
const handleAdd = () => {
39+
dialog.input({
40+
title: 'Add Recording',
41+
placeholder: 'Video URL',
42+
helperText: 'Paste a video URL (YouTube)',
43+
buttons: [
44+
{ text: 'Cancel' },
45+
{
46+
text: 'Add',
47+
disabled: (value) => !value || !value.trim() || !isValidUrl(value),
48+
onPress: (url) => {
49+
if (!url || !isValidUrl(url)) return;
50+
const updated = [...videos, { url: url.trim(), title: '', description: '' }];
51+
setVideos(updated);
52+
onVideoChange?.(updated);
53+
}
54+
}
55+
]
56+
});
57+
};
58+
59+
return (
60+
<View>
61+
<View
62+
style={{
63+
flexDirection: 'row',
64+
justifyContent: 'space-between',
65+
alignItems: 'center',
66+
marginBottom: 12,
67+
paddingHorizontal: 14
68+
}}
69+
>
70+
<Text
71+
style={{
72+
fontSize: 24,
73+
fontWeight: 'bold',
74+
}}
75+
>
76+
Recordings <InfoTooltip title="Add uploaded recordings of the disassembly." />
77+
</Text>
78+
{editMode && (
79+
<TouchableOpacity onPress={handleAdd} style={{ marginTop: 4 }}>
80+
<Text style={{ color: 'blue' }}>Add recording</Text>
81+
</TouchableOpacity>
82+
)}
83+
</View>
84+
85+
{videos.length === 0 && (
86+
<Text style={{ paddingHorizontal: 14, opacity: 0.7, marginBottom: 8 }}>This product has no associated recordings.</Text>
87+
)}
88+
89+
{videos.map((video, idx) => (
90+
<View key={video.id ?? idx} style={{ marginBottom: 16, flexDirection: 'row', alignItems: 'center' }}>
91+
<View style={{ flex: 1 }}>
92+
<TextInput
93+
style={{ paddingHorizontal: 14, fontSize: 20, fontWeight: 'bold', lineHeight: 16 }}
94+
placeholder={'Title'}
95+
value={video.title}
96+
onChangeText={val => handleVideoChange(idx, 'title', val)}
97+
editable={editMode}
98+
errorOnEmpty
99+
/>
100+
{editMode ? (
101+
<TextInput
102+
style={{ paddingHorizontal: 14, fontSize: 16, lineHeight: 26 }}
103+
placeholder={'Video URL'}
104+
value={video.url}
105+
onChangeText={val => handleVideoChange(idx, 'url', val)}
106+
errorOnEmpty
107+
customValidation={isValidUrl}
108+
editable={editMode}
109+
/>
110+
) : (
111+
<TouchableOpacity onPress={() => Linking.openURL(video.url)}>
112+
<Text style={{ paddingHorizontal: 14, fontSize: 16, lineHeight: 26, color: 'blue', textDecorationLine: 'underline' }}>
113+
{video.url}
114+
</Text>
115+
</TouchableOpacity>
116+
)}
117+
{(editMode || Boolean(video.description)) && <TextInput
118+
style={{ paddingHorizontal: 14, fontSize: 16, lineHeight: 16 }}
119+
placeholder={'Add description (optional)'}
120+
value={video.description}
121+
onChangeText={val => handleVideoChange(idx, 'description', val)}
122+
editable={editMode}
123+
/>}
124+
</View>
125+
{editMode && (
126+
<TouchableOpacity
127+
onPress={() => handleRemove(idx)}
128+
style={{
129+
padding: 14,
130+
justifyContent: 'center',
131+
alignItems: 'center'
132+
}}
133+
>
134+
<MaterialCommunityIcons name="delete" size={24} color="red" />
135+
</TouchableOpacity>
136+
)}
137+
</View>
138+
))}
139+
</View>
140+
);
141+
}

frontend-app/src/services/api/fetching.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ type ProductData = {
2929
} | null;
3030
components: { id: number; name: string; description: string }[];
3131
images: ImageData[];
32+
videos: VideoData[];
3233
owner_id: string;
3334
parent_id?: number;
3435
amount_in_parent?: number;
@@ -40,11 +41,18 @@ type ImageData = {
4041
description: string;
4142
};
4243

43-
async function toProduct(data: ProductData): Promise<Required<Product>> {
44+
type VideoData = {
45+
id: number;
46+
url: string;
47+
description: string;
48+
title: string;
49+
};
50+
51+
async function toProduct(data: ProductData): Promise<Product> {
4452
const meId = await getUser().then((user) => user?.id);
4553
return {
4654
id: data.id,
47-
parentID: data.parent_id,
55+
parentID: data.parent_id ?? undefined,
4856
name: data.name,
4957
brand: data.brand,
5058
model: data.model,
@@ -53,7 +61,7 @@ async function toProduct(data: ProductData): Promise<Required<Product>> {
5361
updatedAt: data.updated_at,
5462
productTypeID: data.product_type_id,
5563
ownedBy: data.owner_id === meId ? 'me' : data.owner_id,
56-
amountInParent: data.amount_in_parent,
64+
amountInParent: data.amount_in_parent ?? undefined,
5765
physicalProperties: {
5866
weight: data.physical_properties.weight_kg,
5967
height: data.physical_properties.height_cm,
@@ -85,6 +93,7 @@ async function toProduct(data: ProductData): Promise<Required<Product>> {
8593
},
8694
componentIDs: data.components.map(({ id }) => id),
8795
images: data.images.map((img) => ({ ...img, url: baseUrl + img.image_url })),
96+
videos: data.videos || [],
8897
};
8998
}
9099

@@ -93,7 +102,7 @@ export async function getProduct(id: number | 'new'): Promise<Product> {
93102
return newProduct();
94103
}
95104
const url = new URL(baseUrl + `/products/${id}`);
96-
['physical_properties', 'circularity_properties', 'images', 'product_type', 'components'].forEach((inc) =>
105+
['physical_properties', 'circularity_properties', 'images', 'product_type', 'components', 'videos'].forEach((inc) =>
97106
url.searchParams.append('include', inc),
98107
);
99108

@@ -115,7 +124,7 @@ export function newProduct(
115124
): Product {
116125
return {
117126
id: 'new',
118-
parentID: parentID,
127+
parentID: isNaN(parentID) ? undefined : parentID,
119128
name: name,
120129
brand: brand,
121130
model: model,
@@ -138,6 +147,7 @@ export function newProduct(
138147
},
139148
componentIDs: [],
140149
images: [],
150+
videos: [],
141151
ownedBy: 'me',
142152
};
143153
}
@@ -146,7 +156,7 @@ export async function allProducts(
146156
include = ['physical_properties', 'circularity_properties', 'images', 'product_type', 'components'],
147157
page = 1,
148158
size = 50,
149-
): Promise<Required<Product>[]> {
159+
): Promise<Product[]> {
150160
const url = new URL(baseUrl + '/products');
151161
include.forEach((inc) => url.searchParams.append('include', inc));
152162
url.searchParams.append('page', page.toString());
@@ -169,7 +179,7 @@ export async function myProducts(
169179
include = ['physical_properties', 'circularity_properties', 'images', 'product_type', 'components'],
170180
page = 1,
171181
size = 50,
172-
): Promise<Required<Product>[]> {
182+
): Promise<Product[]> {
173183
const url = new URL(baseUrl + '/users/me/products');
174184
include.forEach((inc) => url.searchParams.append('include', inc));
175185
url.searchParams.append('page', page.toString());

0 commit comments

Comments
 (0)