[JavaScript, Node.JS, ReactJS] Домашнее IoT-устройство глазами JS-разработчика
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
В один момент мы задумались с товарищем, а почему бы не попробовать сделать свое домашнее IoT-устройство? Недолго думая, мы остановились на концепции устройства, которое позволяет отслеживать незваных гостей и оповещать хозяина. Как это можно сделать и что для этого требуется?Через какое-то время стало ясно, что для нашей задачи должен подойти Raspberry pi в сопровождении датчика движения и камеры. На него напишем драйвер, повесим несколько различных сервисов на удаленном сервере, сделаем мобильное приложение и цель будет достигнута. Звучит вполне неплохо, самое время пробовать.Для начала мы заказали:
- сам Raspberry
- модуль камеры
- модуль детектора движения с ИК-пироэлектрическим датчиком
- соединительные провода
В заказе отсутствовал блок питания - в качестве замены полностью подойдет зарядное устройство от мобильного телефона 5V/1A. В результате получилось такого вида устройство:
Архитектура IoT-системыСледующий шагом была спроектирована архитектура:
Для нашего устройства был необходим драйвер, который отслеживал бы сигнал с датчика движения, запускал камеру, собирал всю полученную информацию и отправлял дальше. В результате мы применили комплексное решение с использованием библиотек на Java и Python. Отправленная информация поступала на вход к «гвоздю»(на текущем этапе не было особой необходимости в «гвозде», так как трафик с одного устройства не загрузил бы базу, но мы решили добавить его сразу на будущее). Основная задача «гвоздя» - управление трафиком и постепенная запись в БД (на Postgres) событий. «Гвоздь» был реализован на Java.Далее к БД обращались 3 сервиса:
- Rest API (Java) предоставлял всю необходимую информацию для клиента
- Auth (Node.JS) - сервис авторизации
- Notification (Node.JS) - сервис для push-уведомлений
И, собственно, само мобильное приложение. В качестве инструмента был выбран React Native.Мы распределили с товарищем обязанности: так как я являюсь JS-разработчиком, я взял на себя реализацию мобильного приложения, Auth и Notification сервисов. Далее в статье рассмотрим подробнее реализацию этих элементов. Описание остальных деталей будет в отдельном материале (Ссылка на будущее на отдельную статью).Auth service Сервис авторизации реализован на основе JWT-токена. Он включает в себя функциональность регистрации и аутентификации пользователей.Роутинг сервиса выглядит следующим образом:
const router = require('express').Router();
const {loggedIn, adminOnly} = require("../helpers/auth.middleware");
const userController = require('../controllers/user.controller');
// Регистрация нового пользователя
router.post('/register', userController.register);
// Логин
router.post('/login', userController.login);
// Проверка на авторизацию для сторонних сервисов
router.get('/auth', loggedIn, (req, res) => res.send(true));
// Только для админа
router.get('/adminonly', loggedIn, adminOnly, userController.adminonly);
module.exports = router;
При регистрации генерируется хэш-пароль с использованием bcryptjs и отправляется дальше в БД.
exports.register = async (req, res) => {
// Генерируем хэш
const salt = await bcrypt.genSalt(10);
const hasPassword = await bcrypt.hash(req.body.password, salt);
// Создаем экземпляр юзера
const user = new User({
mobile: req.body.mobile,
email: req.body.email,
username: req.body.username,
password: hasPassword,
status: req.body.status || 1
});
// Сохраняем пользователя в БД
try {
const id = await User.create(user);
user.id = id;
delete user.password;
res.send(user);
}
catch (err){
res.status(500).send({error: err.message});
}
};
В итоге имеем такие записи:
Для самой авторизации использовался пакет jsonwebtoken:
exports.login = async (req, res) => {
try {
// Проверяем существует ли пользователь
const user = await User.login(req.body.username);
if (user) {
const validPass = await bcrypt.compare(req.body.password, user.password);
if (!validPass) return res.status(400).send({error: "Password is wrong"});
// Создаем и устанавливаем токен
const token = jwt.sign({id: user.id, user_type_id: user.user_type_id}, config.TOKEN_SECRET,{ expiresIn: config.EXPIRATION});
res.header("auth-token", token).send({"token": token, user: user.username});
}
}
catch (err) {
if( err instanceof NotFoundError ) {
res.status(401).send({error: err.message});
}
else {
const error_data = {
entity: 'User',
model_obj: {param: req.params, body: req.body},
error_obj: err,
error_msg: err.message
};
res.status(500).send(error_data);
}
}
};
Для сторонних сервисов был реализован отдельный метод проверки токена:
exports.loggedIn = function (req, res, next) {
let token = req.header('Authorization');
if (!token) return res.status(401).send("Access Denied");
try {
// Выцепляем токен из заголовка
if (token.startsWith('Bearer ')) {
token = token.slice(7, token.length).trimLeft();
}
// Проверяем на валидность, что токен активен
const verified = jwt.verify(token, config.TOKEN_SECRET);
req.user = verified;
next();
}
catch (err) {
res.status(400).send("Invalid Token");
}
}
Мобильное приложениеТребования к приложению достаточно простые:
- экран авторизации
- экран с устройствами (возможность добавлять, удалять, смотреть информацию)
- экран со списком событий (просмотренные/непросмотренные)
- возможность смотреть детальную информацию по каждому из них, включая видео
До этого момента у меня практически не было опыта в мобильной разработке. Так как большую часть времени я занимаюсь front-end разработкой на различных фреймворках, в частности на React, то выбор пал сразу на React Native. Осталось только определиться, использовать ли Expo. Рассмотрим основные «за» и «против»:
Плюсы использования Expo:
- Настройка проекта проста и может быть выполнена за считанные минуты;
- Общий доступ к приложению очень прост (через QR-код или ссылку) - вам не нужно отправлять весь файл .apk или .ipa;
- Интегрирует некоторые базовые библиотеки в стандартный проект (Push-уведомления, Asset Manager,...).
Минусы:
- Нельзя добавить собственные модули, написанные на Java / Objective-C;
- Из-за большого количества интегрированных библиотек, вес приложения увеличивается.
Взвесив все «за» и «против», понял, что с Expo процесс разработки пройдет заметно быстрее, это было самым главным на тот момент. Так оно по итогу и оказалось. Но если рассматривать дальнейшие перспективы, различные доработки, то становится понятно, что все может быть не так радужно. В случае использования нативных модулей пришлось бы делать detach, который, по опыту многих знакомых, работает криво. К счастью, мне с головой хватило того, что возможно делать с Expo.Создав пустой проект и открыв его, сразу стало понятно, что больших различий с проектами на React я не вижу. А это хорошо!В качестве state-менеджера выбрал MobX - мне нравится концепция observable и с его использованием не нужно писать много кода.Для HTTP запросов я всегда обращаюсь к axios, но в этот раз решил использовать superagent для разнообразия. В итоге, все запросы были разбиты на сущности:
import superagentPromise from 'superagent-promise';
import _superagent from 'superagent';
import Auth from './auth';
import Alarms from './alarms';
import Notification from './notification';
import Devices from './devices';
import commonStore from "../store/commonStore";
import authStore from "../store/authStore";
import getEnvVars from "../environment";
const superagent = superagentPromise(_superagent, global.Promise);
const {apiRoot: API_ROOT} = getEnvVars();
const handleErrors = (err: any) => {
if (err && err.response && err.response.status === 401) {
authStore.logout();
}
return err;
};
const responseBody = (res: any) => res.body;
//Добавление токена к запросу
const tokenPlugin = (req: any) => {
if (commonStore.token) {
req.set('authorization', `Token ${commonStore.token}`);
}
};
export interface RequestsAgent {
del: (url: string) => any;
get: (url: string) => any;
put: (url: string, body: object) => any;
post: (url: string, body: object, root?: string) => any;
}
const requests: RequestsAgent = {
del: (url: string) =>
superagent
.del(`${API_ROOT}${url}`)
.use(tokenPlugin)
.end(handleErrors)
.then(responseBody),
get: (url: string) =>
superagent
.get(`${API_ROOT}${url}`)
.use(tokenPlugin)
.end(handleErrors)
.then(responseBody),
put: (url: string, body: object) =>
superagent
.put(`${API_ROOT}${url}`, body)
.use(tokenPlugin)
.end(handleErrors)
.then(responseBody),
post: (url: string, body: object, root?: string) =>
superagent
.post(`${root ? root : API_ROOT}${url}`, body)
.use(tokenPlugin)
.end(handleErrors)
.then(responseBody),
};
export default {
Auth: Auth(requests),
Alarms: Alarms(requests),
Notification: Notification(requests),
Devices: Devices(requests)
};
Пример api из auth.ts:
import {RequestsAgent} from "./index";
import getEnvVars from "../environment";
const {apiAuth} = getEnvVars();
export default (requests: RequestsAgent) => {
return {
login: (username: string, password: string) =>
requests.post('/api/users/login', {username, password}, apiAuth),
register: (username: string, email: string, password: string) =>
requests.post('/api/users/register', { user: { username, email, password } }),
};
}
Далее к ним можно обратиться из необходимых мест. Пример из authStore:
@action
register(): any {
this.inProgress = true;
this.errors = null;
return agent.Auth.register(this.values.username, this.values.email, this.values.password)
.then(({ user }) => commonStore.setToken(user.token))
.then(() => userStore.pullUser())
.catch(action((err) => {
this.errors = err.response && err.response.body && err.response.body.errors;
throw err;
}))
.finally(action(() => { this.inProgress = false; }));
}
К слову для хранения информации на клиенте, в случае с React Native, мы не можем обратиться к LocalStorage, для этого есть AsyncStorage. Туда я положил token для авторизации. Работа с AsyncStorage выглядит привычным образом за исключением того, что операции асинхронные:
const token = await AsyncStorage.getItem('token');
При генерации пустого приложения Expo добавляется дефолтный роутинг и создается структура с BottomTabNavigator. Мне этот вариант отлично подошел - осталось только корректно прописать роутинги для нужных экранов:
const BottomTab = createBottomTabNavigator<BottomTabParamList>();
export default function BottomTabNavigator() {
const colorScheme = useColorScheme();
return (
<BottomTab.Navigator
tabBarOptions={{activeTintColor: Colors[colorScheme].tint}}>
<BottomTab.Screen
name="Устройства"
component={DeviceNavigator}
options={{
tabBarIcon: ({color}) => <TabBarIcon name="calculator-outline" color={color}></TabBarIcon>,
}}
/>
<BottomTab.Screen
name="События"
component={AlarmsNavigator}
options={{
tabBarIcon: ({color}) => <NotificationBadge color={color}/>,
}}
/>
</BottomTab.Navigator>
);
}
И для примера - сам DeviceNavigator:
const TabThreeStack = createStackNavigator<TabThreeParamList>();
function DeviceNavigator() {
const navigation = useNavigation();
const {colors} = useTheme();
return (
<TabThreeStack.Navigator>
<TabThreeStack.Screen
name="DeviceScreen"
component={DevicesScreen}
options={{
headerTitle: 'Устройства',
headerRight: () => <Ionicons color={colors.primary} onPress={() => navigation.navigate('DeviceScreenAdd')} name={"add-circle-outline"}/>
}}
/>
<TabThreeStack.Screen
name="AddDeviceScreen"
component={AddDeviceScreen}
options={{
headerTitle: 'Добавить устройство'
}}
/>
<TabThreeStack.Screen
name="DeviceInfoScreen"
component={DeviceInfoScreen}
options={{
headerTitle: 'Информация о устройстве'
}}
/>
</TabThreeStack.Navigator>
);
}
Далее началась реализации самих экранов и привычная разработка для react-разработчика со своими тонкостями. По итогу получили такие экраны:
Для воспроизведения видео использовался пакет expo-video-player. Вставляем в необходимое место сам видеоплеер, в uri прокидываем ссылку на стрим видео. Важно, чтобы на сервере корректно была настроена работа с Content-range. В итоге получили:
Notification serviceДля push-уведомлений создаем отдельный сервис. Наши push уведомления происходят после добавления нового события в БД. Для этого вешаем слушатель:
client.query('LISTEN new_alarm_event');
client.on('notification', async (data) => {
writeToAll(data.payload)
});
Во время данного события говорим expo сгенерировать уведомление через функцию:
const writeToAll = async msg => {
const tokensArray = Array.from(tokensSet);
if (tokensArray.length > 0) {
const messages = tokensArray.map(token => ({
to: token,
sound: 'default',
body: msg,
data: { msg },
}))
// Группируем сообщения, чтобы отправить все разом
let chunks = expo.chunkPushNotifications(messages);
(async () => {
for (let chunk of chunks) {
try {
// Отправляем пакет в службу уведомлений Expo
const receipts = await expo.sendPushNotificationsAsync(chunk);
console.log(receipts);
} catch (error) {
console.error(error);
}
}
})();
}
else {
console.log(`cant write, ${tokensArray.length} users`)
}
return tokensArray.length
}
Также не забываем зарегистрировать устройство в самом мобильном приложении:
const registerForPushNotifications = async () => {
const { status } = await Permissions.askAsync(Permissions.NOTIFICATIONS);
if (status !== 'granted') {
alert('No notification permissions!');
return;
}
// получаем токен для мобильного устройства
let token = await Notifications.getExpoPushTokenAsync();
// отправляем на регистрацию в наш notification service
await sendPushNotification(token);
}
export default registerForPushNotifications;
ЗаключениеВ течение небольшого промежутка времени была реализована IoT-система. Устройство полностью справляется с поставленной на текущий момент задачей. В дальнейших планах - добавление отслеживания хозяина устройства, чтобы не детектировать лишнее событие входа (на основе телефона хозяина). Оценивая проделанную работу, мне приятно осознавать, что в текущий момент знание JS позволяет заниматься не только frontend разработкой, но и брать на себя задачи, связанные с backend, мобильной и десктопной разработкой. Это расширяет кругозор и дает новые возможности.На сегодня все.Всем добра!
===========
Источник:
habr.com
===========
Похожие новости:
- [JavaScript, Программирование, HTML, Браузеры, DIY или Сделай сам] How I create browser applications inside browsers (перевод)
- [JavaScript, API, ReactJS] Делаем страницу на React с базой сотрудников при помощи Airtable и Quarkly
- [Информационная безопасность, Криптография, Разработка под Android, Софт] Google по ошибке удалила мессенджер Element из каталога Google Play, затем вернула обратно
- [Ненормальное программирование, JavaScript, HTML, Браузеры, DIY или Сделай сам] Как я создаю приложения для браузера прямо в браузере
- [PHP, PostgreSQL, SQL] Установка Redmine за 15 минут (RVM + RoR + Unicorn + Nginx)
- [Информационная безопасность, Разработка веб-сайтов, JavaScript] Опасная уязвимость в популярной библиотеке Sequelize
- [Разработка веб-сайтов, JavaScript, Программирование] Углублённое руководство по JavaScript: генераторы. Часть 2, простой пример использования (перевод)
- [JavaScript, Программирование, Тестирование веб-сервисов] Тестирование с использованием Puppeteer
- [Разработка для интернета вещей, Интернет вещей] Системы контроля управления доступом в IoT — умеем, знаем, практикуем
- [Программирование микроконтроллеров, Компьютерное железо, DIY или Сделай сам] Raspberry Pi Pico на МК RP2040: начало и первые шаги. Что есть поесть за $4
Теги для поиска: #_javascript, #_node.js, #_reactjs, #_javascript, #_iot, #_expo, #_nodejs, #_raspberry, #_react, #_blog_kompanii_megafon (
Блог компании МегаФон
), #_javascript, #_node.js, #_reactjs
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 14:37
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
В один момент мы задумались с товарищем, а почему бы не попробовать сделать свое домашнее IoT-устройство? Недолго думая, мы остановились на концепции устройства, которое позволяет отслеживать незваных гостей и оповещать хозяина. Как это можно сделать и что для этого требуется?Через какое-то время стало ясно, что для нашей задачи должен подойти Raspberry pi в сопровождении датчика движения и камеры. На него напишем драйвер, повесим несколько различных сервисов на удаленном сервере, сделаем мобильное приложение и цель будет достигнута. Звучит вполне неплохо, самое время пробовать.Для начала мы заказали:
Архитектура IoT-системыСледующий шагом была спроектирована архитектура: Для нашего устройства был необходим драйвер, который отслеживал бы сигнал с датчика движения, запускал камеру, собирал всю полученную информацию и отправлял дальше. В результате мы применили комплексное решение с использованием библиотек на Java и Python. Отправленная информация поступала на вход к «гвоздю»(на текущем этапе не было особой необходимости в «гвозде», так как трафик с одного устройства не загрузил бы базу, но мы решили добавить его сразу на будущее). Основная задача «гвоздя» - управление трафиком и постепенная запись в БД (на Postgres) событий. «Гвоздь» был реализован на Java.Далее к БД обращались 3 сервиса:
const router = require('express').Router();
const {loggedIn, adminOnly} = require("../helpers/auth.middleware"); const userController = require('../controllers/user.controller'); // Регистрация нового пользователя router.post('/register', userController.register); // Логин router.post('/login', userController.login); // Проверка на авторизацию для сторонних сервисов router.get('/auth', loggedIn, (req, res) => res.send(true)); // Только для админа router.get('/adminonly', loggedIn, adminOnly, userController.adminonly); module.exports = router; exports.register = async (req, res) => {
// Генерируем хэш const salt = await bcrypt.genSalt(10); const hasPassword = await bcrypt.hash(req.body.password, salt); // Создаем экземпляр юзера const user = new User({ mobile: req.body.mobile, email: req.body.email, username: req.body.username, password: hasPassword, status: req.body.status || 1 }); // Сохраняем пользователя в БД try { const id = await User.create(user); user.id = id; delete user.password; res.send(user); } catch (err){ res.status(500).send({error: err.message}); } }; Для самой авторизации использовался пакет jsonwebtoken: exports.login = async (req, res) => {
try { // Проверяем существует ли пользователь const user = await User.login(req.body.username); if (user) { const validPass = await bcrypt.compare(req.body.password, user.password); if (!validPass) return res.status(400).send({error: "Password is wrong"}); // Создаем и устанавливаем токен const token = jwt.sign({id: user.id, user_type_id: user.user_type_id}, config.TOKEN_SECRET,{ expiresIn: config.EXPIRATION}); res.header("auth-token", token).send({"token": token, user: user.username}); } } catch (err) { if( err instanceof NotFoundError ) { res.status(401).send({error: err.message}); } else { const error_data = { entity: 'User', model_obj: {param: req.params, body: req.body}, error_obj: err, error_msg: err.message }; res.status(500).send(error_data); } } }; exports.loggedIn = function (req, res, next) {
let token = req.header('Authorization'); if (!token) return res.status(401).send("Access Denied"); try { // Выцепляем токен из заголовка if (token.startsWith('Bearer ')) { token = token.slice(7, token.length).trimLeft(); } // Проверяем на валидность, что токен активен const verified = jwt.verify(token, config.TOKEN_SECRET); req.user = verified; next(); } catch (err) { res.status(400).send("Invalid Token"); } }
Плюсы использования Expo:
import superagentPromise from 'superagent-promise';
import _superagent from 'superagent'; import Auth from './auth'; import Alarms from './alarms'; import Notification from './notification'; import Devices from './devices'; import commonStore from "../store/commonStore"; import authStore from "../store/authStore"; import getEnvVars from "../environment"; const superagent = superagentPromise(_superagent, global.Promise); const {apiRoot: API_ROOT} = getEnvVars(); const handleErrors = (err: any) => { if (err && err.response && err.response.status === 401) { authStore.logout(); } return err; }; const responseBody = (res: any) => res.body; //Добавление токена к запросу const tokenPlugin = (req: any) => { if (commonStore.token) { req.set('authorization', `Token ${commonStore.token}`); } }; export interface RequestsAgent { del: (url: string) => any; get: (url: string) => any; put: (url: string, body: object) => any; post: (url: string, body: object, root?: string) => any; } const requests: RequestsAgent = { del: (url: string) => superagent .del(`${API_ROOT}${url}`) .use(tokenPlugin) .end(handleErrors) .then(responseBody), get: (url: string) => superagent .get(`${API_ROOT}${url}`) .use(tokenPlugin) .end(handleErrors) .then(responseBody), put: (url: string, body: object) => superagent .put(`${API_ROOT}${url}`, body) .use(tokenPlugin) .end(handleErrors) .then(responseBody), post: (url: string, body: object, root?: string) => superagent .post(`${root ? root : API_ROOT}${url}`, body) .use(tokenPlugin) .end(handleErrors) .then(responseBody), }; export default { Auth: Auth(requests), Alarms: Alarms(requests), Notification: Notification(requests), Devices: Devices(requests) }; import {RequestsAgent} from "./index";
import getEnvVars from "../environment"; const {apiAuth} = getEnvVars(); export default (requests: RequestsAgent) => { return { login: (username: string, password: string) => requests.post('/api/users/login', {username, password}, apiAuth), register: (username: string, email: string, password: string) => requests.post('/api/users/register', { user: { username, email, password } }), }; } @action
register(): any { this.inProgress = true; this.errors = null; return agent.Auth.register(this.values.username, this.values.email, this.values.password) .then(({ user }) => commonStore.setToken(user.token)) .then(() => userStore.pullUser()) .catch(action((err) => { this.errors = err.response && err.response.body && err.response.body.errors; throw err; })) .finally(action(() => { this.inProgress = false; })); } const token = await AsyncStorage.getItem('token');
const BottomTab = createBottomTabNavigator<BottomTabParamList>();
export default function BottomTabNavigator() { const colorScheme = useColorScheme(); return ( <BottomTab.Navigator tabBarOptions={{activeTintColor: Colors[colorScheme].tint}}> <BottomTab.Screen name="Устройства" component={DeviceNavigator} options={{ tabBarIcon: ({color}) => <TabBarIcon name="calculator-outline" color={color}></TabBarIcon>, }} /> <BottomTab.Screen name="События" component={AlarmsNavigator} options={{ tabBarIcon: ({color}) => <NotificationBadge color={color}/>, }} /> </BottomTab.Navigator> ); } const TabThreeStack = createStackNavigator<TabThreeParamList>();
function DeviceNavigator() { const navigation = useNavigation(); const {colors} = useTheme(); return ( <TabThreeStack.Navigator> <TabThreeStack.Screen name="DeviceScreen" component={DevicesScreen} options={{ headerTitle: 'Устройства', headerRight: () => <Ionicons color={colors.primary} onPress={() => navigation.navigate('DeviceScreenAdd')} name={"add-circle-outline"}/> }} /> <TabThreeStack.Screen name="AddDeviceScreen" component={AddDeviceScreen} options={{ headerTitle: 'Добавить устройство' }} /> <TabThreeStack.Screen name="DeviceInfoScreen" component={DeviceInfoScreen} options={{ headerTitle: 'Информация о устройстве' }} /> </TabThreeStack.Navigator> ); } Для воспроизведения видео использовался пакет expo-video-player. Вставляем в необходимое место сам видеоплеер, в uri прокидываем ссылку на стрим видео. Важно, чтобы на сервере корректно была настроена работа с Content-range. В итоге получили: Notification serviceДля push-уведомлений создаем отдельный сервис. Наши push уведомления происходят после добавления нового события в БД. Для этого вешаем слушатель: client.query('LISTEN new_alarm_event');
client.on('notification', async (data) => { writeToAll(data.payload) }); const writeToAll = async msg => {
const tokensArray = Array.from(tokensSet); if (tokensArray.length > 0) { const messages = tokensArray.map(token => ({ to: token, sound: 'default', body: msg, data: { msg }, })) // Группируем сообщения, чтобы отправить все разом let chunks = expo.chunkPushNotifications(messages); (async () => { for (let chunk of chunks) { try { // Отправляем пакет в службу уведомлений Expo const receipts = await expo.sendPushNotificationsAsync(chunk); console.log(receipts); } catch (error) { console.error(error); } } })(); } else { console.log(`cant write, ${tokensArray.length} users`) } return tokensArray.length } const registerForPushNotifications = async () => {
const { status } = await Permissions.askAsync(Permissions.NOTIFICATIONS); if (status !== 'granted') { alert('No notification permissions!'); return; } // получаем токен для мобильного устройства let token = await Notifications.getExpoPushTokenAsync(); // отправляем на регистрацию в наш notification service await sendPushNotification(token); } export default registerForPushNotifications; =========== Источник: habr.com =========== Похожие новости:
Блог компании МегаФон ), #_javascript, #_node.js, #_reactjs |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 14:37
Часовой пояс: UTC + 5