ساخت اپلیکیشن نیویورک تایمز با ری اکت و بوت استرپ — از صفر تا صد

۸۳ بازدید
آخرین به‌روزرسانی: ۰۴ مهر ۱۴۰۲
زمان مطالعه: ۹ دقیقه
ساخت اپلیکیشن نیویورک تایمز با ری اکت و بوت استرپ — از صفر تا صد

ری‌اکت فریمورکی عالی برای ساخت وب‌اپلیکیشن‌های تعاملی است و مجموعه قابلیت‌های پایه زیادی ارائه می‌کند. ری‌اکت می‌تواند در زمان به‌روز شدن داده‌ها صفحه را رندر کند و ساختار آسانی برای نوشتن کد به شیوه‌ای ساده ارائه می‌کند. می‌توان از این فریمورک به سادگی برای ساخت اپلیکیشن‌هایی که از API عمومی استفاده می‌کنند بهره گرفت. با ما همراه باشید تا در این راهنما با مراحل ساخت اپلیکیشن نیویورک تایمز با ری‌اکت و بوت‌استرپ آشنا شوید.

در این اپلیکیشن می‌توانید متن استاتیکی را در اپلیکیشن به زبان انگلیسی یا فرانسه مشاهده کنید. این اپلیکیشن می‌تواند اخبار را از بخش‌های مختلف بگیرد و همچنین آن‌ها را جستجو کند. اپلیکیشن ما از درخواست‌های HTTP بین دامنه‌ای (cross domain) پشتیبانی می‌کند و از این رو می‌توانیم اپلیکیشن‌های سمت کلاینتی بنویسیم که از API بهره بگیرند.

پیش از ساخت اپلیکیشن باید در وب‌سایت نیویورک‌تایمز (+) ثبت نام کنیم تا کلید API آن را به دست آوریم. برای شروع به ساخت اپلیکیشن از ابزار خط فرمان Create React App استفاده می‌کنیم که چارچوب کد را ایجاد می‌کند. برای استفاده از این ابزار باید دستور زیر را اجرا کنید تا کد در پوشه nyt-app ایجاد شود:

npx create-react-app nyt-app

سپس باید برخی کتابخانه‌ها را نصب کنید. ما به یک کلاینت HTTP به نام Axios، یک کتابخانه برای تبدیل اشیا به رشته‌های کوئری، کتابخانه بوت‌استرپ برای زیبا ساختن اپلیکیشن، React Router برای مسیریابی و به Formik و Yup برای ایجاد ساده فرم‌ها نیاز داریم. برای ترجمه و محلی‌سازی از کتابخانه React-i18next استفاده می‌کنیم که امکان ترجمه متن به زبان انگلیسی و فرانسوی را می‌دهد. برای نصب کتابخانه‌های فوق دستور زیر را اجرا کنید:

npm i axios bootstrap formik i18next i18next-browser-languagedetector i18next-xhr-backend querystring react-bootstrap react-i18next react-router-dom yup

اکنون که همه کتابخانه‌ها نصب شدند، می‌توانیم شروع به کدنویسی بکنیم. به منظور حفظ سادگی، همه چیز را در پوشه src قرار می‌دهیم. کار خود را با ویرایش App.js آغاز می‌کنیم. کد موجود را با کد زیر عوض می‌کنیم:

1import React from "react";
2import { Router, Route, Link } from "react-router-dom";
3import HomePage from "./HomePage";
4import TopBar from "./TopBar";
5import { createBrowserHistory as createHistory } from "history";
6import "./App.css";
7import SearchPage from "./SearchPage";
8import { useTranslation } from "react-i18next";
9import { useState, useEffect } from "react";
10const history = createHistory();
11function App() {
12  const { t, i18n } = useTranslation();
13  const [initialized, setInitialized] = useState(false);
14  const changeLanguage = lng => {
15    i18n.changeLanguage(lng);
16  };
17useEffect(() => {
18    if (!initialized) {
19      changeLanguage(localStorage.getItem("language") || "en");
20      setInitialized(true);
21    }
22  });
23return (
24    <div className="App">
25      <Router history={history}>
26        <TopBar />
27        <Route path="/" exact component={HomePage} />
28        <Route path="/search" exact component={SearchPage} />
29      </Router>
30    </div>
31  );
32}
33export default App;

این کامپوننت ریشه اپلیکیشن ما است و در زمان بارگذاری نخست اپلیکیشن بارگذاری خواهد شد. از تابع useTranslation از کتابخانه react-i18next استفاده می‌کنیم که یک شیء با مشخصه s و مشخصه i18n بازگشت می‌دهد. سپس مشخصه‌های شیء بازگشتی را به متغیرهای جداگانه‌ای انتساب می‌دهیم. از t که کلید ترجمه است برای دریافت متن انگلیسی یا فرانسه بسته به زبان تعیین شده استفاده می‌کنیم. در این فایل از تابع i18n برای تعیین زبان با تابع i18n.changeLanguage استفاده می‌کنیم. همچنین زبان را در صورت عرضه شدن از local storage تعیین می‌کنیم تا زبان انتخابی پس از رفرش شدن نیز حفظ شود.

همچنین مسیرهایی برای صفحه‌ها اضافه می‌کنیم که از سوی React router مورد استفاده قرار خواهد گرفت. در فایل App.css کد زیر را وارد کنید تا برخی متن‌ها به صورت مرکزی تنظیم شوند:

1.center {
2  text-align: center;
3}

سپس صفحه اصلی را می‌سازیم. به این منظور فایلی به نام HomePage.js ایجاد کرده و کد زیر را در آن قرار دهید:

1import React from "react";
2import { useState, useEffect } from "react";
3import Form from "react-bootstrap/Form";
4import ListGroup from "react-bootstrap/ListGroup";
5import Card from "react-bootstrap/Card";
6import Button from "react-bootstrap/Button";
7import { getArticles } from "./requests";
8import { useTranslation } from "react-i18next";
9import "./HomePage.css";
10const sections = `arts, automobiles, books, business, fashion, food, health,
11home, insider, magazine, movies, national, nyregion, obituaries,
12opinion, politics, realestate, science, sports, sundayreview,
13technology, theater, tmagazine, travel, upshot, world`
14  .replace(/ /g, "")
15  .split(",");
16function HomePage() {
17  const [selectedSection, setSelectedSection] = useState("arts");
18  const [articles, setArticles] = useState([]);
19  const [initialized, setInitialized] = useState(false);
20  const { t, i18n } = useTranslation();
21const load = async section => {
22    setSelectedSection(section);
23    const response = await getArticles(section);
24    setArticles(response.data.results || []);
25  };
26const loadArticles = async e => {
27    if (!e || !e.target) {
28      return;
29    }
30    setSelectedSection(e.target.value);
31    load(e.target.value);
32  };
33const initializeArticles = () => {
34    load(selectedSection);
35    setInitialized(true);
36  };
37useEffect(() => {
38    if (!initialized) {
39      initializeArticles();
40    }
41  });
42return (
43    <div className="HomePage">
44      <div className="col-12">
45        <div className="row">
46          <div className="col-md-3 d-none d-md-block d-lg-block d-xl-block">
47            <ListGroup className="sections">
48              {sections.map(s => (
49                <ListGroup.Item
50                  key={s}
51                  className="list-group-item"
52                  active={s == selectedSection}
53                >
54                  <a
55                    className="link"
56                    onClick={() => {
57                      load(s);
58                    }}
59                  >
60                    {t(s)}
61                  </a>
62                </ListGroup.Item>
63              ))}
64            </ListGroup>
65          </div>
66          <div className="col right">
67            <Form className="d-sm-block d-md-none d-lg-none d-xl-none">
68              <Form.Group controlId="section">
69                <Form.Label>{t("Section")}</Form.Label>
70                <Form.Control
71                  as="select"
72                  onChange={loadArticles}
73                  value={selectedSection}
74                >
75                  {sections.map(s => (
76                    <option key={s} value={s}>{t(s)}</option>
77                  ))}
78                </Form.Control>
79              </Form.Group>
80            </Form>
81            <h1>{t(selectedSection)}</h1>
82            {articles.map((a, i) => (
83              <Card key={i}>
84                <Card.Body>
85                  <Card.Title>{a.title}</Card.Title>
86                  <Card.Img
87                    variant="top"
88                    className="image"
89                    src={
90                      Array.isArray(a.multimedia) &&
91                      a.multimedia[a.multimedia.length - 1]
92                        ? a.multimedia[a.multimedia.length - 1].url
93                        : null
94                    }
95                  />
96                  <Card.Text>{a.abstract}</Card.Text>
97                  <Button
98                    variant="primary"
99                    onClick={() => (window.location.href = a.url)}
100                  >
101                    {t("Go")}
102                  </Button>
103                </Card.Body>
104              </Card>
105            ))}
106          </div>
107        </div>
108      </div>
109    </div>
110  );
111}
112export default HomePage;

در فایل فوق یک لی‌آوت واکنش‌گرا نمایش می‌دهیم که در صورت عریض بودن صفحه یک نوار سمت چپ در صفحه نمایش می‌یابد و در غیر این صورت یک منوی بازشدنی در سمت راست نمایش خواهد یافت. آیتم‌ها را در بخش انتخابی که از پنل سمت چپ یا منوی بازشدنی انتخاب کرده‌ایم نمایش می‌دهیم. برای نمایش آیتم‌ها از ویجت card مربوط به React Bootstrap استفاده می‌کنیم. همچنین از تابع t ارائه شده از سوی react-i18next برای بارگذاری متن از فایل ترجمه که ایجاد خواهیم کرد بهره می‌گیریم.

برای بارگذاری مدخل‌های اولیه مقاله، تابع را در callback مربوط به تابع useEffect اجرا می‌کنیم تا بارگذاری آیتم‌ها از API نیویورک‌تایمز را نشان دهیم. باید از فلگ initialized استفاده کنیم تا تابع در callback در هر بار رندر مجدد بارگذاری نشود. در منوی بازشدنی کدی را برای بارگذاری مقالات در زمان تغییر یافتن موارد انتخابی اضافه می‌کنیم. فایل HomePage.css را ایجاد کرده و کد زیر را به آن اضافه کنید:

1.link {
2  cursor: pointer;
3}
4.right {
5  padding: 20px;
6}
7.image {
8  max-width: 400px;
9  text-align: center;
10}
11.sections {
12    margin-top: 20px;
13}

استایل کرسر را برای دکمه Go تغییر می‌دهیم و مقداری padding به پنل سمت راست اضافه می‌کنیم. سپس یک فایل برای بارگذاری ترجمه‌ها و تعیین زبان پیش‌فرض ایجاد می‌کنیم. فایلی به نام i18n.js ساخته و کد زیر را به آن اضافه کنید:

1import i18n from "i18next";
2import { initReactI18next } from "react-i18next";
3import { resources } from "./translations";
4import Backend from "i18next-xhr-backend";
5import LanguageDetector from "i18next-browser-languagedetector";
6i18n
7  .use(Backend)
8  .use(LanguageDetector)
9  .use(initReactI18next)
10  .init({
11    resources,
12    lng: "en",
13    fallbackLng: "en",
14    debug: true,
15interpolation: {
16      escapeValue: false,
17    },
18  });
19export default i18n;

در این فایل ترجمه را از یک فایل بارگذاری می‌کنیم و زبان پیش‌فرض را روی انگلیسی تنظیم می‌کنیم. از آنجا که react-i18next همه چیز را escape می‌کند، می‌توانیم مقدار escapeValue را برای interpolation روی False قرار دهیم، زیرا دوباره‌کاری محسوب می‌شود. باید یک فایل داشته باشیم که کد ایجاد درخواست‌های HTTP را در آن قرار دهیم. به این منظور فایلی به نام requests.js ایجاد کرده و کد زیر را در آن می‌نویسیم:

1const APIURL = "https://api.nytimes.com/svc";
2const axios = require("axios");
3const querystring = require("querystring");
4export const search = data => {
5  Object.keys(data).forEach(key => {
6    data["api-key"] = process.env.REACT_APP_APIKEY;
7    if (!data[key]) {
8      delete data[key];
9    }
10  });
11  return axios.get(
12    `${APIURL}/search/v2/articlesearch.json?${querystring.encode(data)}`
13  );
14};
15export const getArticles = section =>
16  axios.get(
17    `${APIURL}/topstories/v2/${section}.json?api-key=${process.env.REACT_APP_APIKEY}`
18  );

کلید API را از متغیر process.env.REACT_APP_APIKEY بارگذاری می‌کنیم که از سوی متغیر محیطی در فایل env. واقع در پوشه ریشه عرضه شده است. این فایل را باید خودمان بسازیم و کد زیر را در آن قرار دهیم:

REACT_APP_APIKEY='you New York Times API key'

مقدار سمت راست را با کلید API که پس از ثبت نام در وب‌سایت نیویورک‌تایمز دریافت کردید عوض کنید. سپس یک صفحه جستجو می‌سازیم. یک فایل به نام SearchPage.js ایجاد کرده و کد زیر را در آن قرار دهید:

1import React from "react";
2import { useState } from "react";
3import { useTranslation } from "react-i18next";
4import "./SearchPage.css";
5import * as yup from "yup";
6import { Formik } from "formik";
7import Form from "react-bootstrap/Form";
8import Col from "react-bootstrap/Col";
9import Button from "react-bootstrap/Button";
10import { Trans } from "react-i18next";
11import { search } from "./requests";
12import Card from "react-bootstrap/Card";
13const schema = yup.object({
14  keyword: yup.string().required("Keyword is required"),
15});
16function SearchPage() {
17  const { t } = useTranslation();
18  const [articles, setArticles] = useState([]);
19  const [count, setCount] = useState(0);
20const handleSubmit = async e => {
21    const response = await search({ q: e.keyword });
22    setArticles(response.data.response.docs || []);
23  };
24return (
25    <div className="SearchPage">
26      <h1 className="center">{t("Search")}</h1>
27      <Formik validationSchema={schema} onSubmit={handleSubmit}>
28        {({
29          handleSubmit,
30          handleChange,
31          handleBlur,
32          values,
33          touched,
34          isInvalid,
35          errors,
36        }) => (
37          <Form noValidate onSubmit={handleSubmit} className="form">
38            <Form.Row>
39              <Form.Group as={Col} md="12" controlId="keyword">
40                <Form.Label>{t("Keyword")}</Form.Label>
41                <Form.Control
42                  type="text"
43                  name="keyword"
44                  placeholder={t("Keyword")}
45                  value={values.keyword || ""}
46                  onChange={handleChange}
47                  isInvalid={touched.keyword && errors.keyword}
48                />
49                <Form.Control.Feedback type="invalid">
50                  {errors.keyword}
51                </Form.Control.Feedback>
52              </Form.Group>
53            </Form.Row>
54            <Button type="submit" style={{ marginRight: "10px" }}>
55              {t("Search")}
56            </Button>
57          </Form>
58        )}
59      </Formik>
60      <h3 className="form">
61        <Trans i18nKey="numResults" count={articles.length}>
62          There are <strong>{{ count }}</strong> results.
63        </Trans>
64      </h3>
65      {articles.map((a, i) => (
66        <Card key={i}>
67          <Card.Body>
68            <Card.Title>{a.headline.main}</Card.Title>
69            <Card.Text>{a.abstract}</Card.Text>
70            <Button
71              variant="primary"
72              onClick={() => (window.location.href = a.web_url)}
73            >
74              {t("Go")}
75            </Button>
76          </Card.Body>
77        </Card>
78      ))}
79    </div>
80  );
81}
82export default SearchPage;

این همان جایی است که یک فرم جستجو با فیلد کلیدواژه می‌سازیم که برای جستجوی API استفاده می‌شود. زمانی که کاربر روی کلید جستجو کلیک کنید، API نیویورک‌تایمز برای یافتن مقالات دارای کلیدواژه ارائه شده مورد جستجو قرار می‌گیرد. از Formik برای مدیریت تغییرات مقادیر فرم و ارائه مقادیر در شیء e در پارامتر handleSubmit استفاده می‌کنیم. از React Bootstrap برای دکمه‌ها، عناصر فرم و کارت‌ها استفاده می‌کنیم. پس از کلیک روی جستجو، مقدار articles دریافتی تنظیم می‌شود و کارت‌ها برای مقالات بارگذاری می‌شوند.

از کامپوننت Trans که از سوی react-i18next ارائه شده است برای ترجمه کردن متن‌هایی که نوعی محتوای دینامیک دارند مانند مثالی که در ادامه ارائه شده است، بهره می‌گیریم. متغیری در متن برای تعداد نتایج داریم. هر زمان که چیزی مشابه این داشته باشید، آن را درون کامپوننت Trans قرار می‌دهید و مانند مثال فوق در متغیرها به صورت props ارسال می‌کنید. سپس متغیر را در متن بین تگ‌های Trans نمایش می‌دهید. ما همچنان از میان‌یابی متغیر در ترجمه‌ها استفاده می‌کنیم که از طریق درج کد زیر برای ترجمه انگلیسی:

1“There are <1>{{count}}</1> results.

و با درج کد زیر:

1“Il y a <1>{{count}}</1> résultats.

برای ترجمه فرانسوی استفاده می‌شود. تگ 1 متناظر با تگ strong است. تعداد در این مورد دلخواه است. تا زمانی که الگو با الگوی کامپوننت سازگار است، کار خواهد کرد. بنابراین تگ strong در این مورد همواره باید مقدار 1 در رشته ترجمه داشته باشد. برای اضافه کردن ترجمه‌هایی که در بخش فوق اشاره شده است به بقیه ترجمه‌ها یک فایل به نام translations.js ایجاد کرده و کد زیر را به آن اضافه می‌کنیم:

1const resources = {
2  en: {
3    translation: {
4      "New York Times App": "New York Times App",
5      arts: "Arts",
6      automobiles: "Automobiles",
7      books: "Books",
8      business: "Business",
9      fashion: "Fashion",
10      food: "Food",
11      health: "Health",
12      home: "Home",
13      insider: "Inside",
14      magazine: "Magazine",
15      movies: "Movies",
16      national: "National",
17      nyregion: "New York Region",
18      obituaries: "Obituaries",
19      opinion: "Opinion",
20      politics: "Politics",
21      realestate: "Real Estate",
22      science: "Science",
23      sports: "Sports",
24      sundayreview: "Sunday Review",
25      technology: "Technology",
26      theater: "Theater",
27      tmagazine: "T Magazine",
28      travel: "Travel",
29      upshot: "Upshot",
30      world: "World",
31      Search: "Search",
32      numResults: "There are <1>{{count}}</1> results.",
33      Home: "Home",
34      Search: "Search",
35      Language: "Language",
36      English: "English",
37      French: "French",
38      Keyword: "Keyword",
39      Go: "Go",
40      Section: "Section",
41    },
42  },
43  fr: {
44    translation: {
45      "New York Times App": "App New York Times",
46      arts: "Arts",
47      automobiles: "Les automobiles",
48      books: "Livres",
49      business: "Entreprise",
50      fashion: "Mode",
51      food: "Aliments",
52      health: "Santé",
53      home: "Maison",
54      insider: "Initiée",
55      magazine: "Magazine",
56      movies: "Films",
57      national: "Nationale",
58      nyregion: "La région de new york",
59      obituaries: "Notices nécrologiques",
60      opinion: "Opinion",
61      politics: "Politique",
62      realestate: "Immobilier",
63      science: "Science",
64      sports: "Des sports",
65      sundayreview: "Avis dimanche",
66      technology: "La technologie",
67      theater: "Théâtre",
68      tmagazine: "Magazine T",
69      travel: "Voyage",
70      upshot: "Résultat",
71      world: "Monde",
72      Search: "Search",
73      numResults: "Il y a <1>{{count}}</1> résultats.",
74      Home: "Page d'accueil",
75      Search: "Chercher",
76      Language: "La langue",
77      English: "Anglais",
78      French: "Français",
79      Keyword: "Mot-clé",
80      Go: "Aller",
81      Section: "Section",
82    },
83  },
84};
85export { resources };

بدین ترتیب ترجمه متون استاتیک را داریم و متن میان‌یابی شده مورد اشاره فوق در این فایل قرار می‌گیرد. در نهایت نوار فوقانی را با ایجاد فایل TopBar.js و درج کد زیر می‌سازیم:

1import React from "react";
2import Navbar from "react-bootstrap/Navbar";
3import Nav from "react-bootstrap/Nav";
4import NavDropdown from "react-bootstrap/NavDropdown";
5import "./TopBar.css";
6import { withRouter } from "react-router-dom";
7import { useTranslation } from "react-i18next";
8function TopBar({ location }) {
9  const { pathname } = location;
10  const { t, i18n } = useTranslation();
11  const changeLanguage = lng => {
12    localStorage.setItem("language", lng);
13    i18n.changeLanguage(lng);
14  };
15return (
16    <Navbar bg="primary" expand="lg" variant="dark">
17      <Navbar.Brand href="#home">{t("New York Times App")}</Navbar.Brand>
18      <Navbar.Toggle aria-controls="basic-navbar-nav" />
19      <Navbar.Collapse id="basic-navbar-nav">
20        <Nav className="mr-auto">
21          <Nav.Link href="/" active={pathname == "/"}>
22            {t("Home")}
23          </Nav.Link>
24          <Nav.Link href="/search" active={pathname.includes("/search")}>
25            {t("Search")}
26          </Nav.Link>
27          <NavDropdown title={t("Language")} id="basic-nav-dropdown">
28            <NavDropdown.Item onClick={() => changeLanguage("en")}>
29              {t("English")}
30            </NavDropdown.Item>
31            <NavDropdown.Item onClick={() => changeLanguage("fr")}>
32              {t("French")}
33            </NavDropdown.Item>
34          </NavDropdown>
35        </Nav>
36      </Navbar.Collapse>
37    </Navbar>
38  );
39}
40export default withRouter(TopBar);

از کامپوننت NavBar که از سوی React Boostrap ارائه شده است استفاده می‌کنیم و یک منوی بازشدنی برای کاربرانی که یک زبان را انتخاب می‌کنند اضافه می‌کنیم. به این ترتیب وقتی روی آن آیتم‌ها کلیک کنند می‌توانند زبان را تنظیم کنند. توجه کنید که کامپوننت TopBar را درون تابع withRouter قرار داده‌ایم به طوری که مقدار مسیر جاری را با یک prop به نام location به دست می‌آوریم و از آن برای تنظیم لینک فعال با تعیین پراپ active در کامپوننت Nav.Link استفاده می‌کنیم. در نهایت کد موجود در فایل index.html را با کد زیر عوض می‌کنیم:

1<!DOCTYPE html>
2<html lang="en">
3  <head>
4    <meta charset="utf-8" />
5    <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
6    <meta name="viewport" content="width=device-width, initial-scale=1" />
7    <meta name="theme-color" content="#000000" />
8    <meta
9      name="description"
10      content="Web site created using create-react-app"
11    />
12    <link rel="apple-touch-icon" href="logo192.png" />
13    <!--
14      manifest.json provides metadata used when your web app is installed on a
15      user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
16    -->
17    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
18    <!--
19      Notice the use of %PUBLIC_URL% in the tags above.
20      It will be replaced with the URL of the `public` folder during the build.
21      Only files inside the `public` folder can be referenced from the HTML.
22Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
23      work correctly both with client-side routing and a non-root public URL.
24      Learn how to configure a non-root public URL by running `npm run build`.
25    -->
26    <title>React New York Times App</title>
27    <link
28      rel="stylesheet"
29      href="https://maxcdn.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
30      integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T"
31      crossorigin="anonymous"
32    />
33  </head>
34  <body>
35    <noscript>You need to enable JavaScript to run this app.</noscript>
36    <div id="root"></div>
37    <!--
38      This HTML file is a template.
39      If you open it directly in the browser, you will see an empty page.
40You can add webfonts, meta tags, or analytics to this file.
41      The build step will place the bundled scripts into the <body> tag.
42To begin the development, run `npm start` or `yarn start`.
43      To create a production bundle, use `npm run build` or `yarn build`.
44    -->
45  </body>
46</html>

به این ترتیب Bootstrap CSS اضافه شده و عنوان اپلیکیشن تغییر می‌یابد. پس از این انجام همه این کارها وقتی دستور npm start را اجرا کنیم، نتیجه‌ای مانند زیر به دست می‌آید:

ساخت اپلیکیشن نیویورک تایمز

ساخت اپلیکیشن نیویورک تایمز

ساخت اپلیکیشن نیویورک تایمز

ساخت اپلیکیشن نیویورک تایمز

 

اگر این مطلب برای شما مفید بوده است، آموزش‌های زیر نیز به شما پیشنهاد می‌شوند:

==

بر اساس رای ۰ نفر
آیا این مطلب برای شما مفید بود؟
اگر بازخوردی درباره این مطلب دارید یا پرسشی دارید که بدون پاسخ مانده است، آن را از طریق بخش نظرات مطرح کنید.
منابع:
swlh
نظر شما چیست؟

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *