Compare commits

..

13 Commits

Author SHA1 Message Date
Nikolai Giman b9ce7261f0 chore: version 2026-07-11 23:25:00 +02:00
Nikolai Giman 83979ac47c chore: docs 2026-07-11 22:39:47 +02:00
Nikolai Giman a9be0d987a feat: set default project 2026-07-11 22:18:36 +02:00
Nikolai Giman 30274069a0 feat: set base api url from env 2026-07-11 21:49:10 +02:00
Nikolai Giman 0a8497af59 feat: edit default user and change password 2026-07-11 20:29:27 +02:00
Nikolai Giman 284a49ce8c fix: task id and search 2026-07-11 14:43:52 +02:00
Nikolai Giman 64a227303e Merge pull request #80 from Gimanh/feat/mobile-widgets
feat: mobile widgets
2026-07-11 10:59:14 +02:00
Nikolai Giman ae88d0f42a feat: mobile widgets 2026-07-11 10:49:38 +02:00
Nikolai Giman d40cc0aa1b Merge pull request #79 from Gimanh/fix/settings-ui
chore: version
2026-07-07 23:23:08 +02:00
Nikolai Giman e99d9d5515 chore: version 2026-07-07 23:20:02 +02:00
Nikolai Giman 9b38e3cd9d Merge pull request #78 from Gimanh/fix/77
fix: #77
2026-07-07 00:19:14 +02:00
Nikolai Giman 9c6d33cefe fix: #77 2026-07-07 00:16:26 +02:00
Nikolai Giman d008fa4f78 Merge pull request #75 from Gimanh/chore/v-1-49-0
chore: version 1.49.0
2026-07-06 00:11:07 +02:00
120 changed files with 4339 additions and 455 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-api-server",
"version": "1.49.0",
"version": "1.50.1",
"scripts": {
"dev": "bun run --watch ./server.ts",
"start": "NODE_ENV=production node ./dist/taskview-server.js",
+13
View File
@@ -5,6 +5,7 @@ import { corsMiddleware } from './middlewares/cors';
import errorHandler from './middlewares/error-handler';
import routes from './routes';
import passport, { initPassportLogin } from './tv-modules/auth/strategies/passport-login';
import { LoginMethods } from './tv-modules/auth/LoginMethods';
import cookieParser from 'cookie-parser';
import { registerAllEventHandlers, startAllWorkers } from './core/all-events';
@@ -13,6 +14,8 @@ export default class App {
public port: number;
constructor(port: number) {
LoginMethods.validateOnStartup();
this.app = express();
this.port = port;
@@ -31,7 +34,17 @@ export default class App {
protected extendApp(): void { }
protected extendMiddlewares(): void { }
private resolveTrustProxy(): boolean | number | string {
const raw = process.env.TRUST_PROXY?.trim();
if (!raw || raw.toLowerCase() === 'false') return false;
if (raw.toLowerCase() === 'true') return true;
if (/^\d+$/.test(raw)) return Number(raw);
return raw;
}
private initializeMiddlewares() {
this.app.set('trust proxy', this.resolveTrustProxy());
//add tvJson method, clien need response format like {response: data}
this.app.use((_req: Request, res: Response, next) => {
res.tvJson = function (data: any) {
@@ -2,7 +2,7 @@ import type { AnalyticsDataset, AnalyticsSeriesPayload, LocalizedText } from 'ta
import type { AmountPerTagMonthSectionRow } from '../row.types'
import { UNTAGGED_TAG_ID } from '../../types'
const UNTAGGED_LABEL: LocalizedText = { ru: 'Без тегов', en: 'Untagged' }
const UNTAGGED_LABEL: LocalizedText = { ru: 'Без тегов', en: 'Untagged', de: 'Ohne Tags', es: 'Sin etiquetas' }
export type BuildTagAmountPayloadArgs = {
rows: AmountPerTagMonthSectionRow[]
File diff suppressed because it is too large Load Diff
+190
View File
@@ -6,7 +6,11 @@ import { z } from 'zod';
import { Email } from '../../core/Email';
import { $logger } from '../../modules/logget';
import {
ChangeDefaultUserCredentialsSchema,
ChangeOwnPasswordByPasswordSchema,
ChangeOwnPasswordSchema,
ChangePasswordDataScheme,
type PasswordChangeConfirmationMode,
ConfirmEmailReqDataSchema,
RefreshTokenSchema,
RemindPasswordSchema,
@@ -15,6 +19,7 @@ import {
UserJwtPayloadSchema,
} from '../../types/auth.types';
import { generateString, isEmail, time } from '../../utils/helpers';
import { LoginMethods } from './LoginMethods';
import EnEmailTemplate from './mail/confirm-email-en';
import RuEmailTemplate from './mail/confirm-email-ru';
import LoginCodeEmailTemplate from './mail/login-code-en';
@@ -23,6 +28,10 @@ import { OrganizationRepository } from '../organizations/OrganizationRepository'
import { GoalsRepository } from '../goals/GoalsRepository';
const LOGIN_CODE_TTL_MS = 5 * 60 * 1000;
const PASSWORD_CHANGE_CODE_TTL_S = 15 * 60;
const PASSWORD_CHANGE_CODE_RESEND_COOLDOWN_S = 60;
// Seeded by migration 0.0.0 (app_permissions.sql) on self-hosted installs.
const DEFAULT_USER_EMAIL = 'test@mail.dest';
export default class AuthController {
private readonly jwtAlg: Algorithm = process.env.JWT_ALG as Algorithm;
@@ -656,6 +665,187 @@ export default class AuthController {
return res.json(newTokens);
};
getLoginOptions = async (_req: Request, res: Response) => {
return res.status(200).send({
magicLink: LoginMethods.isEnabled('magic-link'),
password: LoginMethods.isEnabled('password'),
sso: LoginMethods.isEnabled('sso'),
socialProviders: LoginMethods.availableSocialProviders(),
});
};
private passwordChangeConfirmationMode(): PasswordChangeConfirmationMode {
return process.env.PASSWORD_CHANGE_CONFIRMATION === 'password' ? 'password' : 'email';
}
getPasswordChangeMode = async (_req: Request, res: Response) => {
return res.status(200).send({ mode: this.passwordChangeConfirmationMode() });
};
sendPasswordChangeCode = async (req: Request, res: Response) => {
if (this.passwordChangeConfirmationMode() !== 'email') {
return res.status(403).send();
}
const userEmail = req.appUser.getUserData()?.email;
if (!userEmail) {
return res.status(400).end();
}
const userData = await req.appUser.authManager.repository.getUserByLogin(userEmail, true);
if (!userData) {
return res.status(400).end();
}
const now = Math.floor(Date.now() / 1000);
const sinceLastCode = userData.remind_password_time ? now - userData.remind_password_time : null;
if (sinceLastCode !== null && sinceLastCode < PASSWORD_CHANGE_CODE_RESEND_COOLDOWN_S) {
return res.status(429).send({
message: 'Please wait before requesting another code.',
retryAfter: PASSWORD_CHANGE_CODE_RESEND_COOLDOWN_S - sinceLastCode,
});
}
// High-entropy code: the shared remind_password_code column is also redeemable
// via the unauthenticated /password/reset endpoint, so a short numeric code
// would be brute-forceable there.
const code = generateString(12);
const saved = await req.appUser.authManager.repository.setReminderCodeAndTime(userEmail, code, now);
if (!saved) {
$logger.error(`Can not save password change code for user ${userData.id}`);
return res.status(500).end();
}
const text = `Your TaskView password change code is ${code}\n\nUse this code to confirm your new password. The code expires in 15 minutes.\n\nIf you didn't request this change, ignore this email.`;
Email.send({
text,
to: userEmail,
subject: `Your TaskView password change code: ${code}`,
from: process.env.SMTP_FROM_EMAIL as string,
})
.then((ok) => {
if (!ok) $logger.error({ to: userEmail }, 'Failed to send password change code email');
})
.catch((err) => $logger.error({ err, to: userEmail }, 'Failed to send password change code email'));
return res.status(200).end();
};
changeOwnPassword = async (req: Request, res: Response) => {
const userEmail = req.appUser.getUserData()?.email;
if (!userEmail) {
return res.status(400).end();
}
const userData = await req.appUser.authManager.repository.getUserByLogin(userEmail, true);
if (!userData) {
return res.status(400).send();
}
if (this.passwordChangeConfirmationMode() === 'password') {
const parsedData = ChangeOwnPasswordByPasswordSchema.safeParse(req.body);
if (!parsedData.success) {
return res.status(400).send();
}
const validPassword = await this.comparePasswords(parsedData.data.currentPassword, userData.password);
if (!validPassword) {
return res.status(403).send({ field: 'currentPassword' });
}
return this.applyNewPassword(res, req, userData.id, parsedData.data.password);
}
const parsedData = ChangeOwnPasswordSchema.safeParse(req.body);
if (!parsedData.success) {
return res.status(400).send();
}
if (!userData.remind_password_code || !userData.remind_password_time) {
return res.status(400).send();
}
const now = Math.floor(Date.now() / 1000);
if (now > userData.remind_password_time + PASSWORD_CHANGE_CODE_TTL_S) {
return res.status(400).send();
}
if (userData.remind_password_code !== parsedData.data.code) {
return res.status(400).send();
}
await req.appUser.authManager.repository.setReminderCodeAndTime(userEmail, null, null);
return this.applyNewPassword(res, req, userData.id, parsedData.data.password);
};
private async applyNewPassword(res: Response, req: Request, userId: number, newPassword: string) {
const passwordHash = hashSync(newPassword, 10);
const result = await req.appUser.authManager.repository.updateUserPassword(passwordHash, userId);
if (!result) {
$logger.error(`Can not update password for user ${userId}`);
return res.status(500).send();
}
const currentSessionId = req.appUser.getTokenId();
await req.appUser.authManager.sessionStorage.deleteAllSessions(userId, currentSessionId);
return res.status(200).send({ changed: true });
}
changeDefaultUserCredentials = async (req: Request, res: Response) => {
const parsedData = ChangeDefaultUserCredentialsSchema.safeParse(req.body);
if (!parsedData.success) {
return res.status(400).send();
}
const userEmail = req.appUser.getUserData()?.email;
if (!userEmail) {
return res.status(400).end();
}
const userData = await req.appUser.authManager.repository.getUserByLogin(userEmail, true);
if (!userData || userData.email.toLowerCase() !== DEFAULT_USER_EMAIL) {
return res.status(403).send();
}
const validPassword = await this.comparePasswords(parsedData.data.currentPassword, userData.password);
if (!validPassword) {
return res.status(403).send({ field: 'currentPassword' });
}
const { login, email } = parsedData.data;
if (login !== userData.login && (await req.appUser.authManager.repository.getUserByLogin(login))) {
return res.status(409).send({ field: 'login' });
}
if (email !== userData.email && (await req.appUser.authManager.repository.getUserByLogin(email, true))) {
return res.status(409).send({ field: 'email' });
}
const updated = await req.appUser.authManager.repository.updateUserCredentials({
userId: userData.id,
oldEmail: userData.email,
login,
email,
passwordHash: hashSync(parsedData.data.password, 10),
});
if (updated === 'conflict') {
return res.status(409).send({ field: 'email' });
}
if (updated !== 'ok') {
return res.status(500).send();
}
// JWTs carry login/email and refresh does not re-read them from the DB,
// so drop every session and make the user sign in with the new credentials.
await req.appUser.authManager.sessionStorage.deleteAllSessions(userData.id);
this.clearRefreshToken(res);
return res.status(200).send({ changed: true });
};
sendDeleteAccountCode = async (req: Request, res: Response) => {
const userId = req.appUser.getUserData()?.id;
const userEmail = req.appUser.getUserData()?.email;
+35 -1
View File
@@ -1,6 +1,8 @@
import { eq } from 'drizzle-orm';
import { CollaborationUsersSchema, OrganizationMembersSchema, SsoIdentitiesSchema, UsersSchema } from 'taskview-db-schemas';
import { Database } from '../../modules/db';
import { $logger } from '../../modules/logget';
import type { RegisterUserInDb, UserDbRecord } from '../../types/auth.types';
import type { RegisterUserInDb, UpdateUserCredentialsArgs, UpdateUserCredentialsResult, UserDbRecord } from '../../types/auth.types';
export default class AuthModel {
private readonly db: Database;
@@ -133,6 +135,38 @@ export default class AuthModel {
}
}
async updateUserCredentials(args: UpdateUserCredentialsArgs): Promise<UpdateUserCredentialsResult> {
try {
await this.db.dbDrizzle.transaction(async (tx) => {
await tx
.update(UsersSchema)
.set({ login: args.login, email: args.email, password: args.passwordHash })
.where(eq(UsersSchema.id, args.userId));
await tx
.update(OrganizationMembersSchema)
.set({ email: args.email })
.where(eq(OrganizationMembersSchema.email, args.oldEmail));
await tx
.update(CollaborationUsersSchema)
.set({ email: args.email })
.where(eq(CollaborationUsersSchema.email, args.oldEmail));
await tx
.update(SsoIdentitiesSchema)
.set({ email: args.email })
.where(eq(SsoIdentitiesSchema.userId, args.userId));
});
return 'ok';
} catch (error) {
// unique(organization_id, email): the new email is already an invited member of one of the user's orgs
const pgCode = (error as { code?: string })?.code ?? (error as { cause?: { code?: string } })?.cause?.code;
if (pgCode === '23505') {
return 'conflict';
}
$logger.error(error, `Can not update credentials for user ${args.userId}`);
return 'error';
}
}
async updateUserPassword(password: string, userId: number): Promise<boolean> {
try {
const query = 'UPDATE tv_auth.users SET password = $1 WHERE id = $2';
+15 -5
View File
@@ -2,6 +2,8 @@ import { Router, type NextFunction, type Request, type Response } from 'express'
import type { Routable } from '../../types/routable.type';
import AuthController from './AuthController';
import { IsLoggedIn } from './middlewares/is-logged-in';
import { RejectApiTokenAuth } from '../api-tokens/middlewares/RejectApiTokenAuth';
import { RequireLoginMethod, RequireSocialProvider } from './middlewares/require-login-method';
import passport from './strategies/passport-login';
import { ExternalProviderScope } from './strategies/external-auth.types';
export default class AuthRoutes implements Routable {
@@ -19,13 +21,18 @@ export default class AuthRoutes implements Routable {
}
initRoutes() {
this.router.post('/send-login-code', this.authController.sendLoginCode);
this.router.post('/login-by-code', this.authController.loginByCode);
this.router.post('/login', this.authController.login);
this.router.get('/login-options', this.authController.getLoginOptions);
this.router.post('/send-login-code', [RequireLoginMethod('magic-link')], this.authController.sendLoginCode);
this.router.post('/login-by-code', [RequireLoginMethod('magic-link')], this.authController.loginByCode);
this.router.post('/login', [RequireLoginMethod('password')], this.authController.login);
this.router.post('/registration', this.authController.registration);
this.router.get('/confirm/email/:code/login/:login', this.authController.confirmEmail);
this.router.post('/email/recovery', this.authController.remindPassword);
this.router.post('/password/reset', this.authController.changeRemindedPassword);
this.router.post('/email/recovery', [RequireLoginMethod('password')], this.authController.remindPassword);
this.router.post('/password/reset', [RequireLoginMethod('password')], this.authController.changeRemindedPassword);
this.router.get('/password/change/mode', [IsLoggedIn], this.authController.getPasswordChangeMode);
this.router.post('/password/change/code', [IsLoggedIn, RejectApiTokenAuth], this.authController.sendPasswordChangeCode);
this.router.post('/password/change', [IsLoggedIn, RejectApiTokenAuth], this.authController.changeOwnPassword);
this.router.post('/credentials/change', [IsLoggedIn, RejectApiTokenAuth], this.authController.changeDefaultUserCredentials);
this.router.post('/logout', [IsLoggedIn], this.authController.logout);
this.router.post('/refresh/token', this.authController.refreshTokens);
this.router.post('/delete/account/code', [IsLoggedIn], this.authController.sendDeleteAccountCode);
@@ -33,6 +40,7 @@ export default class AuthRoutes implements Routable {
this.router.get(
'/provider/:providerName',
RequireSocialProvider,
(req: Request, res: Response, next: NextFunction) => passport.authenticate(req.params.providerName, {
scope: ExternalProviderScope[req.params.providerName],
session: false,
@@ -43,6 +51,7 @@ export default class AuthRoutes implements Routable {
);
this.router.get(
'/provider/:providerName/callback',
RequireSocialProvider,
(req: Request, res: Response, next: NextFunction) => passport.authenticate(req.params.providerName, {
scope: ExternalProviderScope[req.params.providerName], session: false
})(req, res, next),
@@ -51,6 +60,7 @@ export default class AuthRoutes implements Routable {
this.router.post(
'/provider/:providerName/callback',
RequireSocialProvider,
(req: Request, res: Response, next: NextFunction) => passport.authenticate(req.params.providerName, {
scope: ExternalProviderScope[req.params.providerName], session: false
})(req, res, next),
+66
View File
@@ -0,0 +1,66 @@
import type { LoginMethod } from '../../types/auth.types';
export class LoginMethods {
static readonly ALL: LoginMethod[] = ['magic-link', 'password', 'sso', 'social'];
static enabled(): Set<LoginMethod> {
const raw = process.env.AUTH_LOGIN_METHODS;
if (!raw || !raw.trim()) {
return new Set(LoginMethods.ALL);
}
return new Set(LoginMethods.parse(raw).valid);
}
static isEnabled(method: LoginMethod): boolean {
return LoginMethods.enabled().has(method);
}
static validateOnStartup(): void {
const raw = process.env.AUTH_LOGIN_METHODS;
if (!raw || !raw.trim()) return;
const { valid, invalid } = LoginMethods.parse(raw);
if (invalid.length > 0) {
throw new Error(
`AUTH_LOGIN_METHODS contains unknown values: ${invalid.join(', ')}. Allowed: ${LoginMethods.ALL.join(', ')}`
);
}
if (valid.length === 0) {
throw new Error('AUTH_LOGIN_METHODS disables every login method — nobody would be able to sign in');
}
}
static configuredSocialProviders(): string[] {
const providers: string[] = [];
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET && process.env.GOOGLE_CALLBACK_URL) {
providers.push('google');
}
if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET && process.env.GITHUB_CALLBACK_URL) {
providers.push('github');
}
if (
process.env.APPLE_CLIENT_ID &&
process.env.APPLE_TEAM_ID &&
process.env.APPLE_KEY_ID &&
process.env.APPLE_CALLBACK_URL &&
process.env.APPLE_KEY_LOCATION
) {
providers.push('apple');
}
return providers;
}
static availableSocialProviders(): string[] {
return LoginMethods.isEnabled('social') ? LoginMethods.configuredSocialProviders() : [];
}
private static parse(raw: string): { valid: LoginMethod[]; invalid: string[] } {
const values = raw
.split(',')
.map((value) => value.trim().toLowerCase())
.filter(Boolean);
const valid = values.filter((value): value is LoginMethod => (LoginMethods.ALL as string[]).includes(value));
const invalid = values.filter((value) => !(LoginMethods.ALL as string[]).includes(value));
return { valid, invalid };
}
}
@@ -0,0 +1,20 @@
import type { NextFunction, Request, Response } from 'express';
import type { LoginMethod } from '../../../types/auth.types';
import { LoginMethods } from '../LoginMethods';
export const RequireLoginMethod = (method: LoginMethod) => {
return (_req: Request, res: Response, next: NextFunction) => {
if (!LoginMethods.isEnabled(method)) {
return res.status(403).send();
}
return next();
};
};
export const RequireSocialProvider = (req: Request, res: Response, next: NextFunction) => {
const providerName = String(req.params.providerName || '').toLowerCase();
if (!LoginMethods.availableSocialProviders().includes(providerName)) {
return res.status(403).send();
}
return next();
};
@@ -16,8 +16,7 @@ export function initAppleStrategy() {
!process.env.APPLE_KEY_ID ||
!process.env.APPLE_CALLBACK_URL ||
!process.env.APPLE_KEY_LOCATION) {
$logger.warn("APPLE_CLIENT_ID, APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_CALLBACK_URL, and APPLE_KEY_LOCATION must be set");
console.warn("APPLE_CLIENT_ID, APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_CALLBACK_URL, and APPLE_KEY_LOCATION must be set");
$logger.debug("Apple login is not configured (APPLE_CLIENT_ID / APPLE_TEAM_ID / APPLE_KEY_ID / APPLE_CALLBACK_URL / APPLE_KEY_LOCATION) — skipping");
return;
}
@@ -7,8 +7,7 @@ import type { VerifyCallback } from "passport-google-oauth20";
export function initGithubStrategy() {
if (!process.env.GITHUB_CLIENT_ID || !process.env.GITHUB_CLIENT_SECRET || !process.env.GITHUB_CALLBACK_URL) {
$logger.warn("GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, and GITHUB_CALLBACK_URL must be set");
console.warn("GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, and GITHUB_CALLBACK_URL must be set");
$logger.debug("GitHub login is not configured (GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET / GITHUB_CALLBACK_URL) — skipping");
return;
}
@@ -5,8 +5,7 @@ import type { ExternalAuthUser } from "./external-auth.types";
export function initGoogleStrategy() {
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET || !process.env.GOOGLE_CALLBACK_URL) {
$logger.warn("GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and GOOGLE_CALLBACK_URL must be set");
console.warn("GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and GOOGLE_CALLBACK_URL must be set");
$logger.debug("Google login is not configured (GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET / GOOGLE_CALLBACK_URL) — skipping");
return;
}
const options = {
+5 -4
View File
@@ -3,6 +3,7 @@ import type { Routable } from '../../types/routable.type'
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
import { IsOrgAdmin } from '../organizations/middlewares/IsOrgAdmin'
import { IsSsoConfigAdmin } from './middlewares/IsSsoConfigAdmin'
import { RequireLoginMethod } from '../auth/middlewares/require-login-method'
import { SsoController } from './SsoController'
export default class SsoRoutes implements Routable {
@@ -20,10 +21,10 @@ export default class SsoRoutes implements Routable {
}
initRoutes() {
this.router.get('/providers', this.controller.listPublicProviders)
this.router.get('/login/:configId', this.controller.initiateLogin)
this.router.get('/callback/:configId', this.controller.handleCallback)
this.router.post('/callback/:configId', this.controller.handleCallback)
this.router.get('/providers', [RequireLoginMethod('sso')], this.controller.listPublicProviders)
this.router.get('/login/:configId', [RequireLoginMethod('sso')], this.controller.initiateLogin)
this.router.get('/callback/:configId', [RequireLoginMethod('sso')], this.controller.handleCallback)
this.router.post('/callback/:configId', [RequireLoginMethod('sso')], this.controller.handleCallback)
this.router.get('/admin/metadata', [IsLoggedIn, IsOrgAdmin], this.controller.parseMetadata)
this.router.get('/admin/configs', [IsLoggedIn, IsOrgAdmin], this.controller.listConfigs)
@@ -110,7 +110,8 @@ export class OidcProvider implements SsoProvider {
throw new Error('CSRF state mismatch — possible CSRF attack')
}
const currentUrl = new URL(req.originalUrl, `${req.protocol}://${req.get('host')}`)
const callbackOrigin = new URL(this.config.oidcCallbackUrl!).origin
const currentUrl = new URL(req.originalUrl, callbackOrigin)
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
pkceCodeVerifier: codeVerifier,
expectedState: returnedState,
+1 -1
View File
@@ -89,7 +89,7 @@ export class StartManager {
await this.fetchSharedGoals(organizationId);
const goalIds = await this.getAllGoalsIds(organizationId);
const tasks = await this.repository.searchTask(description.trim(), goalIds);
const tasks = await this.repository.searchTask({ description, goalsIds: goalIds });
return tasks;
}
}
+27 -20
View File
@@ -1,5 +1,5 @@
import { and, eq, inArray, isNotNull, or } from 'drizzle-orm';
import { GoalsSchema, GoalsListSchema } from 'taskview-db-schemas';
import { and, eq, ilike, inArray, isNotNull, isNull, or } from 'drizzle-orm';
import { GoalsSchema, GoalsListSchema, TasksSchema } from 'taskview-db-schemas';
import type { AppUser } from '../../core/AppUser';
import { Database } from '../../modules/db';
import { $logger } from '../../modules/logget';
@@ -8,7 +8,7 @@ import { logError } from '../../utils/api';
import { callWithCatch } from '../../utils/helpers';
import type { TagToTaskInDb } from '../tags/tags.types';
import { TaskItemForClient } from '../tasks/TaskItemForClient';
import type { AssigneesForTaskFromDb, FetchAllListsResult, UsersByProjectsFromDb } from './start.types';
import type { AssigneesForTaskFromDb, FetchAllListsResult, SearchTaskArgs, SearchTaskResult, UsersByProjectsFromDb } from './start.types';
//TODO: refactor
export class StartRepository {
@@ -371,31 +371,38 @@ export class StartRepository {
return [...taskIdToTaskMap.values()];
}
async searchTask(description: string, goalsIds: number[]): Promise<TaskItemForClient[]> {
if (goalsIds.length === 0 || !description.trim()) {
async searchTask(args: SearchTaskArgs): Promise<SearchTaskResult[]> {
const description = args.description.trim();
if (args.goalsIds.length === 0 || !description) {
return [];
}
const placeholders = goalsIds.map((_id, index) => {
return `$${index + 1}`;
});
const result = await this.db.query<TaskItemInDb>(
`select * from tasks.tasks where goal_id in (${placeholders.join(',')}) and complete = FALSE and parent_id is null and description ILIKE $${goalsIds.length + 1}`,
[...goalsIds, `%${description}%`]
const idMatch = description.match(/^#(\d+)$/);
const searchCondition = idMatch
? eq(TasksSchema.id, Number(idMatch[1]))
: and(
eq(TasksSchema.complete, false),
isNull(TasksSchema.parentId),
ilike(TasksSchema.description, `%${description}%`),
);
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(TasksSchema)
.where(and(inArray(TasksSchema.goalId, args.goalsIds), searchCondition))
);
if (!result) {
return [];
}
const map: Map<number, TaskItemForClient> = new Map();
result.rows.forEach((t) => {
if (!map.get(t.id)) {
map.set(t.id, new TaskItemForClient(t));
}
});
return [...map.values()];
return result.map((task) => ({
...task,
tags: [],
assignedUsers: [],
historyId: null,
subtasks: [],
}));
}
}
+14
View File
@@ -1,3 +1,17 @@
import type { TasksSchemaTypeForSelect } from 'taskview-db-schemas';
export type SearchTaskArgs = {
description: string;
goalsIds: number[];
};
export type SearchTaskResult = TasksSchemaTypeForSelect & {
tags: number[];
assignedUsers: number[];
historyId: number | null;
subtasks: SearchTaskResult[];
};
export type FetchAllListsResult = {
goalName: string | null;
listName: string | null;
@@ -44,6 +44,8 @@ const firstDayOfWeekArkType = type('number.integer').narrow((v, ctx) =>
export const UiSettingsArkType = type({
'firstDayOfWeek?': firstDayOfWeekArkType,
'defaultProjectId?': 'number.integer >= 1',
'defaultView?': "'tasks' | 'kanban' | 'graph' | 'sprints'",
})
export type UiSettings = typeof UiSettingsArkType.infer
+6
View File
@@ -23,6 +23,12 @@ export const AppEnvSchema = z.object({
SMTP_FROM_EMAIL: z.string().optional(),
APP_URL: z.string(),
// How account password changes are confirmed: code sent by email (default) or current password
PASSWORD_CHANGE_CONFIRMATION: z.enum(['email', 'password']).optional(),
// Comma-separated list of enabled login methods (magic-link, password, sso, social); unset = all enabled
AUTH_LOGIN_METHODS: z.string().optional(),
TELEGRAM_BOT_TOKEN: z.string().optional(),
TELEGRAM_BOT_USERNAME: z.string().optional(),
TELEGRAM_WEBHOOK_SECRET: z.string().optional(),
+55
View File
@@ -62,6 +62,61 @@ export const ChangePasswordDataScheme = z
export type ChangePasswordData = z.infer<typeof ChangePasswordDataScheme>;
export const ChangeOwnPasswordSchema = z
.object({
code: z.string().min(1).max(64),
password: z.string().min(6).max(128),
passwordRepeat: z.string().max(128),
})
.refine((data) => data.password === data.passwordRepeat, {
message: "Passwords don't match",
path: ['passwordRepeat'],
});
export type ChangeOwnPassword = z.infer<typeof ChangeOwnPasswordSchema>;
export const ChangeOwnPasswordByPasswordSchema = z
.object({
currentPassword: z.string().min(1).max(128),
password: z.string().min(6).max(128),
passwordRepeat: z.string().max(128),
})
.refine((data) => data.password === data.passwordRepeat, {
message: "Passwords don't match",
path: ['passwordRepeat'],
});
export type ChangeOwnPasswordByPassword = z.infer<typeof ChangeOwnPasswordByPasswordSchema>;
export type PasswordChangeConfirmationMode = 'email' | 'password';
export type LoginMethod = 'magic-link' | 'password' | 'sso' | 'social';
export const ChangeDefaultUserCredentialsSchema = z
.object({
currentPassword: z.string().min(1).max(128),
login: z.string().min(3).max(64).regex(/^[a-zA-Z0-9._-]+$/).toLowerCase(),
email: z.string().email().max(255).toLowerCase(),
password: z.string().min(6).max(128),
passwordRepeat: z.string().max(128),
})
.refine((data) => data.password === data.passwordRepeat, {
message: "Passwords don't match",
path: ['passwordRepeat'],
});
export type ChangeDefaultUserCredentials = z.infer<typeof ChangeDefaultUserCredentialsSchema>;
export type UpdateUserCredentialsArgs = {
userId: number;
oldEmail: string;
login: string;
email: string;
passwordHash: string;
};
export type UpdateUserCredentialsResult = 'ok' | 'conflict' | 'error';
export const RefreshTokenSchema = z.object({
refreshToken: z.string(),
});
+23 -22
View File
@@ -144,6 +144,10 @@ services:
taskview-webapp:
image: gimanhead/taskview-ce-webapp:latest
restart: unless-stopped
environment:
# The web app will always use this API server and hide the server selector on the login page.
# Remove this variable if you want to pick the API server manually on the login page.
TASKVIEW_API_URL: "http://localhost:1725"
ports:
- "8888:80"
# Enable for realtime notification read https://taskview.tech/docs/configuration/environment-variables#centrifugo-configuration-file
@@ -175,14 +179,16 @@ Go to [http://localhost:8888](http://localhost:8888) in your browser. You'll see
### Configure the API server
Before logging in, you need to tell the web app where the API server is running. Click the **server settings** icon on the login page and add the API server URL:
The web app (port 8888) serves the frontend, while the API server (port 1725) handles authentication, projects, tasks, and all backend operations.
If you set `TASKVIEW_API_URL` on the `taskview-webapp` service (as in the compose file above), there is nothing to configure — the web app already knows where the API is, and the server selector is hidden from the login page.
Without `TASKVIEW_API_URL`, click the **server settings** section on the login page and add the API server URL manually:
```
http://localhost:1725
```
This is the API server that handles authentication, projects, tasks, and all backend operations. The web app (port 8888) serves the frontend, while the API server (port 1725) handles the data.
### Log in with the default user
The database migration creates a default user so you can log in right away:
@@ -193,31 +199,25 @@ The database migration creates a default user so you can log in right away:
Use these credentials to verify that everything is working - check that the UI loads, you can create a project, add tasks, etc.
::callout{icon="i-lucide-alert-triangle" color="error"}
**Important:** The default user is for initial setup only. Once you've confirmed the system works, delete the default user and create your own account with a secure password.
**Important:** The default credentials are publicly known — anyone who has read this page can sign in to a fresh installation. Claim the account right after the first login.
::
### Replacing the default user
### Claim the default account
Make the default account your own — no SMTP or database access needed:
1. Log in with the default credentials
2. Register a new account with your real email and a strong password
3. Delete the default `admin` account
2. Open **Account settings** — the highlighted **Login and email** card is shown at the top (it is visible only to the default user)
3. Set your own login, email and a strong password, confirm with the current password (`user1!#Q`), and click **Save and sign out**
4. Sign in again with your new login and password
If you prefer to create the first user directly in the database, generate a password hash:
![The Login and email card in Account settings for claiming the default account](/taskview/change-def-account.png)
```ts
import { hashSync } from 'bcryptjs'
Your organizations, projects and permissions are preserved. Once the email is changed, the card disappears and the claim endpoint is disabled.
const passwordHash = hashSync('your-secure-password', 12)
console.log(passwordHash)
```
Or as a one-liner:
```bash
node -e "console.log(require('bcryptjs').hashSync('your-secure-password', 12))"
```
Then insert the user into the database with the generated hash.
::callout{icon="i-lucide-mail" color="info"}
Changing the password later requires a confirmation code sent by email. If your installation has no SMTP, set `PASSWORD_CHANGE_CONFIRMATION="password"` in `.env.taskview` so password changes are confirmed with the current password instead. See [Environment Variables](/docs/configuration/environment-variables#authentication).
::
## Updating
@@ -233,7 +233,8 @@ The migration container will automatically apply any new database changes on sta
## Production tips
- **Use a reverse proxy** (Nginx, Caddy, Traefik) to terminate SSL and serve everything over HTTPS
- **Update `APP_URL` and `API_URL`** in `.env.taskview` to match your production domain
- **Update `APP_URL`** in `.env.taskview` and `TASKVIEW_API_URL` on the webapp service to match your production domains
- **Trim the login page** — set `AUTH_LOGIN_METHODS` in `.env.taskview` to offer only the sign-in methods you actually use (e.g. `AUTH_LOGIN_METHODS="password"`). Google/GitHub/Apple buttons are shown only when the provider is configured.
- **Back up the database** - the `pgdata` volume contains all your data
- **Set `restart: unless-stopped`** on all services so they survive server reboots
- **SMTP setup** - add SMTP variables to `.env.taskview` if you want email features (password recovery, invitations). See [Configuration](/docs/configuration/environment-variables) for details.
+47
View File
@@ -0,0 +1,47 @@
---
title: UI Customization
description: Personalize TaskView per user - reorder and hide task fields and analytics blocks, set the first day of week, and pick a default project and view to open right after signing in.
navigation:
icon: i-lucide-sliders-horizontal
---
TaskView lets every user tune the interface to how they actually work. All settings on this page are personal — they are stored per user on the server and follow you across devices and browsers.
Open **Settings → UI customization** from the user menu.
## Reorder and hide items
Three sections are driven by drag-and-drop lists:
- **Tasks** — the fields shown in the task detail view (note, status, priority, assignees, tags, deadline, sprint, estimate, time tracking, history, …)
- **Analytics — Indicators** — the KPI tiles on the analytics page
- **Analytics — Charts** — the charts on the analytics page
For every item you can:
- **Reorder** — drag by the handle on the left
- **Show / hide** — toggle visibility
- **Narrow / wide** — for task fields, choose whether the field takes half or the full width of the detail view
All available items are listed here; permission checks still apply at render time, so an enabled item may stay hidden if you lack the permission to see it.
## Others
### First day of week
Sets which day calendars start on. Applies to all date pickers across the app. "Default" follows your locale.
### Default project and view
Normally, after signing in you land on the home screen and navigate to your project and board manually. If you always start in the same place, set it as the default:
- **Default project** — the project TaskView opens right after you sign in (or reopen the app with an active session)
- **Default view** — which view of that project to open: **Tasks**, **Kanban**, **Graph** or **Sprints**
When a default project is set, a quick-jump button with the project name also appears in the sidebar next to **Inbox** — click it from anywhere to return to your project in the chosen view.
If the default project is deleted or you lose access to it, TaskView falls back to the home screen. Choose "Home screen (default)" to turn the feature off.
## How it is stored
Preferences are saved automatically (no Save button) through the `ui-preferences` API and kept per user account. Resetting the browser or switching devices does not lose them.
@@ -25,6 +25,15 @@ These must match your PostgreSQL setup.
|---|---|---|---|
| `APP_PORT` | No | `1401` | Port the API server listens on |
| `APP_URL` | Yes | https://app.taskview.tech | Full URL of the web app (e.g. `https://tasks.company.com`). Used for OAuth redirects and email links. |
| `TRUST_PROXY` | No | `false` | Set when running behind a reverse proxy so `X-Forwarded-Proto`/`X-Forwarded-For` are honoured (correct `https` URLs, real client IP). Use the number of proxies in front of the app (`1` for a single Caddy/nginx), or an IP/subnet list (`10.0.0.0/8`, `uniquelocal`). Leave unset for direct access. Avoid `true` (trusts any hop, allows header spoofing). |
## Web app
Unlike everything else on this page, this variable is set on the **web app container** (`taskview-webapp`), not in `.env.taskview`.
| Variable | Required | Default | Description |
|---|---|---|---|
| `TASKVIEW_API_URL` | No | - | Pins the API server URL for the web app (e.g. `https://api.company.com`). When set, the "Select server" section disappears from the login page and the app always talks to this API. When unset, users pick the API server on the login page themselves. |
## Authentication
@@ -34,6 +43,8 @@ These must match your PostgreSQL setup.
| `ACCESS_LIFE_TIME` | No | `1d` | How long access tokens are valid. Examples: `1h`, `1d`, `7d` |
| `REFRESH_LIFE_TIME` | No | `2d` | How long refresh tokens are valid |
| `JWT_ALG` | No | `HS256` | JWT signing algorithm |
| `AUTH_LOGIN_METHODS` | No | all enabled | Comma-separated list of login methods to offer: `magic-link`, `password`, `sso`, `social`. Disabled methods disappear from the login page and their API endpoints return 403. The API refuses to start if the list contains a typo or disables every method. |
| `PASSWORD_CHANGE_CONFIRMATION` | No | `email` | How account password changes are confirmed: `email` — a confirmation code is sent to the user's email (requires SMTP); `password` — the user confirms with their current password (works without SMTP, recommended for installs without a mail server). |
::callout{icon="i-lucide-shield" color="warning"}
Generate a strong JWT secret: `node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"`
@@ -208,6 +219,11 @@ JWT_SIGN="secret"
ACCESS_LIFE_TIME="3d"
REFRESH_LIFE_TIME="9d"
# Login methods offered on the login page (unset = all enabled)
#AUTH_LOGIN_METHODS="magic-link,password,sso,social"
# Password change confirmation: "email" (code by email, needs SMTP) or "password" (no SMTP needed)
#PASSWORD_CHANGE_CONFIRMATION="email"
SMTP_HOST=smtp
SMTP_PORT=587
SMTP_USERNAME=
+28
View File
@@ -7,6 +7,21 @@ navigation:
TaskView supports multiple ways to sign in - email/password, email/code, GitHub, Google, and Apple. You can enable whichever methods make sense for your team.
## Choosing login methods
By default the login page offers every method. Use the `AUTH_LOGIN_METHODS` environment variable to offer only the ones you need:
```env
# Comma-separated list: magic-link, password, sso, social
AUTH_LOGIN_METHODS="password,sso"
```
Disabled methods disappear from the login page and their API endpoints return 403 — the setting is enforced server-side, not just hidden in the UI. Google/GitHub/Apple buttons are additionally shown only when the provider is actually configured, so unconfigured providers never render dead buttons.
::callout{icon="i-lucide-shield" color="warning"}
The API refuses to start if `AUTH_LOGIN_METHODS` contains an unknown value or disables every method — a broken config can't silently lock everyone out.
::
## Email and password
This is the default method and works out of the box. Users register with an email and password, and log in the same way (email conformation is required).
@@ -14,10 +29,23 @@ This is the default method and works out of the box. Users register with an emai
If you have SMTP configured, users will receive a confirmation email after registration.
Without SMTP, email confirmation is skipped and accounts should be activated manually.
### Changing your password
Users can set or change their password from **Account settings → Password**. How the change is confirmed depends on the `PASSWORD_CHANGE_CONFIRMATION` environment variable:
- `email` (default) — a confirmation code is sent to the user's email. Requires SMTP.
- `password` — the user confirms with their current password. No SMTP needed; recommended for installations without a mail server (password login is the only way in there, so every user knows their password).
After a successful change all other sessions are signed out; the current one stays active.
### Password recovery
Requires SMTP. Users click "Forgot password" on the login screen, enter their email, and receive a reset link. Without SMTP configured, password recovery is not available - you'll need to reset passwords manually in the database.
### The default user (self-hosted)
Fresh installations ship a preinstalled user (`user` / `user1!#Q`). That account gets a dedicated **Login and email** card in Account settings to claim it in one step — set your own login, email and password, confirmed by the current password, no SMTP required. See [Installation → Claim the default account](/docs/getting-started/installation#claim-the-default-account).
## OAuth providers
TaskView can use external providers for login. This is separate from the integration OAuth (which is for connecting GitHub/GitLab repositories).
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-monorepo",
"version": "1.49.0",
"version": "1.50.1",
"private": true,
"description": "TaskView CE monorepo containing web, API, and packages",
"workspaces": [
@@ -0,0 +1,6 @@
android/build/
android/.gradle/
android/local.properties
.swiftpm/
ios/.build/
DerivedData/
@@ -0,0 +1,24 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "CapacitorWidgetBridge",
platforms: [.iOS(.v15)],
products: [
.library(
name: "CapacitorWidgetBridge",
targets: ["WidgetBridgePlugin"])
],
dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")
],
targets: [
.target(
name: "WidgetBridgePlugin",
dependencies: [
.product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm")
],
path: "ios/Sources/WidgetBridgePlugin")
]
)
@@ -0,0 +1,51 @@
ext {
junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.1'
}
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
}
}
apply plugin: 'com.android.library'
android {
namespace = "tech.taskview.plugins.widgetbridge"
compileSdk = project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
defaultConfig {
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36
versionCode 1
versionName "1.0"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
lintOptions {
abortOnError = false
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
repositories {
google()
mavenCentral()
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':capacitor-android')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
}
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,48 @@
package tech.taskview.plugins.widgetbridge;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
@CapacitorPlugin(name = "WidgetBridge")
public class WidgetBridgePlugin extends Plugin {
public static final String PREFS_NAME = "taskview_widget";
public static final String SNAPSHOT_KEY = "widgetSnapshot";
public static final String ACTION_WIDGET_UPDATE = "tech.taskview.widget.UPDATE";
@PluginMethod
public void setSnapshot(PluginCall call) {
String snapshot = call.getString("snapshot");
if (snapshot == null) {
call.reject("snapshot is required");
return;
}
prefs().edit().putString(SNAPSHOT_KEY, snapshot).apply();
notifyWidgets();
call.resolve();
}
@PluginMethod
public void clearSnapshot(PluginCall call) {
prefs().edit().remove(SNAPSHOT_KEY).apply();
notifyWidgets();
call.resolve();
}
private SharedPreferences prefs() {
return getContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
}
private void notifyWidgets() {
Context context = getContext();
Intent intent = new Intent(ACTION_WIDGET_UPDATE);
intent.setPackage(context.getPackageName());
context.sendBroadcast(intent);
}
}
@@ -0,0 +1,44 @@
import Foundation
import Capacitor
import WidgetKit
@objc(WidgetBridgePlugin)
public class WidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "WidgetBridgePlugin"
public let jsName = "WidgetBridge"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "setSnapshot", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "clearSnapshot", returnType: CAPPluginReturnPromise)
]
static let snapshotKey = "widgetSnapshot"
private var sharedDefaults: UserDefaults? {
guard let appGroup = getConfig().getString("appGroup") else { return nil }
return UserDefaults(suiteName: appGroup)
}
@objc func setSnapshot(_ call: CAPPluginCall) {
guard let snapshot = call.getString("snapshot") else {
call.reject("snapshot is required")
return
}
guard let defaults = sharedDefaults else {
call.reject("WidgetBridge appGroup is not configured in capacitor.config")
return
}
defaults.set(snapshot, forKey: Self.snapshotKey)
WidgetCenter.shared.reloadAllTimelines()
call.resolve()
}
@objc func clearSnapshot(_ call: CAPPluginCall) {
guard let defaults = sharedDefaults else {
call.reject("WidgetBridge appGroup is not configured in capacitor.config")
return
}
defaults.removeObject(forKey: Self.snapshotKey)
WidgetCenter.shared.reloadAllTimelines()
call.resolve()
}
}
@@ -0,0 +1,41 @@
{
"name": "capacitor-widget-bridge",
"private": false,
"version": "0.1.0",
"type": "module",
"description": "Capacitor bridge that shares a data snapshot with native home-screen widgets (iOS WidgetKit / Android App Widgets)",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"ios",
"android",
"Package.swift"
],
"scripts": {
"build": "tsc",
"type-check": "tsc --noEmit"
},
"capacitor": {
"ios": {
"src": "ios"
},
"android": {
"src": "android"
}
},
"peerDependencies": {
"@capacitor/core": "^8.0.0"
},
"devDependencies": {
"@capacitor/core": "^8.1.0",
"typescript": "~5.8.3"
}
}
@@ -0,0 +1,32 @@
export type WidgetSnapshotTask = {
id: number
title: string
priority: 1 | 2 | 3
overdue: boolean
endTime: string | null
endDate: string | null
path: string
}
export type WidgetSnapshotMode = 'today' | 'upcoming'
export type WidgetSnapshot = {
v: 3
generatedAt: string
locale: string
orgSlug: string | null
mode: WidgetSnapshotMode
todayCount: number
overdueCount: number
upcomingCount: number
tasks: WidgetSnapshotTask[]
}
export type SetSnapshotOptions = {
snapshot: string
}
export type WidgetBridgePlugin = {
setSnapshot(options: SetSnapshotOptions): Promise<void>
clearSnapshot(): Promise<void>
}
@@ -0,0 +1,12 @@
import { registerPlugin } from '@capacitor/core'
import type { WidgetBridgePlugin } from './definitions'
export const WidgetBridge = registerPlugin<WidgetBridgePlugin>('WidgetBridge')
export type {
WidgetBridgePlugin,
WidgetSnapshot,
WidgetSnapshotMode,
WidgetSnapshotTask,
SetSnapshotOptions,
} from './definitions'
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": [
"ES2020"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"noEmit": false,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"src"
]
}
@@ -23,6 +23,8 @@ export type AnalyticsRange = {
export type LocalizedText = {
ru: string
en: string
de?: string
es?: string
}
export type AnalyticsUnit =
@@ -7,8 +7,12 @@ export type UiPreferencesItem = {
export type FirstDayOfWeek = 0 | 1 | 2 | 3 | 4 | 5 | 6
export type DefaultView = 'tasks' | 'kanban' | 'graph' | 'sprints'
export type UiSettings = {
firstDayOfWeek?: FirstDayOfWeek
defaultProjectId?: number
defaultView?: DefaultView
}
export const UI_SETTINGS_KEY = '__settings__'
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.handscreamgnl.taskview.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 14801
versionName "1.48.1"
versionCode 14902
versionName "1.49.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+1
View File
@@ -17,6 +17,7 @@ dependencies {
implementation project(':capacitor-push-notifications')
implementation project(':capacitor-splash-screen')
implementation project(':capgo-capacitor-updater')
implementation project(':capacitor-widget-bridge')
}
@@ -29,6 +29,24 @@
</activity>
<receiver
android:name=".widget.TodayWidgetProvider"
android:exported="false"
android:label="@string/widget_today_title">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="tech.taskview.widget.UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_today_info" />
</receiver>
<service
android:name=".widget.TodayWidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@@ -0,0 +1,141 @@
package com.handscream.taskview.app.widget;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.view.View;
import android.widget.RemoteViews;
import android.widget.RemoteViewsService;
import com.handscream.taskview.app.R;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
import tech.taskview.plugins.widgetbridge.WidgetBridgePlugin;
public class TodayWidgetFactory implements RemoteViewsService.RemoteViewsFactory {
private final Context context;
private final List<JSONObject> tasks = new ArrayList<>();
private boolean upcoming = false;
public TodayWidgetFactory(Context context) {
this.context = context;
}
@Override
public void onCreate() {
}
@Override
public void onDataSetChanged() {
tasks.clear();
upcoming = false;
String snapshot = context
.getSharedPreferences(WidgetBridgePlugin.PREFS_NAME, Context.MODE_PRIVATE)
.getString(WidgetBridgePlugin.SNAPSHOT_KEY, null);
if (snapshot == null) return;
try {
JSONObject parsed = new JSONObject(snapshot);
upcoming = "upcoming".equals(parsed.optString("mode"));
JSONArray items = parsed.optJSONArray("tasks");
if (items == null) return;
for (int i = 0; i < items.length(); i++) {
JSONObject task = items.optJSONObject(i);
if (task != null) tasks.add(task);
}
} catch (JSONException ignored) {
}
}
@Override
public void onDestroy() {
tasks.clear();
}
@Override
public int getCount() {
return tasks.size();
}
@Override
public RemoteViews getViewAt(int position) {
JSONObject task = tasks.get(position);
RemoteViews row = new RemoteViews(context.getPackageName(), R.layout.widget_today_item);
row.setTextViewText(R.id.widget_item_title, task.optString("title"));
row.setImageViewResource(R.id.widget_item_checkbox, priorityCheckbox(task.optInt("priority", 1)));
boolean overdue = !upcoming && task.optBoolean("overdue", false);
String meta = upcoming
? formatEndDate(task)
: (overdue ? context.getString(R.string.widget_overdue) : formatEndTime(task));
if (meta == null || meta.isEmpty()) {
row.setViewVisibility(R.id.widget_item_meta, View.GONE);
} else {
row.setViewVisibility(R.id.widget_item_meta, View.VISIBLE);
row.setTextViewText(R.id.widget_item_meta, meta);
row.setTextColor(
R.id.widget_item_meta,
context.getColor(overdue ? R.color.widget_overdue : R.color.widget_text_secondary));
}
Intent fillIn = new Intent();
String path = task.optString("path", "");
if (!path.isEmpty()) {
fillIn.setData(Uri.parse("taskview://open?path=" + Uri.encode(path)));
}
row.setOnClickFillInIntent(R.id.widget_item_root, fillIn);
return row;
}
private int priorityCheckbox(int priority) {
switch (priority) {
case 3:
return R.drawable.widget_checkbox_high;
case 2:
return R.drawable.widget_checkbox_medium;
default:
return R.drawable.widget_checkbox_low;
}
}
private String formatEndTime(JSONObject task) {
if (task.isNull("endTime")) return null;
String endTime = task.optString("endTime", "");
return endTime.length() >= 5 ? endTime.substring(0, 5) : endTime;
}
private String formatEndDate(JSONObject task) {
if (task.isNull("endDate")) return null;
String endDate = task.optString("endDate", "");
String[] parts = endDate.split("-");
return parts.length == 3 ? parts[2] + "." + parts[1] : endDate;
}
@Override
public RemoteViews getLoadingView() {
return null;
}
@Override
public int getViewTypeCount() {
return 1;
}
@Override
public long getItemId(int position) {
return tasks.get(position).optLong("id", position);
}
@Override
public boolean hasStableIds() {
return true;
}
}
@@ -0,0 +1,90 @@
package com.handscream.taskview.app.widget;
import android.app.PendingIntent;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.widget.RemoteViews;
import com.handscream.taskview.app.MainActivity;
import com.handscream.taskview.app.R;
import org.json.JSONObject;
import tech.taskview.plugins.widgetbridge.WidgetBridgePlugin;
public class TodayWidgetProvider extends AppWidgetProvider {
@Override
public void onUpdate(Context context, AppWidgetManager manager, int[] appWidgetIds) {
for (int appWidgetId : appWidgetIds) {
manager.updateAppWidget(appWidgetId, buildViews(context, appWidgetId));
}
}
@Override
public void onReceive(Context context, Intent intent) {
super.onReceive(context, intent);
if (WidgetBridgePlugin.ACTION_WIDGET_UPDATE.equals(intent.getAction())) {
AppWidgetManager manager = AppWidgetManager.getInstance(context);
int[] ids = manager.getAppWidgetIds(new ComponentName(context, TodayWidgetProvider.class));
if (ids.length == 0) return;
manager.notifyAppWidgetViewDataChanged(ids, R.id.widget_today_list);
onUpdate(context, manager, ids);
}
}
private RemoteViews buildViews(Context context, int appWidgetId) {
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_today);
JSONObject snapshot = readSnapshot(context);
boolean upcoming = snapshot != null && "upcoming".equals(snapshot.optString("mode"));
int count = snapshot == null
? 0
: (upcoming ? snapshot.optInt("upcomingCount", 0) : snapshot.optInt("todayCount", 0));
views.setTextViewText(
R.id.widget_today_title,
context.getString(upcoming ? R.string.widget_upcoming_title : R.string.widget_today_title));
views.setTextViewText(R.id.widget_today_count, String.valueOf(count));
Intent adapter = new Intent(context, TodayWidgetService.class);
adapter.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
adapter.setData(Uri.parse(adapter.toUri(Intent.URI_INTENT_SCHEME)));
views.setRemoteAdapter(R.id.widget_today_list, adapter);
views.setEmptyView(R.id.widget_today_list, R.id.widget_today_empty);
PendingIntent openApp = PendingIntent.getActivity(
context,
0,
new Intent(context, MainActivity.class),
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
views.setOnClickPendingIntent(R.id.widget_today_header, openApp);
views.setOnClickPendingIntent(R.id.widget_today_empty, openApp);
Intent template = new Intent(context, MainActivity.class);
template.setAction(Intent.ACTION_VIEW);
PendingIntent templateIntent = PendingIntent.getActivity(
context,
1,
template,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE);
views.setPendingIntentTemplate(R.id.widget_today_list, templateIntent);
return views;
}
private JSONObject readSnapshot(Context context) {
String snapshot = context
.getSharedPreferences(WidgetBridgePlugin.PREFS_NAME, Context.MODE_PRIVATE)
.getString(WidgetBridgePlugin.SNAPSHOT_KEY, null);
if (snapshot == null) return null;
try {
return new JSONObject(snapshot);
} catch (Exception e) {
return null;
}
}
}
@@ -0,0 +1,12 @@
package com.handscream.taskview.app.widget;
import android.content.Intent;
import android.widget.RemoteViewsService;
public class TodayWidgetService extends RemoteViewsService {
@Override
public RemoteViewsFactory onGetViewFactory(Intent intent) {
return new TodayWidgetFactory(getApplicationContext());
}
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_accent" />
<corners android:radius="999dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_background" />
<corners android:radius="16dp" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@android:color/transparent" />
<stroke
android:width="1.5dp"
android:color="@color/widget_priority_high" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@android:color/transparent" />
<stroke
android:width="1.5dp"
android:color="@color/widget_priority_low" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@android:color/transparent" />
<stroke
android:width="1.5dp"
android:color="@color/widget_priority_medium" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_header_background" />
<corners
android:topLeftRadius="16dp"
android:topRightRadius="16dp" />
</shape>
@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@drawable/widget_bg">
<LinearLayout
android:id="@+id/widget_today_header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="@drawable/widget_header_bg"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="12dp"
android:paddingBottom="12dp">
<ImageView
android:id="@+id/widget_today_logo"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="8dp"
android:src="@mipmap/ic_launcher_round"
android:importantForAccessibility="no" />
<TextView
android:id="@+id/widget_today_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/widget_today_title"
android:textColor="@color/widget_text_primary"
android:textSize="16sp"
android:textStyle="bold"
android:maxLines="1" />
<TextView
android:id="@+id/widget_today_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/widget_badge_bg"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="3dp"
android:paddingBottom="3dp"
android:textColor="@android:color/white"
android:textSize="12sp"
android:textStyle="bold" />
</LinearLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<ListView
android:id="@+id/widget_today_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:divider="@color/widget_divider"
android:dividerHeight="0.5dp" />
<TextView
android:id="@+id/widget_today_empty"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:text="@string/widget_today_empty"
android:textColor="@color/widget_text_secondary"
android:textSize="13sp" />
</FrameLayout>
</LinearLayout>
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_item_root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="8dp"
android:paddingEnd="16dp"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<ImageView
android:id="@+id/widget_item_checkbox"
android:layout_width="32dp"
android:layout_height="32dp"
android:padding="8dp"
android:src="@drawable/widget_checkbox_low"
android:importantForAccessibility="no" />
<TextView
android:id="@+id/widget_item_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="2dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/widget_text_primary"
android:textSize="14sp" />
<TextView
android:id="@+id/widget_item_meta"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:textColor="@color/widget_text_secondary"
android:textSize="11sp" />
</LinearLayout>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="widget_background">#18181B</color>
<color name="widget_header_background">#27272A</color>
<color name="widget_divider">#3F3F46</color>
<color name="widget_text_primary">#FAFAFA</color>
<color name="widget_text_secondary">#A1A1AA</color>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="widget_today_title">Сегодня</string>
<string name="widget_upcoming_title">Ближайшие</string>
<string name="widget_today_empty">Нет задач на сегодня</string>
<string name="widget_overdue">Просрочено</string>
<string name="widget_today_description">Ваши задачи на сегодня</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="widget_today_title">Today</string>
<string name="widget_upcoming_title">Upcoming</string>
<string name="widget_today_empty">No tasks for today</string>
<string name="widget_overdue">Overdue</string>
<string name="widget_today_description">Your tasks for today at a glance</string>
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="widget_background">#FFFFFF</color>
<color name="widget_header_background">#F4F4F5</color>
<color name="widget_divider">#E4E4E7</color>
<color name="widget_text_primary">#18181B</color>
<color name="widget_text_secondary">#71717A</color>
<color name="widget_accent">#16A34A</color>
<color name="widget_overdue">#FF1744</color>
<color name="widget_priority_low">#38D681</color>
<color name="widget_priority_medium">#FF9100</color>
<color name="widget_priority_high">#FF1744</color>
</resources>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="70dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:updatePeriodMillis="1800000"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:initialLayout="@layout/widget_today"
android:previewLayout="@layout/widget_today"
android:description="@string/widget_today_description" />
+12 -9
View File
@@ -1,27 +1,30 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../../node_modules/.pnpm/@capacitor+android@8.1.0_@capacitor+core@8.1.0/node_modules/@capacitor/android/capacitor')
project(':capacitor-android').projectDir = new File('../../../node_modules/.pnpm/@capacitor+android@8.1.0_@capacitor+core@8.1.0/node_modules/@capacitor/android/capacitor')
include ':capacitor-firebase-messaging'
project(':capacitor-firebase-messaging').projectDir = new File('../../node_modules/.pnpm/@capacitor-firebase+messaging@8.1.0_@capacitor+core@8.1.0_firebase@12.10.0/node_modules/@capacitor-firebase/messaging/android')
project(':capacitor-firebase-messaging').projectDir = new File('../../../node_modules/.pnpm/@capacitor-firebase+messaging@8.1.0_@capacitor+core@8.1.0_firebase@12.11.0/node_modules/@capacitor-firebase/messaging/android')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../../node_modules/.pnpm/@capacitor+app@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/app/android')
project(':capacitor-app').projectDir = new File('../../../node_modules/.pnpm/@capacitor+app@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/app/android')
include ':capacitor-browser'
project(':capacitor-browser').projectDir = new File('../../node_modules/.pnpm/@capacitor+browser@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/browser/android')
project(':capacitor-browser').projectDir = new File('../../../node_modules/.pnpm/@capacitor+browser@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/browser/android')
include ':capacitor-device'
project(':capacitor-device').projectDir = new File('../../node_modules/.pnpm/@capacitor+device@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/device/android')
project(':capacitor-device').projectDir = new File('../../../node_modules/.pnpm/@capacitor+device@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/device/android')
include ':capacitor-preferences'
project(':capacitor-preferences').projectDir = new File('../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/preferences/android')
project(':capacitor-preferences').projectDir = new File('../../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/preferences/android')
include ':capacitor-push-notifications'
project(':capacitor-push-notifications').projectDir = new File('../../node_modules/.pnpm/@capacitor+push-notifications@8.0.2_@capacitor+core@8.1.0/node_modules/@capacitor/push-notifications/android')
project(':capacitor-push-notifications').projectDir = new File('../../../node_modules/.pnpm/@capacitor+push-notifications@8.0.3_@capacitor+core@8.1.0/node_modules/@capacitor/push-notifications/android')
include ':capacitor-splash-screen'
project(':capacitor-splash-screen').projectDir = new File('../../node_modules/.pnpm/@capacitor+splash-screen@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/splash-screen/android')
project(':capacitor-splash-screen').projectDir = new File('../../../node_modules/.pnpm/@capacitor+splash-screen@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/splash-screen/android')
include ':capgo-capacitor-updater'
project(':capgo-capacitor-updater').projectDir = new File('../../node_modules/.pnpm/@capgo+capacitor-updater@8.43.8_@capacitor+core@8.1.0/node_modules/@capgo/capacitor-updater/android')
project(':capgo-capacitor-updater').projectDir = new File('../../../node_modules/.pnpm/@capgo+capacitor-updater@8.43.2_@capacitor+core@8.1.0/node_modules/@capgo/capacitor-updater/android')
include ':capacitor-widget-bridge'
project(':capacitor-widget-bridge').projectDir = new File('../node_modules/capacitor-widget-bridge/android')
+3
View File
@@ -25,6 +25,9 @@ const config: CapacitorConfig = {
CapacitorHttp: {
enabled: true,
},
WidgetBridge: {
appGroup: 'group.com.handscream.taskview.app',
},
},
}
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Generates runtime config for the SPA from environment variables.
# Runs automatically at container start (nginx docker-entrypoint.d).
set -e
if [ -n "${TASKVIEW_API_URL:-}" ]; then
echo "window.__TASKVIEW_CONFIG__ = { apiUrl: \"${TASKVIEW_API_URL}\" };" > /usr/share/nginx/html/config.js
echo "TaskView runtime config: apiUrl=${TASKVIEW_API_URL}"
fi
+2
View File
@@ -2,6 +2,8 @@ FROM nginx:alpine
COPY dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY docker-runtime-config.sh /docker-entrypoint.d/40-taskview-runtime-config.sh
RUN chmod +x /docker-entrypoint.d/40-taskview-runtime-config.sh
EXPOSE 80
+1
View File
@@ -13,6 +13,7 @@
<link href="https://fonts.bunny.net/css?family=public-sans:400,500,600,700"
rel="stylesheet" />
<title>TaskView</title>
<script src="/config.js"></script>
<meta name="description"
content="TaskView is a self-hosted project and task management platform focused on clarity, ownership, and control.">
</head>
+214 -4
View File
@@ -8,6 +8,9 @@
/* Begin PBXBuildFile section */
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
30163AB74EEE183517D215DC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 41EECE9262690D5E2EF23CE9 /* Assets.xcassets */; };
30F5ABA9CC301D3E3BA985C7 /* TaskViewWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D90258576D7FDAEEBD7298B /* TaskViewWidgetBundle.swift */; };
336711805E8F2928D054F35E /* TodayWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4822ABF5AC8CD3030A5CE020 /* TodayWidget.swift */; };
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
@@ -15,11 +18,43 @@
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
7E638BA1BFCF4E185865F85D /* TaskViewWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7F4057F5AC9B7A7AC7E70240 /* TaskViewWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
A71181DC51C8DA03879B72ED /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 029A385E0A8D9B1FCBD9CC75 /* Foundation.framework */; };
AAA632B22F6BB598007C705D /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = AAA632B12F6BB598007C705D /* GoogleService-Info.plist */; };
ABED08BA434D23E174534E03 /* WidgetSnapshotModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 873F85DE4FF5908A8CA6A467 /* WidgetSnapshotModel.swift */; };
AB10F00D00000000000000A2 /* OpenTaskIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB10F00D00000000000000A1 /* OpenTaskIntent.swift */; };
AB10F00D00000000000000A3 /* OpenTaskIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB10F00D00000000000000A1 /* OpenTaskIntent.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
8471300B26377F4336A38D36 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
proxyType = 1;
remoteGlobalIDString = 13D58B519FF779A139CB09EB;
remoteInfo = TaskViewWidget;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
E6DDB52E9A683D16F43B337C /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
7E638BA1BFCF4E185865F85D /* TaskViewWidget.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
029A385E0A8D9B1FCBD9CC75 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
41EECE9262690D5E2EF23CE9 /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
4822ABF5AC8CD3030A5CE020 /* TodayWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodayWidget.swift; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@@ -28,9 +63,15 @@
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
7F4057F5AC9B7A7AC7E70240 /* TaskViewWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TaskViewWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
873F85DE4FF5908A8CA6A467 /* WidgetSnapshotModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetSnapshotModel.swift; sourceTree = "<group>"; };
AB10F00D00000000000000A1 /* OpenTaskIntent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OpenTaskIntent.swift; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
9D90258576D7FDAEEBD7298B /* TaskViewWidgetBundle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TaskViewWidgetBundle.swift; sourceTree = "<group>"; };
AAA632B02F69F429007C705D /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
AAA632B12F6BB598007C705D /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
EB7495A9A40AB2365CC0624E /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
F1EEEB83D95DC32C9728EAC4 /* TaskViewWidget.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = TaskViewWidget.entitlements; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -42,9 +83,25 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
8331741D08C05C5C85275E50 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
A71181DC51C8DA03879B72ED /* Foundation.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
4961C8EBB434EDD15B55395B /* Frameworks */ = {
isa = PBXGroup;
children = (
7E3EA782F4406B2B6D7A0121 /* iOS */,
);
name = Frameworks;
sourceTree = "<group>";
};
504EC2FB1FED79650016851F = {
isa = PBXGroup;
children = (
@@ -52,6 +109,8 @@
958DCC722DB07C7200EA8C5F /* debug.xcconfig */,
504EC3061FED79650016851F /* App */,
504EC3051FED79650016851F /* Products */,
4961C8EBB434EDD15B55395B /* Frameworks */,
79D12E01B1FBD77844324865 /* TaskViewWidget */,
);
sourceTree = "<group>";
};
@@ -59,6 +118,7 @@
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* App.app */,
7F4057F5AC9B7A7AC7E70240 /* TaskViewWidget.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -79,9 +139,49 @@
path = App;
sourceTree = "<group>";
};
79D12E01B1FBD77844324865 /* TaskViewWidget */ = {
isa = PBXGroup;
children = (
9D90258576D7FDAEEBD7298B /* TaskViewWidgetBundle.swift */,
4822ABF5AC8CD3030A5CE020 /* TodayWidget.swift */,
873F85DE4FF5908A8CA6A467 /* WidgetSnapshotModel.swift */,
AB10F00D00000000000000A1 /* OpenTaskIntent.swift */,
EB7495A9A40AB2365CC0624E /* Info.plist */,
F1EEEB83D95DC32C9728EAC4 /* TaskViewWidget.entitlements */,
41EECE9262690D5E2EF23CE9 /* Assets.xcassets */,
);
name = TaskViewWidget;
path = TaskViewWidget;
sourceTree = "<group>";
};
7E3EA782F4406B2B6D7A0121 /* iOS */ = {
isa = PBXGroup;
children = (
029A385E0A8D9B1FCBD9CC75 /* Foundation.framework */,
);
name = iOS;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
13D58B519FF779A139CB09EB /* TaskViewWidget */ = {
isa = PBXNativeTarget;
buildConfigurationList = BA6B4CB7C019DA2E47F2AE5D /* Build configuration list for PBXNativeTarget "TaskViewWidget" */;
buildPhases = (
2B48B6699C53A2E975CEE972 /* Sources */,
8331741D08C05C5C85275E50 /* Frameworks */,
8F380AD759A2116160808CCC /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = TaskViewWidget;
productName = TaskViewWidget;
productReference = 7F4057F5AC9B7A7AC7E70240 /* TaskViewWidget.appex */;
productType = "com.apple.product-type.app-extension";
};
504EC3031FED79650016851F /* App */ = {
isa = PBXNativeTarget;
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
@@ -89,10 +189,12 @@
504EC3001FED79650016851F /* Sources */,
504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */,
E6DDB52E9A683D16F43B337C /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
70738FEC670F89727813A33B /* PBXTargetDependency */,
);
name = App;
packageProductDependencies = (
@@ -135,6 +237,7 @@
projectRoot = "";
targets = (
504EC3031FED79650016851F /* App */,
13D58B519FF779A139CB09EB /* TaskViewWidget */,
);
};
/* End PBXProject section */
@@ -154,19 +257,48 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
8F380AD759A2116160808CCC /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
30163AB74EEE183517D215DC /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
2B48B6699C53A2E975CEE972 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
30F5ABA9CC301D3E3BA985C7 /* TaskViewWidgetBundle.swift in Sources */,
336711805E8F2928D054F35E /* TodayWidget.swift in Sources */,
ABED08BA434D23E174534E03 /* WidgetSnapshotModel.swift in Sources */,
AB10F00D00000000000000A2 /* OpenTaskIntent.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
504EC3001FED79650016851F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
AB10F00D00000000000000A3 /* OpenTaskIntent.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
70738FEC670F89727813A33B /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = TaskViewWidget;
target = 13D58B519FF779A139CB09EB /* TaskViewWidget */;
targetProxy = 8471300B26377F4336A38D36 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
504EC30B1FED79650016851F /* Main.storyboard */ = {
isa = PBXVariantGroup;
@@ -304,7 +436,7 @@
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1.48.1;
CURRENT_PROJECT_VERSION = 1.49.2;
DEVELOPMENT_TEAM = H2W2SG48JT;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -312,7 +444,12 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.48.1;
MARKETING_VERSION = 1.49.2;
OTHER_LDFLAGS = (
"$(inherited)",
"-weak_framework",
AppIntents,
);
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.handscream.taskview.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -329,7 +466,7 @@
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1.48.1;
CURRENT_PROJECT_VERSION = 1.49.2;
DEVELOPMENT_TEAM = H2W2SG48JT;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -337,7 +474,12 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.48.1;
MARKETING_VERSION = 1.49.2;
OTHER_LDFLAGS = (
"$(inherited)",
"-weak_framework",
AppIntents,
);
PRODUCT_BUNDLE_IDENTIFIER = com.handscream.taskview.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -346,6 +488,65 @@
};
name = Release;
};
578B436A7C18D66B2BDFCF0B /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = TaskViewWidget/TaskViewWidget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1.49.2;
DEVELOPMENT_TEAM = H2W2SG48JT;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TaskViewWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "TaskView Widget";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.49.2;
PRODUCT_BUNDLE_IDENTIFIER = com.handscream.taskview.app.widget;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) WIDGET_EXTENSION";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
AC8696991AE5C61127E97D40 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = TaskViewWidget/TaskViewWidget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1.49.2;
DEVELOPMENT_TEAM = H2W2SG48JT;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TaskViewWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "TaskView Widget";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.49.2;
PRODUCT_BUNDLE_IDENTIFIER = com.handscream.taskview.app.widget;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) WIDGET_EXTENSION";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -367,6 +568,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
BA6B4CB7C019DA2E47F2AE5D /* Build configuration list for PBXNativeTarget "TaskViewWidget" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AC8696991AE5C61127E97D40 /* Release */,
578B436A7C18D66B2BDFCF0B /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
+4
View File
@@ -4,5 +4,9 @@
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.handscream.taskview.app</string>
</array>
</dict>
</plist>
+11 -9
View File
@@ -12,14 +12,15 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.1.0"),
.package(name: "CapacitorFirebaseMessaging", path: "../../../../node_modules/.pnpm/@capacitor-firebase+messaging@8.1.0_@capacitor+core@8.1.0_firebase@12.10.0/node_modules/@capacitor-firebase/messaging"),
.package(name: "CapacitorApp", path: "../../../../node_modules/.pnpm/@capacitor+app@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/app"),
.package(name: "CapacitorBrowser", path: "../../../../node_modules/.pnpm/@capacitor+browser@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/browser"),
.package(name: "CapacitorDevice", path: "../../../../node_modules/.pnpm/@capacitor+device@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/device"),
.package(name: "CapacitorPreferences", path: "../../../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/preferences"),
.package(name: "CapacitorPushNotifications", path: "../../../../node_modules/.pnpm/@capacitor+push-notifications@8.0.2_@capacitor+core@8.1.0/node_modules/@capacitor/push-notifications"),
.package(name: "CapacitorSplashScreen", path: "../../../../node_modules/.pnpm/@capacitor+splash-screen@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/splash-screen"),
.package(name: "CapgoCapacitorUpdater", path: "../../../../node_modules/.pnpm/@capgo+capacitor-updater@8.43.8_@capacitor+core@8.1.0/node_modules/@capgo/capacitor-updater")
.package(name: "CapacitorFirebaseMessaging", path: "../../../../../node_modules/.pnpm/@capacitor-firebase+messaging@8.1.0_@capacitor+core@8.1.0_firebase@12.11.0/node_modules/@capacitor-firebase/messaging"),
.package(name: "CapacitorApp", path: "../../../../../node_modules/.pnpm/@capacitor+app@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/app"),
.package(name: "CapacitorBrowser", path: "../../../../../node_modules/.pnpm/@capacitor+browser@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/browser"),
.package(name: "CapacitorDevice", path: "../../../../../node_modules/.pnpm/@capacitor+device@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/device"),
.package(name: "CapacitorPreferences", path: "../../../../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/preferences"),
.package(name: "CapacitorPushNotifications", path: "../../../../../node_modules/.pnpm/@capacitor+push-notifications@8.0.3_@capacitor+core@8.1.0/node_modules/@capacitor/push-notifications"),
.package(name: "CapacitorSplashScreen", path: "../../../../../node_modules/.pnpm/@capacitor+splash-screen@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/splash-screen"),
.package(name: "CapgoCapacitorUpdater", path: "../../../../../node_modules/.pnpm/@capgo+capacitor-updater@8.43.2_@capacitor+core@8.1.0/node_modules/@capgo/capacitor-updater"),
.package(name: "CapacitorWidgetBridge", path: "../../../node_modules/capacitor-widget-bridge")
],
targets: [
.target(
@@ -34,7 +35,8 @@ let package = Package(
.product(name: "CapacitorPreferences", package: "CapacitorPreferences"),
.product(name: "CapacitorPushNotifications", package: "CapacitorPushNotifications"),
.product(name: "CapacitorSplashScreen", package: "CapacitorSplashScreen"),
.product(name: "CapgoCapacitorUpdater", package: "CapgoCapacitorUpdater")
.product(name: "CapgoCapacitorUpdater", package: "CapgoCapacitorUpdater"),
.product(name: "CapacitorWidgetBridge", package: "CapacitorWidgetBridge")
]
)
]
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "logo.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,39 @@
import Foundation
import AppIntents
#if !WIDGET_EXTENSION
import UIKit
#endif
@available(iOS 18.0, *)
struct OpenTaskIntent: AppIntent {
static let title: LocalizedStringResource = "Open Task"
static let isDiscoverable = false
static let openAppWhenRun = true
@Parameter(title: "URL")
var urlString: String
init() {
urlString = "taskview://open"
}
init(urlString: String) {
self.urlString = urlString
}
private var resolvedURL: URL {
URL(string: urlString) ?? URL(string: "taskview://open")!
}
#if WIDGET_EXTENSION
func perform() async throws -> some IntentResult & OpensIntent {
.result(opensIntent: OpenURLIntent(resolvedURL))
}
#else
@MainActor
func perform() async throws -> some IntentResult {
await UIApplication.shared.open(resolvedURL)
return .result()
}
#endif
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.handscream.taskview.app</string>
</array>
</dict>
</plist>
@@ -0,0 +1,9 @@
import WidgetKit
import SwiftUI
@main
struct TaskViewWidgetBundle: WidgetBundle {
var body: some Widget {
TodayWidget()
}
}
@@ -0,0 +1,273 @@
import WidgetKit
import SwiftUI
struct TodayEntry: TimelineEntry {
let date: Date
let snapshot: WidgetSnapshot?
}
struct TodayProvider: TimelineProvider {
func placeholder(in context: Context) -> TodayEntry {
TodayEntry(date: .now, snapshot: nil)
}
func getSnapshot(in context: Context, completion: @escaping (TodayEntry) -> Void) {
completion(TodayEntry(date: .now, snapshot: WidgetSnapshot.load()))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<TodayEntry>) -> Void) {
let entry = TodayEntry(date: .now, snapshot: WidgetSnapshot.load())
let refresh = Calendar.current.date(byAdding: .minute, value: 30, to: .now) ?? .now
completion(Timeline(entries: [entry], policy: .after(refresh)))
}
}
struct TodayWidget: Widget {
let kind = "TaskViewTodayWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: TodayProvider()) { entry in
TodayWidgetView(entry: entry)
.containerBackground(for: .widget) {
Color(uiColor: .systemBackground)
}
}
.configurationDisplayName("TaskView")
.description("Today's tasks / Задачи на сегодня")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
.contentMarginsDisabled()
}
}
struct TodayWidgetView: View {
@Environment(\.widgetFamily) private var family
let entry: TodayEntry
private var strings: WidgetStrings {
WidgetStrings.forLocale(entry.snapshot?.locale)
}
var body: some View {
GeometryReader { geo in
TaskListTodayView(
snapshot: entry.snapshot,
strings: strings,
maxSlots: slots(for: geo.size.height),
compact: family == .systemSmall,
pinMoreToBottom: true
)
}
}
// Rows are given a fixed frame (24pt compact / 32pt regular), so this math is exact:
// header = top + bottom padding + content; chrome = list top + bottom padding;
// each slot after the first adds a hairline divider.
private func slots(for height: CGFloat) -> Int {
let compact = family == .systemSmall
let headerHeight: CGFloat = compact ? 38 : 46
let listChrome: CGFloat = compact ? 9 : 12
let rowHeight: CGFloat = compact ? 24 : 32
let dividerHeight: CGFloat = 0.34
let available = height - headerHeight - listChrome + dividerHeight
return max(2, Int(available / (rowHeight + dividerHeight)))
}
}
struct TaskListTodayView: View {
let snapshot: WidgetSnapshot?
let strings: WidgetStrings
let maxSlots: Int
let compact: Bool
let pinMoreToBottom: Bool
private var horizontalPadding: CGFloat { compact ? 16 : 20 }
private var rowFixedHeight: CGFloat { compact ? 24 : 32 }
private var rowVerticalPadding: CGFloat { compact ? 3 : 6 }
private var dividerInset: CGFloat { compact ? 42 : 52 }
private var isUpcoming: Bool {
snapshot?.isUpcoming ?? false
}
private var visibleTasks: [WidgetSnapshotTask] {
guard let snapshot else { return [] }
let count = snapshot.activeCount > maxSlots ? maxSlots - 1 : maxSlots
return Array(snapshot.tasks.prefix(count))
}
private var hiddenCount: Int {
guard let snapshot else { return 0 }
return max(0, snapshot.activeCount - visibleTasks.count)
}
var body: some View {
VStack(spacing: 0) {
HStack(spacing: compact ? 6 : 8) {
WidgetLogoView(size: compact ? 16 : 20)
Text(isUpcoming ? strings.upcoming : strings.today)
.font(compact ? .footnote.weight(.semibold) : .headline)
.lineLimit(1)
.truncationMode(.tail)
Spacer()
Text("\(snapshot?.activeCount ?? 0)")
.font(.caption2.weight(.bold))
.contentTransition(.numericText(countsDown: true))
.foregroundStyle(.white)
.padding(.horizontal, compact ? 6 : 8)
.padding(.vertical, compact ? 2 : 3)
.background(WidgetPalette.accent, in: Capsule())
}
.padding(.horizontal, horizontalPadding)
.padding(.top, compact ? 16 : 18)
.padding(.bottom, compact ? 4 : 6)
.background(WidgetPalette.headerBackground)
if !visibleTasks.isEmpty {
VStack(spacing: 0) {
ForEach(Array(visibleTasks.enumerated()), id: \.element.id) { index, task in
VStack(spacing: 0) {
if index > 0 {
Divider()
.padding(.leading, dividerInset)
}
TaskRowView(task: task, strings: strings, compact: compact, showDate: isUpcoming)
.padding(.horizontal, horizontalPadding)
.frame(height: pinMoreToBottom ? rowFixedHeight : nil)
.padding(.vertical, pinMoreToBottom ? 0 : rowVerticalPadding)
}
.transition(.opacity.combined(with: .move(edge: .trailing)))
}
if hiddenCount > 0 {
Divider()
.padding(.leading, dividerInset)
if pinMoreToBottom {
Spacer(minLength: 0)
}
Text(strings.more(hiddenCount))
.font(compact ? .caption2 : .footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, dividerInset)
.padding(.trailing, horizontalPadding)
.frame(height: pinMoreToBottom ? rowFixedHeight : nil)
.padding(.vertical, pinMoreToBottom ? 0 : rowVerticalPadding)
}
}
.padding(.top, compact ? 3 : 4)
.padding(.bottom, compact ? 6 : 8)
if !(pinMoreToBottom && hiddenCount > 0) {
Spacer(minLength: 0)
}
} else {
Spacer()
Text(snapshot == nil ? strings.openApp : strings.empty)
.font(compact ? .caption : .footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.horizontal, horizontalPadding)
Spacer()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.widgetURL(compact ? URL(string: "taskview://open") : nil)
}
}
struct TaskRowView: View {
let task: WidgetSnapshotTask
let strings: WidgetStrings
let compact: Bool
let showDate: Bool
private var destination: URL {
task.deepLinkURL ?? URL(string: "taskview://open")!
}
var body: some View {
if compact {
if #available(iOS 18.0, *) {
Button(intent: OpenTaskIntent(urlString: destination.absoluteString)) {
row
}
.buttonStyle(.plain)
} else {
row
}
} else {
Link(destination: destination) {
row
}
}
}
private var row: some View {
HStack(spacing: compact ? 4 : 6) {
Circle()
.strokeBorder(WidgetPalette.priority(task.priority), lineWidth: compact ? 1.2 : 1.5)
.frame(width: compact ? 14 : 18, height: compact ? 14 : 18)
.padding(4)
content
}
}
private var content: some View {
HStack(spacing: compact ? 8 : 10) {
Text(task.title)
.font(compact ? .caption : .subheadline)
.foregroundStyle(.primary)
.lineLimit(1)
Spacer(minLength: 4)
if showDate {
if let endDate = task.shortEndDate {
Text(endDate)
.font(compact ? .caption2 : .caption)
.foregroundStyle(.secondary)
}
} else if task.overdue {
Text(compact ? "!" : strings.overdue)
.font(compact ? .caption.weight(.bold) : .caption.weight(.medium))
.foregroundStyle(WidgetPalette.overdue)
} else if let endTime = task.shortEndTime {
Text(endTime)
.font(compact ? .caption2 : .caption)
.foregroundStyle(.secondary)
}
}
}
}
struct WidgetLogoView: View {
let size: CGFloat
var body: some View {
Image("WidgetLogo")
.resizable()
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: size * 0.28))
}
}
enum WidgetPalette {
static let accent = Color(red: 0.086, green: 0.639, blue: 0.290)
static let overdue = Color(red: 1.0, green: 0.090, blue: 0.267)
static let headerBackground = Color(uiColor: .secondarySystemBackground).opacity(0.7)
static func priority(_ priority: Int) -> Color {
switch priority {
case 3: return Color(red: 1.0, green: 0.090, blue: 0.267)
case 2: return Color(red: 1.0, green: 0.569, blue: 0.0)
default: return Color(red: 0.220, green: 0.839, blue: 0.506)
}
}
}
@@ -0,0 +1,94 @@
import Foundation
struct WidgetSnapshotTask: Decodable, Identifiable {
let id: Int
let title: String
let priority: Int
let overdue: Bool
let endTime: String?
let endDate: String?
let path: String
var deepLinkURL: URL? {
guard let encoded = path.addingPercentEncoding(withAllowedCharacters: .alphanumerics) else { return nil }
return URL(string: "taskview://open?path=\(encoded)")
}
var shortEndTime: String? {
guard let endTime, endTime.count >= 5 else { return endTime }
return String(endTime.prefix(5))
}
var shortEndDate: String? {
guard let endDate else { return nil }
let parts = endDate.split(separator: "-")
guard parts.count == 3 else { return endDate }
return "\(parts[2]).\(parts[1])"
}
}
struct WidgetSnapshot: Decodable {
let v: Int
let generatedAt: String
let locale: String
let orgSlug: String?
let mode: String?
let todayCount: Int
let overdueCount: Int
let upcomingCount: Int?
let tasks: [WidgetSnapshotTask]
var isUpcoming: Bool {
mode == "upcoming"
}
var activeCount: Int {
isUpcoming ? (upcomingCount ?? tasks.count) : todayCount
}
static let appGroup = "group.com.handscream.taskview.app"
static let snapshotKey = "widgetSnapshot"
static func load() -> WidgetSnapshot? {
guard let defaults = UserDefaults(suiteName: appGroup),
let raw = defaults.string(forKey: snapshotKey),
let data = raw.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(WidgetSnapshot.self, from: data)
}
}
struct WidgetStrings {
let today: String
let upcoming: String
let empty: String
let overdue: String
let openApp: String
let moreFormat: String
static let ru = WidgetStrings(
today: "Сегодня",
upcoming: "Ближайшие",
empty: "Нет задач на сегодня",
overdue: "Просрочено",
openApp: "Откройте TaskView",
moreFormat: "и ещё %d"
)
static let en = WidgetStrings(
today: "Today",
upcoming: "Upcoming",
empty: "No tasks for today",
overdue: "Overdue",
openApp: "Open TaskView",
moreFormat: "+%d more"
)
func more(_ count: Int) -> String {
String(format: moreFormat, count)
}
static func forLocale(_ locale: String?) -> WidgetStrings {
let resolved = locale ?? Locale.preferredLanguages.first ?? "ru"
return resolved.hasPrefix("ru") ? ru : en
}
}
+5
View File
@@ -30,6 +30,11 @@ server {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Runtime config generated from env at container start — never cache
location = /config.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
error_page 404 /index.html;
}
+3 -2
View File
@@ -2,10 +2,10 @@
"name": "web-nuxt-ui",
"private": true,
"type": "module",
"version": "1.49.0",
"version": "1.50.1",
"scripts": {
"dev": "vite",
"build:packages": "pnpm --filter taskview-db-schemas build && pnpm --filter taskview-api build",
"build:packages": "pnpm --filter taskview-db-schemas build && pnpm --filter taskview-api build && pnpm --filter capacitor-widget-bridge build",
"build": "pnpm run build:packages && pnpm run typecheck && vite build",
"build-only": "vite build",
"preview": "vite preview",
@@ -58,6 +58,7 @@
"@vueuse/core": "^14.1.0",
"arktype": "2.1.20",
"axios": "1.13.5",
"capacitor-widget-bridge": "workspace:^",
"centrifuge": "^5.5.3",
"chart.js": "^4.5.1",
"chartjs-plugin-annotation": "^3.1.0",
+3
View File
@@ -0,0 +1,3 @@
// Runtime configuration. On self-hosted Docker deployments this file is
// overwritten at container start from the TASKVIEW_API_URL environment variable.
window.__TASKVIEW_CONFIG__ = {}
+5 -222
View File
@@ -38,85 +38,33 @@
/>
</template>
</UButton>
<template #chip-leading="{ item }">
<div class="inline-flex items-center justify-center shrink-0 size-5">
<span
class="rounded-full ring ring-bg bg-(--chip-light) dark:bg-(--chip-dark) size-2"
:style="{
'--chip-light': `var(--color-${(item as any).chip}-500)`,
'--chip-dark': `var(--color-${(item as any).chip}-400)`
}"
/>
</div>
</template>
</UDropdownMenu>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { computed } from 'vue'
import type { DropdownMenuItem } from '@nuxt/ui'
import { useColorMode } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app.store'
import { useLogout } from '@/composables/useLogout'
import { saveLocale } from '@/plugins/i18n'
import { useUpdater } from '@/composables/useUpdater'
import { useUserStore } from '@/stores/user.store'
import { useOrganizationStore } from '@/stores/organization.store'
import { useOrgSwitcher } from '@/composables/useOrgSwitcher'
import { $ls } from '@/plugins/axios'
import avatarImg from '@/assets/images/avatar-1.jpeg'
defineProps<{
collapsed?: boolean
}>()
const { t, locale } = useI18n()
const { t } = useI18n()
const colorMode = useColorMode()
const appStore = useAppStore()
const router = useRouter()
const toast = useToast()
const userStore = useUserStore()
const orgStore = useOrganizationStore()
const { switchOrg } = useOrgSwitcher()
const prodOrDev = ref<'prod' | 'dev'>('prod')
let counter = 0
let timeout: number
let timeoutChangeProdOrDev: number
onMounted(async () => {
prodOrDev.value = (await $ls.getValue('update_loading')) === 'dev' ? 'dev' : 'prod'
})
async function checkForUpdates() {
counter++
if (timeout) clearTimeout(timeout)
if (timeoutChangeProdOrDev) clearTimeout(timeoutChangeProdOrDev)
timeout = window.setTimeout(() => {
counter = 0
}, 500)
if (counter === 7) {
timeoutChangeProdOrDev = window.setTimeout(async () => {
if ((await $ls.getValue('update_loading')) !== 'dev') {
await $ls.setValue('update_loading', 'dev')
prodOrDev.value = 'dev'
} else {
await $ls.setValue('update_loading', 'prod')
prodOrDev.value = 'prod'
}
console.log(await $ls.getValue('update_loading'))
await useUpdater(true)
clearTimeout(timeoutChangeProdOrDev)
}, 2000)
}
}
async function handleLogout() {
const success = await useLogout()
if (success) {
@@ -151,7 +99,6 @@ const items = computed<DropdownMenuItem[][]>(() => [
{
label: orgStore.currentOrg?.name || t('userMenu.switchOrganization'),
icon: 'i-lucide-building-2',
children: orgStore.organizations.map(org => ({
label: org.name,
icon: org.id === orgStore.currentOrg?.id ? 'i-lucide-check' : undefined,
@@ -164,42 +111,12 @@ const items = computed<DropdownMenuItem[][]>(() => [
] : [],
[
{
label: t('userMenu.accountSettings'),
label: t('settings.title'),
icon: 'i-lucide-settings',
onSelect() {
router.push({ name: 'account' })
router.push({ name: 'settings' })
},
},
{
label: t('userMenu.uiCustomization'),
icon: 'i-lucide-sliders-horizontal',
onSelect() {
router.push({ name: 'ui-customization' })
},
},
{
label: t('userMenu.organizations'),
icon: 'i-lucide-building-2',
onSelect() {
router.push({ name: 'organizations' })
},
},
{
label: t('userMenu.analytics'),
icon: 'i-lucide-bar-chart-3',
onSelect() {
router.push({ name: 'analytics' })
},
},
{
label: t('userMenu.timeReports'),
icon: 'i-lucide-clock-4',
onSelect() {
router.push({ name: 'time-reports' })
},
},
],
[
{
label: t('userMenu.appearance'),
icon: 'i-lucide-sun-moon',
@@ -219,147 +136,13 @@ const items = computed<DropdownMenuItem[][]>(() => [
icon: 'i-lucide-moon',
type: 'checkbox',
checked: colorMode.value === 'dark',
onUpdateChecked(checked: boolean) {
if (checked) {
colorMode.value = 'dark'
}
},
onSelect(e: Event) {
e.preventDefault()
colorMode.value = 'dark'
},
},
],
},
{
label: t('userMenu.language'),
icon: 'i-lucide-languages',
children: [
{
label: 'English',
type: 'checkbox',
checked: locale.value === 'en',
onSelect(e: Event) {
e.preventDefault()
saveLocale('en')
},
},
{
label: 'Русский',
type: 'checkbox',
checked: locale.value === 'ru',
onSelect(e: Event) {
e.preventDefault()
saveLocale('ru')
},
},
{
label: 'Deutsch',
type: 'checkbox',
checked: locale.value === 'de',
onSelect(e: Event) {
e.preventDefault()
saveLocale('de')
},
},
{
label: 'Español',
type: 'checkbox',
checked: locale.value === 'es',
onSelect(e: Event) {
e.preventDefault()
saveLocale('es')
},
},
],
},
{
label: t('userMenu.taskDetailView'),
icon: 'i-lucide-panel-right',
children: [
{
label: t('userMenu.taskDetailSlideover'),
icon: 'i-lucide-panel-right',
type: 'checkbox',
checked: appStore.taskDetailDisplayMode === 'slideover',
onSelect(e: Event) {
e.preventDefault()
appStore.setTaskDetailDisplayMode('slideover')
},
},
{
label: t('userMenu.taskDetailModal'),
icon: 'i-lucide-square',
type: 'checkbox',
checked: appStore.taskDetailDisplayMode === 'modal',
onSelect(e: Event) {
e.preventDefault()
appStore.setTaskDetailDisplayMode('modal')
},
},
],
},
{
label: t('userMenu.sidebarView'),
icon: 'i-lucide-panel-left',
children: [
{
label: t('userMenu.sidebarViewSecond'),
icon: 'i-lucide-layout-list',
type: 'checkbox',
checked: appStore.sidebarView === 'second',
onSelect(e: Event) {
e.preventDefault()
appStore.setSidebarView('second')
},
},
{
label: t('userMenu.sidebarViewFirst'),
icon: 'i-lucide-panel-left',
type: 'checkbox',
checked: appStore.sidebarView === 'first',
onSelect(e: Event) {
e.preventDefault()
appStore.setSidebarView('first')
},
},
],
},
],
[
{
label: t('userMenu.site'),
icon: 'i-lucide-globe',
to: 'https://taskview.tech/',
target: '_blank',
},
{
label: t('userMenu.documentation'),
icon: 'i-lucide-book-open',
to: 'https://taskview.tech/docs/',
target: '_blank',
},
{
label: t('userMenu.github'),
icon: 'simple-icons:github',
to: 'https://github.com/Gimanh/taskview-community',
target: '_blank',
},
{
label: t('userMenu.docker'),
icon: 'simple-icons:docker',
to: 'https://hub.docker.com/u/gimanhead',
target: '_blank',
},
],
[
{
label: `v ${APP_VERSION}${prodOrDev.value === 'dev' ? '_d' : ''}`,
icon: 'i-lucide-info',
onSelect(e: Event) {
e.preventDefault()
checkForUpdates()
},
},
],
[
{
@@ -2,15 +2,39 @@
<div class="flex flex-col gap-6 p-1 lg:p-6 w-full max-w-full lg:max-w-2xl m-0 lg:mx-auto">
<NotificationSettings />
<UPageCard class="w-full rounded-3xl">
<UPageCard
variant="soft"
class="w-full rounded-3xl"
>
<SessionsPanel />
</UPageCard>
<UPageCard class="w-full rounded-3xl">
<UPageCard
variant="soft"
class="w-full rounded-3xl"
>
<ApiTokensPanel />
</UPageCard>
<UPageCard class="w-full rounded-3xl">
<UPageCard
v-if="isDefaultUser"
variant="soft"
class="w-full rounded-3xl bg-warning/10 ring-2 ring-warning/60"
>
<DefaultUserCredentials />
</UPageCard>
<UPageCard
variant="soft"
class="w-full rounded-3xl"
>
<PasswordSettings />
</UPageCard>
<UPageCard
variant="soft"
class="w-full rounded-3xl"
>
<div class="flex flex-col gap-4">
<h2 class="text-lg font-semibold">
{{ t('account.management') }}
@@ -34,12 +58,14 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user.store'
import DeleteAccountButton from './parts/DeleteAccountButton.vue'
import DeleteAccountCodeModal from './parts/DeleteAccountCodeModal.vue'
import NotificationSettings from './parts/NotificationSettings.vue'
import PasswordSettings from './parts/PasswordSettings.vue'
import DefaultUserCredentials from './parts/DefaultUserCredentials.vue'
import SessionsPanel from '@/components/features/sessions/SessionsPanel.vue'
import ApiTokensPanel from '@/components/features/api-tokens/ApiTokensPanel.vue'
@@ -49,6 +75,9 @@ const toast = useToast()
const showCodeModal = ref(false)
// Seeded default user on self-hosted installs (migration 0.0.0); backend enforces the same check
const isDefaultUser = computed(() => userStore.email.toLowerCase() === 'test@mail.dest')
function onCodeSendError() {
toast.add({
title: t('account.codeSendError'),
@@ -0,0 +1,217 @@
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3">
<h2 class="flex items-center gap-2 text-lg font-semibold">
<UIcon
name="i-lucide-triangle-alert"
class="size-5 text-warning shrink-0"
/>
{{ t('account.defaultCredsTitle') }}
</h2>
<UAlert
color="warning"
variant="subtle"
icon="i-lucide-shield-alert"
:title="t('account.defaultCredsWarning')"
:description="t('account.defaultCredsDescription')"
/>
</div>
<UForm
:state="state"
:schema="CredentialsSchema"
:validate="validatePasswordsMatch"
class="space-y-4"
@submit="save"
>
<UFormField
:label="t('account.newLogin')"
name="login"
>
<UInput
v-model="state.login"
:placeholder="t('account.newLoginPlaceholder')"
icon="i-lucide-user"
autocomplete="username"
class="w-full"
/>
</UFormField>
<UFormField
:label="t('account.newEmail')"
name="email"
>
<UInput
v-model="state.email"
type="email"
:placeholder="t('auth.emailPlaceholder')"
icon="i-lucide-mail"
autocomplete="email"
class="w-full"
/>
</UFormField>
<UFormField
:label="t('auth.newPassword')"
name="password"
>
<UInput
v-model="state.password"
:type="showNewPassword ? 'text' : 'password'"
:placeholder="t('auth.newPasswordPlaceholder')"
icon="i-lucide-lock"
autocomplete="new-password"
class="w-full"
>
<template #trailing>
<UButton
:icon="showNewPassword ? 'i-lucide-eye-off' : 'i-lucide-eye'"
color="neutral"
variant="link"
size="sm"
@click="showNewPassword = !showNewPassword"
/>
</template>
</UInput>
</UFormField>
<UFormField
:label="t('auth.confirmPassword')"
name="passwordRepeat"
>
<UInput
v-model="state.passwordRepeat"
:type="showNewPasswordRepeat ? 'text' : 'password'"
:placeholder="t('auth.confirmPasswordPlaceholder')"
icon="i-lucide-lock"
autocomplete="new-password"
class="w-full"
>
<template #trailing>
<UButton
:icon="showNewPasswordRepeat ? 'i-lucide-eye-off' : 'i-lucide-eye'"
color="neutral"
variant="link"
size="sm"
@click="showNewPasswordRepeat = !showNewPasswordRepeat"
/>
</template>
</UInput>
</UFormField>
<UFormField
:label="t('account.currentPassword')"
name="currentPassword"
>
<UInput
v-model="state.currentPassword"
:type="showPassword ? 'text' : 'password'"
:placeholder="t('account.currentPasswordPlaceholder')"
icon="i-lucide-lock"
autocomplete="current-password"
class="w-full"
>
<template #trailing>
<UButton
:icon="showPassword ? 'i-lucide-eye-off' : 'i-lucide-eye'"
color="neutral"
variant="link"
size="sm"
@click="showPassword = !showPassword"
/>
</template>
</UInput>
</UFormField>
<div>
<UButton
:label="t('account.saveCredentials')"
type="submit"
color="primary"
:loading="isSaving"
/>
</div>
</UForm>
</div>
</template>
<script setup lang="ts">
import { type } from 'arktype'
import { isAxiosError } from 'axios'
import { ref, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import $api from '@/helpers/axios'
import { $ls } from '@/plugins/axios'
const { t } = useI18n()
const toast = useToast()
const router = useRouter()
const isSaving = ref(false)
const showPassword = ref(false)
const showNewPassword = ref(false)
const showNewPasswordRepeat = ref(false)
const CredentialsSchema = type({
login: type('3 <= string <= 64').configure({ message: t('account.loginInvalid') }),
email: type('string.email').configure({ message: t('auth.invalidEmail') }),
password: type('string >= 6').configure({ message: t('auth.passwordTooShort') }),
passwordRepeat: type('string >= 6').configure({ message: t('auth.passwordTooShort') }),
currentPassword: type('string >= 1').configure({ message: t('account.currentPasswordRequired') }),
})
const state = reactive({
login: '',
email: '',
password: '',
passwordRepeat: '',
currentPassword: '',
})
function validatePasswordsMatch(formState: Partial<{ password: string; passwordRepeat: string }>) {
const errors = []
if (formState.password && formState.passwordRepeat && formState.password !== formState.passwordRepeat) {
errors.push({ name: 'passwordRepeat', message: t('auth.passwordsDoNotMatch') })
}
return errors
}
async function save() {
isSaving.value = true
try {
const result = await $api.post<{ changed: boolean }>('/module/auth/credentials/change', {
currentPassword: state.currentPassword,
login: state.login.trim(),
email: state.email.trim(),
password: state.password,
passwordRepeat: state.passwordRepeat,
})
if (result.data.changed) {
toast.add({
title: t('account.credentialsChanged'),
color: 'success',
})
$ls.invalidateTokens()
router.push('/')
}
} catch (error) {
toast.add({
title: errorMessage(error),
color: 'error',
})
} finally {
isSaving.value = false
}
}
function errorMessage(error: unknown): string {
if (isAxiosError(error)) {
const field = error.response?.data?.field
if (error.response?.status === 403 && field === 'currentPassword') return t('account.wrongPassword')
if (error.response?.status === 409 && field === 'login') return t('account.loginTaken')
if (error.response?.status === 409 && field === 'email') return t('account.emailTaken')
}
return t('account.credentialsChangeError')
}
</script>
@@ -1,5 +1,8 @@
<template>
<UPageCard class="w-full rounded-3xl">
<UPageCard
variant="soft"
class="w-full rounded-3xl"
>
<div class="flex flex-col gap-4">
<div>
<h2 class="text-lg font-semibold">
@@ -0,0 +1,106 @@
<template>
<UModal
v-model:open="open"
:fullscreen="isMobile"
>
<template #content>
<div class="flex flex-col gap-4 p-6">
<div>
<h3 class="text-lg font-semibold">
{{ t('account.confirmPasswordChange') }}
</h3>
<p class="text-sm text-muted mt-1">
{{ t('account.passwordCodeSent') }}
</p>
</div>
<UInput
v-model="code"
:placeholder="t('account.enterCode')"
spellcheck="false"
autocomplete="one-time-code"
variant="soft"
:ui="{ base: 'rounded-xl' }"
/>
<div class="flex justify-end gap-2">
<UButton
color="neutral"
variant="outline"
@click="open = false"
>
{{ t('account.cancel') }}
</UButton>
<UButton
color="primary"
:disabled="!code"
:loading="isLoading"
@click="handleConfirm"
>
{{ t('account.changePassword') }}
</UButton>
</div>
</div>
</template>
</UModal>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import $api from '@/helpers/axios'
import { useTaskView } from '@/composables/useTaskView'
const props = defineProps<{
password: string
passwordRepeat: string
}>()
const emit = defineEmits<{
changed: []
}>()
const open = defineModel<boolean>('open', { required: true })
const { t } = useI18n()
const { isMobile } = useTaskView()
const toast = useToast()
const code = ref('')
const isLoading = ref(false)
watch(open, (value) => {
if (value) code.value = ''
})
async function handleConfirm() {
isLoading.value = true
try {
const result = await $api.post<{ changed: boolean }>('/module/auth/password/change', {
code: code.value.trim(),
password: props.password,
passwordRepeat: props.passwordRepeat,
})
if (result.data.changed) {
toast.add({
title: t('account.passwordChanged'),
color: 'success',
})
open.value = false
emit('changed')
} else {
toast.add({
title: t('account.passwordChangeError'),
color: 'error',
})
}
} catch {
toast.add({
title: t('account.passwordChangeError'),
color: 'error',
})
} finally {
isLoading.value = false
}
}
</script>
@@ -0,0 +1,214 @@
<template>
<div class="flex flex-col gap-4">
<div>
<h2 class="text-lg font-semibold">
{{ t('account.password') }}
</h2>
<p class="text-sm text-muted mt-1">
{{ byCurrentPassword ? t('account.passwordDescriptionByPassword') : t('account.passwordDescription') }}
</p>
</div>
<UForm
:state="state"
:schema="PasswordSchema"
:validate="validatePasswordsMatch"
class="space-y-4"
@submit="sendCode"
>
<UFormField
:label="t('auth.newPassword')"
name="password"
>
<UInput
v-model="state.password"
:type="showPassword ? 'text' : 'password'"
:placeholder="t('auth.newPasswordPlaceholder')"
icon="i-lucide-lock"
class="w-full"
>
<template #trailing>
<UButton
:icon="showPassword ? 'i-lucide-eye-off' : 'i-lucide-eye'"
color="neutral"
variant="link"
size="sm"
@click="showPassword = !showPassword"
/>
</template>
</UInput>
</UFormField>
<UFormField
:label="t('auth.confirmPassword')"
name="passwordRepeat"
>
<UInput
v-model="state.passwordRepeat"
:type="showPasswordRepeat ? 'text' : 'password'"
:placeholder="t('auth.confirmPasswordPlaceholder')"
icon="i-lucide-lock"
class="w-full"
>
<template #trailing>
<UButton
:icon="showPasswordRepeat ? 'i-lucide-eye-off' : 'i-lucide-eye'"
color="neutral"
variant="link"
size="sm"
@click="showPasswordRepeat = !showPasswordRepeat"
/>
</template>
</UInput>
</UFormField>
<UFormField
v-if="byCurrentPassword"
:label="t('account.currentPassword')"
name="currentPassword"
>
<UInput
v-model="state.currentPassword"
:type="showCurrentPassword ? 'text' : 'password'"
:placeholder="t('account.currentPasswordPlaceholder')"
icon="i-lucide-lock"
autocomplete="current-password"
class="w-full"
>
<template #trailing>
<UButton
:icon="showCurrentPassword ? 'i-lucide-eye-off' : 'i-lucide-eye'"
color="neutral"
variant="link"
size="sm"
@click="showCurrentPassword = !showCurrentPassword"
/>
</template>
</UInput>
</UFormField>
<div>
<UButton
:label="byCurrentPassword ? t('account.changePassword') : t('account.sendPasswordCode')"
type="submit"
color="primary"
:loading="isSending"
/>
</div>
</UForm>
<PasswordCodeModal
v-model:open="showCodeModal"
:password="state.password"
:password-repeat="state.passwordRepeat"
@changed="onPasswordChanged"
/>
</div>
</template>
<script setup lang="ts">
import { type } from 'arktype'
import { isAxiosError } from 'axios'
import { ref, computed, reactive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import $api from '@/helpers/axios'
import { logError } from '@/helpers/Helper'
import PasswordCodeModal from './PasswordCodeModal.vue'
const { t } = useI18n()
const toast = useToast()
const isSending = ref(false)
const showPassword = ref(false)
const showPasswordRepeat = ref(false)
const showCurrentPassword = ref(false)
const showCodeModal = ref(false)
const confirmationMode = ref<'email' | 'password'>('email')
const byCurrentPassword = computed(() => confirmationMode.value === 'password')
onMounted(async () => {
const result = await $api
.get<{ mode: 'email' | 'password' }>('/module/auth/password/change/mode')
.catch(logError)
if (result) confirmationMode.value = result.data.mode
})
const PasswordSchema = type({
password: type('string >= 6').configure({ message: t('auth.passwordTooShort') }),
passwordRepeat: type('string >= 6').configure({ message: t('auth.passwordTooShort') }),
})
const state = reactive({
password: '',
passwordRepeat: '',
currentPassword: '',
})
function validatePasswordsMatch(formState: Partial<{ password: string; passwordRepeat: string; currentPassword: string }>) {
const errors = []
if (formState.password && formState.passwordRepeat && formState.password !== formState.passwordRepeat) {
errors.push({ name: 'passwordRepeat', message: t('auth.passwordsDoNotMatch') })
}
if (byCurrentPassword.value && !formState.currentPassword) {
errors.push({ name: 'currentPassword', message: t('account.currentPasswordRequired') })
}
return errors
}
async function sendCode() {
if (byCurrentPassword.value) {
await changeByCurrentPassword()
return
}
isSending.value = true
try {
await $api.post('/module/auth/password/change/code')
showCodeModal.value = true
} catch (error) {
const isCooldown = isAxiosError(error) && error.response?.status === 429
toast.add({
title: isCooldown ? t('account.passwordCodeCooldown') : t('account.passwordCodeSendError'),
color: 'error',
})
} finally {
isSending.value = false
}
}
async function changeByCurrentPassword() {
isSending.value = true
try {
const result = await $api.post<{ changed: boolean }>('/module/auth/password/change', {
currentPassword: state.currentPassword,
password: state.password,
passwordRepeat: state.passwordRepeat,
})
if (result.data.changed) {
toast.add({
title: t('account.passwordChanged'),
color: 'success',
})
onPasswordChanged()
}
} catch (error) {
const isWrongPassword = isAxiosError(error) && error.response?.status === 403
toast.add({
title: isWrongPassword ? t('account.wrongPassword') : t('account.passwordChangeError'),
color: 'error',
})
} finally {
isSending.value = false
}
}
function onPasswordChanged() {
state.password = ''
state.passwordRepeat = ''
state.currentPassword = ''
showPassword.value = false
showPasswordRepeat.value = false
showCurrentPassword.value = false
}
</script>
@@ -61,7 +61,7 @@
<UButton
:label="t('common.delete')"
color="error"
variant="outline"
variant="soft"
:loading="deleting"
@click="handleDelete"
/>
+81 -10
View File
@@ -19,11 +19,26 @@
</template>
<!-- Login Views -->
<template v-else-if="isLoadingOptions">
<div class="flex justify-center py-10">
<UIcon
name="i-lucide-loader-circle"
class="size-6 animate-spin text-muted"
/>
</div>
</template>
<template v-else>
<SocialButtons />
<SocialButtons
v-if="loginOptions.socialProviders.length > 0"
:providers="loginOptions.socialProviders"
/>
<!-- Divider -->
<div class="relative">
<div
v-if="loginOptions.socialProviders.length > 0 && tabs.length > 0"
class="relative"
>
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-default" />
</div>
@@ -32,8 +47,23 @@
</div>
</div>
<!-- Single method no tabs needed -->
<template v-if="tabs.length === 1">
<LoginByCode
v-if="tabs[0].value === 'code'"
@success="handleSuccess"
/>
<LoginByPassword
v-else-if="tabs[0].value === 'password'"
@success="handleSuccess"
@forgot-password="currentView = 'forgot'"
/>
<LoginBySso v-else-if="tabs[0].value === 'sso'" />
</template>
<!-- Tabs -->
<UTabs
v-else-if="tabs.length > 1"
v-model="currentView"
:items="tabs"
class="w-full"
@@ -62,8 +92,11 @@
</UTabs>
</template>
<!-- Server Selector -->
<UCollapsible class="flex flex-col gap-2">
<!-- Server Selector (hidden when the API URL is pinned at deploy time) -->
<UCollapsible
v-if="!isServerLocked"
class="flex flex-col gap-2"
>
<UButton
class="group"
:label="t('server.selectServer')"
@@ -99,8 +132,11 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, reactive, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import $api from '@/helpers/axios'
import { logError } from '@/helpers/Helper'
import { getConfiguredApiUrl } from '@/helpers/serverConfig'
import LoginByCode from './LoginByCode.vue'
import LoginByPassword from './LoginByPassword.vue'
import LoginBySso from './LoginBySso.vue'
@@ -116,13 +152,48 @@ const emit = defineEmits<{
type View = 'code' | 'password' | 'sso' | 'forgot'
type LoginOptions = {
magicLink: boolean
password: boolean
sso: boolean
socialProviders: string[]
}
const currentView = ref<View>('code')
const tabs = computed(() => [
{ value: 'code', label: t('auth.magicLink'), slot: 'code' as const },
{ value: 'password', label: t('auth.password'), slot: 'password' as const },
{ value: 'sso', label: 'SSO', slot: 'sso' as const },
])
const isLoadingOptions = ref(true)
const isServerLocked = getConfiguredApiUrl() !== null
const loginOptions = reactive<LoginOptions>({
magicLink: true,
password: true,
sso: true,
socialProviders: ['google', 'github', 'apple'],
})
onMounted(async () => {
try {
const result = await $api.get<LoginOptions>('/module/auth/login-options').catch(logError)
if (result) Object.assign(loginOptions, result.data)
} finally {
isLoadingOptions.value = false
}
})
const tabs = computed(() => {
const items = []
if (loginOptions.magicLink) items.push({ value: 'code', label: t('auth.magicLink'), slot: 'code' as const })
if (loginOptions.password) items.push({ value: 'password', label: t('auth.password'), slot: 'password' as const })
if (loginOptions.sso) items.push({ value: 'sso', label: 'SSO', slot: 'sso' as const })
return items
})
watch(tabs, (items) => {
if (currentView.value === 'forgot') return
if (!items.some((item) => item.value === currentView.value)) {
currentView.value = (items[0]?.value ?? 'code') as View
}
})
function onTabChange(value: string | number) {
currentView.value = value as View
@@ -1,6 +1,7 @@
<template>
<div class="space-y-3">
<UButton
v-if="providers.includes('google')"
:label="t('auth.continueWithGoogle')"
icon="i-lucide-chrome"
color="neutral"
@@ -11,6 +12,7 @@
@click="handleLogin('google')"
/>
<UButton
v-if="providers.includes('github')"
:label="t('auth.continueWithGithub')"
icon="i-lucide-github"
color="neutral"
@@ -21,6 +23,7 @@
@click="handleLogin('github')"
/>
<UButton
v-if="providers.includes('apple')"
:label="t('auth.continueWithApple')"
icon="i-lucide-apple"
color="neutral"
@@ -42,6 +45,10 @@ import { useAdditionalServer } from '@/composables/useAdditionalServer'
type Provider = 'google' | 'github' | 'apple'
defineProps<{
providers: string[]
}>()
const { t } = useI18n()
const isLoading = ref<Provider | null>(null)
@@ -1,15 +1,51 @@
import { ALL_TASKS_LIST_ID, type DefaultView } from 'taskview-api'
import type { RouteLocationRaw, Router } from 'vue-router'
import { $tvApi } from '@/plugins/axios'
import { useUserStore } from '@/stores/user.store'
import { useOrganizationStore } from '@/stores/organization.store'
import { Router } from 'vue-router'
import { useUiPreferencesStore } from '@/stores/uiPreferences.store'
const VIEW_ROUTES: Record<DefaultView, string> = {
tasks: 'user',
kanban: 'kanban',
graph: 'graph',
sprints: 'sprints',
}
export const redirectToUser = async (router: Router) => {
const userStore = useUserStore()
if (userStore.accessToken) {
const orgStore = useOrganizationStore()
if (!orgStore.organizations.length) {
await orgStore.fetchOrganizations()
orgStore.restoreCurrentOrg()
}
await router.push({ name: 'user', params: { orgSlug: orgStore.currentOrgSlug } })
if (!userStore.accessToken) return
const orgStore = useOrganizationStore()
if (!orgStore.organizations.length) {
await orgStore.fetchOrganizations()
orgStore.restoreCurrentOrg()
}
}
const defaultRoute = await resolveDefaultRoute()
await router.push(defaultRoute ?? { name: 'user', params: { orgSlug: orgStore.currentOrgSlug } })
}
export const resolveDefaultRoute = async (): Promise<RouteLocationRaw | null> => {
const uiPrefs = useUiPreferencesStore()
if (!uiPrefs.loaded) await uiPrefs.fetch()
const projectId = uiPrefs.settings.defaultProjectId
if (!projectId) return null
// The default project may live in any of the user's organizations; try the current one first
const orgStore = useOrganizationStore()
const orgs = [...orgStore.organizations].sort((a) => (a.slug === orgStore.currentOrgSlug ? -1 : 1))
for (const org of orgs) {
const goals = await $tvApi.goals.fetchGoals(org.id)
if (!goals?.some((goal) => goal.id === projectId)) continue
const view = uiPrefs.settings.defaultView ?? 'tasks'
const params: Record<string, string | number> = { orgSlug: org.slug, projectId }
if (view === 'tasks') params.listId = ALL_TASKS_LIST_ID
return { name: VIEW_ROUTES[view], params }
}
return null
}
@@ -44,7 +44,7 @@ const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const { isSidebarOpen } = useDashboard()
const { isUserRoute, isAccountRoute, hasProject, projectId } = useAppRouteInfo()
const { isUserRoute, isSettingsRoute, hasProject, projectId } = useAppRouteInfo()
const goalsStore = useGoalsStore()
const currentGoal = computed(() =>
@@ -105,11 +105,11 @@ const navItems = computed<NavItem[]>(() => {
// so drop Settings there — it stays reachable from the global tabs.
if (!hasProject.value) {
items.push({
key: 'account',
key: 'settings',
label: t('account.nav'),
icon: 'i-lucide-settings',
to: { name: 'account' },
active: () => isAccountRoute.value,
to: { name: 'settings' },
active: () => isSettingsRoute.value,
})
}
@@ -0,0 +1,43 @@
<template>
<div class="mx-auto flex w-full max-w-2xl flex-col gap-6 p-3 pb-24 lg:p-6 lg:pb-6">
<SettingsProfileHeader
:name="identity.name"
:email="identity.email"
/>
<SettingsSection
v-for="section in sections"
:key="section.key"
:title="section.title"
>
<template
v-for="item in section.items"
:key="item.key"
>
<SettingsNavRow
v-if="item.kind === 'nav'"
:item="item"
/>
<SettingsSelectRow
v-else
:item="item"
/>
</template>
<SettingsVersionRow v-if="section.key === 'about'" />
</SettingsSection>
<SettingsLogoutRow />
</div>
</template>
<script setup lang="ts">
import { useSettingsHub } from './composables/useSettingsHub'
import SettingsProfileHeader from './parts/SettingsProfileHeader.vue'
import SettingsSection from './parts/SettingsSection.vue'
import SettingsNavRow from './parts/SettingsNavRow.vue'
import SettingsSelectRow from './parts/SettingsSelectRow.vue'
import SettingsVersionRow from './parts/SettingsVersionRow.vue'
import SettingsLogoutRow from './parts/SettingsLogoutRow.vue'
const { identity, sections } = useSettingsHub()
</script>
@@ -0,0 +1,139 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useColorMode } from '@vueuse/core'
import type { Locale } from '@/locales'
import type { SidebarView, TaskDetailDisplayMode } from '@/types/global-app.types'
import { useAppStore } from '@/stores/app.store'
import { useUserStore } from '@/stores/user.store'
import { useOrganizationStore } from '@/stores/organization.store'
import { useOrgSwitcher } from '@/composables/useOrgSwitcher'
import { saveLocale } from '@/plugins/i18n'
import type { SettingsSection } from '../types'
const LANGUAGE_OPTIONS = [
{ label: 'English', value: 'en' },
{ label: 'Русский', value: 'ru' },
{ label: 'Deutsch', value: 'de' },
{ label: 'Español', value: 'es' },
]
export function useSettingsHub() {
const { t, locale } = useI18n()
const colorMode = useColorMode()
const appStore = useAppStore()
const userStore = useUserStore()
const orgStore = useOrganizationStore()
const { switchOrg } = useOrgSwitcher()
const identity = computed(() => ({
name: userStore.login || userStore.email,
email: userStore.email,
}))
const sections = computed<SettingsSection[]>(() => {
const account: SettingsSection = {
key: 'account',
title: t('settings.account'),
items: [
{ kind: 'nav', key: 'account', icon: 'i-lucide-user-cog', color: 'emerald', title: t('userMenu.accountSettings'), to: { name: 'account' } },
{ kind: 'nav', key: 'organizations', icon: 'i-lucide-building-2', color: 'violet', title: t('userMenu.organizations'), to: { name: 'organizations' } },
],
}
if (orgStore.organizations.length > 1) {
account.items.unshift({
kind: 'select',
key: 'org-switch',
icon: 'i-lucide-arrow-left-right',
color: 'indigo',
title: t('userMenu.switchOrganization'),
options: orgStore.organizations.map((o) => ({ label: o.name, value: String(o.id) })),
get: () => String(orgStore.currentOrg?.id ?? ''),
set: (value) => {
const org = orgStore.organizations.find((o) => String(o.id) === value)
if (org) switchOrg(org)
},
})
}
const appearance: SettingsSection = {
key: 'appearance',
title: t('settings.appearance'),
items: [
{
kind: 'select',
key: 'theme',
icon: 'i-lucide-sun-moon',
color: 'amber',
title: t('userMenu.appearance'),
options: [
{ label: t('userMenu.light'), value: 'light' },
{ label: t('userMenu.dark'), value: 'dark' },
{ label: t('settings.system'), value: 'auto' },
],
get: () => colorMode.value,
set: (value) => { colorMode.value = value as typeof colorMode.value },
},
{
kind: 'select',
key: 'language',
icon: 'i-lucide-languages',
color: 'sky',
title: t('userMenu.language'),
options: LANGUAGE_OPTIONS,
get: () => locale.value,
set: (value) => saveLocale(value as Locale),
},
{ kind: 'nav', key: 'ui-customization', icon: 'i-lucide-sliders-horizontal', color: 'rose', title: t('userMenu.uiCustomization'), to: { name: 'ui-customization' } },
],
}
const layout: SettingsSection = {
key: 'layout',
title: t('settings.layout'),
items: [
{
kind: 'select',
key: 'task-detail',
icon: 'i-lucide-panel-right',
color: 'indigo',
title: t('userMenu.taskDetailView'),
options: [
{ label: t('userMenu.taskDetailSlideover'), value: 'slideover' },
{ label: t('userMenu.taskDetailModal'), value: 'modal' },
],
get: () => appStore.taskDetailDisplayMode,
set: (value) => appStore.setTaskDetailDisplayMode(value as TaskDetailDisplayMode),
},
{
kind: 'select',
key: 'sidebar-view',
icon: 'i-lucide-panel-left',
color: 'teal',
title: t('userMenu.sidebarView'),
options: [
{ label: t('userMenu.sidebarViewSecond'), value: 'second' },
{ label: t('userMenu.sidebarViewFirst'), value: 'first' },
],
get: () => appStore.sidebarView,
set: (value) => appStore.setSidebarView(value as SidebarView),
},
],
}
const about: SettingsSection = {
key: 'about',
title: t('settings.about'),
items: [
{ kind: 'nav', key: 'site', icon: 'i-lucide-globe', color: 'zinc', title: t('userMenu.site'), href: 'https://taskview.tech/' },
{ kind: 'nav', key: 'docs', icon: 'i-lucide-book-open', color: 'zinc', title: t('userMenu.documentation'), href: 'https://taskview.tech/docs/' },
{ kind: 'nav', key: 'github', icon: 'simple-icons:github', color: 'zinc', title: t('userMenu.github'), href: 'https://github.com/Gimanh/taskview-community' },
{ kind: 'nav', key: 'docker', icon: 'simple-icons:docker', color: 'zinc', title: t('userMenu.docker'), href: 'https://hub.docker.com/u/gimanhead' },
],
}
return [account, appearance, layout, about]
})
return { identity, sections }
}
@@ -0,0 +1,36 @@
<template>
<span
class="flex items-center justify-center shrink-0 size-9 rounded-xl"
:class="tint"
>
<UIcon
:name="icon"
class="size-5"
/>
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { SettingsTint } from '../types'
const props = defineProps<{
icon: string
color: SettingsTint
}>()
const TILE_TINTS: Record<SettingsTint, string> = {
emerald: 'bg-emerald-100 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-400',
violet: 'bg-violet-100 text-violet-600 dark:bg-violet-950 dark:text-violet-400',
indigo: 'bg-indigo-100 text-indigo-600 dark:bg-indigo-950 dark:text-indigo-400',
blue: 'bg-blue-100 text-blue-600 dark:bg-blue-950 dark:text-blue-400',
cyan: 'bg-cyan-100 text-cyan-600 dark:bg-cyan-950 dark:text-cyan-400',
amber: 'bg-amber-100 text-amber-600 dark:bg-amber-950 dark:text-amber-400',
sky: 'bg-sky-100 text-sky-600 dark:bg-sky-950 dark:text-sky-400',
rose: 'bg-rose-100 text-rose-600 dark:bg-rose-950 dark:text-rose-400',
teal: 'bg-teal-100 text-teal-600 dark:bg-teal-950 dark:text-teal-400',
zinc: 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400',
}
const tint = computed(() => TILE_TINTS[props.color])
</script>
@@ -0,0 +1,39 @@
<template>
<button
type="button"
class="flex w-full items-center gap-3 rounded-2xl bg-elevated/40 border border-default px-3 py-2.5 text-left transition-colors hover:bg-error/10 focus-visible:bg-error/10 focus:outline-none"
@click="onLogout"
>
<SettingsIconTile
icon="i-lucide-log-out"
color="rose"
/>
<span class="min-w-0 flex-1 truncate text-sm font-medium text-error">
{{ t('userMenu.logout') }}
</span>
</button>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useLogout } from '@/composables/useLogout'
import SettingsIconTile from './SettingsIconTile.vue'
const { t } = useI18n()
const router = useRouter()
const toast = useToast()
async function onLogout() {
const success = await useLogout()
if (success) {
router.push('/')
} else {
toast.add({
title: t('userMenu.logoutFailed'),
description: t('userMenu.logoutFailedDescription'),
color: 'error',
})
}
}
</script>
@@ -0,0 +1,33 @@
<template>
<ULink
:to="item.to"
:href="item.href"
:target="isExternal ? '_blank' : undefined"
:rel="isExternal ? 'noopener noreferrer' : undefined"
class="flex items-center gap-3 px-3 py-2.5 transition-colors hover:bg-elevated focus-visible:bg-elevated focus:outline-none"
>
<SettingsIconTile
:icon="item.icon"
:color="item.color"
/>
<span class="min-w-0 flex-1 truncate text-sm font-medium text-highlighted">
{{ item.title }}
</span>
<UIcon
:name="isExternal ? 'i-lucide-arrow-up-right' : 'i-lucide-chevron-right'"
class="size-4 shrink-0 text-dimmed"
/>
</ULink>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { SettingsNavRowItem } from '../types'
import SettingsIconTile from './SettingsIconTile.vue'
const props = defineProps<{
item: SettingsNavRowItem
}>()
const isExternal = computed(() => !!props.item.href)
</script>
@@ -0,0 +1,28 @@
<template>
<div class="flex items-center gap-4 rounded-2xl bg-elevated/40 border border-default p-4">
<UAvatar
:alt="name"
icon="i-lucide-user"
size="xl"
class="ring-2 ring-primary/40"
/>
<div class="flex min-w-0 flex-col">
<span class="truncate text-base font-semibold text-highlighted">
{{ name }}
</span>
<span
v-if="email && email !== name"
class="truncate text-sm text-muted"
>
{{ email }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
name: string
email: string
}>()
</script>
@@ -0,0 +1,19 @@
<template>
<section class="flex flex-col gap-2">
<h2
v-if="title"
class="px-3 text-xs font-semibold uppercase tracking-wider text-muted"
>
{{ title }}
</h2>
<div class="overflow-hidden rounded-2xl bg-elevated/40 border border-default divide-y divide-default">
<slot />
</div>
</section>
</template>
<script setup lang="ts">
defineProps<{
title?: string
}>()
</script>
@@ -0,0 +1,29 @@
<template>
<div class="flex items-center gap-3 px-3 py-2.5">
<SettingsIconTile
:icon="item.icon"
:color="item.color"
/>
<span class="min-w-0 flex-1 truncate text-sm font-medium text-highlighted">
{{ item.title }}
</span>
<USelect
:model-value="item.get()"
:items="item.options"
variant="soft"
color="neutral"
class="w-40 max-w-[45%]"
:ui="{ base: 'rounded-lg' }"
@update:model-value="item.set($event)"
/>
</div>
</template>
<script setup lang="ts">
import type { SettingsSelectRowItem } from '../types'
import SettingsIconTile from './SettingsIconTile.vue'
defineProps<{
item: SettingsSelectRowItem
}>()
</script>
@@ -0,0 +1,59 @@
<template>
<button
type="button"
class="flex w-full items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-elevated focus-visible:bg-elevated focus:outline-none"
@click="onTap"
>
<SettingsIconTile
icon="i-lucide-info"
color="zinc"
/>
<span class="min-w-0 flex-1 truncate text-sm font-medium text-highlighted">
{{ t('settings.version') }}
</span>
<span class="shrink-0 text-sm text-muted tabular-nums">
v{{ appVersion }}{{ prodOrDev === 'dev' ? '_d' : '' }}
</span>
</button>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useUpdater } from '@/composables/useUpdater'
import { $ls } from '@/plugins/axios'
import SettingsIconTile from './SettingsIconTile.vue'
const { t } = useI18n()
const appVersion = APP_VERSION
const prodOrDev = ref<'prod' | 'dev'>('prod')
let counter = 0
let resetTimer: number
let toggleTimer: number
onMounted(async () => {
prodOrDev.value = (await $ls.getValue('update_loading')) === 'dev' ? 'dev' : 'prod'
})
async function onTap() {
counter++
if (resetTimer) clearTimeout(resetTimer)
if (toggleTimer) clearTimeout(toggleTimer)
resetTimer = window.setTimeout(() => {
counter = 0
}, 500)
if (counter === 7) {
toggleTimer = window.setTimeout(async () => {
const next = (await $ls.getValue('update_loading')) === 'dev' ? 'prod' : 'dev'
await $ls.setValue('update_loading', next)
prodOrDev.value = next
await useUpdater(true)
clearTimeout(toggleTimer)
}, 2000)
}
}
</script>
@@ -0,0 +1,46 @@
import type { RouteLocationRaw } from 'vue-router'
export type SettingsTint =
| 'emerald'
| 'violet'
| 'indigo'
| 'blue'
| 'cyan'
| 'amber'
| 'sky'
| 'rose'
| 'teal'
| 'zinc'
type SettingsRowBase = {
key: string
icon: string
color: SettingsTint
title: string
}
export type SettingsNavRowItem = SettingsRowBase & {
kind: 'nav'
to?: RouteLocationRaw
href?: string
}
export type SettingsSelectOption = {
label: string
value: string
}
export type SettingsSelectRowItem = SettingsRowBase & {
kind: 'select'
options: SettingsSelectOption[]
get: () => string
set: (value: string) => void
}
export type SettingsRowItem = SettingsNavRowItem | SettingsSelectRowItem
export type SettingsSection = {
key: string
title: string
items: SettingsRowItem[]
}
@@ -26,6 +26,10 @@
</UTextarea>
</div>
</div>
<TaskIdCopy
:task-id="task.id"
class="self-start -mt-2"
/>
<!-- Source link (GitHub/GitLab issue) -->
<a
v-if="task.sourceUrl"
@@ -170,6 +174,7 @@ import TaskHistory from '@/components/features/tasks/parts/TaskHistory.vue'
import TvDeadlineSelect from '@/components/features/base/TvDeadlineSelect.vue'
import TaskRecurrence from '@/components/features/tasks/parts/TaskRecurrence.vue'
import TaskSubtasks from '@/components/features/tasks/parts/TaskSubtasks.vue'
import TaskIdCopy from '@/components/features/tasks/parts/TaskIdCopy.vue'
import TvSprintSelect from '@/components/features/base/TvSprintSelect.vue'
import TaskEstimateInput from '@/components/features/tasks/parts/TaskEstimateInput.vue'
import type { PriorityValue } from '@/composables/usePriorityOptions'
@@ -0,0 +1,34 @@
<template>
<UButton
variant="link"
color="neutral"
size="sm"
:icon="copied ? 'i-lucide-check' : 'i-lucide-hash'"
class="text-muted hover:text-default"
:title="t('tasks.copyId')"
@click="copyId"
>
{{ taskId }}
</UButton>
</template>
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
taskId: number
}>()
const { t } = useI18n()
const toast = useToast()
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
async function copyId() {
await copy(`#${props.taskId}`)
toast.add({
title: t('tasks.idCopied'),
color: 'success',
})
}
</script>
@@ -14,19 +14,84 @@
:ui="{ base: 'rounded-xl' }"
/>
</UFormField>
<UFormField
:label="t('uiCustomization.others.defaultProject')"
:description="t('uiCustomization.others.defaultProjectHint')"
>
<USelectMenu
v-model="defaultProject"
:items="projectItems"
value-key="value"
variant="soft"
class="w-full lg:w-72"
size="xl"
:ui="{ base: 'rounded-xl' }"
/>
</UFormField>
<UFormField
:label="t('uiCustomization.others.defaultView')"
:description="t('uiCustomization.others.defaultViewHint')"
>
<USelectMenu
v-model="defaultView"
:items="viewItems"
value-key="value"
:disabled="defaultProject === NONE"
variant="soft"
class="w-full lg:w-72"
size="xl"
:ui="{ base: 'rounded-xl' }"
/>
</UFormField>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import type { FirstDayOfWeek } from 'taskview-api'
import type { DefaultView, FirstDayOfWeek } from 'taskview-api'
import { useUiPreferencesStore } from '@/stores/uiPreferences.store'
import { useGoalsStore } from '@/stores/goals.store'
const { t } = useI18n()
const store = useUiPreferencesStore()
const goalsStore = useGoalsStore()
const DEFAULT = -1
const NONE = -1
onMounted(() => {
if (!goalsStore.initialized) goalsStore.fetchGoals()
})
const defaultProject = computed<number>({
get: () => store.settings.defaultProjectId ?? NONE,
set: (value) => {
store.setSetting('defaultProjectId', value === NONE ? undefined : value)
if (value === NONE) store.setSetting('defaultView', undefined)
},
})
const defaultView = computed<DefaultView>({
get: () => store.settings.defaultView ?? 'tasks',
set: (value) => {
store.setSetting('defaultView', value === 'tasks' ? undefined : value)
},
})
const projectItems = computed(() => [
{ value: NONE, label: t('uiCustomization.others.defaultProjectNone') },
...goalsStore.goals.map((goal) => ({ value: goal.id, label: goal.name })),
])
const viewItems = computed(() => [
{ value: 'tasks', label: t('uiCustomization.others.viewTasks') },
{ value: 'kanban', label: t('uiCustomization.others.viewKanban') },
{ value: 'graph', label: t('uiCustomization.others.viewGraph') },
{ value: 'sprints', label: t('uiCustomization.others.viewSprints') },
])
const weekStart = computed<number>({
get: () => store.settings.firstDayOfWeek ?? DEFAULT,
@@ -19,7 +19,11 @@
<NotificationBell />
</div>
<SidebarInboxLink />
<div class="flex items-stretch gap-2">
<SidebarInboxLink class="flex-1" />
<SidebarDefaultProjectLink />
</div>
<SidebarWorkspaceLinks />
<USeparator />
@@ -37,6 +41,8 @@ import { useDashboard } from '@/composables/useDashboard'
import TvGoalLikeItem from '@/components/features/base/TvGoalLikeItem.vue'
import ProjectsSidebar from '@/components/features/projects/ProjectsSidebar.vue'
import SidebarInboxLink from '@/components/sidebars/SidebarInboxLink.vue'
import SidebarDefaultProjectLink from '@/components/sidebars/SidebarDefaultProjectLink.vue'
import SidebarWorkspaceLinks from '@/components/sidebars/SidebarWorkspaceLinks.vue'
import NotificationBell from '@/components/NotificationBell.vue'
import ActiveTimerIndicator from '@/components/ActiveTimerIndicator.vue'
import UserMenu from '@/components/UserMenu.vue'

Some files were not shown because too many files have changed in this diff Show More