Compare commits

...

12 Commits

Author SHA1 Message Date
ddcdca156c fix ntfy
All checks were successful
Deploy to Private Server / deploy (push) Successful in 13s
2025-12-30 14:58:31 +07:00
e749386bd5 fix ntfy
All checks were successful
Deploy to Private Server / deploy (push) Successful in 12s
2025-12-30 14:56:30 +07:00
932931977e update notify step deploy
All checks were successful
Deploy to Private Server / deploy (push) Successful in 12s
2025-12-30 11:34:50 +07:00
4feec19d2c ci: add Gitea Actions workflow for Docker-based deployment to private server.
All checks were successful
Deploy to Private Server / deploy (push) Successful in 13s
2025-12-29 16:48:37 +07:00
6c0f9074f8 feat: add Gitea workflow for automated Docker deployment to a private server.
All checks were successful
Deploy to Private Server / deploy (push) Successful in 12s
2025-12-29 16:39:02 +07:00
62a7aab9ff ci: Add Gitea workflow for automated Docker image deployment to private server.
All checks were successful
Deploy to Private Server / deploy (push) Successful in 8s
2025-12-29 16:33:44 +07:00
24e0d7980f feat: add Gitea Actions workflow for private server deployment.
Some checks failed
Deploy to Private Server / deploy (push) Failing after 3s
2025-12-29 16:30:12 +07:00
e8cd2a8f14 feat: Implement timelapse calculator application with core logic, UI components, and Gitea deployment workflow.
Some checks failed
Deploy to Private Server / deploy (push) Failing after 2s
2025-12-29 16:27:20 +07:00
f3382717f3 feat: Add new UI components for number input, time input, and calculator mode selection.
All checks were successful
Deploy to Private Server / deploy (push) Successful in 23s
2025-12-29 15:23:54 +07:00
5a277ccc6b commit
All checks were successful
Deploy to Private Server / deploy (push) Successful in 31s
2025-12-25 17:06:32 +07:00
55dca5f75f Merge branch 'main' of https://git.hcmc.online/tienngo/timelapse-calc 2025-12-25 17:05:54 +07:00
015f1415a1 commit 2025-12-25 17:05:11 +07:00
8 changed files with 267 additions and 142 deletions

35
.dockerignore Normal file
View File

@@ -0,0 +1,35 @@
# Dependencies
node_modules
npm-debug.log*
# Build outputs
dist
dist-ssr
*.local
# Git
.git
.gitignore
# IDE
.vscode
.idea
*.swp
*.swo
.DS_Store
# Testing
coverage
.nyc_output
# Documentation
README.md
*.md
# CI/CD
.gitea
# Other
*.log
.cache

View File

@@ -2,48 +2,44 @@ name: Deploy to Private Server
on: on:
push: push:
branches: [ "main" ] branches: ["main"]
jobs: jobs:
build:
runs-on: ubuntu-latest
container:
image: node:20-slim
steps:
- uses: actions/checkout@v4
- name: Build Vite app
run: |
npm ci
npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v3
with:
name: build-output
path: dist/
deploy: deploy:
runs-on: ubuntu-latest runs-on: homesrv
needs: build
container:
image: node:20-slim
options: -v /var/www/vite-app:/deploy/vite-app --user 1000:1000
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Download build artifacts - name: Build Docker image
uses: actions/download-artifact@v3
with:
name: build-output
path: dist/
- name: Deploy to host
run: | run: |
rm -rf /deploy/vite-app/timelapse-calc docker build -t timelapse-calc:latest \
mkdir -p /deploy/vite-app/timelapse-calc -f Dockerfile .
cp -r dist/* /deploy/vite-app/timelapse-calc/
echo "Deployment completed successfully" - name: Stop old container if exists
echo "Deployed files:" run: |
ls -la /deploy/vite-app/timelapse-calc/ if docker ps -a --format '{{.Names}}' | grep -q '^timelapse-calc$'; then
docker stop timelapse-calc
docker rm timelapse-calc
fi
- name: Run new container
run: |
docker run -d \
--restart always \
--name timelapse-calc \
-p 3005:80 \
timelapse-calc:latest
- name: Send Deployment Notification
uses: https://git.hcmc.online/actions/deploy-notify@main
if: always()
with:
status: ${{ job.status }}
title: "Deploy: ${{ github.repository }}"
message: |
Ref: ${{ github.ref_name }}
Commit: ${{ github.sha }}
Message: ${{ github.event.head_commit.message }}
Status: ${{ job.status }}
Actor: ${{ github.event.head_commit.author.name }}

41
Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
# Build stage
FROM node:20-alpine AS build
WORKDIR /app
# Copy package files
COPY package.json package-lock.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built assets from build stage
COPY --from=build /app/dist /usr/share/nginx/html
RUN echo 'server { \
listen 80; \
server_name _; \
root /usr/share/nginx/html; \
index index.html; \
location / { \
try_files $uri $uri/ /index.html; \
} \
# Cache static assets \
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { \
expires 1y; \
add_header Cache-Control "public, immutable"; \
} \
}' > /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { Card, ConfigProvider } from 'antd'; import { Card, ConfigProvider, Segmented } from 'antd';
import Header from './components/Header'; import Header from './components/Header';
import CalculatorMode from './components/CalculatorMode'; import CalculatorMode from './components/CalculatorMode';
import TimeInput from './components/TimeInput'; import TimeInput from './components/TimeInput';
@@ -13,43 +13,56 @@ function App() {
// State for inputs // State for inputs
const [clipLength, setClipLength] = useState({ hours: 0, minutes: 0, seconds: 20 }); const [clipLength, setClipLength] = useState({ hours: 0, minutes: 0, seconds: 20 });
const [eventDuration, setEventDuration] = useState({ hours: 1, minutes: 0, seconds: 0 }); const [eventDuration, setEventDuration] = useState({ hours: 0, minutes: 40, seconds: 0 });
const [intervalInput, setIntervalInput] = useState(2); // Seconds
const [fps, setFps] = useState(30); const [fps, setFps] = useState(30);
const [imageSize, setImageSize] = useState(12);
// State for results
const [shootingInterval, setShootingInterval] = useState(0);
const [numberOfPhotos, setNumberOfPhotos] = useState(0);
const [totalMemory, setTotalMemory] = useState(0);
// Helper function to convert time object to seconds // Helper function to convert time object to seconds
const timeToSeconds = (time) => { const timeToSeconds = (time) => {
return time.hours * 3600 + time.minutes * 60 + time.seconds; return time.hours * 3600 + time.minutes * 60 + time.seconds;
}; };
// Calculate results whenever inputs change // Helper to convert seconds to time object (for derived values if needed, but we output seconds)
useEffect(() => { // ...
const clipSeconds = timeToSeconds(clipLength);
const eventSeconds = timeToSeconds(eventDuration);
if (clipSeconds > 0 && eventSeconds > 0 && fps > 0) { // Calculate results
// Calculate shooting interval (in seconds) const calculateResults = () => {
const interval = eventSeconds / (clipSeconds * fps); const clipSec = timeToSeconds(clipLength);
setShootingInterval(interval); const eventSec = timeToSeconds(eventDuration);
// Calculate number of photos let photos = 0;
const photos = Math.ceil(eventSeconds / interval); let result = 0;
setNumberOfPhotos(photos);
// Calculate total memory (in MB) if (mode === 'shooting-interval') {
const memory = photos * imageSize; if (clipSec > 0 && fps > 0) {
setTotalMemory(memory); result = eventSec / (clipSec * fps);
} else { if (result > 0) photos = Math.ceil(eventSec / result);
setShootingInterval(0); }
setNumberOfPhotos(0); } else if (mode === 'clip-length') {
setTotalMemory(0); if (intervalInput > 0 && fps > 0) {
result = eventSec / (intervalInput * fps);
photos = Math.ceil(eventSec / intervalInput);
}
} else if (mode === 'event-duration') {
if (clipSec > 0 && intervalInput > 0 && fps > 0) {
result = clipSec * fps * intervalInput;
photos = Math.ceil(result / intervalInput);
}
} }
}, [clipLength, eventDuration, fps, imageSize]);
return { resultSeconds: result, numberOfPhotos: photos };
};
const { resultSeconds, numberOfPhotos } = calculateResults();
const getResultLabel = () => {
switch (mode) {
case 'shooting-interval': return "Shooting Interval";
case 'clip-length': return "Clip Length";
case 'event-duration': return "Event Duration";
default: return "Result";
}
};
return ( return (
<ConfigProvider <ConfigProvider
@@ -68,6 +81,11 @@ function App() {
InputNumber: { InputNumber: {
controlHeight: 44, controlHeight: 44,
}, },
Segmented: {
itemSelectedBg: '#F4D03F',
itemSelectedColor: '#2C3E50',
trackBg: '#F8F9FA'
}
}, },
}} }}
> >
@@ -77,44 +95,62 @@ function App() {
<Card className="calculator-card"> <Card className="calculator-card">
<CalculatorMode value={mode} onChange={setMode} /> <CalculatorMode value={mode} onChange={setMode} />
<TimeInput {mode !== 'clip-length' && (
label="Clip length" <TimeInput
hours={clipLength.hours} label="Clip length"
minutes={clipLength.minutes} hours={clipLength.hours}
seconds={clipLength.seconds} minutes={clipLength.minutes}
onChange={setClipLength} seconds={clipLength.seconds}
/> onChange={setClipLength}
/>
)}
<TimeInput {mode !== 'event-duration' && (
label="Event duration" <TimeInput
hours={eventDuration.hours} label="Event duration"
minutes={eventDuration.minutes} hours={eventDuration.hours}
seconds={eventDuration.seconds} minutes={eventDuration.minutes}
onChange={setEventDuration} seconds={eventDuration.seconds}
/> onChange={setEventDuration}
/>
)}
<NumberInput {mode !== 'shooting-interval' && (
label="Frames per second" <NumberInput
value={fps} label="Shooting Interval (sec)"
onChange={setFps} value={intervalInput}
unit="fps" onChange={setIntervalInput}
min={1} unit="s"
step={1} min={0.1}
/> step={0.1}
/>
)}
<NumberInput <div className="form-row">
label="Image size" <label className="form-label">Frames per second</label>
value={imageSize} <Segmented
onChange={setImageSize} options={[
unit="MB" { label: '24', value: 24 },
min={0.1} { label: '30', value: 30 },
step={0.1} { label: '60', value: 60 },
/> ]}
value={fps}
onChange={setFps}
block
size="large"
style={{
backgroundColor: '#F8F9FA',
padding: 4,
borderRadius: 8,
border: '1px solid #DEE2E6'
}}
/>
</div>
<Results <Results
shootingInterval={shootingInterval} label={getResultLabel()}
resultSeconds={resultSeconds}
numberOfPhotos={numberOfPhotos} numberOfPhotos={numberOfPhotos}
totalMemory={totalMemory}
/> />
</Card> </Card>
</div> </div>

View File

@@ -1,22 +1,27 @@
import { Select } from 'antd'; import { Segmented } from 'antd';
export default function CalculatorMode({ value, onChange }) { export default function CalculatorMode({ value, onChange }) {
return ( return (
<div className="form-row"> <div className="form-row">
<label htmlFor="calculator-mode" className="form-label"> <label className="form-label" style={{ display: 'block', marginBottom: 8 }}>
Calculate Calculate
</label> </label>
<Select <Segmented
id="calculator-mode"
value={value} value={value}
onChange={onChange} onChange={onChange}
block
size="large" size="large"
style={{ width: '100%' }}
options={[ options={[
{ value: 'shooting-interval', label: 'Shooting interval' }, { value: 'shooting-interval', label: 'Interval' },
{ value: 'clip-length', label: 'Clip length' }, { value: 'clip-length', label: 'Clip' },
{ value: 'event-duration', label: 'Event duration' }, { value: 'event-duration', label: 'Event' },
]} ]}
style={{
backgroundColor: '#F8F9FA',
padding: 4,
borderRadius: 8,
border: '1px solid #DEE2E6'
}}
/> />
</div> </div>
); );

View File

@@ -1,4 +1,4 @@
import { InputNumber } from 'antd'; import { InputNumber, Button, Flex } from 'antd';
export default function NumberInput({ label, value, onChange, unit, min = 0, step = 1 }) { export default function NumberInput({ label, value, onChange, unit, min = 0, step = 1 }) {
const handleChange = (newValue) => { const handleChange = (newValue) => {
@@ -6,20 +6,45 @@ export default function NumberInput({ label, value, onChange, unit, min = 0, ste
onChange(numValue); onChange(numValue);
}; };
const handleDecrement = () => {
handleChange(value - step);
};
const handleIncrement = () => {
handleChange(value + step);
};
return ( return (
<div className="form-row"> <div className="form-row">
<label className="form-label">{label}</label> <label className="form-label">{label}</label>
<InputNumber <Flex gap="small">
value={value} <Button
onChange={handleChange} onClick={handleDecrement}
min={min} size="large"
step={step} disabled={value <= min}
placeholder="0" style={{ width: 44, padding: 0 }}
addonAfter={unit} >
size="large" -
style={{ width: '100%' }} </Button>
controls={false} <InputNumber
/> value={value}
onChange={handleChange}
min={min}
step={step}
placeholder="0"
suffix={<span style={{ color: '#999' }}>{unit}</span>}
size="large"
style={{ flex: 1 }}
controls={false}
/>
<Button
onClick={handleIncrement}
size="large"
style={{ width: 44, padding: 0 }}
>
+
</Button>
</Flex>
</div> </div>
); );
} }

View File

@@ -1,9 +1,11 @@
import { Statistic, Row, Col } from 'antd'; import { Statistic, Row, Col } from 'antd';
export default function Results({ shootingInterval, numberOfPhotos, totalMemory }) { export default function Results({ label, resultSeconds, numberOfPhotos }) {
const formatTime = (seconds) => { const formatTime = (seconds) => {
if (!seconds) return '0s';
if (seconds < 60) { if (seconds < 60) {
return `${seconds.toFixed(0)}s`; // For interval, we might want decimals. For others, maybe not strictly required but harmless.
return `${parseFloat(seconds.toFixed(1))}s`;
} else if (seconds < 3600) { } else if (seconds < 3600) {
const mins = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60); const secs = Math.floor(seconds % 60);
@@ -16,38 +18,23 @@ export default function Results({ shootingInterval, numberOfPhotos, totalMemory
} }
}; };
const formatMemory = (mb) => {
if (mb < 1024) {
return `${mb.toFixed(2)}MB`;
} else {
return `${(mb / 1024).toFixed(2)}GB`;
}
};
return ( return (
<div className="results-section"> <div className="results-section">
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col xs={24} sm={8}> <Col xs={24} sm={12}>
<Statistic <Statistic
title="Shooting interval" title={label}
value={formatTime(shootingInterval)} value={formatTime(resultSeconds)}
valueStyle={{ color: '#2C3E50', fontSize: '1.5rem', fontWeight: 600 }} valueStyle={{ color: '#2C3E50', fontSize: '1.5rem', fontWeight: 600 }}
/> />
</Col> </Col>
<Col xs={24} sm={8}> <Col xs={24} sm={12}>
<Statistic <Statistic
title="Number of photos" title="Number of photos"
value={numberOfPhotos} value={numberOfPhotos}
valueStyle={{ color: '#2C3E50', fontSize: '1.5rem', fontWeight: 600 }} valueStyle={{ color: '#2C3E50', fontSize: '1.5rem', fontWeight: 600 }}
/> />
</Col> </Col>
<Col xs={24} sm={8}>
<Statistic
title="Total memory usage"
value={formatMemory(totalMemory)}
valueStyle={{ color: '#2C3E50', fontSize: '1.5rem', fontWeight: 600 }}
/>
</Col>
</Row> </Row>
</div> </div>
); );

View File

@@ -20,7 +20,7 @@ export default function TimeInput({ label, hours, minutes, seconds, onChange })
onChange={(value) => handleChange('hours', value)} onChange={(value) => handleChange('hours', value)}
min={0} min={0}
placeholder="0" placeholder="0"
addonAfter="h" suffix={<span style={{ color: '#999' }}>h</span>}
size="large" size="large"
style={{ width: '33.33%' }} style={{ width: '33.33%' }}
controls={false} controls={false}
@@ -31,7 +31,7 @@ export default function TimeInput({ label, hours, minutes, seconds, onChange })
min={0} min={0}
max={59} max={59}
placeholder="0" placeholder="0"
addonAfter="m" suffix={<span style={{ color: '#999' }}>m</span>}
size="large" size="large"
style={{ width: '33.33%' }} style={{ width: '33.33%' }}
controls={false} controls={false}
@@ -42,7 +42,7 @@ export default function TimeInput({ label, hours, minutes, seconds, onChange })
min={0} min={0}
max={59} max={59}
placeholder="0" placeholder="0"
addonAfter="s" suffix={<span style={{ color: '#999' }}>s</span>}
size="large" size="large"
style={{ width: '33.34%' }} style={{ width: '33.34%' }}
controls={false} controls={false}